liusheng
2026-09-29 60ade5154522a042c816abaa1840751011ae0371
1.省立同德满度问题处理
2.门急诊数据采集优化
已修改17个文件
已添加3个文件
1560 ■■■■■ 文件已修改
ruoyi-admin/src/main/java/com/ruoyi/web/controller/smartor/ServiceSubtaskDetailTraceController.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-quartz/src/main/java/com/ruoyi/quartz/enums/MzGatherParamEnum.java 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-quartz/src/main/java/com/ruoyi/quartz/enums/MzGatherSlotEnum.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-quartz/src/main/java/com/ruoyi/quartz/enums/MzGatherStatusEnum.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/RyTask.java 451 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/domain/DTO/DetailTraceDealDTO.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/domain/ServiceSLTDInhospReqVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/domain/SvyLibTemplateCategory.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/mapper/PatMedOuthospMapper.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/mapper/ServiceSubtaskDetailMapper.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/mapper/ServiceSubtaskDetailTraceMapper.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/service/IPatMedOuthospService.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/service/impl/PatMedOuthospServiceImpl.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/service/impl/PatSatisfactionServiceImpl.java 152 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/service/impl/ServiceSLTDHealthcareRecordServiceImpl.java 192 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/service/impl/ServiceSubtaskDetailTraceServiceImpl.java 170 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/resources/mapper/smartor/PatMedOuthospMapper.xml 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/resources/mapper/smartor/ServiceSubtaskDetailMapper.xml 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/resources/mapper/smartor/ServiceSubtaskDetailTraceMapper.xml 222 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/resources/mapper/smartor/SvyLibTemplageCategoryMapper.xml 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/smartor/ServiceSubtaskDetailTraceController.java
@@ -62,22 +62,16 @@
    @AddOrgId(field = "orgid", paramIndex = 0, campusField = "campusid")
    @PostMapping("/tracedeallist")
    public Map<String, Object> tracedeallist(@RequestBody DetailTraceDealVO detailTraceDealVO) {
        // PageUtils.getOffset è¿”回的是 offset,SQL é‡ŒæŒ‰ limit/offset ä½¿ç”¨
        Integer offset = PageUtils.getOffset(detailTraceDealVO.getPageNum(), detailTraceDealVO.getPageSize());
        detailTraceDealVO.setPageNum(offset);
        // ä¸€æ¬¡æŸ¥è¯¢åŒæ—¶è¿”回分页数据、问题总数和全量汇总
        // ï¼ˆåŽŸæ¥ä¸ºäº†æ‹¿æ€»æ•°æŠŠ method åˆå…¨é‡è·‘了一遍,N+1 ä¹Ÿè·Ÿç€è·‘第二遍)
        Map<String, Object> result = serviceSubtaskDetailTraceService.tracedeallist(detailTraceDealVO);
        // æŸ¥è¯¢æ€»æ•°ï¼ˆä¸åˆ†é¡µï¼‰
        detailTraceDealVO.setPageNum(null);
        detailTraceDealVO.setPageSize(null);
        Map<String, Object> totalResult = serviceSubtaskDetailTraceService.tracedeallist(detailTraceDealVO);
        List<?> totalRows = (List<?>) totalResult.get("detailTraceDealDTOList");
        //将总数信息添加到结果中
        result.put("totalException", totalResult.get("totalException"));
        result.put("noDealException", totalResult.get("noDealException"));
        result.put("yesDealException", totalResult.get("yesDealException"));
        return getDataTable3(CollectionUtils.isNotEmpty(totalRows) ? totalRows.size() : 0, result);
        Object questionTotal = result.get("questionTotal");
        long total = questionTotal == null ? 0L : Long.parseLong(String.valueOf(questionTotal));
        return getDataTable3(total, result);
    }
    /**
ruoyi-quartz/src/main/java/com/ruoyi/quartz/enums/MzGatherParamEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,56 @@
package com.ruoyi.quartz.enums;
/**
 * çœç«‹åŒå¾·é—¨æ€¥è¯Šé‡‡é›†å‚数(sys_config,机构 orgid=20001001)
 * <p>
 * æŠŠé…ç½® key、默认值、取值范围绑在一起,读取时只要传枚举本身,
 * é¿å… key å’Œé»˜è®¤å€¼å†™æ•£åœ¨ä»»åŠ¡é‡Œã€æ”¹ä¸€ä¸ªæ¼ä¸€ä¸ª
 *
 * @author smartor
 */
public enum MzGatherParamEnum {
    /**
     * å•轮采集时间预算(分钟)
     * å¿…须小于调度间隔,保证 30 åˆ†é’Ÿä¸€è°ƒæ—¶æœ¬è½®èƒ½èµ¶åœ¨ä¸‹ä¸€è½®ä¹‹å‰ç»“束
     */
    BUDGET_MINUTES("sltd.gather.mz.budget.minutes", 20L, 1L, 24L * 60L),
    /** åˆ†é¡µæ¯é¡µæ¡æ•° */
    PAGE_SIZE("sltd.gather.mz.page.size", 500L, 1L, 5000L),
    /** å•日最多翻页数,防止接口异常一直返回满页导致死循环 */
    MAX_PAGE("sltd.gather.mz.max.page", 2000L, 1L, 100000L);
    /** sys_config.config_key */
    private final String key;
    /** é…ç½®ç¼ºå¤±æˆ–超范围时使用的默认值 */
    private final long defaultValue;
    /** å…è®¸çš„æœ€å°å€¼ï¼ˆå«ï¼‰ */
    private final long min;
    /** å…è®¸çš„æœ€å¤§å€¼ï¼ˆå«ï¼‰ */
    private final long max;
    MzGatherParamEnum(String key, long defaultValue, long min, long max) {
        this.key = key;
        this.defaultValue = defaultValue;
        this.min = min;
        this.max = max;
    }
    public String getKey() {
        return key;
    }
    public long getDefaultValue() {
        return defaultValue;
    }
    public long getMin() {
        return min;
    }
    public long getMax() {
        return max;
    }
}
ruoyi-quartz/src/main/java/com/ruoyi/quartz/enums/MzGatherSlotEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,37 @@
package com.ruoyi.quartz.enums;
/**
 * çœç«‹åŒå¾·é—¨æ€¥è¯Šé‡‡é›†ï¼šæ–­ç‚¹æ§½ä½
 * <p>
 * æ¯å¤©ä¸€è½®å…¨é‡åˆ·æ–°ï¼ŒåŒæ—¶è¿˜æœ‰"昨天收尾"和"手工回溯"两个轮次,
 * æ¯è½®å„自可能被预算或接口失败打断,所以断点分槽位保存,互不覆盖
 *
 * @author smartor
 */
public enum MzGatherSlotEnum {
    /** ä»Šå¤©çš„æ•´è½®åˆ·æ–° */
    TODAY("1", "今天整轮刷新"),
    /** æ˜¨å¤©çš„æ”¶å°¾é‡‡é›†ï¼Œæ¯å¤©åªåšä¸€æ¬¡ï¼Œå…œä½ 23:xx åŽå†™å…¥ HIS çš„记录和跨零点数据 */
    FINAL("2", "昨日收尾采集"),
    /** ä»»åŠ¡å…¥å‚æŒ‡å®šæ—¥æœŸçš„ä¸€æ¬¡æ€§å›žæº¯ */
    BACKFILL("3", "手工回溯");
    private final String code;
    private final String desc;
    MzGatherSlotEnum(String code, String desc) {
        this.code = code;
        this.desc = desc;
    }
    public String getCode() {
        return code;
    }
    public String getDesc() {
        return desc;
    }
}
ruoyi-quartz/src/main/java/com/ruoyi/quartz/enums/MzGatherStatusEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
package com.ruoyi.quartz.enums;
/**
 * çœç«‹åŒå¾·é—¨æ€¥è¯Šé‡‡é›†ï¼šå•日采集结果
 * <p>
 * ç”¨äºŽ {@code RyTask#runDayPass} çš„返回值,告诉调用方这一天是采完了、预算用尽了,还是接口挂了
 *
 * @author smartor
 */
public enum MzGatherStatusEnum {
    /** è¯¥æ—¥æœŸå·²ä»Žç¬¬ä¸€é¡µé‡‡åˆ°æœ€åŽä¸€é¡µï¼Œæ•´è½®å®Œæˆ */
    DONE("0", "采集完成"),
    /** æœ¬è½®æ—¶é—´é¢„算用尽,页码断点已保存,下一轮从该页继续 */
    BUDGET_UP("1", "本轮预算用尽"),
    /** æŽ¥å£å±‚连续失败,本轮提前结束,页码断点保留在第一个失败页 */
    FAILED("2", "接口连续失败");
    private final String code;
    private final String desc;
    MzGatherStatusEnum(String code, String desc) {
        this.code = code;
        this.desc = desc;
    }
    public String getCode() {
        return code;
    }
    public String getDesc() {
        return desc;
    }
}
ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/RyTask.java
@@ -18,6 +18,10 @@
import com.ruoyi.common.utils.*;
import com.ruoyi.common.utils.http.HttpUtils;
import com.ruoyi.common.utils.sms.smsUtils;
import com.ruoyi.quartz.constant.MzGatherConstant;
import com.ruoyi.quartz.enums.MzGatherParamEnum;
import com.ruoyi.quartz.enums.MzGatherSlotEnum;
import com.ruoyi.quartz.enums.MzGatherStatusEnum;
import com.ruoyi.quartz.service.ICollectHISService;
import com.ruoyi.quartz.util.CreateDataTable;
import com.ruoyi.system.domain.SysConfig;
@@ -630,96 +634,395 @@
    }
    /**
     * HIS省立同德门急诊数据采集(这个只能一天执行一次,不能半个小时执行一个,数据量太大,影响性能)
     * å®šæ—¶ä»»åŠ¡ï¼šé—¨è¯Šæ•°æ®
     * todayZeroStr  å½“前的时间   ä¾‹å¦‚:2026-05-06 00:00:00
     * HIS省立同德门急诊数据采集(每半小时一轮,按天全量刷新 + åˆ†é¡µæ–­ç‚¹ç»­é‡‡ï¼‰
     * <p>
     * HIS ä¾§é—¨æ€¥è¯Šæ•°æ®åªèƒ½æŒ‰å¤©æŸ¥ï¼Œæ¯è½®æ‹‰åˆ°çš„都是当天全量(到晚上一万多条),这个取数成本改不了;
     * èƒ½æ”¹çš„æ˜¯å¤„理成本:当天数据里绝大多数上一轮已经入库,靠"整页批量查重 + å·²å…¥åº“直接跳过",
     * æ¯è½®çœŸæ­£éœ€è¦åšæ¡£æ¡ˆå¤„理和入库的只有新出现的那几百条,一万多条也能在很短时间内跑完。
     * <p>
     * æ¯ä¸€è½®åšä¸‰ä»¶äº‹ï¼š
     * <ol>
     *   <li>今天的整轮刷新:第 1 é¡µç¿»åˆ°æœ€åŽä¸€é¡µï¼Œåªå¤„理新记录;</li>
     *   <li>昨天的收尾采集:每天只做一次,兜住 23:xx ä¹‹åŽæ‰å†™å…¥ HIS çš„记录和跨零点的数据;</li>
     *   <li>手工回溯:任务入参给了日期时,把那天补采一次(同一天只补一次,不会每轮重复补)。</li>
     * </ol>
     * æ¯è½®æœ€å¤šè·‘ {@link MzGatherParamEnum#BUDGET_MINUTES}(默认 20 åˆ†é’Ÿï¼‰ï¼Œåˆ°ç‚¹ä¿å­˜é¡µç æ–­ç‚¹æ­£å¸¸é€€å‡ºï¼Œä¸‹ä¸€è½®ä»Žè¯¥é¡µç»§ç»­ï¼›
     * æ¯é‡‡å®Œä¸€é¡µéƒ½ä¼šè½ä¸€æ¬¡æ–­ç‚¹ï¼Œè¿›ç¨‹è¢«æ€ä¹Ÿä¸ä¼šä»Žå¤´é‡æ‰«ã€‚
     * <p>
     * å¤±è´¥ä¸€å¾‹ä¸é˜»æ–­é‡‡é›†è¿›åº¦ï¼š
     * <ul>
     *   <li>数据层失败(转换/档案/入库)落 pat_med_outhosp_error è¡¨äº¤äººå·¥å¤„理;</li>
     *   <li>接口层失败(网络超时、来未来返回非 200)没有数据可落表,只写 error æ—¥å¿—(含院区、日期、页码、接口入参),
     *       è·³è¿‡è¯¥é¡µç»§ç»­å¾€åŽé‡‡ï¼Œæœ¬è½®ç»“束时把断点留在第一个失败页;因为每轮都是整天从头全量刷新,
     *       è¢«è·³è¿‡çš„页下一轮必然重新采到,不会漏;</li>
     *   <li>连续 {@link MzGatherConstant#MAX_CONSECUTIVE_PAGE_FAIL} é¡µæŽ¥å£å¤±è´¥åˆ¤å®šä¸ºæŽ¥å£æ•´ä½“不可用,本轮提前结束(避免空转几十页),
     *       æ–­ç‚¹ç•™åœ¨ç¬¬ä¸€ä¸ªå¤±è´¥é¡µï¼Œä¸‹ä¸€è½®å†è¯•。</li>
     * </ul>
     *
     * @param todayZeroStr å¯é€‰ï¼Œæ‰‹å·¥æŒ‡å®šè¦ä¸€æ¬¡æ€§å›žæº¯çš„æ—¥æœŸï¼Œæ”¯æŒ yyyy-MM-dd HH:mm:ss æˆ– yyyy-MM-dd;为空时只刷新今天+收尾昨天
     */
    public void dealHisDataMZ(String todayZeroStr) {
        log.info("【dealHisData】门急诊数据开始执行HIS数据采集任务");
        // èŽ·å–æ˜¨å¤©0点到今天0点的时间范围
        LocalDateTime todayZero = LocalDateTime.now().with(LocalTime.MIN);
        if (StringUtils.isNotBlank(todayZeroStr)) {
            DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
            todayZero = LocalDateTime.parse(todayZeroStr, formatter);
        }
        LocalDateTime tomorrowdayZero = todayZero.plusDays(1);
        if (active.trim().equals("sltd") || active.trim().equals("nhfy")) {
            //省立同德与华南附一用同一套采集方法(都是来未来提供)
            try {
                //获取省立同德要采集的院区
                SysConfig cf = new SysConfig();
                cf.setConfigKey("sltd.gather.campusid");
                cf.setOrgid("20001001");
                List<SysConfig> sysConfigcampusids = configService.selectConfigList(cf);
                List<Long> campusidList = new ArrayList<>();
                if (CollectionUtils.isNotEmpty(sysConfigcampusids)) {
                    String[] split = sysConfigcampusids.get(0).getConfigValue().split(",");
                    campusidList = Arrays.stream(split).map(Long::valueOf).collect(Collectors.toList());
                } else {
                    log.error("SysConfig中的院区ID为空!");
        log.info("【dealHisDataMZ】门急诊数据采集开始,入参:{}", todayZeroStr);
        // å—华附一的先不采门急诊数据
        if (!"sltd".equals(active.trim())) {
            log.info("【dealHisDataMZ】当前环境 {} ä¸é‡‡é›†é—¨æ€¥è¯Šæ•°æ®ï¼Œè·³è¿‡", active.trim());
                    return;
                }
                // çœç«‹åŒå¾·æ•°æ®é‡‡é›†
                log.info("--campusidList的值为:{}", campusidList);
                for (Long cid : campusidList) {
                    String nowTime = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
                    ServiceSLTDInhospReqVO serviceSLTDInhospReqVO = new ServiceSLTDInhospReqVO();
                    serviceSLTDInhospReqVO.setOrgId("20001001");
                    serviceSLTDInhospReqVO.setCampusId(cid);
                    serviceSLTDInhospReqVO.setEncounterTimeStart(nowTime);
                    serviceSLTDInhospReqVO.setEncounterTimeEnd(nowTime);
        long budgetMinutes = getMzConfigLong(MzGatherParamEnum.BUDGET_MINUTES);
        long pageSize = getMzConfigLong(MzGatherParamEnum.PAGE_SIZE);
        long maxPage = getMzConfigLong(MzGatherParamEnum.MAX_PAGE);
                    // é—¨æ€¥è¯Šåˆ†é¡µé‡‡é›†ï¼šæ¯é¡µ40条,直到无数据为止(南华附一的先不采门急诊数据)
                    if (active.trim().equals("sltd")) {
                        log.info("【dealHisData】省立同德“门急诊”开始分页采集数据,院区:{}", cid);
                        serviceSLTDInhospReqVO.setStartHeadTime(null);
                        serviceSLTDInhospReqVO.setStartTailTime(null);
                        //查询当天的数据
                        serviceSLTDInhospReqVO.setEncounterTimeEnd(tomorrowdayZero.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
                        serviceSLTDInhospReqVO.setEncounterTimeStart(todayZero.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
                        serviceSLTDInhospReqVO.setStatusList(null);
                        List<String> list1 = new ArrayList<>();
                        list1.add("FH0108.01");
                        list1.add("FH0108.03");
                        serviceSLTDInhospReqVO.setHealthcareRecordTypeList(list1);
                        //从缓存里获取起始页(零点前10分钟内用昨天的日期,避免跨天漏采)
                        LocalDateTime now = LocalDateTime.now();
                        String mzCacheDate = (now.getHour() == 0 && now.getMinute() < 10) ? now.minusDays(1).format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) : now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
                        Long outpPage = redisCache.getCacheObject("mzCurrent" + cid + mzCacheDate);
                        if (outpPage == null) outpPage = 1L;
                        //那边暂时只能设置40
                        final long OUTP_PAGE_SIZE = 500L;
        LocalDateTime now = LocalDateTime.now().withNano(0);
        LocalDateTime deadline = now.plusMinutes(budgetMinutes);
        // èŽ·å–çœç«‹åŒå¾·è¦é‡‡é›†çš„é™¢åŒº
        SysConfig cf = new SysConfig();
        cf.setConfigKey("sltd.gather.campusid");
        cf.setOrgid(MzGatherConstant.ORG_ID);
        List<SysConfig> sysConfigcampusids = configService.selectConfigList(cf);
        if (CollectionUtils.isEmpty(sysConfigcampusids) || StringUtils.isBlank(sysConfigcampusids.get(0).getConfigValue())) {
            log.error("【dealHisDataMZ】SysConfig中的院区ID为空!");
            return;
        }
        List<Long> campusidList = Arrays.stream(sysConfigcampusids.get(0).getConfigValue().split(","))
                .map(String::trim).filter(StringUtils::isNotBlank).map(Long::valueOf).collect(Collectors.toList());
        log.info("【dealHisDataMZ】院区:{},单轮预算:{}分钟,每页:{}条", campusidList, budgetMinutes, pageSize);
        for (Long cid : campusidList) {
            if (LocalDateTime.now().isAfter(deadline)) {
                log.warn("【dealHisDataMZ】本轮时间预算已用尽,剩余院区留到下一轮继续,院区:{}", cid);
                break;
            }
            // é˜²æ­¢ä¸Šä¸€è½®è¿˜æ²¡è·‘完就被下一轮叠着跑(预算配置过大、或手工触发时兜底)
            String lockKey = "mzRunning:" + cid;
            if (Boolean.TRUE.equals(redisCache.hasKey(lockKey))) {
                log.warn("【dealHisDataMZ】该院区上一轮采集尚未结束,本轮跳过,院区:{}(断点已保留,下一轮继续)", cid);
                continue;
            }
            redisCache.setCacheObject(lockKey, MzGatherConstant.MINUTE_FMT.format(now), budgetMinutes * 2, TimeUnit.MINUTES);
            try {
                collectOutpatientDaily(cid, todayZeroStr, pageSize, maxPage, deadline);
            } catch (Exception e) {
                log.error("【dealHisDataMZ】门急诊采集异常,院区:{},断点已保留,下一轮从断点继续", cid, e);
            } finally {
                redisCache.deleteObject(lockKey);
            }
        }
        log.info("【dealHisDataMZ】门急诊采集本轮结束,耗时:{}秒",
                java.time.Duration.between(now, LocalDateTime.now()).getSeconds());
    }
    /**
     * é‡‡é›†æŸä¸ªé™¢åŒºï¼šå…ˆè¡¥ä¸€æ¬¡æ€§å›žæº¯æ—¥æœŸï¼Œå†åšæ˜¨å¤©çš„æ”¶å°¾é‡‡é›†ï¼Œæœ€åŽæ•´è½®åˆ·æ–°ä»Šå¤©
     */
    private void collectOutpatientDaily(Long cid, String backfillParam, long pageSize, long maxPage,
                                        LocalDateTime deadline) {
        String cursorKey = "mzDayCursor:" + cid;
        MzDayCursor cursor = loadMzDayCursor(cursorKey);
        LocalDate today = LocalDate.now();
        LocalDate yesterday = today.minusDays(1);
        // æ‰‹å·¥å…¥å‚指定的一次性回溯:同一个日期只补一次,避免定时任务每轮重复补
        LocalDateTime backfillTime = parseMzStartTime(backfillParam);
        if (backfillTime != null) {
            LocalDate backfillDay = backfillTime.toLocalDate();
            if (backfillDay.toString().equals(cursor.backfillDone)) {
                log.info("【dealHisDataMZ】入参日期 {} å·²å®Œæˆå›žæº¯ï¼Œè·³è¿‡ï¼Œé™¢åŒºï¼š{}", backfillDay, cid);
            } else if (backfillDay.equals(today)) {
                // å…¥å‚就是今天,交给下面的整轮刷新处理即可
                cursor.backfillDone = backfillDay.toString();
                saveMzDayCursor(cursorKey, cursor);
            } else {
                cursor.backfillDay = backfillDay.toString();
                cursor.backfillPage = 1L;
                saveMzDayCursor(cursorKey, cursor);
                log.info("【dealHisDataMZ】待回溯日期:{},院区:{}", backfillDay, cid);
            }
        }
        // 1) ä¸€æ¬¡æ€§å›žæº¯
        if (cursor.backfillDay != null) {
            LocalDate day = LocalDate.parse(cursor.backfillDay);
            MzGatherStatusEnum status = runDayPass(cid, cursorKey, cursor, MzGatherSlotEnum.BACKFILL, day,
                    cursor.backfillPage, pageSize, maxPage, deadline);
            if (status != MzGatherStatusEnum.DONE) {
                log.warn("【dealHisDataMZ】回溯日期 {} æœ¬è½®æœªå®Œæˆï¼ˆ{}),断点已保留,院区:{}", day, status.getDesc(), cid);
                return;
            }
            cursor.backfillDone = cursor.backfillDay;
            cursor.backfillDay = null;
            cursor.backfillPage = 1L;
            saveMzDayCursor(cursorKey, cursor);
        }
        // 2) æ˜¨å¤©æ”¶å°¾é‡‡é›†ï¼šæ¯å¤©ä¸€æ¬¡ï¼Œå…œä½ 23:xx ä¹‹åŽæ‰å†™è¿› HIS çš„记录和跨零点的数据
        if (!yesterday.toString().equals(cursor.finalDay)) {
            MzGatherStatusEnum status = runDayPass(cid, cursorKey, cursor, MzGatherSlotEnum.FINAL, yesterday,
                    cursor.finalPage, pageSize, maxPage, deadline);
            if (status != MzGatherStatusEnum.DONE) {
                log.warn("【dealHisDataMZ】昨日收尾采集 {} æœ¬è½®æœªå®Œæˆï¼ˆ{}),断点已保留,院区:{}", yesterday, status.getDesc(), cid);
                return;
            }
            cursor.finalDay = yesterday.toString();
            cursor.finalPage = 1L;
            saveMzDayCursor(cursorKey, cursor);
        }
        // 3) ä»Šå¤©çš„æ•´è½®åˆ·æ–°
        if (!today.toString().equals(cursor.day)) {
            cursor.day = today.toString();
            cursor.page = 1L;
            saveMzDayCursor(cursorKey, cursor);
        }
        MzGatherStatusEnum status = runDayPass(cid, cursorKey, cursor, MzGatherSlotEnum.TODAY, today,
                cursor.page, pageSize, maxPage, deadline);
        if (status == MzGatherStatusEnum.DONE) {
            // æ•´è½®åˆ·æ–°å®Œæˆï¼Œé¡µç å½’位,下一轮重新从第一页整轮刷新
            cursor.page = 1L;
            saveMzDayCursor(cursorKey, cursor);
            log.info("【dealHisDataMZ】{} é—¨æ€¥è¯Šæ•´è½®åˆ·æ–°å®Œæˆï¼Œé™¢åŒºï¼š{}", today, cid);
        }
    }
    /**
     * å…¨é‡é‡‡é›†æŸä¸€å¤©ï¼ˆåˆ†é¡µç›´åˆ°é‡‡å®Œï¼‰ï¼Œæ¯é‡‡å®Œä¸€é¡µç«‹å³è½ä¸€æ¬¡æ–­ç‚¹
     *
     * @param slot æ–­ç‚¹æ§½ä½ï¼ˆä»Šå¤©æ•´è½®åˆ·æ–° / æ˜¨æ—¥æ”¶å°¾ / æ‰‹å·¥å›žæº¯ï¼‰ï¼Œå†³å®šä¸­æ–­é¡µç è®°åˆ°å“ªä¸ªå­—段
     * @return {@link MzGatherStatusEnum} é‡‡é›†å®Œæˆ / é¢„算用尽 / æŽ¥å£è¿žç»­å¤±è´¥æå‰ç»“束
     */
    private MzGatherStatusEnum runDayPass(Long cid, String cursorKey, MzDayCursor cursor, MzGatherSlotEnum slot,
                                          LocalDate day, long startPage, long pageSize, long maxPage,
                                          LocalDateTime deadline) {
        String startStr = day.format(MzGatherConstant.DAY_FMT);
        String endStr = day.plusDays(1).format(MzGatherConstant.DAY_FMT);
        ServiceSLTDInhospReqVO reqVO = new ServiceSLTDInhospReqVO();
        reqVO.setOrgId(MzGatherConstant.ORG_ID);
        reqVO.setCampusId(cid);
        reqVO.setStartHeadTime(null);
        reqVO.setStartTailTime(null);
        reqVO.setStatusList(null);
        // æŽ¥å£åªæ”¯æŒæŒ‰å¤©æŸ¥ï¼Œèµ·æ­¢éƒ½ä¼ æ—¥æœŸ
        reqVO.setEncounterTimeStart(startStr);
        reqVO.setEncounterTimeEnd(endStr);
        List<String> typeList = new ArrayList<>();
        typeList.add("FH0108.01"); // é—¨è¯Š
        typeList.add("FH0108.03"); // æ€¥è¯Š
        reqVO.setHealthcareRecordTypeList(typeList);
        reqVO.setSize(pageSize);
        long page = startPage < 1L ? 1L : startPage;
        long total = 0L;
        long pageCount = 0L;
        // æŽ¥å£å±‚失败跳过的页数、第一个失败页、连续失败页数
        long skippedPages = 0L;
        long firstFailPage = -1L;
        int consecutiveFail = 0;
                        while (true) {
                            serviceSLTDInhospReqVO.setCurrent(outpPage);
                            serviceSLTDInhospReqVO.setSize(OUTP_PAGE_SIZE);
                            log.info("【dealHisData】省立同德“门急诊”采集第{}页数据,院区:{}", outpPage, cid);
                            List<ServiceSLTDInhospResDTO> outpResult = serviceSLTDHealthcareRecordService.queryHealthcareRecordList(serviceSLTDInhospReqVO);
                            if (CollectionUtils.isEmpty(outpResult)) {
                                log.info("【dealHisData】省立同德“门急诊”第{}页无数据,采集结束,院区:{}", outpPage, cid);
            if (LocalDateTime.now().isAfter(deadline)) {
                // æœ¬è½®æœ‰è·³è¿‡çš„页时,断点回到第一个失败页,保证下一轮先把这些页补回来
                long resumePage = (skippedPages > 0 && firstFailPage > 0) ? firstFailPage : page;
                saveMzDayPage(cursorKey, cursor, slot, resumePage);
                log.warn("【dealHisDataMZ】本轮时间预算用尽,{} æœ¬è½®å·²é‡‡{}页{}条{},下次从第{}页继续,院区:{}",
                        day, pageCount, total, skippedLog(skippedPages), resumePage, cid);
                return MzGatherStatusEnum.BUDGET_UP;
            }
            reqVO.setCurrent(page);
            log.info("【dealHisDataMZ】采集门急诊数据,院区:{},日期:{},第{}页", cid, day, page);
            List<ServiceSLTDInhospResDTO> pageData = null;
            Exception lastError = null;
            for (int attempt = 1; attempt <= MzGatherConstant.PAGE_RETRY_TIMES; attempt++) {
                try {
                    pageData = serviceSLTDHealthcareRecordService.queryHealthcareRecordList(reqVO);
                    lastError = null;
                    break;
                } catch (Exception e) {
                    lastError = e;
                    log.error("【dealHisDataMZ】门急诊第{}页第{}次采集失败,院区:{},日期:{}", page, attempt, cid, day, e);
                    if (attempt < MzGatherConstant.PAGE_RETRY_TIMES) {
                        try {
                            Thread.sleep(MzGatherConstant.PAGE_RETRY_SLEEP_MS);
                        } catch (InterruptedException ie) {
                            Thread.currentThread().interrupt();
                                break;
                            }
                            log.info("【dealHisData】省立同德“门急诊”第{}页采集{}条,院区:{}", outpPage, outpResult.size(), cid);
                            if (outpResult.size() < OUTP_PAGE_SIZE) {
                                //设置一个缓存,用来存在下次的起始页
                                redisCache.setCacheObject("mzCurrent" + cid + mzCacheDate, outpPage, 24, TimeUnit.HOURS);
                                log.info("【dealHisData】省立同德“门急诊”的缓存key:{}下次的起始页{},院区:{}", "mzCurrent" + cid + mzCacheDate, outpPage, cid);
                                break;
                            }
                            outpPage++;
                        }
                        // æ¸…除分页参数,避免影响后续出入院查询
                        serviceSLTDInhospReqVO.setCurrent(null);
                        serviceSLTDInhospReqVO.setSize(null);
                        log.info("【dealHisData】省立同德“门急诊”分页采集完成,院区:{}", cid);
                    }
            if (lastError != null) {
                // æŽ¥å£å±‚失败(网络超时 / æ¥æœªæ¥è¿”回非200)没有数据可以落表,只写 error æ—¥å¿—,然后跳过该页继续往后采,不阻断进度。
                // æ¯è½®éƒ½æ˜¯æ•´å¤©ä»Žå¤´å…¨é‡åˆ·æ–°ï¼Œè¢«è·³è¿‡çš„页下一轮必然重新采到,所以不会漏数据。
                skippedPages++;
                if (firstFailPage < 0) {
                    firstFailPage = page;
                }
                consecutiveFail++;
                log.error("【dealHisDataMZ】接口层采集失败,已记 error æ—¥å¿—并跳过该页,本轮继续往后采;"
                                + "院区:{},日期:{},第{}页,接口入参:{},异常:{}",
                        cid, day, page, new Gson().toJson(reqVO), lastError.getMessage(), lastError);
                if (consecutiveFail >= MzGatherConstant.MAX_CONSECUTIVE_PAGE_FAIL) {
                    // è¿žç»­å¤šé¡µæŽ¥å£å¤±è´¥ï¼Œåˆ¤å®šæ¥æœªæ¥æŽ¥å£æ•´ä½“不可用,本轮提前结束(不再空转后面几十页),断点留在第一个失败页
                    log.error("【dealHisDataMZ】连续{}页接口失败,判定来未来接口不可用,本轮提前结束;院区:{},日期:{},断点保留在第{}页",
                            consecutiveFail, cid, day, firstFailPage);
                    saveMzDayPage(cursorKey, cursor, slot, firstFailPage);
                    return MzGatherStatusEnum.FAILED;
                }
                page++;
                continue;
            }
            consecutiveFail = 0;
            if (CollectionUtils.isEmpty(pageData)) {
                log.info("【dealHisDataMZ】{} ç¬¬{}页无数据,采集结束,院区:{},本轮{}页共{}条{}",
                        day, page, cid, pageCount, total, skippedLog(skippedPages));
                return MzGatherStatusEnum.DONE;
            }
            pageCount++;
            total += pageData.size();
            log.info("【dealHisDataMZ】{} ç¬¬{}页采集{}条,院区:{}", day, page, pageData.size(), cid);
            if (pageData.size() < pageSize) {
                log.info("【dealHisDataMZ】{} é‡‡é›†ç»“束,院区:{},本轮{}页共{}条{}", day, cid, pageCount, total, skippedLog(skippedPages));
                return MzGatherStatusEnum.DONE;
            }
            page++;
            if (page > maxPage) {
                log.error("【dealHisDataMZ】{} å·²ç¿»åˆ°ç¬¬{}页,超过单日上限{}页,按采完处理,院区:{}", day, page, maxPage, cid);
                return MzGatherStatusEnum.DONE;
            }
            // æ¯é‡‡å®Œä¸€é¡µç«‹å³è½æ–­ç‚¹ï¼šè¿›ç¨‹è¢«é‡å¯/强杀也能从这一页继续,不从头重扫,也不会漏
            saveMzDayPage(cursorKey, cursor, slot, page);
        }
    }
    /** æŒ‰æ§½ä½ä¿å­˜ä¸­æ–­é¡µç  */
    private void saveMzDayPage(String cursorKey, MzDayCursor cursor, MzGatherSlotEnum slot, long page) {
        if (slot == MzGatherSlotEnum.FINAL) {
            cursor.finalPage = page;
        } else if (slot == MzGatherSlotEnum.BACKFILL) {
            cursor.backfillPage = page;
        } else {
            cursor.page = page;
        }
        saveMzDayCursor(cursorKey, cursor);
    }
    /** æ—¥å¿—里提示接口层失败跳过了几页(下一轮整轮刷新会重新采到) */
    private String skippedLog(long skippedPages) {
        return skippedPages > 0L ? ",接口失败跳过" + skippedPages + "页(下一轮整轮刷新会重新采到)" : "";
    }
    /** è¯»å– sys_config é‡Œçš„门急诊采集参数(机构 orgid=20001001),取不到或超范围时用枚举里的默认值 */
    private long getMzConfigLong(MzGatherParamEnum param) {
        try {
            SysConfig config = new SysConfig();
            config.setConfigKey(param.getKey());
            config.setOrgid(MzGatherConstant.ORG_ID);
            List<SysConfig> configs = configService.selectConfigList(config);
            if (CollectionUtils.isNotEmpty(configs) && StringUtils.isNotBlank(configs.get(0).getConfigValue())) {
                long value = Long.parseLong(configs.get(0).getConfigValue().trim());
                if (value >= param.getMin() && value <= param.getMax()) {
                    return value;
                }
                log.warn("【dealHisDataMZ】配置{}的值{}超出范围[{},{}],使用默认值{}",
                        param.getKey(), value, param.getMin(), param.getMax(), param.getDefaultValue());
                }
            } catch (Exception e) {
                log.error("【dealHisData】省立同德数据门急诊采集异常", e);
            log.warn("【dealHisDataMZ】读取配置{}失败,使用默认值{}", param.getKey(), param.getDefaultValue(), e);
        }
        return param.getDefaultValue();
    }
    /** è§£æžä»»åŠ¡å…¥å‚ï¼ˆè¦ä¸€æ¬¡æ€§å›žæº¯çš„æ—¥æœŸï¼‰ï¼Œæ”¯æŒ yyyy-MM-dd HH:mm:ss ä¸Ž yyyy-MM-dd */
    private LocalDateTime parseMzStartTime(String text) {
        LocalDateTime time = parseMzTimeQuiet(text);
        if (time == null && StringUtils.isNotBlank(text)) {
            log.warn("【dealHisDataMZ】入参 {} æ— æ³•解析为日期,按未指定处理", text);
        }
        return time;
    }
    /** é™é»˜è§£æžæ—¥æœŸï¼Œæ”¯æŒ yyyy-MM-dd HH:mm:ss ä¸Ž yyyy-MM-dd,格式不符返回 null(不刷日志) */
    private LocalDateTime parseMzTimeQuiet(String text) {
        if (StringUtils.isBlank(text)) {
            return null;
        }
        String value = text.trim();
        try {
            return LocalDateTime.parse(value, MzGatherConstant.MINUTE_FMT);
        } catch (Exception ignore) {
            // å…¼å®¹åªä¼ æ—¥æœŸçš„æƒ…况,落到下面按日期解析
        }
        try {
            return LocalDate.parse(value).atStartOfDay();
        } catch (Exception ignore) {
            return null;
            }
        }
    /** ä¿å­˜é—¨æ€¥è¯Šé‡‡é›†æ–­ç‚¹ï¼ˆå„轮次中断的页码) */
    private void saveMzDayCursor(String cursorKey, MzDayCursor cursor) {
        JSONObject json = new JSONObject();
        json.put("day", cursor.day);
        json.put("page", cursor.page);
        json.put("finalDay", cursor.finalDay);
        json.put("finalPage", cursor.finalPage);
        json.put("backfillDay", cursor.backfillDay);
        json.put("backfillPage", cursor.backfillPage);
        json.put("backfillDone", cursor.backfillDone);
        json.put("updateTime", MzGatherConstant.MINUTE_FMT.format(LocalDateTime.now().withNano(0)));
        redisCache.setCacheObject(cursorKey, json.toJSONString(), MzGatherConstant.CACHE_TTL_HOURS, TimeUnit.HOURS);
    }
    /** è¯»å–门急诊采集断点,读不到或解析失败时返回空断点(等价于从第一页开始整轮刷新) */
    private MzDayCursor loadMzDayCursor(String cursorKey) {
        MzDayCursor cursor = new MzDayCursor();
        try {
            String json = redisCache.getCacheObject(cursorKey);
            if (StringUtils.isBlank(json)) {
                return cursor;
            }
            JSONObject obj = JSONObject.parseObject(json);
            cursor.day = obj.getString("day");
            cursor.page = obj.getLongValue("page");
            cursor.finalDay = obj.getString("finalDay");
            cursor.finalPage = obj.getLongValue("finalPage");
            cursor.backfillDay = obj.getString("backfillDay");
            cursor.backfillPage = obj.getLongValue("backfillPage");
            cursor.backfillDone = obj.getString("backfillDone");
            if (cursor.page < 1L) {
                cursor.page = 1L;
            }
            if (cursor.finalPage < 1L) {
                cursor.finalPage = 1L;
            }
            if (cursor.backfillPage < 1L) {
                cursor.backfillPage = 1L;
            }
            return cursor;
        } catch (Exception e) {
            log.warn("【dealHisDataMZ】读取断点{}失败,按空断点处理", cursorKey, e);
            return new MzDayCursor();
        }
    }
    /**
     * é—¨æ€¥è¯Šé‡‡é›†æ–­ç‚¹ï¼šæ¯å¤©ä¸€è½®å…¨é‡åˆ·æ–°ï¼Œè¿™é‡Œåˆ†åˆ«è®°å½•三个轮次中断在第几页
     * ï¼ˆé¡µç ä¸º 1 è¡¨ç¤ºè¯¥è½®æ¬¡ä¸‹ä¸€è½®ä»Žç¬¬ä¸€é¡µæ•´è½®å¼€å§‹ï¼‰
     */
    private static class MzDayCursor {
        /** ä»Šå¤©çš„æ•´è½®åˆ·æ–° */
        private String day;
        private long page = 1L;
        /** æ˜¨å¤©çš„æ”¶å°¾é‡‡é›† */
        private String finalDay;
        private long finalPage = 1L;
        /** å¾…做的一次性回溯 */
        private String backfillDay;
        private long backfillPage = 1L;
        /** å·²ç»è¡¥è¿‡çš„回溯日期,避免定时任务每轮重复补同一天 */
        private String backfillDone;
    }
    /**
smartor/src/main/java/com/smartor/domain/DTO/DetailTraceDealDTO.java
@@ -37,7 +37,12 @@
    @Excel(name = "问题ID")
    private Long scriptid;
    /**
     * é—®é¢˜ID
     */
    @ApiModelProperty("问题ID集合")
    @Excel(name = "问题ID集合")
    private List<Long> scriptids;
    /**
     * æ¨¡æ¿ç±»åž‹ï¼š1.语音 2.问卷
smartor/src/main/java/com/smartor/domain/ServiceSLTDInhospReqVO.java
@@ -68,10 +68,10 @@
    @ApiModelProperty(value = "出院时间-结束时间 yyyy-MM-dd")
    private String preOutHospitalTailDate;
    @ApiModelProperty(value = "就诊时间-开始时间 :   é—¨è¯Šè¯Šç–—记录为号源时间  ä½é™¢è¯Šç–—记录为入院登记时间 yyyy-MM-dd")
    @ApiModelProperty(value = "就诊时间-开始时间 :   é—¨è¯Šè¯Šç–—记录为号源时间  ä½é™¢è¯Šç–—记录为入院登记时间 yyyy-MM-dd æˆ– yyyy-MM-dd HH:mm:ss(门急诊增量采集精确到秒)")
    private String encounterTimeStart;
    @ApiModelProperty(value = "就诊时间-结束时间 yyyy-MM-dd")
    @ApiModelProperty(value = "就诊时间-结束时间 yyyy-MM-dd æˆ– yyyy-MM-dd HH:mm:ss(门急诊增量采集精确到秒)")
    private String encounterTimeEnd;
    @ApiModelProperty(value = "出院结算时间-开始时间 yyyy-MM-dd")
smartor/src/main/java/com/smartor/domain/SvyLibTemplateCategory.java
@@ -66,5 +66,11 @@
    @ApiModelProperty(value = "医院机构id")
    private Integer seqno;
    /**
     * é—®é¢˜ç±»åž‹ï¼ˆä¾‹å¦‚,门诊满意度:mzmyd     ä½é™¢æ»¡æ„åº¦ï¼šzymyd  ...)
     */
    @Excel(name = "问题类型(例如,门诊满意度:mzmyd     ä½é™¢æ»¡æ„åº¦ï¼šzymyd  ...)")
    @ApiModelProperty("问题类型(例如,门诊满意度:mzmyd     ä½é™¢æ»¡æ„åº¦ï¼šzymyd  ...)")
    private String type;
}
smartor/src/main/java/com/smartor/mapper/PatMedOuthospMapper.java
@@ -1,5 +1,6 @@
package com.smartor.mapper;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@@ -90,6 +91,15 @@
    // æŸ¥è¯¢æ‰€æœ‰ pat_med_outhosp å¼€å¤´çš„表名
    List<String> getAllOuthospTableNames();
    /**
     * æ‰¹é‡æŸ¥è¯¢æŒ‡å®šåˆ†è¡¨ä¸­å·²å­˜åœ¨çš„ serialnum(一次 SQL ä»£æ›¿é€æ¡ countBySerialnum)
     *
     * @param tableName  åˆ†è¡¨å
     * @param serialnums å¾…查询的诊疗记录号
     * @return å·²å­˜åœ¨çš„ serialnum é›†åˆ
     */
    List<String> selectExistingSerialnums(@Param("tableName") String tableName, @Param("serialnums") Collection<String> serialnums);
    // æŸ¥è¯¢æŒ‡å®šè¡¨çš„字段列表(按 ordinal_position æŽ’序)
    List<String> getTableColumns(@Param("tableName") String tableName);
smartor/src/main/java/com/smartor/mapper/ServiceSubtaskDetailMapper.java
@@ -51,6 +51,29 @@
     */
    public List<Map<String, Object>> selectSatisfactionFillStatByMatchedtext(SatisfactionStatQuery query);
    /**
     * æ»¡æ„åº¦æŸ±çŠ¶å›¾ï¼šä¸€æ¬¡æ€§ç»Ÿè®¡æœ¬æ¬¡è¯·æ±‚å…¨éƒ¨æ¨¡æ¿çš„ã€Œå›žæ”¶äººæ•°ã€
     * ï¼ˆæŒ‰ libtemplateid åˆ†ç»„,count(distinct sub_id):同一个人答了多道题只算一人)
     * <p>
     * satisfactionGraph / satisfactionGraphDimension çš„回收人数必须是人数口径,
     * ä¸èƒ½åƒ selectSatisfactionFillStat é‚£æ ·æŒ‰é¢˜ç›®æ±‡æ€»ç­”案行数(那是题量口径)
     *
     * @param query èšåˆæŸ¥è¯¢æ¡ä»¶
     * @return æ¯è¡Œå« libtemplateid、cnt(该模板已回收的份数/人数)
     */
    public List<Map<String, Object>> selectSatisfactionFillPersonCount(SatisfactionStatQuery query);
    /**
     * æŒ‰ scriptid æ‰¹é‡ç»Ÿè®¡å¡«å†™è®°å½•数(一次 SQL æ›¿ä»£é€ä¸ª scriptid æŸ¥æ˜Žç»†ï¼‰
     * <p>
     * å£å¾„与原来「query.setScriptid(scriptId) åŽ selectServiceSubtaskDetailList(...).size()」一致:
     * åªæŒ‰ scriptid è¿‡æ»¤ï¼Œä¸å¸¦ del_flag(原 SQL é‡Œå¹¶æ²¡æœ‰ del_flag æ¡ä»¶ï¼‰ã€‚
     *
     * @param scriptIds é—®é¢˜ID集合
     * @return æ¯è¡Œå« scriptid、cnt
     */
    public List<Map<String, Object>> countDetailByScriptIds(@Param("scriptIds") List<Long> scriptIds);
    public ServiceSubtaskDetailDTO isExistServiceSubtaskDetail(@Param("subid") Long subid, @Param("scriptid") Long scriptid);
    /**
smartor/src/main/java/com/smartor/mapper/ServiceSubtaskDetailTraceMapper.java
@@ -1,10 +1,12 @@
package com.smartor.mapper;
import java.util.List;
import java.util.Map;
import com.smartor.domain.VO.DetailTraceDealVO;
import com.smartor.domain.VO.ServiceSubtaskDetailTraceVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import com.smartor.domain.ServiceSubtaskDetailTrace;
/**
@@ -32,7 +34,39 @@
    public List<ServiceSubtaskDetailTrace> selectServiceSubtaskDetailTtraceList(ServiceSubtaskDetailTraceVO ServiceSubtaskDetailTtrace);
    public List<ServiceSubtaskDetailTrace> tracedeallist(DetailTraceDealVO detailTraceDealVO);
    /**
     * å¼‚常问题处理情况查询:当前页的问题分组聚合(一个 SQL æ‹¿åˆ°æœ¬é¡µæ¯ä¸ªé—®é¢˜çš„异常数、已处理/未处理数)
     * <p>
     * åˆ†é¡µæŒ‰ã€Œé—®é¢˜ã€ç»´åº¦ä¸‹æŽ¨ï¼Œä¸å†æŠŠæ•´è¡¨ trace æ˜Žç»†æ‹‰å›ž Java å†åˆ†ç»„
     *
     * @param vo æŸ¥è¯¢æ¡ä»¶ï¼ˆpageNum ä¸º offset,与 PageUtils.getOffset ä¸€è‡´ï¼‰
     * @return æ¯è¡Œå« questiontext、scriptid、templateType、exceptionFillNum、yesDeal、noDeal
     */
    public List<Map<String, Object>> selectTraceDealGroups(@Param("vo") DetailTraceDealVO vo);
    /**
     * å¼‚常问题处理情况查询:问题总数(分页 total,按 questiontext åŽ»é‡ï¼‰
     */
    public long countTraceDealQuestions(@Param("vo") DetailTraceDealVO vo);
    /**
     * å¼‚常问题处理情况查询:全量汇总(不受分页影响)
     *
     * @return totalException å¼‚常总数、yesDeal å·²å¤„理数
     */
    public Map<String, Object> sumTraceDeal(@Param("vo") DetailTraceDealVO vo);
    /**
     * å¼‚常问题处理情况查询:当前页问题的负责科室(按 todeptcode åŽ»é‡ï¼‰
     */
    public List<Map<String, Object>> selectTraceDealDepts(@Param("vo") DetailTraceDealVO vo,
                                                         @Param("questiontexts") List<String> questiontexts);
    /**
     * å¼‚常问题处理情况查询:当前页问题的最新处理人和处理时间(handle_time æœ€å¤§çš„一条)
     */
    public List<Map<String, Object>> selectTraceDealLatestHandle(@Param("vo") DetailTraceDealVO vo,
                                                                @Param("questiontexts") List<String> questiontexts);
    /**
     * æ–°å¢žéšè®¿ä»»åŠ¡å¼‚å¸¸è®°å½•è·Ÿè¸ª
smartor/src/main/java/com/smartor/service/IPatMedOuthospService.java
@@ -1,7 +1,9 @@
package com.smartor.service;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.Set;
import com.ruoyi.common.core.page.TableDataInfo;
import com.smartor.domain.*;
@@ -39,6 +41,18 @@
    public int insertPatMedOuthosp(PatMedOuthosp patMedOuthosp);
    /**
     * æ‰¹é‡æŸ¥è¯¢å·²å…¥åº“çš„ serialnum(内部按就诊日期定位分表,一次 SQL å®Œæˆï¼‰
     * <p>
     * é—¨æ€¥è¯ŠæŒ‰å¤©å…¨é‡é‡‡é›†æ—¶ï¼Œæ¯åŠå°æ—¶ä¼šæŠŠå½“天上万个诊疗记录重新拉一遍,
     * ç”¨è¿™ä¸ªæ–¹æ³•一次查出已经入库的,直接跳过,避免对重复数据再做一遍档案处理和入库
     *
     * @param admitDateStr å°±è¯Šæ—¥æœŸ yyyy-MM-dd,为空或解析失败时按主表查询
     * @param serialnums   å¾…查询的诊疗记录号集合
     * @return å·²å­˜åœ¨çš„ serialnum é›†åˆ
     */
    public Set<String> selectExistingSerialnums(String admitDateStr, Collection<String> serialnums);
    /**
     * ä¿®æ”¹æ‚£è€…门诊记录
     *
     * @param patMedOuthosp æ‚£è€…门诊记录
smartor/src/main/java/com/smartor/service/impl/PatMedOuthospServiceImpl.java
@@ -197,7 +197,48 @@
        patMedOuthosp.setUpdateTime(DateUtils.getNowDate());
        patMedOuthosp.setGuid(UUID.randomUUID().toString());
        log.info("[insert] è·¯ç”±åˆ°åˆ†è¡¨: {}", targetTable);
        try {
        return patMedOuthospMapper.insertIntoTable(targetTable, patMedOuthosp);
        } catch (org.springframework.dao.DuplicateKeyException e) {
            // åŠ äº† serialnum å”¯ä¸€ç´¢å¼•后,极端并发或重复采集会被数据库直接拦下,
            // è¿™å±žäºŽ"记录已存在",按正常跳过处理(返回0),不要当成入库失败写进错误表
            log.info("[insert] serialnum={} å·²å­˜åœ¨äºŽè¡¨ {}(唯一索引拦截),跳过插入", patMedOuthosp.getSerialnum(), targetTable);
            return 0;
        }
    }
    /**
     * æ‰¹é‡æŸ¥è¯¢å·²å…¥åº“çš„ serialnum:按就诊日期定位分表,一次 IN æŸ¥è¯¢æŸ¥å‡ºæ•´é¡µå·²å­˜åœ¨çš„记录
     * <p>
     * åŽŸæ¥æ¯æ¡è®°å½•éƒ½è¦ countBySerialnum ä¸€æ¬¡ï¼Œä¸€é¡µ 500 æ¡å°±æ˜¯ 500 æ¬¡ SQL;
     * é—¨æ€¥è¯ŠæŒ‰å¤©å…¨é‡é‡‡é›†æ—¶æ¯å¤©è¦è¿™ä¹ˆè·‘ 48 è½®ï¼Œè¿™é‡Œåˆå¹¶æˆ 1 æ¬¡æŸ¥è¯¢
     */
    @Override
    public Set<String> selectExistingSerialnums(String admitDateStr, Collection<String> serialnums) {
        Set<String> existing = new HashSet<>();
        if (CollectionUtils.isEmpty(serialnums)) {
            return existing;
        }
        Date admitDate = null;
        if (StringUtils.isNotBlank(admitDateStr)) {
            try {
                admitDate = new java.text.SimpleDateFormat("yyyy-MM-dd").parse(admitDateStr);
            } catch (java.text.ParseException e) {
                log.warn("[selectExistingSerialnums] å°±è¯Šæ—¥æœŸè§£æžå¤±è´¥ï¼š{},按主表查询", admitDateStr);
            }
        }
        // ä¸Žæ’入时的路由规则保持一致,保证查重查的就是要写入的那张表
        String targetTable = admitDate == null ? null : resolveTargetTable(admitDate);
        if (targetTable == null) {
            targetTable = "pat_med_outhosp";
        }
        List<String> existed = patMedOuthospMapper.selectExistingSerialnums(targetTable, serialnums);
        if (!CollectionUtils.isEmpty(existed)) {
            existing.addAll(existed);
        }
        log.info("[selectExistingSerialnums] æ—¥æœŸ={},分表={},待查{}条,已存在{}条",
                admitDateStr, targetTable, serialnums.size(), existing.size());
        return existing;
    }
    /**
smartor/src/main/java/com/smartor/service/impl/PatSatisfactionServiceImpl.java
@@ -326,31 +326,40 @@
            //2 å†èŽ·å–è¯¥é—®é¢˜æ»¡æ„åº¦åˆ†ç±»ä¸‹çš„IvrLibaTemplateScript的集合
            List<IvrLibaTemplateScript> ivrLibaTemplateScripts = ivrLibaTemplateScriptMapper.queryLibTemplateIds(mydIdList);
            //2.1 ä¸€æ¬¡æ€§æŠŠæœ¬æ¬¡è¯·æ±‚全部模板的发送量、填报量查回来(原来是每个模板问题各查两次)
            //2.1 ä¸€æ¬¡æ€§æŠŠæœ¬æ¬¡è¯·æ±‚全部模板的发送量、回收人数查回来(原来是每个模板问题各查两次)
            SatisfactionStatQuery statQuery = buildSatisfactionStatQuery(patSatisfactionReqVO, "1",
                    ivrLibaTemplateScripts.stream().map(IvrLibaTemplateScript::getTemplateid).collect(Collectors.toList()));
            Map<Long, Long> sendCountMap = mapSatisfactionSendCount(statQuery);
            SatisfactionFillStat fillStat = querySatisfactionFillStat(statQuery);
            Map<Long, Long> fillPersonCountMap = mapSatisfactionFillPersonCount(statQuery);
            //3 æŒ‰scriptAssortname分组,并获取每个集合进行遍历,计算每个分类的总人数和填报人数
            Map<String, List<IvrLibaTemplateScript>> scriptGroupByAssortName = ivrLibaTemplateScripts.stream().filter(item -> item.getScriptAssortname() != null).collect(Collectors.groupingBy(IvrLibaTemplateScript::getScriptAssortname));
            if (scriptGroupByAssortName != null && !scriptGroupByAssortName.isEmpty()) {
                for (String assortNameKey : scriptGroupByAssortName.keySet()) {
                    //记录总人数
                    Double subidAll = 0.0;
                    //记录总的填报人数
                    Double fillCountAll = 0.0;
                    //获取该分类的填报总数量
                    // è¯¥åˆ†ç±»æ¶‰åŠçš„去重模板:发送人数/回收人数都按模板统计,
                    // æŒ‰é¢˜ç›®å¾ªçŽ¯ç´¯åŠ ä¼šæŠŠåŒä¸€ä¸ªæ¨¡æ¿çš„é‡é‡å¤è®¡æˆã€Œé¢˜é‡ã€
                    Set<Long> categoryTemplateIds = new LinkedHashSet<>();
                    //获取该分类的填报总数量(只用于算平均分,按题目聚合的行数)
                    List<ScoreCount> allFillCountList = new ArrayList<>();
                    List<IvrLibaTemplateScript> ivrLibaTemplateScriptList = scriptGroupByAssortName.get(assortNameKey);
                    for (IvrLibaTemplateScript ivrLibaTemplateScript : ivrLibaTemplateScriptList) {
                        //通过模板id取问题发送总量(原来是把全部 sub_id æŸ¥å›ž Java å†å– size)
                        subidAll += sendCountMap.getOrDefault(ivrLibaTemplateScript.getTemplateid(), 0L);
                        //获取填报数量(原来是把明细行全部查回 Java å†å– size)
                        List<ScoreCount> fillCount = fillStat.get(ivrLibaTemplateScript.getTemplateid(), ivrLibaTemplateScript.getScriptContent());
                        Long templateId = ivrLibaTemplateScript.getTemplateid();
                        if (templateId != null) {
                            categoryTemplateIds.add(templateId);
                        }
                        //获取填报分值分布(只用于平均分)
                        List<ScoreCount> fillCount = fillStat.get(templateId, ivrLibaTemplateScript.getScriptContent());
                        if (CollectionUtils.isNotEmpty(fillCount)) allFillCountList.addAll(fillCount);
                        fillCountAll += countFillRows(fillCount);
                    }
                    //记录发送总人数、回收总人数
                    Double subidAll = 0.0;
                    Double fillCountAll = 0.0;
                    for (Long templateId : categoryTemplateIds) {
                        subidAll += sendCountMap.getOrDefault(templateId, 0L);
                        fillCountAll += fillPersonCountMap.getOrDefault(templateId, 0L);
                    }
                    // è¿™é‡Œå…ˆè®°å½•一下每个分类的总人数和填报人数
@@ -375,31 +384,40 @@
            //2 å†èŽ·å–è¯¥é—®é¢˜æ»¡æ„åº¦åˆ†ç±»ä¸‹çš„IvrLibaTemplateScript的集合
            List<SvyLibTemplateScript> svyLibTemplateScripts = svyLibTemplateScriptMapper.querySvyLibTemplateIds(mydIdList);
            //2.1 ä¸€æ¬¡æ€§æŠŠæœ¬æ¬¡è¯·æ±‚全部模板的发送量、填报量查回来(原来是每个模板问题各查两次)
            //2.1 ä¸€æ¬¡æ€§æŠŠæœ¬æ¬¡è¯·æ±‚全部模板的发送量、回收人数查回来(原来是每个模板问题各查两次)
            SatisfactionStatQuery statQuery = buildSatisfactionStatQuery(patSatisfactionReqVO, "2",
                    svyLibTemplateScripts.stream().map(SvyLibTemplateScript::getSvyid).collect(Collectors.toList()));
            Map<Long, Long> sendCountMap = mapSatisfactionSendCount(statQuery);
            SatisfactionFillStat fillStat = querySatisfactionFillStat(statQuery);
            Map<Long, Long> fillPersonCountMap = mapSatisfactionFillPersonCount(statQuery);
            //3 æŒ‰scriptAssortname分组,并获取每个集合进行遍历,计算每个分类的总人数和填报人数
            Map<String, List<SvyLibTemplateScript>> scriptGroupByatCegoryName = svyLibTemplateScripts.stream().filter(item -> item.getCategoryName() != null).collect(Collectors.groupingBy(SvyLibTemplateScript::getCategoryName));
            if (scriptGroupByatCegoryName != null && !scriptGroupByatCegoryName.isEmpty()) {
                for (String categoryNameKey : scriptGroupByatCegoryName.keySet()) {
                    //记录总人数
                    Double subidAll = 0.0;
                    //记录总的填报人数
                    Double fillCountAll = 0.0;
                    //获取该分类的填报总数量
                    // è¯¥åˆ†ç±»æ¶‰åŠçš„去重模板:发送人数/回收人数都按模板统计,
                    // æŒ‰é¢˜ç›®å¾ªçŽ¯ç´¯åŠ ä¼šæŠŠåŒä¸€ä¸ªæ¨¡æ¿çš„é‡é‡å¤è®¡æˆã€Œé¢˜é‡ã€
                    Set<Long> categoryTemplateIds = new LinkedHashSet<>();
                    //获取该分类的填报总数量(只用于算平均分,按题目聚合的行数)
                    List<ScoreCount> allFillCountList = new ArrayList<>();
                    List<SvyLibTemplateScript> svyLibTemplateScriptList = scriptGroupByatCegoryName.get(categoryNameKey);
                    for (SvyLibTemplateScript svyLibTemplateScript : svyLibTemplateScriptList) {
                        //通过模板id取问题发送总量(原来是把全部 sub_id æŸ¥å›ž Java å†å– size)
                        subidAll += sendCountMap.getOrDefault(svyLibTemplateScript.getSvyid(), 0L);
                        //获取填报数量(原来是把明细行全部查回 Java å†å– size)
                        List<ScoreCount> fillCount = fillStat.get(svyLibTemplateScript.getSvyid(), svyLibTemplateScript.getScriptContent());
                        Long templateId = svyLibTemplateScript.getSvyid();
                        if (templateId != null) {
                            categoryTemplateIds.add(templateId);
                        }
                        //获取填报分值分布(只用于平均分)
                        List<ScoreCount> fillCount = fillStat.get(templateId, svyLibTemplateScript.getScriptContent());
                        if (CollectionUtils.isNotEmpty(fillCount)) allFillCountList.addAll(fillCount);
                        fillCountAll += countFillRows(fillCount);
                    }
                    //记录发送总人数、回收总人数
                    Double subidAll = 0.0;
                    Double fillCountAll = 0.0;
                    for (Long templateId : categoryTemplateIds) {
                        subidAll += sendCountMap.getOrDefault(templateId, 0L);
                        fillCountAll += fillPersonCountMap.getOrDefault(templateId, 0L);
                    }
@@ -453,28 +471,36 @@
                Map<String, List<IvrLibaTemplateScript>> groupByDimension = dimensionScripts.stream().collect(Collectors.groupingBy(IvrLibaTemplateScript::getDimension));
                if (groupByDimension != null && !groupByDimension.isEmpty()) {
                    //3.1 ä¸€æ¬¡æ€§æŠŠæœ¬æ¬¡è¯·æ±‚全部模板的发送量、填报量查回来(原来是每个模板问题各查两次)
                    //3.1 ä¸€æ¬¡æ€§æŠŠæœ¬æ¬¡è¯·æ±‚全部模板的发送量、回收人数查回来(原来是每个模板问题各查两次)
                    SatisfactionStatQuery statQuery = buildSatisfactionStatQuery(patSatisfactionReqVO, "1",
                            dimensionScripts.stream().map(IvrLibaTemplateScript::getTemplateid).collect(Collectors.toList()));
                    Map<Long, Long> sendCountMap = mapSatisfactionSendCount(statQuery);
                    SatisfactionFillStat fillStat = querySatisfactionFillStat(statQuery);
                    Map<Long, Long> fillPersonCountMap = mapSatisfactionFillPersonCount(statQuery);
                    for (String dimension : groupByDimension.keySet()) {
                        //记录总人数
                        Double subidAll = 0.0;
                        //记录总的填报人数
                        Double fillCountAll = 0.0;
                        //获取该维度的填报总数量
                        // è¯¥ç»´åº¦æ¶‰åŠçš„去重模板:发送人数/回收人数都按模板统计,按题目累加会变成题量
                        Set<Long> dimensionTemplateIds = new LinkedHashSet<>();
                        //获取该维度的填报分值分布(只用于算平均分)
                        List<ScoreCount> allFillCountList = new ArrayList<>();
                        List<IvrLibaTemplateScript> ivrLibaTemplateScriptList = groupByDimension.get(dimension);
                        for (IvrLibaTemplateScript ivrLibaTemplateScript : ivrLibaTemplateScriptList) {
                            //通过模板id取问题发送总量(原来是把全部 sub_id æŸ¥å›ž Java å†å– size)
                            subidAll += sendCountMap.getOrDefault(ivrLibaTemplateScript.getTemplateid(), 0L);
                            //获取填报数量(原来是把明细行全部查回 Java å†å– size)
                            List<ScoreCount> fillCount = fillStat.get(ivrLibaTemplateScript.getTemplateid(), ivrLibaTemplateScript.getScriptContent());
                            Long templateId = ivrLibaTemplateScript.getTemplateid();
                            if (templateId != null) {
                                dimensionTemplateIds.add(templateId);
                            }
                            //获取填报分值分布(只用于平均分)
                            List<ScoreCount> fillCount = fillStat.get(templateId, ivrLibaTemplateScript.getScriptContent());
                            if (CollectionUtils.isNotEmpty(fillCount)) allFillCountList.addAll(fillCount);
                            fillCountAll += countFillRows(fillCount);
                        }
                        //记录发送总人数、回收总人数
                        Double subidAll = 0.0;
                        Double fillCountAll = 0.0;
                        for (Long templateId : dimensionTemplateIds) {
                            subidAll += sendCountMap.getOrDefault(templateId, 0L);
                            fillCountAll += fillPersonCountMap.getOrDefault(templateId, 0L);
                        }
                        // è¿™é‡Œå…ˆè®°å½•一下每个分类的总人数和填报人数
@@ -502,28 +528,36 @@
                List<SvyLibTemplateScript> dimensionScripts = svyLibTemplateScripts.stream().filter(item -> item.getDimension() != null).collect(Collectors.toList());
                Map<String, List<SvyLibTemplateScript>> groupByDimension = dimensionScripts.stream().collect(Collectors.groupingBy(SvyLibTemplateScript::getDimension));
                if (groupByDimension != null && !groupByDimension.isEmpty()) {
                    //3.1 ä¸€æ¬¡æ€§æŠŠæœ¬æ¬¡è¯·æ±‚全部模板的发送量、填报量查回来(原来是每个模板问题各查两次)
                    //3.1 ä¸€æ¬¡æ€§æŠŠæœ¬æ¬¡è¯·æ±‚全部模板的发送量、回收人数查回来(原来是每个模板问题各查两次)
                    SatisfactionStatQuery statQuery = buildSatisfactionStatQuery(patSatisfactionReqVO, "2",
                            dimensionScripts.stream().map(SvyLibTemplateScript::getSvyid).collect(Collectors.toList()));
                    Map<Long, Long> sendCountMap = mapSatisfactionSendCount(statQuery);
                    SatisfactionFillStat fillStat = querySatisfactionFillStat(statQuery);
                    Map<Long, Long> fillPersonCountMap = mapSatisfactionFillPersonCount(statQuery);
                    for (String dimension : groupByDimension.keySet()) {
                        //记录总人数
                        Double subidAll = 0.0;
                        //记录总的填报人数
                        Double fillCountAll = 0.0;
                        //获取该分类的填报总数量
                        // è¯¥ç»´åº¦æ¶‰åŠçš„去重模板:发送人数/回收人数都按模板统计,按题目累加会变成题量
                        Set<Long> dimensionTemplateIds = new LinkedHashSet<>();
                        //获取该分类的填报分值分布(只用于算平均分)
                        List<ScoreCount> allFillCountList = new ArrayList<>();
                        List<SvyLibTemplateScript> svyLibTemplateScriptList = groupByDimension.get(dimension);
                        for (SvyLibTemplateScript svyLibTemplateScript : svyLibTemplateScriptList) {
                            //通过模板id取问题发送总量(原来是把全部 sub_id æŸ¥å›ž Java å†å– size)
                            subidAll += sendCountMap.getOrDefault(svyLibTemplateScript.getSvyid(), 0L);
                            //获取填报数量(原来是把明细行全部查回 Java å†å– size)
                            List<ScoreCount> fillCount = fillStat.get(svyLibTemplateScript.getSvyid(), svyLibTemplateScript.getScriptContent());
                            Long templateId = svyLibTemplateScript.getSvyid();
                            if (templateId != null) {
                                dimensionTemplateIds.add(templateId);
                            }
                            //获取填报分值分布(只用于平均分)
                            List<ScoreCount> fillCount = fillStat.get(templateId, svyLibTemplateScript.getScriptContent());
                            if (CollectionUtils.isNotEmpty(fillCount)) allFillCountList.addAll(fillCount);
                            fillCountAll += countFillRows(fillCount);
                        }
                        //记录发送总人数、回收总人数
                        Double subidAll = 0.0;
                        Double fillCountAll = 0.0;
                        for (Long templateId : dimensionTemplateIds) {
                            subidAll += sendCountMap.getOrDefault(templateId, 0L);
                            fillCountAll += fillPersonCountMap.getOrDefault(templateId, 0L);
                        }
@@ -644,6 +678,24 @@
    }
    /**
     * ä¸€æ¬¡æŸ¥å‡ºå…¨éƒ¨æ¨¡æ¿çš„「回收人数」(按模板去重,同一个人答多道题只算一人)
     *
     * @return key=模板id,value=回收人数
     */
    private Map<Long, Long> mapSatisfactionFillPersonCount(SatisfactionStatQuery statQuery) {
        Map<Long, Long> fillPersonCountMap = new HashMap<>();
        if (statQuery == null || CollectionUtils.isEmpty(statQuery.getLibTemplateIds())) return fillPersonCountMap;
        List<Map<String, Object>> rows = serviceSubtaskDetailMapper.selectSatisfactionFillPersonCount(statQuery);
        if (CollectionUtils.isEmpty(rows)) return fillPersonCountMap;
        for (Map<String, Object> row : rows) {
            Long templateId = toLong(row.get("libtemplateid"));
            if (templateId == null) continue;
            fillPersonCountMap.put(templateId, toLong(row.get("cnt"), 0L));
        }
        return fillPersonCountMap;
    }
    /**
     * ä¸€æ¬¡æŸ¥å‡ºå…¨éƒ¨æ¨¡æ¿çš„填报量(按 æ¨¡æ¿+问题+分值 åˆ†ç»„)
     */
    private SatisfactionFillStat querySatisfactionFillStat(SatisfactionStatQuery statQuery) {
@@ -662,18 +714,6 @@
            fillStat.add(templateId, questiontextValue == null ? null : String.valueOf(questiontextValue), scoreCount);
        }
        return fillStat;
    }
    /**
     * å–填报明细行数(原 fillCount.size())
     */
    private long countFillRows(List<ScoreCount> fillCount) {
        if (CollectionUtils.isEmpty(fillCount)) return 0L;
        long rows = 0L;
        for (ScoreCount scoreCount : fillCount) {
            rows += scoreCount.getCount();
        }
        return rows;
    }
    /**
smartor/src/main/java/com/smartor/service/impl/ServiceSLTDHealthcareRecordServiceImpl.java
@@ -93,6 +93,9 @@
    @Autowired
    private PatMedInhospMapper patMedInhospMapper;
    @Autowired
    private PatMedOuthospErrorMapper patMedOuthospErrorMapper;
    @Override
    public List<ServiceSLTDInhospResDTO> queryHealthcareRecordList(ServiceSLTDInhospReqVO reqVO) {
        try {
@@ -503,7 +506,12 @@
            log.info("【parseResponseData】成功解析dataList:{}条健康记录数据", dataList.size());
            List<ServiceSLTDInhospResDTO> resultList = new ArrayList<>();
            for (Map<String, Object> dataItem : dataList) {
                try {
                resultList.add(convertToDTO(dataItem));
                } catch (Exception e) {
                    // å•条数据转换失败不再让整页失败:写错误表交给人工处理,其余记录照常入库
                    saveOuthospError(dataItem, "CONVERT", e.getMessage(), new Gson().toJson(dataItem), e);
                }
            }
            log.info("【parseResponseData】成功解析resultList:{}条健康记录数据", resultList.size());
@@ -613,34 +621,184 @@
    }
    private Boolean outHospitalDate(List<ServiceSLTDInhospResDTO> resultList) {
        List<PatMedOuthosp> batchList = new ArrayList<>();
        // é—¨æ€¥è¯Šåœ¨ HIS ä¾§åªèƒ½æŒ‰å¤©æŸ¥ï¼Œé‡‡é›†ä»»åŠ¡æ¯åŠå°æ—¶éƒ½ä¼šæŠŠå½“å¤©ä¸Šä¸‡ä¸ªè¯Šç–—è®°å½•é‡æ–°æ‹‰ä¸€éï¼Œ
        // å…¶ä¸­ç»å¤§å¤šæ•°ä¸Šä¸€è½®å·²ç»å…¥åº“。这里先整页批量查重,把已入库的直接跳过,
        // åªå¯¹æ–°å¢žè®°å½•做"档案处理 + å…¥åº“",否则每条重复数据都要走一遍
        // Redis加锁 + æ¡£æ¡ˆæŸ¥é‡ + æ¡£æ¡ˆæ›´æ–° + countBySerialnum + insert,一万条就是几万次 SQL,半小时跑不完。
        Set<String> pageSerialnums = new HashSet<>();
        List<ServiceSLTDInhospResDTO> uniqueList = new ArrayList<>();
        List<ServiceSLTDInhospResDTO> noSerialnumList = new ArrayList<>();
        Map<String, List<String>> serialnumsByDate = new LinkedHashMap<>();
        for (ServiceSLTDInhospResDTO dto : resultList) {
            if (Objects.isNull(dto)) {
                continue;
            }
            PatArchive patArchive = processPatientArchive(dto);
            PatMedOuthosp patMedOuthosp = buildPatMedOuthosp(dto, patArchive);
            String serialnum = dto.getHealthcareRecordNo();
            if (StringUtils.isEmpty(serialnum)) {
                // æ²¡æœ‰è¯Šç–—记录号就没法查重,保持原有行为照常处理(入库环节还会再兜一次)
                log.warn("【outHospitalDate】诊疗记录号为空,无法查重,按新增处理:patientName={}", dto.getPatientName());
                noSerialnumList.add(dto);
                continue;
            }
            if (!pageSerialnums.add(serialnum)) {
                // åŒä¸€é¡µå†…重复的记录,只处理一条
                continue;
            }
            uniqueList.add(dto);
            // æŒ‰å°±è¯Šæ—¥æœŸåˆ†ç»„(跨零点时最多两个日期),保证查重的分表和入库时分表路由一致
            String admitDateStr = formatAdmitDate(dto.getLastEncounterTime());
            serialnumsByDate.computeIfAbsent(admitDateStr == null ? "" : admitDateStr, k -> new ArrayList<>()).add(serialnum);
        }
        // ä¸€æ¬¡ SQL æŸ¥å‡ºè¿™ä¸€é¡µé‡Œå·²ç»å…¥åº“çš„ serialnum(取代逐条 countBySerialnum)
        Set<String> existingSerialnums = new HashSet<>();
        for (Map.Entry<String, List<String>> entry : serialnumsByDate.entrySet()) {
            try {
                existingSerialnums.addAll(patMedOuthospService.selectExistingSerialnums(entry.getKey(), entry.getValue()));
            } catch (Exception e) {
                // æ‰¹é‡æŸ¥é‡å¤±è´¥ä¸å½±å“æ­£ç¡®æ€§ï¼šä¸‹é¢å…¥åº“时每条还会再查一次重,只是慢一些
                log.error("【outHospitalDate】批量查重失败,退化为逐条查重,就诊日期:{}", entry.getKey(), e);
            }
        }
        List<PatMedOuthosp> batchList = new ArrayList<>();
        int existSkipCount = 0;
        for (ServiceSLTDInhospResDTO dto : uniqueList) {
            if (existingSerialnums.contains(dto.getHealthcareRecordNo())) {
                existSkipCount++;
                continue;
            }
            PatMedOuthosp patMedOuthosp = buildNewOuthosp(dto);
            if (patMedOuthosp != null) {
            batchList.add(patMedOuthosp);
        }
        }
        for (ServiceSLTDInhospResDTO dto : noSerialnumList) {
            PatMedOuthosp patMedOuthosp = buildNewOuthosp(dto);
            if (patMedOuthosp != null) {
                batchList.add(patMedOuthosp);
            }
        }
        log.info("【outHospitalDate】本页{}条,去重后{}条,已入库跳过{}条,待入库{}条",
                resultList.size(), uniqueList.size(), existSkipCount, batchList.size());
        // æ‰¹é‡æ’入,自动忽略重复
        if (CollectionUtils.isNotEmpty(batchList)) {
            try {
                //这里用自动忽略重复会有问题,如果表重新建,serialnum索引会丢失。就无法去重,所以还是需要内存里去重
                batchList = DistinctByProperty.distinctByProperty(batchList, PatMedOuthosp::getSerialnum);
                Integer insertCount = 0;
            int insertCount = 0;
            int skipCount = 0;
            List<String> failSerialnums = new ArrayList<>();
                for (PatMedOuthosp patMedOuthosp : batchList) {
                    insertCount += patMedOuthospService.insertPatMedOuthosp(patMedOuthosp);
                try {
                    int inserted = patMedOuthospService.insertPatMedOuthosp(patMedOuthosp);
                    if (inserted > 0) {
                        insertCount += inserted;
                    } else {
                        // è¿”回0表示已存在(重复采集)或就诊时间为空,属于正常跳过,不算失败
                        skipCount++;
                }
                log.info("批量插入门急诊记录,总数:{}, å®žé™…插入:{}", batchList.size(), insertCount);
            } catch (Exception e) {
                log.error("批量插入门急诊记录失败", e);
                    // å•条入库失败不再中断采集:落错误表交给人工处理,采集断点照常推进
                    failSerialnums.add(patMedOuthosp.getSerialnum());
                    saveOuthospError(null, patMedOuthosp, "INSERT", e.getMessage(), new Gson().toJson(patMedOuthosp), e);
                }
            }
            log.info("批量插入门急诊记录,总数:{}, å®žé™…插入:{}, è·³è¿‡ï¼ˆå·²å­˜åœ¨/无就诊时间):{}, è½é”™è¯¯è¡¨ï¼š{}",
                    batchList.size(), insertCount, skipCount, failSerialnums.size());
            if (!failSerialnums.isEmpty()) {
                // å¤±è´¥æ˜Žç»†å·²é€æ¡å†™è¿› pat_med_outhosp_error,这里只提示,不再抛异常阻断采集
                log.error("门急诊记录入库失败 {} æ¡ï¼Œå·²å†™å…¥ pat_med_outhosp_error ç­‰äººå·¥å¤„理,serialnum:{}",
                        failSerialnums.size(), failSerialnums);
            }
        }
        return true;
    }
    /**
     * å¤„理单条新增记录的患者档案并构建门急诊记录;
     * å¤±è´¥æ—¶å†™é”™è¯¯è¡¨äº¤ç»™äººå·¥å¤„理并返回 null,不影响同一页里其他记录
     */
    private PatMedOuthosp buildNewOuthosp(ServiceSLTDInhospResDTO dto) {
        try {
            PatArchive patArchive = processPatientArchive(dto);
            return buildPatMedOuthosp(dto, patArchive);
        } catch (Exception e) {
            saveOuthospError(dto, null, "ARCHIVE", e.getMessage(), new Gson().toJson(dto), e);
            return null;
        }
    }
    /** é‡‡é›†å¤±è´¥çš„原始记录来自接口返回的 Map(数据转换失败时还没有 DTO) */
    private void saveOuthospError(Map<String, Object> dataItem, String errorType, String errorMsg,
                                  String rawData, Throwable cause) {
        PatMedOuthospError error = new PatMedOuthospError();
        error.setSerialnum(getStringValue(dataItem, "healthcareRecordNo"));
        error.setOuthospno(getStringValue(dataItem, "healthcareRecordNo"));
        error.setPatname(getStringValue(dataItem, "patientName"));
        error.setPatno(getStringValue(dataItem, "medicalRecordNo"));
        error.setCampusid(getStringValue(dataItem, "campusId"));
        error.setOrgid(getStringValue(dataItem, "orgId"));
        doSaveOuthospError(error, errorType, errorMsg, rawData, cause);
    }
    /** é‡‡é›†å¤±è´¥çš„原始记录来自 DTO / å¾…入库的 PatMedOuthosp */
    private void saveOuthospError(ServiceSLTDInhospResDTO dto, PatMedOuthosp patMedOuthosp, String errorType,
                                  String errorMsg, String rawData, Throwable cause) {
        PatMedOuthospError error = new PatMedOuthospError();
        if (dto != null) {
            error.setSerialnum(dto.getHealthcareRecordNo());
            error.setOuthospno(dto.getHealthcareRecordNo());
            error.setPatname(dto.getPatientName());
            error.setPatno(dto.getMedicalRecordNo());
            error.setCampusid(dto.getCampusId());
            error.setOrgid(dto.getOrgId());
            error.setAdmitdate(parseDate(dto.getLastEncounterTime()));
        }
        if (patMedOuthosp != null) {
            if (StringUtils.isEmpty(error.getSerialnum())) {
                error.setSerialnum(patMedOuthosp.getSerialnum());
            }
            error.setOuthospno(patMedOuthosp.getOuthospno());
            error.setPatname(patMedOuthosp.getPatname());
            error.setPatno(patMedOuthosp.getPatno());
            error.setCampusid(patMedOuthosp.getCampusid());
            error.setOrgid(patMedOuthosp.getOrgid());
            error.setAdmitdate(patMedOuthosp.getAdmitdate());
        }
        doSaveOuthospError(error, errorType, errorMsg, rawData, cause);
    }
    /**
     * å†™é—¨æ€¥è¯Šé‡‡é›†å¤±è´¥è®°å½•表,交给人工处理。
     * è¿™é‡Œå…œä½ä¸€åˆ‡å¼‚常:连错误表都写不进去时只打日志(带原始数据),绝不因为写错误表失败而中断采集
     */
    private void doSaveOuthospError(PatMedOuthospError error, String errorType, String errorMsg,
                                    String rawData, Throwable cause) {
        error.setErrorType(errorType);
        error.setErrorMsg(truncate(errorMsg == null || errorMsg.isEmpty() ? String.valueOf(cause) : errorMsg, 1000));
        error.setRawData(rawData);
        error.setFailCount(1);
        error.setHandleStatus("0");
        error.setGatherDate(new SimpleDateFormat("yyyy-MM-dd").format(new Date()));
        error.setCreateTime(DateUtils.getNowDate());
        try {
            patMedOuthospErrorMapper.insertPatMedOuthospError(error);
            log.error("【门急诊采集】{} å¤±è´¥ï¼Œå·²å†™å…¥ pat_med_outhosp_error ç­‰äººå·¥å¤„理,serialnum={},患者={},原因={}",
                    errorType, error.getSerialnum(), error.getPatname(), error.getErrorMsg(), cause);
        } catch (Exception ex) {
            log.error("【门急诊采集】{} å¤±è´¥ï¼Œä¸”写入错误表也失败,serialnum={},原始数据:{}",
                    errorType, error.getSerialnum(), rawData, ex);
        }
    }
    /** æˆªæ–­è¶…长文本,避免超出错误表字段长度导致落表失败 */
    private String truncate(String text, int maxLength) {
        if (text == null) {
            return null;
        }
        return text.length() <= maxLength ? text : text.substring(0, maxLength);
    }
    private PatMedOuthosp buildPatMedOuthosp(ServiceSLTDInhospResDTO dto, PatArchive patArchive) {
@@ -681,6 +839,18 @@
        }
    }
    /**
     * å–就诊日期的 yyyy-MM-dd éƒ¨åˆ†ï¼Œä¸Ž buildPatMedOuthosp é‡Œçš„ admitdate ä¿æŒå®Œå…¨ä¸€è‡´çš„解析规则,
     * ä¿è¯æ‰¹é‡æŸ¥é‡ç”¨çš„分表和入库时路由到的分表是同一张
     */
    private String formatAdmitDate(String lastEncounterTime) {
        Date admitDate = parseDate(lastEncounterTime);
        if (admitDate == null) {
            return null;
        }
        return new SimpleDateFormat("yyyy-MM-dd").format(admitDate);
    }
    private Boolean inHospitalDate(List<ServiceSLTDInhospResDTO> resultList, String cry) {
        for (ServiceSLTDInhospResDTO dto : resultList) {
            if (Objects.isNull(dto)) continue;
smartor/src/main/java/com/smartor/service/impl/ServiceSubtaskDetailTraceServiceImpl.java
@@ -80,78 +80,115 @@
    @Override
    public Map<String, Object> tracedeallist(DetailTraceDealVO detailTraceDealVO) {
        // åˆ†é¡µæŸ¥è¯¢
        List<ServiceSubtaskDetailTrace> tracedeallist = serviceSubtaskDetailTraceMapper.tracedeallist(detailTraceDealVO);
        // æŒ‰ questiontext åˆ†ç»„
        Map<String, List<ServiceSubtaskDetailTrace>> groupByQuestion = tracedeallist.stream().filter(t -> t.getQuestiontext() != null).collect(Collectors.groupingBy(ServiceSubtaskDetailTrace::getQuestiontext));
        // ä¼˜åŒ–前:① è¿™æ¡ SQL æ²¡æœ‰ limit,每次都把整表 trace(含 patdesc/recordurl å¤§å­—段)拉回 Java å†åˆ†ç»„ï¼›
        //        â‘¡ æ¯ä¸ªé—®é¢˜éƒ½æ‹¿ scriptid åŽ» service_subtask_detail æŸ¥ä¸€æ¬¡å…¨è¡¨åªä¸ºå– size(N+1);
        //        â‘¢ controller æŠŠè¿™ä¸ªæ–¹æ³•调了两遍(第二遍为了总数),等于上面两步各跑两次。
        // ä¼˜åŒ–后:固定的 5 æ¡èšåˆ SQL,DB åªå¤„理当前页的题目。
        //  1) å½“前页的问题分组聚合(分页下推到 SQL)
        List<Map<String, Object>> groupRows = serviceSubtaskDetailTraceMapper.selectTraceDealGroups(detailTraceDealVO);
        //  2) é—®é¢˜æ€»æ•°ï¼ˆåˆ†é¡µ total,全量口径)
        long questionTotal = serviceSubtaskDetailTraceMapper.countTraceDealQuestions(detailTraceDealVO);
        //  3) æ±‡æ€»ç»Ÿè®¡ï¼ˆå…¨é‡å£å¾„,不受分页影响)
        Map<String, Object> sumRow = serviceSubtaskDetailTraceMapper.sumTraceDeal(detailTraceDealVO);
        long totalException = toLong(sumRow == null ? null : sumRow.get("totalException"), 0L);
        long yesDealException = toLong(sumRow == null ? null : sumRow.get("yesDeal"), 0L);
        long noDealException = totalException - yesDealException;
        List<DetailTraceDealDTO> detailTraceDealDTOList = new ArrayList<>();
        long totalException = 0L;
        long noDealException = 0L;
        long yesDealException = 0L;
        if (CollectionUtils.isNotEmpty(groupRows)) {
            // æœ¬é¡µæ¶‰åŠçš„问题文本,后面 3 æ¡æŸ¥è¯¢éƒ½æŒ‰å®ƒè¿‡æ»¤
            List<String> questiontexts = groupRows.stream()
                    .map(row -> row.get("questiontext"))
                    .filter(Objects::nonNull)
                    .map(String::valueOf)
                    .distinct()
                    .collect(Collectors.toList());
        for (Map.Entry<String, List<ServiceSubtaskDetailTrace>> entry : groupByQuestion.entrySet()) {
            List<ServiceSubtaskDetailTrace> group = entry.getValue();
            //  4) æœ¬é¡µé—®é¢˜çš„负责科室(按科室编号去重)
            Map<String, List<Map<String, Object>>> deptMap = new HashMap<>();
            //  5) æœ¬é¡µé—®é¢˜çš„æœ€æ–°å¤„理人/处理时间
            Map<String, Map<String, Object>> latestHandleMap = new HashMap<>();
            if (CollectionUtils.isNotEmpty(questiontexts)) {
                for (Map<String, Object> row : serviceSubtaskDetailTraceMapper.selectTraceDealDepts(detailTraceDealVO, questiontexts)) {
                    String questiontext = row.get("questiontext") == null ? null : String.valueOf(row.get("questiontext"));
                    if (questiontext == null) continue;
                    Map<String, Object> deptMapItem = new HashMap<>();
                    deptMapItem.put("deptName", row.get("deptName"));
                    deptMapItem.put("deptCode", row.get("deptCode"));
                    deptMap.computeIfAbsent(questiontext, key -> new ArrayList<>()).add(deptMapItem);
                }
                for (Map<String, Object> row : serviceSubtaskDetailTraceMapper.selectTraceDealLatestHandle(detailTraceDealVO, questiontexts)) {
                    String questiontext = row.get("questiontext") == null ? null : String.valueOf(row.get("questiontext"));
                    if (questiontext == null) continue;
                    // åŒä¸€ä¸ªæœ€å¤§å¤„理时间可能有多条,保留第一条即可(原实现取组内 handle_time æœ€å¤§çš„那条)
                    latestHandleMap.putIfAbsent(questiontext, row);
                }
            }
            //  6) æœ¬é¡µé—®é¢˜çš„æœ‰æ•ˆå¡«å†™æ•°ï¼šä¸€æ¬¡ group by æŸ¥å›žï¼Œæ›¿ä»£é€ä¸ª scriptid æŸ¥è¯¢
            // ä¸€ä¸ªé—®é¢˜å¯èƒ½å¯¹åº”多个 scriptid,所以先把 questiontext -> scriptid åˆ—表解开,
            // å†æŠŠè¯¥é—®é¢˜æ‰€æœ‰ scriptid çš„填写数相加(与原实现 for å¾ªçŽ¯ç´¯åŠ çš„å£å¾„ä¸€è‡´ï¼‰
            Map<String, List<Long>> questionScriptIds = new LinkedHashMap<>();
            Set<Long> scriptIdSet = new LinkedHashSet<>();
            for (Map<String, Object> row : groupRows) {
                String questiontext = row.get("questiontext") == null ? null : String.valueOf(row.get("questiontext"));
                if (questiontext == null) continue;
                List<Long> ids = parseScriptIds(row.get("scriptids"));
                questionScriptIds.put(questiontext, ids);
                scriptIdSet.addAll(ids);
            }
            Map<Long, Long> effectiveFillMap = new HashMap<>();
            if (CollectionUtils.isNotEmpty(scriptIdSet)) {
                for (Map<String, Object> row : serviceSubtaskDetailMapper.countDetailByScriptIds(new ArrayList<>(scriptIdSet))) {
                    Long scriptId = toLongObj(row.get("scriptid"));
                    if (scriptId == null) continue;
                    effectiveFillMap.put(scriptId, toLong(row.get("cnt"), 0L));
                }
            }
            //  7) ç»„装返回结构(顺序 = SQL çš„ order by questiontext,保证分页稳定)
            for (Map<String, Object> row : groupRows) {
                String questiontext = row.get("questiontext") == null ? null : String.valueOf(row.get("questiontext"));
                if (questiontext == null) continue;
            DetailTraceDealDTO dto = new DetailTraceDealDTO();
                dto.setQuestiontext(questiontext);
                Long scriptId = toLongObj(row.get("scriptid"));
                dto.setScriptid(scriptId);
                dto.setScriptids(parseScriptIds(row.get("scriptids")));
                Object templateType = row.get("templateType");
                dto.setTemplateType(templateType == null ? null : Integer.valueOf(String.valueOf(templateType)));
                dto.setResponsibleDept(deptMap.getOrDefault(questiontext, new ArrayList<>()));
            // é—®é¢˜å†…容
            dto.setQuestiontext(entry.getKey());
            if (CollectionUtils.isNotEmpty(group)) {
                dto.setScriptid(group.get(0).getScriptid());
                dto.setTemplateType(group.get(0).getTemplateType());
            }
            // è´Ÿè´£ç§‘室(去重)
            List<Map<String, Object>> responsibleDept = group.stream().filter(t -> t.getTodeptcode() != null).collect(Collectors.collectingAndThen(Collectors.toMap(ServiceSubtaskDetailTrace::getTodeptcode, t -> {
                Map<String, Object> deptMap = new HashMap<>();
                deptMap.put("deptName", t.getTodeptname());
                deptMap.put("deptCode", t.getTodeptcode());
                return deptMap;
            }, (existing, replacement) -> existing), map -> new ArrayList<>(map.values())));
            dto.setResponsibleDept(responsibleDept);
            // æœ‰æ•ˆå¡«å†™æ•°ï¼šé€šè¿‡ç»„内的 scriptid åŽ» service_subtask_detail è¡¨æŸ¥è¯¢
            List<Long> scriptIds = group.stream().map(ServiceSubtaskDetailTrace::getScriptid).filter(Objects::nonNull).distinct().collect(Collectors.toList());
                long exceptionFillNum = toLong(row.get("exceptionFillNum"), 0L);
                // è¯¥é—®é¢˜ä¸‹æ‰€æœ‰ scriptid çš„æœ‰æ•ˆå¡«å†™æ•°ä¹‹å’Œï¼ˆä¸€ä¸ª questiontext å¯èƒ½å¯¹åº”多个 scriptid)
            long effectiveFillNum = 0L;
            if (CollectionUtils.isNotEmpty(scriptIds)) {
                ServiceSubtaskDetail query = new ServiceSubtaskDetail();
                query.setDelFlag("0");
                for (Long scriptId : scriptIds) {
                    query.setScriptid(scriptId);
                    List<ServiceSubtaskDetail> details = serviceSubtaskDetailMapper.selectServiceSubtaskDetailList(query);
                    effectiveFillNum += CollectionUtils.isEmpty(details) ? 0 : details.size();
                for (Long sid : questionScriptIds.getOrDefault(questiontext, new ArrayList<>())) {
                    effectiveFillNum += effectiveFillMap.getOrDefault(sid, 0L);
                }
            }
            // å¼‚常填写数 = å½“前分组的 List æ•°é‡
            long exceptionFillNum = group.size();
            Map<String, Object> fillSituation = new HashMap<>();
            fillSituation.put("effectiveFillNum", effectiveFillNum);
            fillSituation.put("exceptionFillNum", exceptionFillNum);
            dto.setFillSituation(fillSituation);
            // å¼‚常问题处理数量:从 handleFlag ç»Ÿè®¡
            long yesDeal = group.stream().filter(t -> "1".equals(t.getHandleFlag())).count();
            long noDeal = group.stream().filter(t -> !"1".equals(t.getHandleFlag())).count();
                long yesDeal = toLong(row.get("yesDeal"), 0L);
                long noDeal = toLong(row.get("noDeal"), 0L);
            Map<String, Object> exceptionQuesNum = new HashMap<>();
            exceptionQuesNum.put("yesDeal", yesDeal);
            exceptionQuesNum.put("noDeal", noDeal);
            exceptionQuesNum.put("all", exceptionFillNum);
            dto.setExceptionQuesNum(exceptionQuesNum);
            // æ±‡æ€»ç»Ÿè®¡ç´¯åŠ 
            totalException += exceptionFillNum;
            noDealException += noDeal;
            yesDealException += yesDeal;
            // æœ€æ–°å¤„理人和处理时间(取 handleTime æœ€æ–°çš„一条)
            group.stream().filter(t -> t.getHandleTime() != null).max(Comparator.comparing(ServiceSubtaskDetailTrace::getHandleTime)).ifPresent(latest -> {
                dto.setHandleBy(latest.getHandleBy());
                dto.setHandleTime(latest.getHandleTime());
            });
                Map<String, Object> latestHandle = latestHandleMap.get(questiontext);
                if (latestHandle != null) {
                    dto.setHandleBy(latestHandle.get("handleBy") == null ? null : String.valueOf(latestHandle.get("handleBy")));
                    Object handleTime = latestHandle.get("handleTime");
                    dto.setHandleTime(handleTime instanceof Date ? (Date) handleTime : null);
                }
            detailTraceDealDTOList.add(dto);
            }
        }
        Map<String, Object> result = new HashMap<>();
@@ -159,9 +196,38 @@
        result.put("totalException", totalException);
        result.put("noDealException", noDealException);
        result.put("yesDealException", yesDealException);
        // åˆ†é¡µæ€»æ•°ï¼ˆæŒ‰é—®é¢˜ç»´åº¦ï¼‰ï¼Œcontroller ç›´æŽ¥ç”¨å®ƒï¼Œä¸ç”¨å†å…¨é‡æŸ¥ä¸€é
        result.put("questionTotal", questionTotal);
        return result;
    }
    private static Long toLongObj(Object value) {
        if (value == null) return null;
        if (value instanceof Number) return ((Number) value).longValue();
        String text = String.valueOf(value).trim();
        return text.isEmpty() ? null : Long.valueOf(text);
    }
    private static long toLong(Object value, long defaultValue) {
        Long result = toLongObj(value);
        return result == null ? defaultValue : result;
    }
    /**
     * è§£æž group_concat(distinct scriptid) çš„结果(如 "101,102")为 scriptid åˆ—表
     */
    private static List<Long> parseScriptIds(Object value) {
        List<Long> scriptIds = new ArrayList<>();
        if (value == null) return scriptIds;
        for (String item : String.valueOf(value).split(",")) {
            Long scriptId = toLongObj(item);
            if (scriptId != null && !scriptIds.contains(scriptId)) {
                scriptIds.add(scriptId);
            }
        }
        return scriptIds;
    }
    /**
     * æ–°å¢žéšè®¿ä»»åŠ¡å¼‚å¸¸è®°å½•è·Ÿè¸ª
     *
smartor/src/main/resources/mapper/smartor/PatMedOuthospMapper.xml
@@ -454,6 +454,16 @@
        WHERE serialnum = #{serialnum}
    </select>
    <!-- æ‰¹é‡æŸ¥è¯¢æŒ‡å®šåˆ†è¡¨ä¸­å·²å­˜åœ¨çš„ serialnum,一次 SQL å®Œæˆæ•´é¡µåŽ»é‡ï¼Œé¿å…é€æ¡ countBySerialnum -->
    <select id="selectExistingSerialnums" resultType="java.lang.String">
        SELECT serialnum
        FROM ${tableName}
        WHERE serialnum IN
        <foreach collection="serialnums" item="serialnum" open="(" separator="," close=")">
            #{serialnum}
        </foreach>
    </select>
    <update id="updateInTable" parameterType="map">
        update ${tableName}
        <trim prefix="SET" suffixOverrides=",">
smartor/src/main/resources/mapper/smartor/ServiceSubtaskDetailMapper.xml
@@ -185,6 +185,24 @@
    </select>
    <!--
        å¼‚常问题处理情况查询(tracedeallist)的「有效填写数」:
        åŽŸæ¥å¯¹æ¯ä¸ª scriptid éƒ½æ‰§è¡Œä¸€æ¬¡ selectServiceSubtaskDetailList(查回全部列再去 size),
        ä¸€ä¸ªé—®é¢˜ä¸€æ¬¡ã€è€Œä¸”整个接口会被调用两遍,几十上百个问题就是上百次大表扫描;
        è¿™é‡Œåˆå¹¶æˆä¸€æ¡ group by。
        æ³¨æ„ï¼šä¸ºä¸ŽåŽŸå£å¾„å®Œå…¨ä¸€è‡´ï¼Œè¿™é‡Œä¸åŠ  del_flag æ¡ä»¶ï¼ˆåŽŸ selectServiceSubtaskDetailList
        è™½ç„¶ Java é‡Œ setDelFlag("0"),但 SQL é‡Œå¹¶æ²¡æœ‰ del_flag è¿‡æ»¤ï¼‰ã€‚
    -->
    <select id="countDetailByScriptIds" resultType="java.util.Map">
        select scriptid as scriptid, count(*) as cnt
        from service_subtask_detail
        where scriptid in
        <foreach collection="scriptIds" item="scriptId" open="(" separator="," close=")">
            #{scriptId}
        </foreach>
        group by scriptid
    </select>
    <select id="getServiceSubtaskDetails" parameterType="com.smartor.domain.ServiceSubtaskDetailVO"
            resultType="java.util.HashMap">
        select
@@ -327,6 +345,57 @@
        group by ss.libtemplateid, ssd.questiontext, ssd.score
    </select>
    <select id="selectSatisfactionFillPersonCount" parameterType="com.smartor.domain.entity.SatisfactionStatQuery"
            resultType="java.util.Map">
        select ss.libtemplateid as libtemplateid,
               count(distinct ssd.sub_id) as cnt
        from service_subtask_detail ssd
        join service_subtask ss on ss.id = ssd.sub_id
        where ssd.del_flag = 0
        and ss.del_flag = 0
        <if test="orgid != null and orgid != ''">
            and ss.orgid = #{orgid}
        </if>
        <if test="campusid != null and campusid != ''">
            and ss.campusid = #{campusid}
        </if>
        <if test="type != null and type != ''">and ss.type = #{type}</if>
        <if test="startTime != null">
            and ss.visit_time &gt;= #{startTime}
        </if>
        <if test="endTime != null">
            and ss.visit_time &lt; date_add(#{endTime}, interval 1 day)
        </if>
        <if test="libTemplateIds != null and libTemplateIds.size() > 0">
            and ss.libtemplateid IN
            <foreach collection="libTemplateIds" item="libTemplateId" open="(" separator="," close=")">
                #{libTemplateId}
            </foreach>
        </if>
        <if test="(hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0) or (deptcodes != null and deptcodes.size() > 0)">
            AND (
            <if test="hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0">
                ss.leavehospitaldistrictcode IN
                <foreach collection="hospitaldistrictcodes" item="hospitaldistrictcode" open="("
                         separator=","
                         close=")">
                    #{hospitaldistrictcode}
                </foreach>
            </if>
            <if test="hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0 and deptcodes != null and deptcodes.size() > 0">
                OR
            </if>
            <if test="deptcodes != null and deptcodes.size() > 0">
                ss.deptcode IN
                <foreach collection="deptcodes" item="deptcode" open="(" separator="," close=")">
                    #{deptcode}
                </foreach>
            </if>
            )
        </if>
        group by ss.libtemplateid
    </select>
    <!--
        æ»¡æ„åº¦ç»Ÿè®¡ï¼ˆstatistics / statisticsByDimension):一次性统计本次请求全部问题的填报量。
        ç­‰ä»·äºŽå¯¹æ¯ä¸ªé—®é¢˜æ‰§è¡Œ queryFillCount(subids, questiontext),区别是:
smartor/src/main/resources/mapper/smartor/ServiceSubtaskDetailTraceMapper.xml
@@ -326,79 +326,163 @@
        <if test="pageSize != null  and pageNum != null">limit ${pageSize} OFFSET ${pageNum}</if>
    </select>
    <select id="tracedeallist" parameterType="com.smartor.domain.VO.DetailTraceDealVO"
            resultMap="ServiceSubtaskDetailTtraceResult">
        <include refid="selectServiceSubtaskDetailTtraceVo"/>
        where del_flag=0
    <!--
        å¼‚常问题处理情况查询(tracedeallist)的公共过滤条件。
        ä¸‹é¢ 5 æ¡ SQL å…±ç”¨æœ¬ç‰‡æ®µï¼Œä¿è¯ã€Œåˆ†é¡µæ•°æ®ã€é—®é¢˜æ€»æ•°ã€å…¨é‡æ±‡æ€»ã€çš„口径完全一致。
        æ³¨æ„ï¼šquestiontext ä¸ºç©ºçš„行原实现里被 stream().filter(questiontext != null) ä¸¢æŽ‰äº†ï¼Œ
        æ‰€ä»¥è¿™é‡Œç»Ÿä¸€æŽ’除,汇总数才和原来一致。
    -->
    <sql id="traceDealWhere">
        and del_flag = 0
        and questiontext is not null
        <if test="vo.templateid != null  and vo.templateid != ''">
            and templateid = #{vo.templateid}
        </if>
        <if test="vo.todeptcode != null  and vo.todeptcode != ''">
            and FIND_IN_SET(#{vo.todeptcode}, todeptcode)
        </if>
        <if test="vo.todeptname != null  and vo.todeptname != ''">
            and todeptname like concat('%', #{vo.todeptname}, '%')
        </if>
        <if test="vo.handleStartTime != null and vo.handleEndTime != null">
            and handle_time between #{vo.handleStartTime} and #{vo.handleEndTime}
        </if>
        <if test="vo.orgid != null  and vo.orgid != ''">
            and orgid = #{vo.orgid}
        </if>
        <if test="vo.type != null  and vo.type != ''">
            and type = #{vo.type}
        </if>
        <if test="vo.templateType != null">
            and template_type = #{vo.templateType}
        </if>
        <if test="vo.isMydException != null ">
            and is_myd_exception = #{vo.isMydException}
        </if>
        <if test="vo.dimension != null  and vo.dimension != ''">
            and dimension = #{vo.dimension}
        </if>
        <if test="vo.dutyDeptPersonName != null  and vo.dutyDeptPersonName != ''">
            and duty_dept_person_name like concat('%', #{vo.dutyDeptPersonName}, '%')
        </if>
        <if test="vo.dutyDeptPersonCode != null  and vo.dutyDeptPersonCode != ''">
            and duty_dept_person_code like concat('%', #{vo.dutyDeptPersonCode}, '%')
        </if>
        <if test="vo.reportDeptPersonName != null  and vo.reportDeptPersonName != ''">
            and report_dept_person_name like concat('%', #{vo.reportDeptPersonName}, '%')
        </if>
        <if test="vo.reportDeptPersonCode != null  and vo.reportDeptPersonCode != ''">
            and report_dept_person_code like concat('%', #{vo.reportDeptPersonCode}, '%')
        </if>
        <if test="vo.circulationDeptPersonCode != null  and vo.circulationDeptPersonCode != ''">
            and circulation_dept_person_code like concat('%', #{vo.circulationDeptPersonCode}, '%')
        </if>
        <if test="vo.circulationDeptPersonName != null  and vo.circulationDeptPersonName != ''">
            and circulation_dept_person_name like concat('%', #{vo.circulationDeptPersonName}, '%')
        </if>
        <if test="vo.circulationDeptCode != null  and vo.circulationDeptCode != ''">
            and circulation_dept_code like concat('%', #{vo.circulationDeptCode}, '%')
        </if>
        <if test="vo.circulationDeptName != null  and vo.circulationDeptName != ''">
            and circulation_dept_name like concat('%', #{vo.circulationDeptName}, '%')
        </if>
        <if test="vo.circulationExplain != null  and vo.circulationExplain != ''">
            and circulation_explain like concat('%', #{vo.circulationExplain}, '%')
        </if>
        <if test="vo.circulationTime != null ">
            and circulation_time = #{vo.circulationTime}
        </if>
        <if test="vo.circulationStatus != null ">
            and circulation_status = #{vo.circulationStatus}
        </if>
        <if test="vo.circulationPersonName != null  and vo.circulationPersonName != ''">
            and circulation_person_name like concat('%', #{vo.circulationPersonName}, '%')
        </if>
        <if test="vo.circulationPersonCode != null  and vo.circulationPersonCode != ''">
            and circulation_person_code = #{vo.circulationPersonCode}
        </if>
    </sql>
        <if test="templateid != null  and templateid != ''">
            and templateid = #{templateid}
        </if>
        <if test="todeptcode != null  and todeptcode != ''">
            and FIND_IN_SET(#{todeptcode}, todeptcode)
        </if>
        <if test="todeptname != null  and todeptname != ''">
            and todeptname like concat('%', #{todeptname}, '%')
        </if>
        <if test="handleStartTime != null and handleEndTime != null">
            and handle_time between #{handleStartTime} and #{handleEndTime}
        </if>
    <!--
        å½“前页的问题分组聚合:分页按问题维度下推到 SQL。
        åŽŸæ¥æ˜¯æŠŠ service_subtask_detail_trace å…¨è¡¨ï¼ˆå« patdesc/recordurl ç­‰å¤§å­—段)拉回 Java å†åˆ†ç»„,
        pageSize å®Œå…¨æ²¡ç”Ÿæ•ˆï¼›çŽ°åœ¨ DB åªå¤„理当前页的题目。
        <if test="orgid != null  and orgid != ''">
            and orgid = #{orgid}
        æ³¨æ„åˆ†ç»„维度必须是 questiontext(接口文档约定「每个问题对应一条记录」):
        ä¸€ä¸ª questiontext å¯èƒ½å¯¹åº”多个 scriptid,这时要把该问题所有 scriptid çš„æœ‰æ•ˆå¡«å†™æ•°åŠ èµ·æ¥ï¼Œ
        æ‰€ä»¥è¿™é‡Œç”¨ group_concat(distinct scriptid) æŠŠ scriptid åˆ—表带回 Java(scriptid å–最小值用于展示)。
        å¦‚果改成 group by questiontext, scriptid,同一个问题会拆成多行,
        ä¸Ž countTraceDealQuestions(按 questiontext åŽ»é‡ï¼‰ã€total、负责科室、最新处理人的口径都会不一致。
    -->
    <select id="selectTraceDealGroups" resultType="java.util.Map">
        select questiontext                                        as questiontext,
               min(scriptid)                                       as scriptid,
               group_concat(distinct scriptid)                      as scriptids,
               min(template_type)                                  as templateType,
               count(*)                                            as exceptionFillNum,
               sum(case when handle_flag = '1' then 1 else 0 end)   as yesDeal,
               sum(case when handle_flag = '1' then 0 else 1 end)   as noDeal
        from service_subtask_detail_trace
        where 1 = 1
        <include refid="traceDealWhere"/>
        group by questiontext
        order by questiontext
        <if test="vo.pageSize != null and vo.pageNum != null">
            limit ${vo.pageSize} offset ${vo.pageNum}
        </if>
    </select>
        <if test="type != null  and type != ''">
            and type = #{type}
        </if>
        <if test="templateType != null">
            and template_type = #{templateType}
        </if>
        <if test="isMydException != null ">
            and is_myd_exception = #{isMydException}
        </if>
        <if test="dimension != null  and dimension != ''">
            and dimension = #{dimension}
        </if>
        <if test="dutyDeptPersonName != null  and dutyDeptPersonName != ''">
            and duty_dept_person_name like concat('%', #{dutyDeptPersonName}, '%')
        </if>
        <if test="dutyDeptPersonCode != null  and dutyDeptPersonCode != ''">
            and duty_dept_person_code like concat('%', #{dutyDeptPersonCode}, '%')
        </if>
        <if test="reportDeptPersonName != null  and reportDeptPersonName != ''">
            and report_dept_person_name like concat('%', #{reportDeptPersonName}, '%')
        </if>
        <if test="reportDeptPersonCode != null  and reportDeptPersonCode != ''">
            and report_dept_person_code like concat('%', #{reportDeptPersonCode}, '%')
        </if>
        <if test="circulationDeptPersonCode != null  and circulationDeptPersonCode != ''">
            and circulation_dept_person_code like concat('%', #{circulationDeptPersonCode}, '%')
        </if>
        <if test="circulationDeptPersonName != null  and circulationDeptPersonName != ''">
            and circulation_dept_person_name like concat('%', #{circulationDeptPersonName}, '%')
        </if>
        <if test="circulationDeptCode != null  and circulationDeptCode != ''">
            and circulation_dept_code like concat('%', #{circulationDeptCode}, '%')
        </if>
        <if test="circulationDeptName != null  and circulationDeptName != ''">
            and circulation_dept_name like concat('%', #{circulationDeptName}, '%')
        </if>
        <if test="circulationExplain != null  and circulationExplain != ''">
            and circulation_explain like concat('%', #{circulationExplain}, '%')
        </if>
        <if test="circulationTime != null ">
            and circulation_time = #{circulationTime}
        </if>
        <if test="circulationStatus != null ">
            and circulation_status = #{circulationStatus}
        </if>
        <if test="circulationPersonName != null  and circulationPersonName != ''">
            and circulation_person_name like concat('%', #{circulationPersonName}, '%')
        </if>
        <if test="circulationPersonCode != null  and circulationPersonCode != ''">
            and circulation_person_code =#{circulationPersonCode}
        </if>
    <!-- é—®é¢˜æ€»æ•°ï¼ˆåˆ†é¡µ total,按问题维度) -->
    <select id="countTraceDealQuestions" resultType="long">
        select count(distinct questiontext)
        from service_subtask_detail_trace
        where 1 = 1
        <include refid="traceDealWhere"/>
    </select>
    <!-- å…¨é‡æ±‡æ€»ï¼ˆä¸å—分页影响):总数 + å·²å¤„理数,未处理数在 Java é‡Œç›¸å‡ -->
    <select id="sumTraceDeal" resultType="java.util.Map">
        select count(*)                                          as totalException,
               sum(case when handle_flag = '1' then 1 else 0 end) as yesDeal
        from service_subtask_detail_trace
        where 1 = 1
        <include refid="traceDealWhere"/>
    </select>
    <!-- å½“前页问题的负责科室(按科室编号去重,名称取组内非空的一个) -->
    <select id="selectTraceDealDepts" resultType="java.util.Map">
        select questiontext      as questiontext,
               todeptcode        as deptCode,
               min(todeptname)   as deptName
        from service_subtask_detail_trace
        where 1 = 1
        <include refid="traceDealWhere"/>
        and todeptcode is not null and todeptcode != ''
        and questiontext in
        <foreach collection="questiontexts" item="qt" open="(" separator="," close=")">
            #{qt}
        </foreach>
        group by questiontext, todeptcode
    </select>
    <!-- å½“前页问题的最新处理人和处理时间(handle_time æœ€å¤§çš„一条) -->
    <select id="selectTraceDealLatestHandle" resultType="java.util.Map">
        select t.questiontext as questiontext,
               t.handle_by    as handleBy,
               t.handle_time  as handleTime
        from service_subtask_detail_trace t
                 inner join (select questiontext, max(handle_time) as maxHandleTime
                             from service_subtask_detail_trace
                             where 1 = 1
                             <include refid="traceDealWhere"/>
                             and handle_time is not null
                             and questiontext in
                             <foreach collection="questiontexts" item="qt" open="(" separator="," close=")">
                                 #{qt}
                             </foreach>
                             group by questiontext) m
                            on m.questiontext = t.questiontext and m.maxHandleTime = t.handle_time
        where t.del_flag = 0
    </select>
smartor/src/main/resources/mapper/smartor/SvyLibTemplageCategoryMapper.xml
@@ -15,6 +15,7 @@
        <result property="updateBy" column="update_by"/>
        <result property="updateTime" column="update_time"/>
        <result property="orgid" column="orgid"/>
        <result property="type" column="type"/>
        <result property="seqno" column="seqno"/>
    </resultMap>
@@ -29,6 +30,7 @@
               update_by,
               update_time,
               orgid,
               type,
               seqno
        from svy_lib_template_category
    </sql>
@@ -43,6 +45,7 @@
            <if test="guid != null  and guid != ''">and guid = #{guid}</if>
            <if test="orgid != null ">and orgid = #{orgid}</if>
            <if test="seqno != null ">and seqno = #{seqno}</if>
            <if test="type != null ">and type = #{type}</if>
    </select>
@@ -65,6 +68,7 @@
            <if test="updateTime != null">update_time,</if>
            <if test="orgid != null">orgid,</if>
            <if test="seqno != null">seqno,</if>
            <if test="type != null">type,</if>
        </trim>
        <trim prefix="values (" suffix=")" suffixOverrides=",">
            <if test="name != null">#{name},</if>
@@ -77,6 +81,7 @@
            <if test="updateTime != null">#{updateTime},</if>
            <if test="orgid != null">#{orgid},</if>
            <if test="seqno != null">#{seqno},</if>
            <if test="type != null">#{type},</if>
        </trim>
    </insert>
@@ -93,6 +98,7 @@
            <if test="updateTime != null">update_time = #{updateTime},</if>
            <if test="orgid != null">orgid = #{orgid},</if>
            <if test="seqno != null">seqno = #{seqno},</if>
            <if test="type != null">type = #{type},</if>
        </trim>
        where id = #{id}
    </update>