陈昶聿
2 天以前 cf8712a9c0816559f50644f15672e7359028c4c4
ruoyi-task/src/main/java/com/ruoyi/task/service/impl/ServiceSubtaskServiceImpl.java
@@ -42,6 +42,7 @@
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -101,11 +102,8 @@
import com.ruoyi.task.mapper.ServiceOutPathMapper;
import com.ruoyi.task.service.IServiceOutPathService;
import com.ruoyi.task.domain.ServiceSubtaskPreachform;
import com.ruoyi.task.api.spi.TaskPreachformPort;
import com.ruoyi.task.mapper.ServiceSubtaskPreachformMapper;
import com.ruoyi.task.api.spi.TaskArchivePort;
import com.ruoyi.task.api.spi.TaskFtpPort;
import com.ruoyi.task.api.spi.TaskSysConfigPort;
import com.ruoyi.task.api.spi.TaskSysDeptPort;
import com.ruoyi.task.common.ScheduleSubtaskBuilder;
import com.ruoyi.task.domain.ServiceTaskScheduleVO;
import com.ruoyi.task.service.IServiceSubtaskService;
@@ -124,6 +122,11 @@
import com.ruoyi.task.domain.ServiceSubtaskTemplateVO;
import com.ruoyi.task.domain.ServiceTaskVO;
import com.ruoyi.task.domain.VO.TransferDeptVO;
import com.ruoyi.system.service.ISysConfigService;
import com.ruoyi.system.service.ISysDeptService;
import com.ruoyi.system.mapper.ext.SysDept2Mapper;
import com.ruoyi.task.api.contract.OperationSubtaskWriter;
import com.ruoyi.task.event.FollowupCompletedEventPublisher;
/**
 * 单一任务(随访)Service业务层处理
@@ -134,6 +137,18 @@
@Slf4j
@Service
public class ServiceSubtaskServiceImpl implements IServiceSubtaskService {
    /**
     * 随访子任务组装器(原 smartor 侧 IPatMedInhospService#boxedServiceSubtask 在本模块的落地实现)。
     * <p>
     * 二次随访要用「下一任务配置 + 出院记录 + 患者档案」组装新的 service_subtask,
     * 这段口径与长期任务生成完全一致,因此直接复用本模块已有的组装器,不再复制一份。
     */
    @Autowired
    private OperationSubtaskWriter operationSubtaskWriter;
    @Autowired
    private FollowupCompletedEventPublisher followupCompletedEventPublisher;
    /**
     * 满意度模块对外门面(常驻 Bean)。
@@ -166,7 +181,7 @@
    private ServiceSubtaskMapper serviceSubtaskMapper;
    @Autowired
    private TaskPreachformPort taskPreachformPort;
    private ServiceSubtaskPreachformMapper taskPreachformPort;
    @Autowired
    private ServiceSubtaskDetailMapper serviceSubtaskDetailMapper;
@@ -219,10 +234,10 @@
    private TaskArchivePort taskArchivePort;
    @Autowired
    private ScheduleSubtaskBuilder scheduleSubtaskBuilder;
    private ISysDeptService sysDeptService;
    @Autowired
    private TaskFtpPort taskFtpPort;
    private ScheduleSubtaskBuilder scheduleSubtaskBuilder;
@@ -236,7 +251,7 @@
    @Autowired
    private TaskSysConfigPort taskSysConfigPort;
    private ISysConfigService sysConfigService;
    /**
     * 消息发送方工厂(短信/微信公众号),替代原来直接调用各站点发送代码的写法
@@ -255,7 +270,7 @@
    @Autowired
    private TaskSysDeptPort taskSysDeptPort;
    private SysDept2Mapper sysDept2Mapper;
    @Value("${spring.profiles.active}")
    private String active;
@@ -264,6 +279,10 @@
    private ServiceOutPathMapper serviceOutPathMapper;
    private static final String KEY_SEP = "\u0001";
    /** 二次随访生成锁前缀,防止同一原任务并发生成多条二次随访 */
    private static final String AGAIN_LOCK_KEY_PREFIX = "smartor:again:lock:";
    private static final long AGAIN_LOCK_SECONDS = 30L;
    /**
     * 查询单一任务(随访)
@@ -383,8 +402,8 @@
                patTaskRelevance.setDiagname(serviceSubtask1.getLeavediagname());
                patTaskRelevance.setPatid(serviceSubtask1.getPatid());
                patTaskRelevance.setSendStatus(serviceSubtask1.getSendstate());
                patTaskRelevance.setDeptcode(serviceSubtask1.getDeptcode());
                patTaskRelevance.setDeptName(serviceSubtask1.getDeptname());
                patTaskRelevance.setDeptcode(serviceSubtask1.getLeaveldeptcode());
                patTaskRelevance.setDeptName(serviceSubtask1.getLeaveldeptname());
                patTaskRelevance.setLeavehospitaldistrictcode(serviceSubtask1.getLeavehospitaldistrictcode());
                patTaskRelevance.setLeavehospitaldistrictname(serviceSubtask1.getLeavehospitaldistrictname());
                patTaskRelevance.setInhosptime(serviceSubtask1.getInhosptime());
@@ -404,8 +423,8 @@
                patTaskRelevance.setTelcode(serviceSubtask1.getPhone());
                patTaskRelevance.setFinishtime(serviceSubtask1.getFinishtime());
                patTaskRelevance.setAddr(serviceSubtask1.getAddr());
                patTaskRelevance.setDeptName(serviceSubtask1.getDeptname());
                patTaskRelevance.setDeptcode(serviceSubtask1.getDeptcode());
                patTaskRelevance.setDeptName(serviceSubtask1.getLeaveldeptname());
                patTaskRelevance.setDeptcode(serviceSubtask1.getLeaveldeptcode());
                patTaskRelevance.setBedNo(serviceSubtask1.getBedNo());
                patTaskRelevance.setDiagname(serviceSubtask1.getLeavediagname());
                patTaskRelevance.setPatid(serviceSubtask1.getPatid());
@@ -469,6 +488,12 @@
        List<ServiceSubtask> selectServiceSubtaskList = getServiceSubtasks(serviceSubtaskEntity);
        List<ServiceSubtaskRes> serviceSubtaskResList = new ArrayList<>();
        // 同一次请求(列表分页 / 全量导出)里,inhospid 与 patid 高度重复
        // (再次随访、同一患者多任务都会指向同一条住院记录/同一份档案)。
        // 原来每行都要 2 次单条查询,导出几千行就是几千×2 次往返;这里按 id 做进程内缓存,
        // 把「每行 2 次」压成「每个不同 id 1 次」,取数语义不变。
        Map<Long, PatMedInhosp> inhospCache = new HashMap<>();
        Map<Long, PatArchive> archiveCache = new HashMap<>();
        for (ServiceSubtask serviceSubtask : selectServiceSubtaskList) {
            ServiceSubtaskRes serviceSubtaskRes = DtoConversionUtils.sourceToTarget(serviceSubtask, ServiceSubtaskRes.class);
            if (ObjectUtils.isNotEmpty(serviceSubtask.getContinueContent())) {
@@ -483,12 +508,12 @@
            if (StringUtils.isEmpty(serviceSubtask.getCurrentPreachform())) {
                if (StringUtils.isNotEmpty(serviceSubtask.getPreachform())) {
                    //默认取第一个
                    serviceSubtaskRes.setCurrentPreachform(serviceSubtask.getPreachform().split(",")[0]);
                    serviceSubtaskRes.setCurrentPreachform(serviceSubtask.getPreachform().split(",")[0].trim());
                }
            }
            PatMedInhosp inhosp = inhospVisitFacade.selectPatMedInhospByInhospid(serviceSubtask.getInhospid());
            PatMedInhosp inhosp = loadInhospCached(inhospCache, serviceSubtask.getInhospid());
            if (ObjectUtils.isNotEmpty(inhosp)) {
                PatArchive patArchive = taskArchivePort.findByPatid(inhosp.getPatid());
                PatArchive patArchive = loadArchiveCached(archiveCache, inhosp.getPatid());
                if (ObjectUtils.isNotEmpty(patArchive)) {
                    Map<String, String> stringStringMap = calculateAge(patArchive.getBirthdate().toInstant().atZone(ZoneId.systemDefault()).toLocalDate(), LocalDate.now());
                    serviceSubtaskRes.setAge(StringUtils.isNotEmpty(stringStringMap.get("age")) ? Long.valueOf(stringStringMap.get("age")) : null);
@@ -504,11 +529,41 @@
        return serviceSubtaskResList;
    }
    /**
     * 按 inhospid 取住院记录(带进程内缓存)。
     * <p>
     * 只是把「同一次请求内同一个 id 只查一次」这件事收口,<b>不改变取数语义</b>:
     * 查不到(null)也会缓存,避免同一批数据里对不存在的 id 反复打库。
     * 缓存生命周期 = 单次 {@code patItem} 调用,不跨请求、不共享。
     */
    private PatMedInhosp loadInhospCached(Map<Long, PatMedInhosp> cache, Long inhospid) {
        if (inhospid == null) {
            return null;
        }
        if (!cache.containsKey(inhospid)) {
            cache.put(inhospid, inhospVisitFacade.selectPatMedInhospByInhospid(inhospid));
        }
        return cache.get(inhospid);
    }
    /**
     * 按 patid 取患者档案(带进程内缓存),语义同 {@link #loadInhospCached}。
     */
    private PatArchive loadArchiveCached(Map<Long, PatArchive> cache, Long patid) {
        if (patid == null) {
            return null;
        }
        if (!cache.containsKey(patid)) {
            cache.put(patid, taskArchivePort.findByPatid(patid));
        }
        return cache.get(patid);
    }
    public List<ServiceSubtask> getServiceSubtasks(ServiceSubtaskEntity serviceSubtaskEntity) {
        if (serviceSubtaskEntity.getSendstateView() != null) {
            //            1:待随访(1 被领取、2 待发送、3 已发送、5 发送失败、7、超时);2:已完成( 6 已完成)、3:无需随访(4 不执行)
            //            1:待随访(1 被领取、2 待发送、3 已发送、7 超时;发送失败(5) 单独统计);2:已完成( 6 已完成)、3:无需随访(4 不执行)
            if (serviceSubtaskEntity.getSendstateView() == 1)
                serviceSubtaskEntity.setSendstates(new ArrayList<>(Arrays.asList(1L, 2L, 3L, 5L, 7L)));
                serviceSubtaskEntity.setSendstates(new ArrayList<>(Arrays.asList(1L, 2L, 3L, 7L)));
            if (serviceSubtaskEntity.getSendstateView() == 2)
                serviceSubtaskEntity.setSendstates(new ArrayList<>(Arrays.asList(6L)));
            if (serviceSubtaskEntity.getSendstateView() == 3)
@@ -524,8 +579,63 @@
                }
            }
        }
        List<ServiceSubtask> selectServiceSubtaskList = this.selectServiceSubtaskList(serviceSubtaskEntity);
        return selectServiceSubtaskList;
        // 分页请求走「两阶段」:先只取当页 id,再按 id 回表。
        // 列表 select 约 100 列,深翻页时被 offset 跳过的每一行原本都要回表读整行;
        // 只取 id 的查询能走覆盖索引并提前截断,代价小得多。
        if (serviceSubtaskEntity.getPageNum() != null && serviceSubtaskEntity.getPageSize() != null) {
            return selectServiceSubtaskPage(serviceSubtaskEntity);
        }
        return this.selectServiceSubtaskList(serviceSubtaskEntity);
    }
    /**
     * 两阶段分页:① 只查 id(覆盖索引 + limit offset)② 按 id 回表取整行。
     * <p>
     * 第二阶段复用 {@code selectServiceSubtaskList} 的 subIdList 条件回表,
     * 必须把 pageNum/pageSize 摘掉 —— 否则会在「当页 id 集合」上再翻一次页。
     * <p>
     * 统计缓存(Controller 的 {@code getPatItemCountWithCache})用 ReflectUtils 比对整个
     * entity 作为缓存命中条件,{@code subIdList} 参与比对,所以用完必须原样还原,
     * 否则缓存永远不命中,每次请求都要重算一次聚合。
     */
    private List<ServiceSubtask> selectServiceSubtaskPage(ServiceSubtaskEntity serviceSubtaskEntity) {
        List<Long> pageIds = serviceSubtaskMapper.selectServiceSubtaskIdsPage(serviceSubtaskEntity);
        if (CollectionUtils.isEmpty(pageIds)) {
            return new ArrayList<>();
        }
        Integer pageNum = serviceSubtaskEntity.getPageNum();
        Integer pageSize = serviceSubtaskEntity.getPageSize();
        List<Long> originSubIdList = serviceSubtaskEntity.getSubIdList();
        serviceSubtaskEntity.setPageNum(null);
        serviceSubtaskEntity.setPageSize(null);
        serviceSubtaskEntity.setSubIdList(pageIds);
        List<ServiceSubtask> rows;
        try {
            rows = serviceSubtaskMapper.selectServiceSubtaskList(serviceSubtaskEntity);
        } finally {
            serviceSubtaskEntity.setPageNum(pageNum);
            serviceSubtaskEntity.setPageSize(pageSize);
            serviceSubtaskEntity.setSubIdList(originSubIdList);
        }
        if (CollectionUtils.isEmpty(rows)) {
            return new ArrayList<>();
        }
        // 以第一阶段的 id 顺序为准重排:第二阶段的 order by 只保证「同一页内」有序,
        // 遇到排序字段值相同的行时相对顺序不一定与第一阶段一致。
        Map<Long, ServiceSubtask> byId = new HashMap<>(Math.max(16, rows.size() * 2));
        for (ServiceSubtask row : rows) {
            if (row.getId() != null) {
                byId.put(row.getId(), row);
            }
        }
        List<ServiceSubtask> ordered = new ArrayList<>(pageIds.size());
        for (Long id : pageIds) {
            ServiceSubtask row = byId.get(id);
            if (row != null) {
                ordered.add(row);
            }
        }
        return ordered;
    }
    @Override
@@ -604,11 +714,22 @@
    @Override
    public List<ServiceSubtask> patItem2(ServiceSubtaskEntity serviceSubtaskEntity) {
        if (serviceSubtaskEntity.getSendstateView() != null) {
            //            1:待随访(1 被领取、2 待发送、3 已发送、5 发送失败、7、超时);2:已完成( 6 已完成)、3:无需随访(4 不执行)
            if (serviceSubtaskEntity.getSendstateView() == 1)
                serviceSubtaskEntity.setSendstates(new ArrayList<>(Arrays.asList(1L, 2L, 3L, 5L, 7L)));
            if (serviceSubtaskEntity.getSendstateView() == 2)
                serviceSubtaskEntity.setSendstates(new ArrayList<>(Arrays.asList(6L)));
            if (serviceSubtaskEntity.getSendstateView() == 3)
                serviceSubtaskEntity.setSendstates(new ArrayList<>(Arrays.asList(4L)));
        }
        List<ServiceSubtask> serviceSubtaskList = new ArrayList<>();
        for (Long sendstate : serviceSubtaskEntity.getSendstates()) {
            serviceSubtaskEntity.setSendstate(sendstate);
            List<ServiceSubtask> serviceSubtasks = serviceSubtaskMapper.selectServiceSubtaskList(serviceSubtaskEntity);
            serviceSubtaskList.addAll(serviceSubtasks);
        if(CollectionUtils.isNotEmpty(serviceSubtaskEntity.getSendstates())){
            for (Long sendstate : serviceSubtaskEntity.getSendstates()) {
                serviceSubtaskEntity.setSendstate(sendstate);
                List<ServiceSubtask> serviceSubtasks = serviceSubtaskMapper.selectServiceSubtaskList(serviceSubtaskEntity);
                serviceSubtaskList.addAll(serviceSubtasks);
            }
        }
        return serviceSubtaskList;
    }
@@ -678,6 +799,7 @@
            result.putIfAbsent("xsf", 0);
            result.putIfAbsent("dsf", 0);
            result.putIfAbsent("ywc", 0);
            result.putIfAbsent("cs", 0);
            result.putIfAbsent("yc", 0);
            result.putIfAbsent("jg", 0);
        }
@@ -714,11 +836,56 @@
            result.putIfAbsent("dsf", 0);
            result.putIfAbsent("dfs", 0);
            result.putIfAbsent("yfs", 0);
            result.putIfAbsent("cs", 0);
            result.putIfAbsent("ywc", 0);
            result.putIfAbsent("yc", 0);
            result.putIfAbsent("jg", 0);
        }
        return result;
    }
    /**
     * 统一「随访病区」口径:一律以【出院记录】{@code pat_med_inhosp} 为准(2026-10-29 业务确认)。
     *
     * <h3>为什么需要它</h3>
     * 出院随访子任务的科室/病区本应来自出院记录,但「再次随访 / 手工新增」这两个入口是
     * <b>直接按前端传来的字段落库</b>的:前端不带这些字段时
     * {@code leaveldeptcode} / {@code leavehospitaldistrictcode} / {@code campusid} 就是空。
     * 而随访列表是按科室/病区过滤的({@code and leavehospitaldistrictcode = #{...}}),
     * 空值不匹配任何值 → 这些任务"生成了但页面上查不到"(线上 2026-10 实测 314 条)。
     *
     * <p>口径:<b>病区只认出院记录</b> —— 不按患者档案反推、也不用前端传的值。
     * 只对出院随访({@code service_type='2'})且能定位到出院记录({@code inhospid} 非空)的行生效;
     * 其余情况原样放行,对满意度(6)/宣教/门诊等其它类型没有任何影响。
     *
     * @param serviceSubtask 待落库的子任务(就地修改)
     */
    private void alignDischargeWardFromInhosp(ServiceSubtask serviceSubtask) {
        if (serviceSubtask == null || serviceSubtask.getInhospid() == null) {
            return;
        }
        if (!"2".equals(serviceSubtask.getServiceType())) {
            return;
        }
        PatMedInhosp inhosp;
        try {
            inhosp = inhospVisitFacade.selectPatMedInhospByInhospid(serviceSubtask.getInhospid());
        } catch (Exception e) {
            log.warn("【随访任务】按 inhospid={} 取出院记录失败,病区沿用入参:{}",
                    serviceSubtask.getInhospid(), e.toString());
            return;
        }
        if (inhosp == null) {
            log.warn("【随访任务】inhospid={} 查不到出院记录,病区沿用入参", serviceSubtask.getInhospid());
            return;
        }
        serviceSubtask.setLeaveldeptcode(inhosp.getLeaveldeptcode());
        serviceSubtask.setLeaveldeptname(inhosp.getLeaveldeptname());
        serviceSubtask.setLeavehospitaldistrictcode(inhosp.getLeavehospitaldistrictcode());
        serviceSubtask.setLeavehospitaldistrictname(inhosp.getLeavehospitaldistrictname());
        if (StringUtils.isNotEmpty(inhosp.getCampusid())) {
            serviceSubtask.setCampusid(inhosp.getCampusid());
        }
    }
    /**
@@ -750,7 +917,7 @@
            serviceOutPath.setSubId(serviceSubtask1.getId());
            List<ServiceOutPath> serviceOutPaths = serviceOutPathMapper.selectServiceOutPathList(serviceOutPath);
            if (CollectionUtils.isNotEmpty(serviceOutPaths))
                format = String.format("%03X", serviceOutPaths.get(0).getId());
                format = ShortCode.encode(serviceOutPaths.get(0).getId());
            else format = getOutPath(serviceSubtask1, rsaPublicKeyExample);
            serviceSubtask = serviceSubtask1;
        } else {
@@ -763,6 +930,8 @@
            }
            serviceSubtask.setCreateTime(DateUtils.getNowDate());
            // 口径(2026-10-29 业务确认):出院随访的科室/病区一律以出院记录为准
            alignDischargeWardFromInhosp(serviceSubtask);
            serviceSubtaskMapper.insertServiceSubtask(serviceSubtask);
            //修改住院满意度状态(避免重复查询)
            if (serviceSubtask.getServiceType() != null && "6".equals(serviceSubtask.getServiceType())) {
@@ -800,7 +969,7 @@
        serviceOutPath.setSubId(serviceSubtask.getId());
        iServiceOutPathService.insertServiceOutPath(serviceOutPath);
        //转成16进制
        format = String.format("%03X", serviceOutPath.getId());
        format = ShortCode.encode(serviceOutPath.getId());
        serviceOutPath.setRadix(format);
        serviceOutPath.setUpdateTime(new Date());
        iServiceOutPathService.updateServiceOutPath(serviceOutPath);
@@ -814,6 +983,7 @@
     * @return 结果
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public String addSubTaskAgain(ServiceSubtask serviceSubtask) {
        ServiceSubtask serviceSubtask1 = selectServiceSubtaskById(serviceSubtask.getId());
        PatMedInhosp patMedInhosp = new PatMedInhosp();
@@ -823,7 +993,7 @@
        PatArchive patArchive = taskArchivePort.findByPatid(serviceSubtask1.getPatid());
        //景宁 新增再次随访不能触发患者再入院
        boolean inhospAgain = true;
        if (serviceSubtask1.getOrgid().equals("47255004333112711A1001")) {
        if ("47255004333112711A1001".equals(serviceSubtask1.getOrgid())) {
            inhospAgain = false;
        }
        if (patMedInhospList.size() > 0 && inhospAgain) {
@@ -837,13 +1007,17 @@
            serviceSubtask1.setRemark(StringUtils.isNotEmpty(serviceSubtask.getRemark()) ? serviceSubtask.getRemark() + ";无需再次随访,任务被标记不在执行" : "无需再次随访,任务被标记不在执行");
            serviceSubtaskMapper.updateServiceSubtask(serviceSubtask1);
            return "无需再次随访,任务被标记不在执行";
        } else if (StringUtils.isNotEmpty(patArchive.getNotrequiredFlag()) && patArchive.getNotrequiredFlag().equals("1")) {
        } else if (patArchive != null && StringUtils.isNotEmpty(patArchive.getNotrequiredFlag()) && patArchive.getNotrequiredFlag().equals("1")) {
            serviceSubtask1.setIsVisitAgain(2);
            serviceSubtaskMapper.updateServiceSubtask(serviceSubtask1);
            serviceSubtask1.setRemark(StringUtils.isNotEmpty(serviceSubtask.getRemark()) ? serviceSubtask.getRemark() + ";无需再次随访,患者被标记不需要被服务" : "无需再次随访,患者被标记不需要被服务");
            return "无需再次随访,患者被标记不需要被服务";
        } else if (ObjectUtils.isNotEmpty(serviceSubtask1.getIsVisitAgain()) && serviceSubtask1.getIsVisitAgain() == 2) {
            return "无需再次随访,该任务已经再次随访过了";
        }
        //丽水中医院根据下次任务ID生成再次随访
        if ("47231022633110211A2101".equals(serviceSubtask1.getOrgid())) {
            return addSubTaskAgainNextTaskId(serviceSubtask1, serviceSubtask);
        }
        //新增再次随访任务
        ServiceSubtask serviceSubtask2 = DtoConversionUtils.sourceToTarget(serviceSubtask, ServiceSubtask.class);
@@ -856,6 +1030,13 @@
        serviceSubtask2.setVisitType(serviceSubtask.getVisitType());
        serviceSubtask2.setVisitDeptCode(serviceSubtask.getVisitDeptCode());
        serviceSubtask2.setVisitDeptName(serviceSubtask.getVisitDeptName());
        // 口径(2026-10-29 业务确认):随访病区一律取出院记录的
        //   ① 再次随访是从请求 DTO 复制出来的,出院记录ID 不在请求里 → 先用原任务的上;
        //   ② 再按出院记录把科室/病区/院区统一过来(取不到就保持不变)。
        if (serviceSubtask2.getInhospid() == null) {
            serviceSubtask2.setInhospid(serviceSubtask1.getInhospid());
        }
        alignDischargeWardFromInhosp(serviceSubtask2);
        int i = serviceSubtaskMapper.insertServiceSubtask(serviceSubtask2);
        if (i == 1) {
            //新增成功后,需要将之前的任务设置为不要再随访(防止重复点,然后重复生成)
@@ -864,6 +1045,495 @@
            return "再次随访任务创建成功";
        }
        return "再次随访任务创建失败,再请次重试";
    }
    public String addSubTaskAgainNextTaskId(ServiceSubtask serviceSubtask, ServiceSubtask request) {
        if (ObjectUtils.isEmpty(serviceSubtask.getTaskid())) {
            log.error("再次随访任务创建失败,缺少TaskId, 再请次重试");
            return "再次随访任务创建失败,缺少TaskId, 再请次重试";
        }
        // 同一原任务串行生成,防止并发重复插入
        String lockToken = tryLockAgainTask(serviceSubtask.getId());
        if (lockToken == null) {
            return "再次随访任务正在创建中,请勿重复提交";
        }
        try {
            ServiceTask serviceTask = serviceTaskService.selectServiceTaskByTaskid(serviceSubtask.getTaskid());
            if (ObjectUtils.isEmpty(serviceTask)) {
                log.error("再次随访任务创建失败,找不到对应任务 taskid = {}, 再请次重试", serviceSubtask.getTaskid());
                return "再次随访任务创建失败,找不到对应任务 taskid = " + serviceSubtask.getTaskid() + ", 再请次重试";
            }
            // nexttaskflag=0 表示任务链终止,不允许再生成
//            if ("0".equals(serviceTask.getNexttaskflag())) {
//                log.info("无需再次随访,任务已标记无后续随访, taskid = {}", serviceSubtask.getTaskid());
//                return "无需再次随访,该任务无后续随访安排";
//            }
            // 幂等校验:同一原任务只允许生成一条二次随访
            ServiceSubtaskEntity againQuery = new ServiceSubtaskEntity();
            againQuery.setUpid(serviceSubtask.getId().intValue());
            if (CollectionUtils.isNotEmpty(serviceSubtaskMapper.selectServiceSubtaskList(againQuery))) {
                return "无需再次随访,该任务已经再次随访过了";
            }
            // 查询出院记录
            PatMedInhosp patMedInhospVo = new PatMedInhosp();
            patMedInhospVo.setOrgid(serviceSubtask.getOrgid());
            patMedInhospVo.setInhospid(serviceSubtask.getInhospid());
            patMedInhospVo.setCry(1);
            List<PatMedInhosp> patMedInhospList = inhospVisitFacade.selectPatMedInhospList(patMedInhospVo);
            PatMedInhosp patMedInhosp = new PatMedInhosp();
            if (CollectionUtils.isNotEmpty(patMedInhospList)) {
                patMedInhosp = patMedInhospList.get(0);
            } else {
                log.error("再次随访任务创建失败,任务缺少 对应的出院记录, taskid = {}, 再请次重试", serviceSubtask.getTaskid());
                return "再次随访任务创建失败,任务缺少 对应的出院记录, taskid = " + serviceSubtask.getTaskid() + ", 再请次重试";
            }
            // 未配置 nexttaskid 时复用当前任务配置,防止历史任务直接失败
            Long nexttaskid = serviceTask.getNexttaskid();
            if (ObjectUtils.isEmpty(nexttaskid) || Long.valueOf(0L).equals(nexttaskid)) {
                nexttaskid = serviceTask.getTaskid();
                log.warn("再次随访任务未配置 nexttaskid,按当前任务配置生成, taskid = {}", nexttaskid);
            }
            ServiceTask nextServiceTask = serviceTaskService.selectServiceTaskByTaskid(nexttaskid);
            if (ObjectUtils.isEmpty(nextServiceTask)) {
                log.error("再次随访任务创建失败,找不到对应任务 nexttaskid = {}, 再请次重试", nexttaskid);
                return "再次随访任务创建失败,找不到对应任务 nexttaskid = " + nexttaskid + ", 再请次重试";
            }
            PatArchive patArchive = taskArchivePort.findByPatid(serviceSubtask.getPatid());
            if (ObjectUtils.isEmpty(patArchive)) {
                log.error("再次随访任务创建失败,未找到患者档案 patid = {}, subTaskId = {}", serviceSubtask.getPatid(), serviceSubtask.getId());
                return "再次随访任务创建失败,未找到患者档案, subTaskId = " + serviceSubtask.getId() + ", 再请次重试";
            }
            String config = sysConfigService.selectConfigByKey("visit.early.day");
            Integer type = 1;
            String resource = resolveAgainResource(request);
            if (StringUtils.isEmpty(resource)) {
                String requestResource = request == null ? null : request.getResource();
                log.error("再次随访任务创建失败,再次随访方式不合法, resource = {}, subTaskId = {}", requestResource, serviceSubtask.getId());
                return "再次随访任务创建失败,再次随访方式不合法, resource = " + requestResource + ", 再请次重试";
            }
            String visitDeptCode;
            String visitDeptName;
            if ("1".equals(resource)) {
                // 本科室随访:请求科室优先,未传时使用原任务出院科室;不依赖原任务 visitType
                String requestDeptCode = firstNonBlank(request == null ? null : request.getVisitDeptCode());
                String requestDeptName = firstNonBlank(request == null ? null : request.getVisitDeptName());
                if (StringUtils.isNotEmpty(requestDeptCode)) {
                    visitDeptCode = requestDeptCode;
                    visitDeptName = requestDeptName;
                } else {
                    // 出院科室:service_subtask 里对应的是 leaveldeptcode(deptcode 已改为在院科室)
                    visitDeptCode = serviceSubtask.getLeaveldeptcode();
                    visitDeptName = serviceSubtask.getLeaveldeptname();
                }
                if (StringUtils.isEmpty(visitDeptCode)) {
                    log.error("再次随访任务创建失败,本科室随访未找到随访科室, subTaskId = {}", serviceSubtask.getId());
                    return "再次随访任务创建失败,本科室随访未找到随访科室, subTaskId = " + serviceSubtask.getId() + ", 再请次重试";
                }
                SysDept ownDept = selectDeptByCode(serviceSubtask.getOrgid(), visitDeptCode);
                if (ObjectUtils.isNotEmpty(ownDept)) {
                    visitDeptCode = ownDept.getDeptCode();
                    visitDeptName = ownDept.getDeptName();
                } else if (StringUtils.isNotEmpty(requestDeptCode) || StringUtils.isEmpty(visitDeptName)) {
                    log.error("再次随访任务创建失败,本科室随访科室不存在, deptCode = {}, subTaskId = {}", visitDeptCode, serviceSubtask.getId());
                    return "再次随访任务创建失败,本科室随访科室不存在, deptCode = " + visitDeptCode + ", 再请次重试";
                }
            } else {
                // 随访中心科室随访
                String centerDept = sysConfigService.selectConfigByKey("sys.smartor.center.dept", serviceSubtask.getOrgid());
                if (ObjectUtils.isEmpty(centerDept)) {
                    log.error("再次随访任务创建失败,未配置随访中心科室参数 sys.smartor.center.dept ,subTaskId = {}, 再请次重试", serviceSubtask.getId());
                    return "再次随访任务创建失败,未配置随访中心科室参数 sys.smartor.center.dept ,subTaskId = " + serviceSubtask.getId() + ", 再请次重试";
                }
                SysDept sysDeptVo = new SysDept();
                sysDeptVo.setDeptType("1");
                sysDeptVo.setOrgid(serviceSubtask.getOrgid());
                sysDeptVo.setDeptCode(centerDept);
                List<SysDept> sysDepts = sysDeptService.selectDeptList(sysDeptVo);
                SysDept sysDept = new SysDept();
                if (CollectionUtils.isNotEmpty(sysDepts)) {
                    sysDept = sysDepts.get(0);
                } else {
                    log.error("再次随访任务创建失败,未找到参数 sys.smartor.center.dept 配置的随访中心科室 deptCode = {}, 再请次重试", centerDept);
                    return "再次随访任务创建失败,未找到参数 sys.smartor.center.dept 配置的随访中心科室 deptCode = " + centerDept + ", 再请次重试";
                }
                visitDeptCode = sysDept.getDeptCode();
                visitDeptName = sysDept.getDeptName();
            }
            // 按下一任务生成二次随访子任务(表单、发送方式取自下一任务);
            // 组装逻辑已在本模块的 OperationSubtaskWriter 里(原 IPatMedInhospService#boxedServiceSubtask),这里直接复用
            ServiceSubtask serviceSubtaskAgain = operationSubtaskWriter.boxedServiceSubtask(nextServiceTask,
                    patMedInhosp, patArchive, config, type);
            if (ObjectUtils.isEmpty(serviceSubtaskAgain) || ObjectUtils.isEmpty(serviceSubtaskAgain.getTaskid())) {
                log.error("再次随访任务创建失败,下一任务生成子任务为空, subTaskId = {}, nextTaskId = {}", serviceSubtask.getId(), nexttaskid);
                return "再次随访任务创建失败,患者出院记录不完整或下一任务配置异常, 再请次重试";
            }
            Date now = new Date();
            applyAgainVisitTime(serviceSubtaskAgain, serviceSubtask, nextServiceTask, config, now);
            // 医生处理意见取自本次请求,按下一任务重建子任务时需显式带入,否则不会落库
            if (request != null) {
                serviceSubtaskAgain.setDoctorOpinion(request.getDoctorOpinion());
                serviceSubtaskAgain.setRemark(request.getRemark());
                serviceSubtaskAgain.setDrcode(request.getDrcode());
                serviceSubtaskAgain.setDrname(request.getDrname());
                serviceSubtaskAgain.setNurseId(request.getNurseId());
                serviceSubtaskAgain.setNurseName(request.getNurseName());
                serviceSubtaskAgain.setManagementDoctor(request.getManagementDoctor());
                serviceSubtaskAgain.setManagementDoctorCode(request.getManagementDoctorCode());
            }
            serviceSubtaskAgain.setId(null);
            serviceSubtaskAgain.setUpid(serviceSubtask.getId().intValue());
            serviceSubtaskAgain.setVisitCount(ObjectUtils.isNotEmpty(serviceSubtask.getVisitCount())
                    ? serviceSubtask.getVisitCount() + 1 : 2);
            serviceSubtaskAgain.setSendstate(2L);
            serviceSubtaskAgain.setIsVisitAgain(1);
            serviceSubtaskAgain.setCreateTime(now);
            serviceSubtaskAgain.setUpdateTime(now);
            // 新任务的随访方式记录本次选择的再次随访方式;resource 本身不落库
            serviceSubtaskAgain.setVisitType(resource);
            serviceSubtaskAgain.setVisitDeptCode(visitDeptCode);
            serviceSubtaskAgain.setVisitDeptName(visitDeptName);
            int i = serviceSubtaskMapper.insertServiceSubtask(serviceSubtaskAgain);
            if (i != 1) {
                return "再次随访任务创建失败,再请次重试";
            }
            // 初始化发送方式明细,补偿(compensateTask)和超时判断依赖该表
            addServiceSubtaskPreachform(serviceSubtaskAgain, null);
            // 条件更新原任务:0 行说明已被其他请求生成,抛异常回滚本次插入的重复数据
            int updated = serviceSubtaskMapper.updateIsVisitAgainById(serviceSubtask.getId(), 2);
            if (updated != 1) {
                throw new BaseException("无需再次随访,该任务已经再次随访过了");
            }
            return "再次随访任务创建成功";
        } finally {
            unlockAgainTask(serviceSubtask.getId(), lockToken);
        }
    }
    /**
     * 解析再次随访方式:仅取请求参数 resource,不使用原任务 visitType;未传时默认随访中心。
     *
     * @return 1-本科室随访;2-随访中心随访;null-非法值
     */
    private String resolveAgainResource(ServiceSubtask request) {
        String resource = request == null ? null : request.getResource();
        if (StringUtils.isEmpty(resource) || StringUtils.isEmpty(resource.trim())) {
            return "2";
        }
        resource = resource.trim();
        return ("1".equals(resource) || "2".equals(resource)) ? resource : null;
    }
    private String firstNonBlank(String... values) {
        if (values == null) {
            return null;
        }
        for (String value : values) {
            if (StringUtils.isNotEmpty(value) && StringUtils.isNotEmpty(value.trim())) {
                return value.trim();
            }
        }
        return null;
    }
    /**
     * 按 orgid + deptCode 查询启用科室,查不到返回 null。
     */
    private SysDept selectDeptByCode(String orgid, String deptCode) {
        if (StringUtils.isEmpty(orgid) || StringUtils.isEmpty(deptCode)) {
            return null;
        }
        SysDept query = new SysDept();
        query.setDeptType("1");
        query.setOrgid(orgid);
        query.setDeptCode(deptCode);
        List<SysDept> deptList = sysDeptService.selectDeptList(query);
        return CollectionUtils.isEmpty(deptList) ? null : deptList.get(0);
    }
    /**
     * 二次随访时间:首次随访完成时间(未完成取当前时间)+ 下一任务发送天数,过去时间兜底为当前时间。
     * 通知类任务按 visit.early.day 提前发送。
     */
    private void applyAgainVisitTime(ServiceSubtask target, ServiceSubtask origin, ServiceTask nextTask,
                                     String earlyDayConfig, Date now) {
        if (target == null || origin == null || nextTask == null || now == null) {
            return;
        }
        Date baseTime = ObjectUtils.isNotEmpty(origin.getFinishtime()) ? origin.getFinishtime() : now;
        int intervalDays = ObjectUtils.isNotEmpty(nextTask.getSendDay()) ? nextTask.getSendDay().intValue() : 0;
        Date dueTime = addDays(baseTime, intervalDays);
        if (dueTime.before(now)) {
            dueTime = now;
        }
        if(ObjectUtils.isNotEmpty(origin.getVisitTime())){
            dueTime = ObjectUtils.isNotEmpty(origin.getVisitTime()) ? origin.getVisitTime() : now;
        }
        target.setLongSendTime(dueTime);
        Date visitTime = dueTime;
        if ("3".equals(nextTask.getType())) {
            visitTime = addDays(dueTime, -parseIntConfig(earlyDayConfig));
            if (visitTime.before(now)) {
                visitTime = now;
            }
        }
        target.setVisitTime(visitTime);
    }
    private int parseIntConfig(String value) {
        if (StringUtils.isEmpty(value)) {
            return 0;
        }
        try {
            return Integer.parseInt(value.trim());
        } catch (NumberFormatException e) {
            log.warn("配置值不是合法整数,按0处理, value={}", value);
            return 0;
        }
    }
    /**
     * 日期加减天数(days 可为负)。
     * <p>
     * 说明:本模块其它地方用的是 {@code DateUtils.addDays},但 ruoyi-common 的 DateUtils 并未提供该方法,
     * 这里是二次随访时间计算自带的私有实现,避免依赖不存在的工具方法。
     */
    private Date addDays(Date date, int days) {
        if (date == null) {
            return null;
        }
        Calendar calendar = Calendar.getInstance();
        calendar.setTime(date);
        calendar.add(Calendar.DAY_OF_MONTH, days);
        return calendar.getTime();
    }
    /**
     * 首次随访任务超时后,按下一任务自动生成再次随访任务。
     * <p>
     * 定时任务入口 {@code RyTask#dealSubtaskDeadline} 使用。与手动入口一致的部分:
     * 表单/发送方式取下一任务、链路 upid + visit_count、发送方式明细初始化;
     * 差异:超时再次随访固定走随访中心科室(随访中心未配置时回退原任务科室),原任务置为发送失败(sendstate=5)。
     * <p>
     * 幂等:同一原任务只允许生成一条再次随访(Redis 锁 + upid 校验 + 原任务条件更新),
     * 多节点/多线程同时扫描时,后到的事务会在条件更新处拿到 0 行并回滚本次插入。
     *
     * @param serviceSubtask 已超时的首次随访子任务
     * @return 结果描述
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public String addSubTaskAgainByDeadline(ServiceSubtask serviceSubtask) {
        if (ObjectUtils.isEmpty(serviceSubtask) || ObjectUtils.isEmpty(serviceSubtask.getId())) {
            return "再次随访任务创建失败,原任务不存在";
        }
        String lockToken = tryLockAgainTask(serviceSubtask.getId());
        if (lockToken == null) {
            return "再次随访任务正在创建中,请勿重复提交";
        }
        try {
            // 幂等校验:同一原任务只允许生成一条再次随访
            ServiceSubtaskEntity againQuery = new ServiceSubtaskEntity();
            againQuery.setUpid(serviceSubtask.getId().intValue());
            if (CollectionUtils.isNotEmpty(serviceSubtaskMapper.selectServiceSubtaskList(againQuery))) {
                return "无需再次随访,该任务已经再次随访过了";
            }
            // 原任务已被标记为不再随访(手动入口写过 is_visit_again=2)
            if (ObjectUtils.isNotEmpty(serviceSubtask.getIsVisitAgain()) && serviceSubtask.getIsVisitAgain() == 2) {
                return "无需再次随访,该任务已经再次随访过了";
            }
            ServiceTask serviceTask = serviceTaskService.selectServiceTaskByTaskid(serviceSubtask.getTaskid());
            if (ObjectUtils.isEmpty(serviceTask)) {
                return "再次随访任务创建失败,找不到对应任务 taskid = " + serviceSubtask.getTaskid();
            }
            if ("0".equals(serviceTask.getNexttaskflag())) {
                return "无需再次随访,该任务无后续随访安排";
            }
            if (ObjectUtils.isEmpty(serviceTask.getNexttaskid())) {
                return "再次随访任务创建失败,任务缺少 nexttaskid, taskid = " + serviceSubtask.getTaskid();
            }
            ServiceTask nextServiceTask = serviceTaskService.selectServiceTaskByTaskid(serviceTask.getNexttaskid());
            if (ObjectUtils.isEmpty(nextServiceTask)) {
                return "再次随访任务创建失败,找不到对应任务 nexttaskid = " + serviceTask.getNexttaskid();
            }
            PatMedInhosp patMedInhosp = selectOutHospRecord(serviceSubtask);
            if (ObjectUtils.isEmpty(patMedInhosp)) {
                return "再次随访任务创建失败,任务缺少对应的出院记录, taskid = " + serviceSubtask.getTaskid();
            }
            PatArchive patArchive = taskArchivePort.findByPatid(serviceSubtask.getPatid());
            if (ObjectUtils.isEmpty(patArchive)) {
                return "再次随访任务创建失败,未找到患者档案, subTaskId = " + serviceSubtask.getId();
            }
            String config = sysConfigService.selectConfigByKey("visit.early.day");
            String visitDeptCode;
            String visitDeptName;
            // 随访中心科室:未配置(或科室不存在)时回退原任务科室,保持超时入口原有行为
            SysDept centerDept = selectCenterDept(serviceSubtask.getOrgid());
            if (ObjectUtils.isNotEmpty(centerDept)) {
                visitDeptCode = centerDept.getDeptCode();
                visitDeptName = centerDept.getDeptName();
            } else {
                String fallbackDeptCode = firstNonBlank(serviceSubtask.getVisitDeptCode());
                if (StringUtils.isNotEmpty(fallbackDeptCode)) {
                    visitDeptCode = fallbackDeptCode;
                    visitDeptName = serviceSubtask.getVisitDeptName();
                } else {
                    // 出院科室:service_subtask 里对应的是 leaveldeptcode(deptcode 已改为在院科室)
                    visitDeptCode = serviceSubtask.getLeaveldeptcode();
                    visitDeptName = serviceSubtask.getLeaveldeptname();
                }
            }
            // 组装逻辑复用本模块 OperationSubtaskWriter(原 IPatMedInhospService#boxedServiceSubtask)
            ServiceSubtask serviceSubtaskAgain = operationSubtaskWriter.boxedServiceSubtask(nextServiceTask,
                    patMedInhosp, patArchive, config, 1);
            if (ObjectUtils.isEmpty(serviceSubtaskAgain) || ObjectUtils.isEmpty(serviceSubtaskAgain.getTaskid())) {
                return "再次随访任务创建失败,患者出院记录不完整或下一任务配置异常, subTaskId = " + serviceSubtask.getId();
            }
            Date now = new Date();
            serviceSubtaskAgain.setId(null);
            serviceSubtaskAgain.setUpid(serviceSubtask.getId().intValue());
            serviceSubtaskAgain.setVisitCount(ObjectUtils.isNotEmpty(serviceSubtask.getVisitCount())
                    ? serviceSubtask.getVisitCount() + 1 : 2);
            serviceSubtaskAgain.setSendstate(2L);
            serviceSubtaskAgain.setIsVisitAgain(1);
            serviceSubtaskAgain.setCreateTime(now);
            serviceSubtaskAgain.setUpdateTime(now);
            // 超时再次随访固定为随访中心随访
            serviceSubtaskAgain.setVisitType("2");
            serviceSubtaskAgain.setVisitDeptCode(visitDeptCode);
            serviceSubtaskAgain.setVisitDeptName(visitDeptName);
            if (serviceSubtaskMapper.insertServiceSubtask(serviceSubtaskAgain) != 1) {
                return "再次随访任务创建失败,再请次重试";
            }
            // 初始化发送方式明细,补偿(compensateTask)和超时判断依赖该表
            addServiceSubtaskPreachform(serviceSubtaskAgain, null);
            // 条件更新原任务:0 行说明已被其他请求生成,抛异常回滚本次插入的重复数据
            if (serviceSubtaskMapper.updateIsVisitAgainById(serviceSubtask.getId(), 2) != 1) {
                throw new BaseException("无需再次随访,该任务已经再次随访过了");
            }
            // 原任务超时,置为发送失败
            ServiceSubtask originUpdate = new ServiceSubtask();
            originUpdate.setId(serviceSubtask.getId());
            originUpdate.setSendstate(5L);
            originUpdate.setUpdateTime(now);
            serviceSubtaskMapper.updateServiceSubtask(originUpdate);
            return "再次随访任务创建成功";
        } finally {
            unlockAgainTask(serviceSubtask.getId(), lockToken);
        }
    }
    /**
     * 按 orgid + inhospid 取出院记录(超时再次随访用)。
     */
    private PatMedInhosp selectOutHospRecord(ServiceSubtask serviceSubtask) {
        PatMedInhosp patMedInhospVo = new PatMedInhosp();
        patMedInhospVo.setOrgid(serviceSubtask.getOrgid());
        patMedInhospVo.setInhospid(serviceSubtask.getInhospid());
        patMedInhospVo.setCry(1);
        List<PatMedInhosp> patMedInhospList = inhospVisitFacade.selectPatMedInhospList(patMedInhospVo);
        if (CollectionUtils.isEmpty(patMedInhospList)) {
            return null;
        }
        return patMedInhospList.get(0);
    }
    /**
     * 取随访中心科室,未配置或科室不存在时返回 null(由调用方回退原任务科室)。
     */
    private SysDept selectCenterDept(String orgid) {
        String centerDept = sysConfigService.selectConfigByKey("sys.smartor.center.dept", orgid);
        if (StringUtils.isEmpty(centerDept)) {
            return null;
        }
        SysDept sysDeptVo = new SysDept();
        sysDeptVo.setDeptType("1");
        sysDeptVo.setOrgid(orgid);
        sysDeptVo.setDeptCode(centerDept);
        // 定时任务线程没有登录上下文,不能调用带 @DataScope 的 selectDeptList,否则会抛“获取用户信息异常”。
        List<SysDept> sysDepts = sysDeptService.selectSysDeptList(sysDeptVo);
        return CollectionUtils.isEmpty(sysDepts) ? null : sysDepts.get(0);
    }
    /**
     * 获取二次随访生成锁。Redis 异常时降级为数据库幂等校验,不阻断业务。
     */
    private String tryLockAgainTask(Long subId) {
        String token = UUID.randomUUID().toString();
        try {
            Boolean locked = redisCache.setCacheObjectIfAbsent(
                    AGAIN_LOCK_KEY_PREFIX + subId, token, AGAIN_LOCK_SECONDS, TimeUnit.SECONDS);
            return Boolean.TRUE.equals(locked) ? token : null;
        } catch (Exception e) {
            log.warn("再次随访获取Redis锁失败,降级使用数据库幂等校验, subId={}", subId, e);
            return token;
        }
    }
    /**
     * 释放二次随访生成锁,仅释放自己持有的锁。
     */
    private void unlockAgainTask(Long subId, String token) {
        if (token == null) {
            return;
        }
        try {
            String lockKey = AGAIN_LOCK_KEY_PREFIX + subId;
            Object current = redisCache.getCacheObject(lockKey);
            if (token.equals(current)) {
                redisCache.deleteObject(lockKey);
            }
        } catch (Exception e) {
            log.warn("再次随访释放Redis锁失败, subId={}", subId, e);
        }
    }
    /**
     * 按任务的 preachformDesc 初始化发送方式明细(service_subtask_preachform)。
     * <p>
     * 迁移说明:原实现是 smartor 侧 {@code IPatMedInhospService#addServiceSubtaskPreachform},
     * 读写的两张表都属于本模块,故按模块边界搬到这里,语义保持一致:
     * 先按 subid+preachform 查重(已存在就不新增),sendstate 默认为 "1"(传参可覆盖)。
     *
     * @param serviceSubtask 已落库的子任务
     * @param sendstate      发送状态覆盖值,为空则用 "1"
     * @return 固定返回 0(与原实现一致)
     */
    private Integer addServiceSubtaskPreachform(ServiceSubtask serviceSubtask, String sendstate) {
        ServiceTask serviceTask = serviceTaskService.selectServiceTaskByTaskid(serviceSubtask.getTaskid());
        if (serviceTask == null) {
            log.error("addServiceSubtaskPreachform taskid={} 查不到任务,跳过preachform子表生成", serviceSubtask.getTaskid());
            return 0;
        }
        String preachformDesc = serviceTask.getPreachformDesc();
        if (StringUtils.isEmpty(preachformDesc)) {
            return 0;
        }
        List<ServiceSubtaskPreachform> preachformList;
        try {
            preachformList = JSON.parseArray(preachformDesc, ServiceSubtaskPreachform.class);
        } catch (Exception e) {
            log.error("preachform转List<ServiceSubtaskPreachform>报错了:{}", e.getMessage());
            return 0;
        }
        if (CollectionUtils.isEmpty(preachformList)) {
            log.error("preachformDesc解析后为空,跳过preachform子表生成,taskid为:{},preachformDesc为:{}",
                    serviceSubtask.getTaskid(), preachformDesc);
            return 0;
        }
        for (ServiceSubtaskPreachform serviceSubtaskPreachform : preachformList) {
            serviceSubtaskPreachform.setTaskid(serviceSubtask.getTaskid());
            serviceSubtaskPreachform.setSubid(serviceSubtask.getId());
            //先查询一下,是否存在,如果已经存在,就不新增了
            if (CollectionUtils.isNotEmpty(taskPreachformPort.selectServiceSubtaskPreachformList(serviceSubtaskPreachform))) {
                continue;
            }
            serviceSubtaskPreachform.setSendstate(StringUtils.isNotEmpty(sendstate) ? sendstate : "1");
            serviceSubtaskPreachform.setOrgid(serviceSubtask.getOrgid());
            taskPreachformPort.insertServiceSubtaskPreachform(serviceSubtaskPreachform);
        }
        return 0;
    }
    /**
@@ -878,7 +1548,12 @@
        if (serviceSubtask.getSendstate() != null && serviceSubtask.getSendstate() == 6)
            serviceSubtask.setFinishtime(new Date());
        return serviceSubtaskMapper.updateServiceSubtask(serviceSubtask);
        Boolean updated = serviceSubtaskMapper.updateServiceSubtask(serviceSubtask);
        if (Boolean.TRUE.equals(updated) && serviceSubtask.getSendstate() != null
                && serviceSubtask.getSendstate() == 6 && serviceSubtask.getId() != null) {
            followupCompletedEventPublisher.publish(serviceSubtask.getId());
        }
        return updated;
    }
    @Override
@@ -919,6 +1594,20 @@
    }
    /**
     * 科室编码去重,保持原顺序,避免 service_task.deptcode 超长和 service_taskdept 重复绑定
     */
    private String normalizeDeptCodes(String deptCodes) {
        if (StringUtils.isEmpty(deptCodes)) {
            return deptCodes;
        }
        return Arrays.stream(deptCodes.split(","))
                .map(String::trim)
                .filter(code -> StringUtils.isNotEmpty(code))
                .distinct()
                .collect(Collectors.joining(","));
    }
    /**
     * 单一任务
     *
     * @return 结果
@@ -930,6 +1619,8 @@
            log.info("任务入参为空,请检查入参");
            throw new BaseException("任务入参为空,请检查入参");
        }
        // 科室编码去重,避免 service_task.deptcode 超长及 service_taskdept 重复绑定
        serviceTaskVO.setDeptcode(normalizeDeptCodes(serviceTaskVO.getDeptcode()));
        ServiceTask serviceTask = DtoConversionUtils.sourceToTarget(serviceTaskVO, ServiceTask.class);
        if (CollectionUtils.isEmpty(serviceTaskVO.getPreachformList())) throw new BaseException("任务的发送方式为空");
        //将preachfromList放到serviceTask中的preachfrom中
@@ -994,7 +1685,7 @@
            }
            serviceTask.setLeaveldeptcodes(serviceTaskVO.getLeaveldeptcodes());
            if (StringUtils.isEmpty(serviceTask.getLeavehospitaldistrictname()) && StringUtils.isNotEmpty(serviceTask.getLeavehospitaldistrictcode())) {
                SysDept sysDept = taskSysDeptPort.selectDeptByCode(serviceTask.getLeavehospitaldistrictcode(), null);
                SysDept sysDept = sysDept2Mapper.selectDeptByCode(serviceTask.getLeavehospitaldistrictcode(), null);
                serviceTask.setLeavehospitaldistrictname(sysDept.getDeptName());
            }
            if (serviceTaskVO.getLongTask() == 1) serviceTask.setSendState(2L);
@@ -1164,8 +1855,13 @@
                    serviceSubtask.setInhospid(patTaskRelevance.getHospid());
                    //新建完成就是待发送状态
                    serviceSubtask.setSendstate(2L);
                    serviceSubtask.setDeptcode(StringUtils.isNotEmpty(patTaskRelevance.getDeptcode()) ? patTaskRelevance.getDeptcode() : serviceTaskVO.getDeptcode());
                    serviceSubtask.setDeptname(StringUtils.isNotEmpty(patTaskRelevance.getDeptName()) ? patTaskRelevance.getDeptName() : serviceTaskVO.getDeptname());
                    String subtaskDeptCode = patTaskRelevance.getDeptcode();
                    if (StringUtils.isEmpty(subtaskDeptCode) && StringUtils.isNotEmpty(serviceTaskVO.getDeptcode())) {
                        // service_subtask.leaveldeptcode 为单值字段,不能写入任务的多科室拼接串,取第一个科室兜底
                        subtaskDeptCode = serviceTaskVO.getDeptcode().split(",")[0].trim();
                    }
                    serviceSubtask.setLeaveldeptcode(subtaskDeptCode);
                    serviceSubtask.setLeaveldeptname(StringUtils.isNotEmpty(patTaskRelevance.getDeptName()) ? patTaskRelevance.getDeptName() : serviceTaskVO.getDeptname());
                    serviceSubtask.setLeavehospitaldistrictcode(patTaskRelevance.getLeavehospitaldistrictcode());
                    serviceSubtask.setLeavehospitaldistrictname(patTaskRelevance.getLeavehospitaldistrictname());
                    serviceSubtask.setType(serviceTaskVO.getType());
@@ -1237,6 +1933,8 @@
            ServiceTask serviceTask1 = serviceTaskService.selectServiceTaskByTaskid(serviceTask.getTaskid());
            ServiceSubtaskEntity serviceSubtaskVO = new ServiceSubtaskEntity();
            serviceSubtaskVO.setTaskid(serviceTask1.getTaskid());
            //只删除首次随访未完成的
            serviceSubtaskVO.setVisitCount(1);
            serviceSubtaskVO.setSendstate(2L);
            List<ServiceSubtask> serviceSubtaskList = serviceSubtaskMapper.selectServiceSubtaskList(serviceSubtaskVO);
            serviceSubtaskVO.setSendstate(1L);
@@ -1302,6 +2000,11 @@
            if (StringUtils.isNotEmpty(serviceTaskVO.getAppltype())) {
                if (serviceTaskVO.getAppltype().equals("1") || serviceTaskVO.getAppltype().equals("2")) {
                    if (StringUtils.isNotEmpty(serviceTaskVO.getDeptcode())) {
                        // 修改任务时先软删除旧的科室绑定,避免旧科室残留
                        ServiceTaskdept oldDept = new ServiceTaskdept();
                        oldDept.setTaskId(serviceTask.getTaskid());
                        oldDept.setDeptType("1");
                        serviceTaskdeptService.deleteServiceTaskdeptByTaskId(oldDept);
                        String[] deptcodes = serviceTaskVO.getDeptcode().split(",");
                        for (String deptCode : deptcodes) {
                            ServiceTaskdept serviceTaskdept = new ServiceTaskdept();
@@ -1530,10 +2233,10 @@
                    serviceSubtask.setAddr(patTaskRelevance.getAddr());
                    serviceSubtask.setPatid(patTaskRelevance.getPatid());
                    serviceSubtask.setOpenid(patTaskRelevance.getOpenid());
                    serviceSubtask.setDeptcode(patTaskRelevance.getDeptcode());
                    serviceSubtask.setLeaveldeptcode(patTaskRelevance.getDeptcode());
                    serviceSubtask.setLeavehospitaldistrictname(patTaskRelevance.getLeavehospitaldistrictname());
                    serviceSubtask.setLeavehospitaldistrictcode(patTaskRelevance.getLeavehospitaldistrictcode());
                    serviceSubtask.setDeptname(patTaskRelevance.getDeptName());
                    serviceSubtask.setLeaveldeptname(patTaskRelevance.getDeptName());
                    serviceSubtask.setType(serviceTaskVO.getType());
                    serviceSubtask.setCreateTime(DateUtils.getNowDate());
                    serviceSubtask.setDrname(patTaskRelevance.getDrname());
@@ -1765,14 +2468,31 @@
            if (serviceSubtaskCountReq.getDrcode() != null && !serviceSubtaskCountReq.getDrcode().isEmpty()) {
                groupKey = drcode;
            } else {
                groupKey = "deptcode";
                // 2026-11 字段重命名后:deptcode 是在院科室(历史数据为空),
                // 出院科室已改名为 leaveldeptcode,分组必须用后者,
                // 否则 getSfStatisticsGroupKey 会返回 NULL,拼成 IN (NULL) 导致整表查空。
                groupKey = "leaveldeptcode";
            }
        } else if (serviceSubtaskCountReq.getStatisticaltype() != null && serviceSubtaskCountReq.getStatisticaltype() == 3) {
            // 病种统计:按任务ID分组,每个任务即一个病种随访任务
            groupKey = "taskid";
        }
        List<ServiceSubtaskStatistic> serviceSubtaskStatistics = new ArrayList<>();
        // 根据条件进行分组
        Map<String, List<ServiceSubtask>> collect = new HashMap<>();
        serviceSubtaskCountReq.setGroupKey(groupKey);
        List<String> groupKeyList = serviceSubtaskMapper.getSfStatisticsGroupKey(serviceSubtaskCountReq);
        /*
         * 分组键可能为 NULL(历史数据、或重命名后尚未回填的在院维度列),
         * 直接拼进 SQL 会变成 `AND IFNULL(列,'Unknown') IN (NULL)`,恒不成立,整张统计表为空。
         * 这里统一成 'Unknown',与聚合 SQL 的 IFNULL(列,'Unknown') 口径对齐。
         */
        if (groupKeyList != null) {
            groupKeyList = groupKeyList.stream()
                    .map(key -> key == null ? "Unknown" : key)
                    .distinct()
                    .collect(Collectors.toList());
        }
        serviceSubtaskCountReq.setGroupKeyList(groupKeyList);
        List<ServiceSubtask> rawData = serviceSubtaskMapper.getSfStatistics(serviceSubtaskCountReq);
        switch (groupKey) {
@@ -1787,10 +2507,14 @@
                collect = rawData.stream().collect(Collectors.groupingBy(subtask -> Optional.ofNullable(subtask.getManagementDoctorCode()).orElse("Unknown")));
                break;
            case "deptcode":
                collect = rawData.stream().collect(Collectors.groupingBy(subtask -> Optional.ofNullable(subtask.getDeptcode()).orElse("Unknown")));
            case "leaveldeptcode":
                collect = rawData.stream().collect(Collectors.groupingBy(subtask -> Optional.ofNullable(subtask.getLeaveldeptcode()).orElse("Unknown")));
                break;
            case "leavehospitaldistrictcode":
                collect = rawData.stream().collect(Collectors.groupingBy(subtask -> Optional.ofNullable(subtask.getLeavehospitaldistrictcode()).orElse("Unknown")));
                break;
            case "taskid":
                collect = rawData.stream().collect(Collectors.groupingBy(subtask -> Optional.ofNullable(subtask.getTaskid()).map(String::valueOf).orElse("Unknown")));
                break;
            default:
                collect = rawData.stream().collect(Collectors.groupingBy(subtask -> Optional.ofNullable(subtask.getDrcode()).orElse("Unknown")));
@@ -1804,6 +2528,9 @@
                continue;
            }
            ServiceSubtaskStatistic statistic = getStatistic(serviceSubtaskList, serviceSubtaskCountReq.getFollowUpCountStyle(), serviceSubtaskCountReq.getVisitCount());
            if ("taskid".equals(groupKey)) {
                applyTaskGroupInfo(statistic, serviceSubtaskList);
            }
            if (ObjectUtils.isNotEmpty(jsRates)) {
                Map<String, Object> jsRateMap = jsRates.get(collectKey);
                BigDecimal jsRate = new BigDecimal("0");
@@ -1852,6 +2579,21 @@
     * @return
     */
    public Map<String, Map<String, Object>> getSsRateForSfStatistics(ServiceSubtaskCountReq serviceSubtaskCountReq, Map<String, List<ServiceSubtask>> collect) {
        if (ObjectUtils.isEmpty(serviceSubtaskCountReq) || ObjectUtils.isEmpty(collect)) {
            return null;
        }
        return getSsRateForSfStatistics(serviceSubtaskCountReq, new ArrayList<>(collect.keySet()));
    }
    /**
     * 随访统计-及时率统计(分组键直接传值的重载)
     * <p>
     * 统计下推到 SQL 的 {@code getSfStatisticsScript} 不再把明细行捞回内存,拿不到
     * {@code Map<分组, List<明细>>},因此直接传分组键集合;查询语句与原来完全同一个。
     *
     * @param groupKeyList 分组键集合(drcode / leaveldeptcode / leavehospitaldistrictcode 的实际取值)
     */
    public Map<String, Map<String, Object>> getSsRateForSfStatistics(ServiceSubtaskCountReq serviceSubtaskCountReq, List<String> groupKeyList) {
        if (ObjectUtils.isEmpty(serviceSubtaskCountReq)) {
            return null;
        }
@@ -1859,7 +2601,6 @@
        if (StringUtils.isEmpty(groupKey)) {
            return null;
        }
        List<String> groupKeyList = new ArrayList<>(collect.keySet());
        if (ObjectUtils.isEmpty(groupKeyList)) {
            return null;
        }
@@ -1878,11 +2619,30 @@
        ssRate.setGroupKey(groupKey);
        ssRate.setGroupKeyList(groupKeyList);
        ssRate.setRateDay(serviceSubtaskCountReq.getRateDay());
        Map<String, Map<String, Object>> rateResult = serviceSubtaskMapper.selectTimelyRateBatch(ssRate);
        if (rateResult == null || rateResult.isEmpty()) {
            return rateResult;
        }
        // taskid 为数值类型,MyBatis 返回的 Map key 可能是 Long,这里统一转成 String 与分组 key 对齐
        Map<String, Map<String, Object>> jsRates = new HashMap<>();
        if (!collect.isEmpty()) {
            jsRates = serviceSubtaskMapper.selectTimelyRateBatch(ssRate);
        for (Map.Entry<String, Map<String, Object>> entry : rateResult.entrySet()) {
            jsRates.put(String.valueOf(entry.getKey()), entry.getValue());
        }
        return jsRates;
    }
    /**
     * 空值兜底:SUM(CASE ...) 理论上不会是 null,这里只做防御,避免拆箱 NPE。
     */
    private long nvlLong(Long value) {
        return value == null ? 0L : value;
    }
    /**
     * 把数据库聚合值(BigDecimal / Long / Integer 等)转成 int。
     */
    private int toIntValue(Object value) {
        return value instanceof Number ? ((Number) value).intValue() : 0;
    }
    public List<ServiceSubtaskStatistic> getSfStatisticsExport(ServiceSubtaskCountReq serviceSubtaskCountReq) {
@@ -1892,6 +2652,9 @@
            ServiceSubtaskStatistic totalRow = calculateTotalRow(sfStatistics);
            totalRow.setLeavehospitaldistrictname("总计");
            totalRow.setDeptname("总计");
            if (serviceSubtaskCountReq.getStatisticaltype() != null && serviceSubtaskCountReq.getStatisticaltype() == 3) {
                totalRow.setTaskName("总计");
            }
            sfStatistics.add(totalRow);
        }
@@ -1930,7 +2693,7 @@
            if (serviceSubtaskCountReq.getDrcode() != null && !serviceSubtaskCountReq.getDrcode().isEmpty()) {
                collect = serviceSubtaskMapper.getSfStatistics(serviceSubtaskCountReq).stream().collect(Collectors.groupingBy(subtask -> Optional.ofNullable(subtask.getDrname()).orElse("Unknown")));
            } else {
                collect = serviceSubtaskMapper.getSfStatistics(serviceSubtaskCountReq).stream().collect(Collectors.groupingBy(subtask -> Optional.ofNullable(subtask.getDeptname()).orElse("Unknown")));
                collect = serviceSubtaskMapper.getSfStatistics(serviceSubtaskCountReq).stream().collect(Collectors.groupingBy(subtask -> Optional.ofNullable(subtask.getLeaveldeptname()).orElse("Unknown")));
            }
        }
        List<ServiceSubtaskCount> joySubCount = serviceSubtaskMapper.getSfStatisticsJoy(serviceSubtaskCountReq);
@@ -1951,7 +2714,7 @@
                if (CollectionUtils.isNotEmpty(serviceSubtaskCountReq.getLeavehospitaldistrictcodes())) {
                    ss.setLeavehospitaldistrictcode(serviceSubtaskList.get(0).getLeavehospitaldistrictcode());
                } else if (CollectionUtils.isNotEmpty(serviceSubtaskCountReq.getDeptcodes())) {
                    ss.setDeptcode(serviceSubtaskList.get(0).getDeptcode());
                    ss.setLeaveldeptcode(serviceSubtaskList.get(0).getLeaveldeptcode());
                }
            }
@@ -1990,6 +2753,22 @@
        return serviceSubtaskStatistics;
    }
    /**
     * 病种统计(按 taskid 分组)时补齐任务ID、任务名称和疾病病种。
     *
     * @param statistic          统计结果
     * @param serviceSubtaskList 当前任务分组下的子任务
     */
    private void applyTaskGroupInfo(ServiceSubtaskStatistic statistic, List<ServiceSubtask> serviceSubtaskList) {
        if (statistic == null || CollectionUtils.isEmpty(serviceSubtaskList)) {
            return;
        }
        ServiceSubtask first = serviceSubtaskList.get(0);
        statistic.setTaskId(first.getTaskid());
        statistic.setTaskName(first.getTaskName());
        statistic.setDiagType(first.getDiagType());
    }
    private ServiceSubtaskStatistic getStatistic(List<ServiceSubtask> serviceSubtaskList, String followUpCountStyle, Integer visitCount) {
        ServiceSubtaskStatistic serviceSubtaskStatistic = new ServiceSubtaskStatistic();
@@ -2000,12 +2779,14 @@
        } else {
            //格式化为两位小数的百分比
            DecimalFormat percentFormat = new DecimalFormat("##.##%");
            serviceSubtaskStatistic.setDeptname(serviceSubtaskList.get(0).getDeptname());
            serviceSubtaskStatistic.setDeptcode(serviceSubtaskList.get(0).getDeptcode());
            serviceSubtaskStatistic.setDeptname(serviceSubtaskList.get(0).getLeaveldeptname());
            serviceSubtaskStatistic.setDeptcode(serviceSubtaskList.get(0).getLeaveldeptcode());
            serviceSubtaskStatistic.setLeavehospitaldistrictname(serviceSubtaskList.get(0).getLeavehospitaldistrictname());
            serviceSubtaskStatistic.setLeavehospitaldistrictcode(serviceSubtaskList.get(0).getLeavehospitaldistrictcode());
            serviceSubtaskStatistic.setDrcode(serviceSubtaskList.get(0).getDrcode());
            serviceSubtaskStatistic.setDrname(serviceSubtaskList.get(0).getDrname());
            //随访中心科室,用于统计再次随访推送到随访中心的人数
            String centerDept = sysConfigService.selectConfigByKey("sys.smartor.center.dept", serviceSubtaskList.get(0).getOrgid());
            //出院人次
            serviceSubtaskStatistic.setDischargeCount(serviceSubtaskList.size());
            for (ServiceSubtask serviceSubtask : serviceSubtaskList) {
@@ -2020,9 +2801,31 @@
                if (serviceSubtask.getSendstate() != null && serviceSubtask.getSendstate() != 4) {
                    serviceSubtaskStatistic.setFollowUpNeeded(serviceSubtaskStatistic.getFollowUpNeeded() + 1L);
                }
                //及时随访人次:已完成且未超时
                if (isTimelyFollowUp(serviceSubtask)) {
                    serviceSubtaskStatistic.setTimelyFollowUp(serviceSubtaskStatistic.getTimelyFollowUp() + 1L);
                }
                //首次出院人次:visitCount = 1,作为再次随访推送率的分母
                if (serviceSubtask.getVisitCount() != null && serviceSubtask.getVisitCount() == 1) {
                    serviceSubtaskStatistic.setFirstDischargeCount(serviceSubtaskStatistic.getFirstDischargeCount() + 1L);
                }
                //再次随访推送人数:首个再次随访(visitCount = 2)且执行科室为随访中心科室
                if (serviceSubtask.getVisitCount() != null && serviceSubtask.getVisitCount() == 2
                        && StringUtils.isNotEmpty(centerDept)
                        && StringUtils.equals(StringUtils.trim(centerDept), StringUtils.trim(serviceSubtask.getVisitDeptCode()))) {
                    serviceSubtaskStatistic.setAgainFollowUpPushCount(serviceSubtaskStatistic.getAgainFollowUpPushCount() + 1L);
                }
                //人工已拨通总量
                if (ObjectUtils.isNotEmpty(serviceSubtask.getIsManuallyCalled())
                        && serviceSubtask.getIsManuallyCalled().equals(1L)) {
                    serviceSubtaskStatistic.setManuallyCalled(serviceSubtaskStatistic.getManuallyCalled() + 1L);
                }
                //首次出院随访
                if (serviceSubtask.getVisitCount() != null && serviceSubtask.getVisitCount() == 1) {
                    applyFirstFollowUpExtraStatistics(serviceSubtaskStatistic, serviceSubtask);
                    //首次应随访
                    if (serviceSubtask.getSendstate() != null && !serviceSubtask.getSendstate().equals(4L)) {
                        serviceSubtaskStatistic.setNeedFollowUp(serviceSubtaskStatistic.getNeedFollowUp() + 1L);
@@ -2144,6 +2947,7 @@
                    }
                }//二次出院随访
                else if (serviceSubtask.getVisitCount() != null && serviceSubtask.getVisitCount() > 1) {
                    applyAgainFollowUpExtraStatistics(serviceSubtaskStatistic, serviceSubtask);
                    //再次应随访
                    if (serviceSubtask.getSendstate() != null && serviceSubtask.getSendstate() != 4) {
                        serviceSubtaskStatistic.setNeedFollowUpAgain(serviceSubtaskStatistic.getNeedFollowUpAgain() + 1L);
@@ -2282,6 +3086,80 @@
    }
    /**
     * 是否属于「及时随访」:随访已完成且完成时状态为正常完成(未超时)。
     * <p>
     * finish_state:1-正常完成 2-延期完成 3-超时完成,只有 1 计入及时随访。
     *
     * @param serviceSubtask 随访子任务
     * @return true-计入及时随访人次
     */
    private boolean isTimelyFollowUp(ServiceSubtask serviceSubtask) {
        return serviceSubtask.getSendstate() != null && serviceSubtask.getSendstate().equals(6L)
                && serviceSubtask.getFinishState() != null && serviceSubtask.getFinishState().equals(1L);
    }
    /**
     * 市一统计口径:首次待随访、已发送及人工电话超时。
     */
    private void applyFirstFollowUpExtraStatistics(ServiceSubtaskStatistic statistic, ServiceSubtask serviceSubtask) {
        if (serviceSubtask.getVisitCount() == null || serviceSubtask.getVisitCount() != 1 || serviceSubtask.getSendstate() == null) {
            return;
        }
        Long sendstate = serviceSubtask.getSendstate();
        if (sendstate.equals(3L)) {
            statistic.setSent(statistic.getSent() + 1L);
        }
        if (sendstate.equals(7L) && isManuallyNotCalled(serviceSubtask)) {
            statistic.setManualCallTimeout(statistic.getManualCallTimeout() + 1L);
        }
        if (sendstate.equals(1L) || sendstate.equals(2L) || sendstate.equals(3L) || sendstate.equals(7L)) {
            statistic.setPendingFollowUpAll(statistic.getPendingFollowUpAll() + 1L);
        }
        if (sendstate.equals(1L) || sendstate.equals(2L)) {
            statistic.setPendingFollowUpWaitSend(statistic.getPendingFollowUpWaitSend() + 1L);
        }
        if (sendstate.equals(3L) || sendstate.equals(7L)) {
            statistic.setPendingFollowUpSent(statistic.getPendingFollowUpSent() + 1L);
        }
    }
    /**
     * 再次随访附加统计:待随访、已发送、人工电话超时及回院复诊复查(suggest=7)。
     */
    private void applyAgainFollowUpExtraStatistics(ServiceSubtaskStatistic statistic, ServiceSubtask serviceSubtask) {
        if (serviceSubtask.getVisitCount() == null || serviceSubtask.getVisitCount() <= 1) {
            return;
        }
        if (serviceSubtask.getSendstate() == null) {
            return;
        }
        Long sendstate = serviceSubtask.getSendstate();
        // 回院复诊复查:再次随访中按处理意见 suggest=7 统计
        if (StringUtils.equals("7", StringUtils.trim(serviceSubtask.getSuggest()))) {
            statistic.setSuggest7CountAgain(statistic.getSuggest7CountAgain() + 1L);
        }
        if (sendstate.equals(3L)) {
            statistic.setSentAgain(statistic.getSentAgain() + 1L);
        }
        if (sendstate.equals(7L) && isManuallyNotCalled(serviceSubtask)) {
            statistic.setManualCallTimeoutAgain(statistic.getManualCallTimeoutAgain() + 1L);
        }
        if (sendstate.equals(1L) || sendstate.equals(2L) || sendstate.equals(3L) || sendstate.equals(7L)) {
            statistic.setPendingFollowUpAllAgain(statistic.getPendingFollowUpAllAgain() + 1L);
        }
        if (sendstate.equals(1L) || sendstate.equals(2L)) {
            statistic.setPendingFollowUpWaitSendAgain(statistic.getPendingFollowUpWaitSendAgain() + 1L);
        }
        if (sendstate.equals(3L) || sendstate.equals(7L)) {
            statistic.setPendingFollowUpSentAgain(statistic.getPendingFollowUpSentAgain() + 1L);
        }
    }
    private boolean isManuallyNotCalled(ServiceSubtask serviceSubtask) {
        return serviceSubtask.getIsManuallyCalled() != null && serviceSubtask.getIsManuallyCalled().equals(0L);
    }
    /**
     * 首次随访统计
     *
     * @param serviceSubtaskList
@@ -2292,8 +3170,8 @@
        ServiceSubtaskStatistic serviceSubtaskStatistic = new ServiceSubtaskStatistic();
        //格式化为两位小数的百分比
        DecimalFormat percentFormat = new DecimalFormat("##.##%");
        serviceSubtaskStatistic.setDeptname(serviceSubtaskList.get(0).getDeptname());
        serviceSubtaskStatistic.setDeptcode(serviceSubtaskList.get(0).getDeptcode());
        serviceSubtaskStatistic.setDeptname(serviceSubtaskList.get(0).getLeaveldeptname());
        serviceSubtaskStatistic.setDeptcode(serviceSubtaskList.get(0).getLeaveldeptcode());
        serviceSubtaskStatistic.setLeavehospitaldistrictname(serviceSubtaskList.get(0).getLeavehospitaldistrictname());
        serviceSubtaskStatistic.setLeavehospitaldistrictcode(serviceSubtaskList.get(0).getLeavehospitaldistrictcode());
        serviceSubtaskStatistic.setDrcode(serviceSubtaskList.get(0).getDrcode());
@@ -2302,6 +3180,7 @@
        serviceSubtaskStatistic.setDischargeCount(serviceSubtaskList.size());
        for (ServiceSubtask serviceSubtask : serviceSubtaskList) {
            applyFirstFollowUpExtraStatistics(serviceSubtaskStatistic, serviceSubtask);
            //无需随访人次
            if (serviceSubtask.getSendstate() != null && serviceSubtask.getSendstate() == 4) {
                serviceSubtaskStatistic.setNonFollowUp(serviceSubtaskStatistic.getNonFollowUp() + 1L);
@@ -2312,6 +3191,10 @@
            //应随访人次
            if (serviceSubtask.getSendstate() != null && serviceSubtask.getSendstate() != 4) {
                serviceSubtaskStatistic.setFollowUpNeeded(serviceSubtaskStatistic.getFollowUpNeeded() + 1L);
            }
            //及时随访人次:已完成且未超时
            if (isTimelyFollowUp(serviceSubtask)) {
                serviceSubtaskStatistic.setTimelyFollowUp(serviceSubtaskStatistic.getTimelyFollowUp() + 1L);
            }
            //首次出院随访
@@ -2462,8 +3345,8 @@
        ServiceSubtaskStatistic serviceSubtaskStatistic = new ServiceSubtaskStatistic();
        //格式化为两位小数的百分比
        DecimalFormat percentFormat = new DecimalFormat("##.##%");
        serviceSubtaskStatistic.setDeptname(serviceSubtaskList.get(0).getDeptname());
        serviceSubtaskStatistic.setDeptcode(serviceSubtaskList.get(0).getDeptcode());
        serviceSubtaskStatistic.setDeptname(serviceSubtaskList.get(0).getLeaveldeptname());
        serviceSubtaskStatistic.setDeptcode(serviceSubtaskList.get(0).getLeaveldeptcode());
        serviceSubtaskStatistic.setLeavehospitaldistrictname(serviceSubtaskList.get(0).getLeavehospitaldistrictname());
        serviceSubtaskStatistic.setLeavehospitaldistrictcode(serviceSubtaskList.get(0).getLeavehospitaldistrictcode());
        serviceSubtaskStatistic.setDrcode(serviceSubtaskList.get(0).getDrcode());
@@ -2471,6 +3354,7 @@
        //出院人次
        serviceSubtaskStatistic.setDischargeCount(serviceSubtaskList.size());
        for (ServiceSubtask serviceSubtask : serviceSubtaskList) {
            applyAgainFollowUpExtraStatistics(serviceSubtaskStatistic, serviceSubtask);
            //无需随访人次
            if (serviceSubtask.getSendstate() != null && serviceSubtask.getSendstate() == 4) {
                serviceSubtaskStatistic.setNonFollowUp(serviceSubtaskStatistic.getNonFollowUp() + 1L);
@@ -2481,6 +3365,10 @@
            //应随访人次
            if (serviceSubtask.getSendstate() != null && serviceSubtask.getSendstate() != 4) {
                serviceSubtaskStatistic.setFollowUpNeeded(serviceSubtaskStatistic.getFollowUpNeeded() + 1L);
            }
            //及时随访人次:已完成且未超时
            if (isTimelyFollowUp(serviceSubtask)) {
                serviceSubtaskStatistic.setTimelyFollowUp(serviceSubtaskStatistic.getTimelyFollowUp() + 1L);
            }
            //再次出院随访
@@ -2624,8 +3512,8 @@
        ServiceSubtaskStatistic serviceSubtaskStatistic = new ServiceSubtaskStatistic();
        //格式化为两位小数的百分比
        DecimalFormat percentFormat = new DecimalFormat("##.##%");
        serviceSubtaskStatistic.setDeptname(serviceSubtaskList.get(0).getDeptname());
        serviceSubtaskStatistic.setDeptcode(serviceSubtaskList.get(0).getDeptcode());
        serviceSubtaskStatistic.setDeptname(serviceSubtaskList.get(0).getLeaveldeptname());
        serviceSubtaskStatistic.setDeptcode(serviceSubtaskList.get(0).getLeaveldeptcode());
        serviceSubtaskStatistic.setLeavehospitaldistrictname(serviceSubtaskList.get(0).getLeavehospitaldistrictname());
        serviceSubtaskStatistic.setLeavehospitaldistrictcode(serviceSubtaskList.get(0).getLeavehospitaldistrictcode());
        serviceSubtaskStatistic.setDrcode(serviceSubtaskList.get(0).getDrcode());
@@ -2729,15 +3617,16 @@
        ServiceSubtaskStatistic serviceSubtaskStatistic = new ServiceSubtaskStatistic();
        //格式化为两位小数的百分比
        DecimalFormat percentFormat = new DecimalFormat("##.##%");
        serviceSubtaskStatistic.setDeptname(serviceSubtaskList.get(0).getDeptname());
        serviceSubtaskStatistic.setDeptcode(serviceSubtaskList.get(0).getDeptcode());
        serviceSubtaskStatistic.setDeptname(serviceSubtaskList.get(0).getLeaveldeptname());
        serviceSubtaskStatistic.setDeptcode(serviceSubtaskList.get(0).getLeaveldeptcode());
        serviceSubtaskStatistic.setLeavehospitaldistrictname(serviceSubtaskList.get(0).getLeavehospitaldistrictname());
        serviceSubtaskStatistic.setLeavehospitaldistrictcode(serviceSubtaskList.get(0).getLeavehospitaldistrictcode());
        serviceSubtaskStatistic.setDrcode(serviceSubtaskList.get(0).getDrcode());
        serviceSubtaskStatistic.setDrname(serviceSubtaskList.get(0).getDrname());
        serviceSubtaskStatistic.setJoyCount(0);
        serviceSubtaskStatistic.setJoyAllCount(0);
        //出院人次
        serviceSubtaskStatistic.setDischargeCount(serviceSubtaskList.size());
        //获取随访统计
        if (subCounts == null) {
            subCounts = new ArrayList<>();
@@ -2846,11 +3735,14 @@
     * 获取随访统计超链接
     * dischargeCountInfo
     * followUpNeededAllInfo - sendstate != 4 (1,2,3,5,6,7)
     * firstDischargeCountInfo - visitCount = 1
     * againFollowUpPushCountInfo - visitCount = 2 && visitDeptCode = sys.smartor.center.dept(随访中心科室)
     * nonFollowUpInfo - sendstate = 4
     * needFollowUpInfo - visitCount = 1 && sendstate != 4
     * pendingFollowUpInfo - visitCount = 1 && sendstate = 2
     * followUpSuccessInfo - visitCount = 1 && sendstate = 6
     * followUpFailInfo - visitCount = 1 && (sendstate = 5 || sendstate = 7)
     * timelyFollowUpInfo - sendstate = 6 && finish_state = 1(沿用请求的 visitCount 口径)
     * manualInfo - visitCount = 1 && currentPreachForm = "1"
     * voiceInfo - visitCount = 1 && currentPreachForm = "3"
     * smsInfo - visitCount = 1 && currentPreachForm = "4"
@@ -2879,6 +3771,18 @@
     * taskSituation4Info - taskSituation = 4
     * taskSituation5Info - taskSituation = 5
     * taskSituation6Info - taskSituation = 7
     * manuallyCalledInfo - isManuallyCalled = 1L
     * manualCallTimeoutInfo - visitCount = 1 && sendstate = 7 && isManuallyCalled = 0
     * manualCallTimeoutAgainInfo - visitCount > 1 && sendstate = 7 && isManuallyCalled = 0
     * pendingFollowUpAllInfo - visitCount = 1 && sendstate in (1,2,3,7)
     * pendingFollowUpAllAgainInfo - visitCount > 1 && sendstate in (1,2,3,7)
     * pendingFollowUpWaitSendInfo - visitCount = 1 && sendstate in (1,2)
     * pendingFollowUpWaitSendAgainInfo - visitCount > 1 && sendstate in (1,2)
     * pendingFollowUpSentInfo - visitCount = 1 && sendstate in (3,7)
     * pendingFollowUpSentAgainInfo - visitCount > 1 && sendstate in (3,7)
     * sentInfo - visitCount = 1 && sendstate = 3
     * sentAgainInfo - visitCount > 1 && sendstate = 3
     * suggest7CountAgainInfo - visitCount > 1 && suggest = 7
     * filterCountList - taskSituation = 6 && sendstate = 4
     *
     */
@@ -2886,7 +3790,10 @@
    public List<ServiceSubtask> getSfStatisticsHyperlink(ServiceSubtaskCountReq serviceSubtaskCountReq) {
        String hyperLinkInfoType = serviceSubtaskCountReq.getHyperLinkInfoType();
        if (StringUtils.isNotEmpty(hyperLinkInfoType)) {
            applyHyperLinkInfoType(serviceSubtaskCountReq, hyperLinkInfoType);
            if (!applyHyperLinkInfoType(serviceSubtaskCountReq, hyperLinkInfoType)) {
                // 下钻依赖的配置缺失时返回空明细,避免退化成无过滤条件的查询
                return new ArrayList<>();
            }
        }
        String followUpCountStyle = serviceSubtaskCountReq.getFollowUpCountStyle();
@@ -2907,15 +3814,40 @@
        return serviceSubtasks;
    }
    private void applyHyperLinkInfoType(ServiceSubtaskCountReq serviceSubtaskCountReq, String hyperLinkInfoType) {
    private boolean applyHyperLinkInfoType(ServiceSubtaskCountReq serviceSubtaskCountReq, String hyperLinkInfoType) {
        //及时随访明细需要沿用请求的首次/再次口径
        Integer originVisitCount = serviceSubtaskCountReq.getVisitCount();
        serviceSubtaskCountReq.setVisitCount(null);
        serviceSubtaskCountReq.setSendstates(null);
        serviceSubtaskCountReq.setCurrentPreachform(null);
        serviceSubtaskCountReq.setExcep(null);
        serviceSubtaskCountReq.setTaskSituation(null);
        serviceSubtaskCountReq.setFinishState(null);
        serviceSubtaskCountReq.setSuggest(null);
        switch (hyperLinkInfoType) {
            case "dischargeCountInfo":
                break;
            case "firstDischargeCountInfo":
                //首次出院人次:visitCount = 1
                serviceSubtaskCountReq.setVisitCount(1);
                break;
            case "againFollowUpPushCountInfo": {
                //再次随访推送人数:visitCount = 2 且执行科室为随访中心科室
                String centerDept = sysConfigService.selectConfigByKey("sys.smartor.center.dept", serviceSubtaskCountReq.getOrgid());
                if (StringUtils.isBlank(centerDept)) {
                    log.warn("获取再次随访推送明细失败,未配置随访中心科室参数 sys.smartor.center.dept, orgid={}", serviceSubtaskCountReq.getOrgid());
                    return false;
                }
                serviceSubtaskCountReq.setVisitCount(2);
                serviceSubtaskCountReq.setVisitDeptCode(centerDept.trim());
                serviceSubtaskCountReq.setVisitDeptName(null);
                break;
            }
            case "timelyFollowUpInfo":
                serviceSubtaskCountReq.setVisitCount(originVisitCount);
                serviceSubtaskCountReq.setSendstates(new ArrayList<>(Collections.singletonList(6L)));
                serviceSubtaskCountReq.setFinishState(1L);
                break;
            case "followUpNeededAllInfo":
                serviceSubtaskCountReq.setSendstates(new ArrayList<>(Arrays.asList(1L, 2L, 3L, 5L, 6L, 7L)));
@@ -2951,7 +3883,7 @@
                serviceSubtaskCountReq.setVisitCount(1);
                serviceSubtaskCountReq.setCurrentPreachform("4");
                break;
            case "wechatInfo":
            case "weChatInfo":
                serviceSubtaskCountReq.setVisitCount(1);
                serviceSubtaskCountReq.setCurrentPreachform("5");
                break;
@@ -2970,7 +3902,7 @@
                serviceSubtaskCountReq.setSendstates(new ArrayList<>(Collections.singletonList(6L)));
                serviceSubtaskCountReq.setCurrentPreachform("4");
                break;
            case "wechatSuccessInfo":
            case "weChatSuccessInfo":
                serviceSubtaskCountReq.setVisitCount(1);
                serviceSubtaskCountReq.setSendstates(new ArrayList<>(Collections.singletonList(6L)));
                serviceSubtaskCountReq.setCurrentPreachform("5");
@@ -3007,7 +3939,7 @@
                serviceSubtaskCountReq.setVisitCount(2);
                serviceSubtaskCountReq.setCurrentPreachform("4");
                break;
            case "wechatAgainInfo":
            case "weChatAgainInfo":
                serviceSubtaskCountReq.setVisitCount(2);
                serviceSubtaskCountReq.setCurrentPreachform("5");
                break;
@@ -3026,7 +3958,7 @@
                serviceSubtaskCountReq.setSendstates(new ArrayList<>(Collections.singletonList(6L)));
                serviceSubtaskCountReq.setCurrentPreachform("4");
                break;
            case "wechatAgainSuccessInfo":
            case "weChatAgainSuccessInfo":
                serviceSubtaskCountReq.setVisitCount(2);
                serviceSubtaskCountReq.setSendstates(new ArrayList<>(Collections.singletonList(6L)));
                serviceSubtaskCountReq.setCurrentPreachform("5");
@@ -3034,6 +3966,11 @@
            case "abnormalAgainInfo":
                serviceSubtaskCountReq.setVisitCount(2);
                serviceSubtaskCountReq.setExcep("1");
                break;
            case "suggest7CountAgainInfo":
                // 再次随访回院复诊复查:visitCount > 1 且 suggest = 7
                serviceSubtaskCountReq.setVisitCount(2);
                serviceSubtaskCountReq.setSuggest("7");
                break;
            case "taskSituation1Info":
                serviceSubtaskCountReq.setTaskSituation(1);
@@ -3057,9 +3994,55 @@
                serviceSubtaskCountReq.setTaskSituation(6);
                serviceSubtaskCountReq.setSendstates(new ArrayList<>(Collections.singletonList(4L)));
                break;
            case "manuallyCalledInfo":
                serviceSubtaskCountReq.setIsManuallyCalled(1L);
                break;
            case "manualCallTimeoutInfo":
                serviceSubtaskCountReq.setVisitCount(1);
                serviceSubtaskCountReq.setSendstates(new ArrayList<>(Collections.singletonList(7L)));
                serviceSubtaskCountReq.setIsManuallyCalled(0L);
                break;
            case "manualCallTimeoutAgainInfo":
                serviceSubtaskCountReq.setVisitCount(2);
                serviceSubtaskCountReq.setSendstates(new ArrayList<>(Collections.singletonList(7L)));
                serviceSubtaskCountReq.setIsManuallyCalled(0L);
                break;
            case "pendingFollowUpAllInfo":
                serviceSubtaskCountReq.setVisitCount(1);
                serviceSubtaskCountReq.setSendstates(new ArrayList<>(Arrays.asList(1L, 2L, 3L, 7L)));
                break;
            case "pendingFollowUpAllAgainInfo":
                serviceSubtaskCountReq.setVisitCount(2);
                serviceSubtaskCountReq.setSendstates(new ArrayList<>(Arrays.asList(1L, 2L, 3L, 7L)));
                break;
            case "pendingFollowUpWaitSendInfo":
                serviceSubtaskCountReq.setVisitCount(1);
                serviceSubtaskCountReq.setSendstates(new ArrayList<>(Arrays.asList(1L, 2L)));
                break;
            case "pendingFollowUpWaitSendAgainInfo":
                serviceSubtaskCountReq.setVisitCount(2);
                serviceSubtaskCountReq.setSendstates(new ArrayList<>(Arrays.asList(1L, 2L)));
                break;
            case "pendingFollowUpSentInfo":
                serviceSubtaskCountReq.setVisitCount(1);
                serviceSubtaskCountReq.setSendstates(new ArrayList<>(Arrays.asList(3L, 7L)));
                break;
            case "pendingFollowUpSentAgainInfo":
                serviceSubtaskCountReq.setVisitCount(2);
                serviceSubtaskCountReq.setSendstates(new ArrayList<>(Arrays.asList(3L, 7L)));
                break;
            case "sentInfo":
                serviceSubtaskCountReq.setVisitCount(1);
                serviceSubtaskCountReq.setSendstates(new ArrayList<>(Collections.singletonList(3L)));
                break;
            case "sentAgainInfo":
                serviceSubtaskCountReq.setVisitCount(2);
                serviceSubtaskCountReq.setSendstates(new ArrayList<>(Collections.singletonList(3L)));
                break;
            default:
                break;
        }
        return true;
    }
    /**
@@ -3069,72 +4052,186 @@
     * @return
     */
    @Override
    @Cacheable(value = "getSfStatisticsScript", key = "T(org.springframework.util.DigestUtils).md5DigestAsHex(#serviceSubtaskCountReq.toString().getBytes())", unless = "#result == null or #result.isEmpty()")
//    @Cacheable(value = "getSfStatisticsScript", key = "T(org.springframework.util.DigestUtils).md5DigestAsHex(#serviceSubtaskCountReq.toString().getBytes())", unless = "#result == null or #result.isEmpty()")
    public List<ServiceSubtaskStatistic> getSfStatisticsScript(ServiceSubtaskCountReq serviceSubtaskCountReq) {
        log.info("getSfStatisticsScript的入参为:{}", serviceSubtaskCountReq);
        List<ServiceSubtaskStatistic> serviceSubtaskStatistics = new ArrayList<>();
        // 根据条件进行分组
        Map<String, List<ServiceSubtask>> collect = new HashMap<>();
        // 根据条件决定分组维度:离院维度按 leaveldeptcode / leavehospitaldistrictcode(含历史 deptcodes 入参),
        // 在院维度按 deptcode / hospitaldistrictcode
        String groupKey = "drcode";
        boolean hasDrcode = serviceSubtaskCountReq.getDrcode() != null && !serviceSubtaskCountReq.getDrcode().isEmpty();
        if (serviceSubtaskCountReq.getLeavehospitaldistrictcodes() != null && !serviceSubtaskCountReq.getLeavehospitaldistrictcodes().isEmpty()) {
            if (serviceSubtaskCountReq.getDrcode() != null && !serviceSubtaskCountReq.getDrcode().isEmpty()) {
                groupKey = "drcode";
            } else {
                groupKey = "leavehospitaldistrictcode";
            }
            groupKey = hasDrcode ? "drcode" : "leavehospitaldistrictcode";
        } else if (serviceSubtaskCountReq.getLeaveldeptcodes() != null && !serviceSubtaskCountReq.getLeaveldeptcodes().isEmpty()) {
            groupKey = hasDrcode ? "drcode" : "leaveldeptcode";
        } else if (serviceSubtaskCountReq.getHospitaldistrictcodes() != null && !serviceSubtaskCountReq.getHospitaldistrictcodes().isEmpty()) {
            // 在院满意度:按在院病区分组(hospitaldistrictcode)
            groupKey = hasDrcode ? "drcode" : "hospitaldistrictcode";
        } else if (serviceSubtaskCountReq.getHospitaldeptcodes() != null && !serviceSubtaskCountReq.getHospitaldeptcodes().isEmpty()) {
            // 在院满意度:按在院科室分组(deptcode)
            groupKey = hasDrcode ? "drcode" : "deptcode";
        } else if (serviceSubtaskCountReq.getDeptcodes() != null && !serviceSubtaskCountReq.getDeptcodes().isEmpty()) {
            if (serviceSubtaskCountReq.getDrcode() != null && !serviceSubtaskCountReq.getDrcode().isEmpty()) {
                groupKey = "drcode";
            } else {
                groupKey = "deptcode";
            }
            // 历史入参 deptcodes 表示离院科室
            groupKey = hasDrcode ? "drcode" : "leaveldeptcode";
        } else if (serviceSubtaskCountReq.getStatisticaltype() != null && serviceSubtaskCountReq.getStatisticaltype() == 1) {
            // 历史入参(市一等老前端只传 statisticaltype):1 = 病区统计
            groupKey = hasDrcode ? "drcode" : "leavehospitaldistrictcode";
        } else if (serviceSubtaskCountReq.getStatisticaltype() != null && serviceSubtaskCountReq.getStatisticaltype() == 2) {
            // 历史入参:2 = 科室统计(原 deptcode 即现在的离院科室 leaveldeptcode)
            groupKey = hasDrcode ? "drcode" : "leaveldeptcode";
        } else if (serviceSubtaskCountReq.getStatisticaltype() != null && serviceSubtaskCountReq.getStatisticaltype() == 3) {
            // 病种统计:按任务ID分组,每个任务即一个病种随访任务
            groupKey = "taskid";
        }
        serviceSubtaskCountReq.setGroupKey(groupKey);
        // 分组列与过滤列保持一致:离院科室 leaveldeptcode、在院科室 deptcode
        serviceSubtaskCountReq.setGroupColumn(groupKey);
        /*
         * 满意度「病区统计」(离院维度):出院病区为空则回退到在院病区。
         * 满意度子任务(service_type 6/14/15/16)的 leavehospitaldistrictcode 约三成为空,
         * 之前为了“查得到数据”把病区条件写成 `xxx IS NULL OR xxx IN (...)`,
         * 于是选了 A 病区也会把空病区的全量数据带进来,再统一归到 'Unknown' 组,
         * 该组的病区名又是 MAX(hospitaldistrictname) 随便挑的 —— 表现就是
         * “选了血液内科病区,却查出一行显示骨科中心三病区”。
         * 用“有效病区”列过滤 + 分组后:筛选真正生效,空病区的记录也回到它真实的在院病区。
         */
        if ("leavehospitaldistrictcode".equals(groupKey)) {
            String districtColumn = "IFNULL(leavehospitaldistrictcode, hospitaldistrictcode)";
            serviceSubtaskCountReq.setGroupColumn(districtColumn);
            serviceSubtaskCountReq.setDistrictColumn(districtColumn);
        }
        //分组键集合:这一步保持不变(分页参数照旧作用在该查询上),保证“参与统计的分组”与改造前一致
        List<String> groupKeyList = serviceSubtaskMapper.getSfStatisticsGroupKey(serviceSubtaskCountReq);
        /*
         * 分组列可能为 NULL:满意度子任务(service_type 6/14/15/16)的 leavehospitaldistrictcode
         * 大量为 NULL,getSfStatisticsGroupKey 会把 NULL 原样带回来,
         * 拼进 SQL 就变成 `AND xxx IN (null)`(恒不成立),整张统计表直接空。
         * 这里统一成 'Unknown',与聚合 SQL 的 GROUP BY IFNULL(列,'Unknown') 对齐。
         */
        if (groupKeyList != null) {
            groupKeyList = groupKeyList.stream()
                    .map(key -> key == null ? "Unknown" : key)
                    .distinct()
                    .collect(Collectors.toList());
        }
        serviceSubtaskCountReq.setGroupKeyList(groupKeyList);
        List<ServiceSubtask> rawData = serviceSubtaskMapper.getSfStatistics(serviceSubtaskCountReq);
        switch (groupKey) {
            case "deptcode":
                collect = rawData.stream().collect(Collectors.groupingBy(subtask -> Optional.ofNullable(subtask.getDeptcode()).orElse("Unknown")));
                break;
            case "drcode":
                collect = rawData.stream().collect(Collectors.groupingBy(subtask -> Optional.ofNullable(subtask.getDrcode()).orElse("Unknown")));
                break;
            case "leavehospitaldistrictcode":
                collect = rawData.stream().collect(Collectors.groupingBy(subtask -> Optional.ofNullable(subtask.getLeavehospitaldistrictcode()).orElse("Unknown")));
                break;
            default:
                collect = rawData.stream().collect(Collectors.groupingBy(subtask -> Optional.ofNullable(subtask.getDrcode()).orElse("Unknown")));
                break;
        /*
         * 统计下推到数据库:一次 GROUP BY 只返回“分组数”行。
         * 改造前是 serviceSubtaskMapper.getSfStatistics() 把命中的全部明细行(39 个字段,
         * 含 continue_content 等大字段)捞回 JVM 后逐行累加,几千行时传输和遍历都很慢。
         * 这里的聚合口径与原 getStatisticCategory 逐条对齐(见 Mapper 中 getSfStatisticsScriptSum 的注释)。
         */
        List<ServiceSubtaskStatistic> sumStatistics = serviceSubtaskMapper.getSfStatisticsScriptSum(serviceSubtaskCountReq);
        if (CollectionUtils.isEmpty(sumStatistics)) {
            return serviceSubtaskStatistics;
        }
        //获取及时率统计合集
        Map<String, Map<String, Object>> jsRates = getSsRateForSfStatistics(serviceSubtaskCountReq, collect);
        //一次性查询所有分组的题目统计,避免每个分组一次SQL(N+1)
        List<Long> allSubTaskIds = rawData.stream().map(ServiceSubtask::getId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
        List<ServiceSubtaskCount> subCounts = new ArrayList<>();
        if (CollectionUtils.isNotEmpty(allSubTaskIds)) {
            serviceSubtaskCountReq.setSubTaskIds(allSubTaskIds);
            subCounts = serviceSubtaskMapper.getSfStatisticsCategory(serviceSubtaskCountReq);
        }
        for (String collectKey : collect.keySet()) {
            List<ServiceSubtask> serviceSubtaskList = collect.get(collectKey);
            if (CollectionUtils.isEmpty(serviceSubtaskList)) {
                continue;
            }
            ServiceSubtaskStatistic statistic = getStatisticCategory(serviceSubtaskList, serviceSubtaskCountReq, subCounts);
            if (ObjectUtils.isNotEmpty(jsRates)) {
                Map<String, Object> jsRateMap = jsRates.get(collectKey);
                BigDecimal jsRate = new BigDecimal("0");
                if (ObjectUtils.isNotEmpty(jsRateMap) && ObjectUtils.isNotEmpty(jsRateMap.get("rate"))) {
                    jsRate = new BigDecimal(jsRateMap.get("rate").toString());
        /*
         * 病种统计(按 taskid 分组)时补齐任务ID、任务名称和疾病病种。
         * 聚合 SQL 已返回 taskid/task_name,这里再补 diag_type。
         */
        if ("taskid".equals(groupKey)) {
            Map<Long, String> diagTypeCache = new HashMap<>();
            for (ServiceSubtaskStatistic statistic : sumStatistics) {
                Long taskId = statistic.getTaskId();
                if (taskId == null && StringUtils.isNotEmpty(statistic.getGroupCode())) {
                    try {
                        taskId = Long.valueOf(statistic.getGroupCode());
                    } catch (NumberFormatException ignore) {
                        // 历史数据可能不是数值 taskid,保持原分组键即可
                    }
                }
                Double jsRateDouble = (jsRate.setScale(4, RoundingMode.HALF_UP)).doubleValue();
                statistic.setRate(jsRateDouble);
                statistic.setTaskId(taskId);
                if (StringUtils.isEmpty(statistic.getTaskName())) {
                    statistic.setTaskName(statistic.getGroupName());
                }
                if (taskId != null) {
                    if (!diagTypeCache.containsKey(taskId)) {
                        ServiceTask task = serviceTaskService.selectServiceTaskByTaskid(taskId);
                        diagTypeCache.put(taskId, task == null ? null : task.getDiagType());
                    }
                    statistic.setDiagType(diagTypeCache.get(taskId));
                }
            }
            if (ObjectUtils.isNotEmpty(statistic)) {
                serviceSubtaskStatistics.add(statistic);
        }
        /*
         * 满意度题目量:也改成按分组在库里聚合。
         * 改造前是先把本次命中的全部子任务 id 灌进 getSfStatisticsCategory 的 IN 列表查一遍,
         * 再在内存里对每个子任务做一次 subCounts.stream().filter(...)(N*M 次比较)。
         */
        Map<String, Integer> joyCountMap = new HashMap<>();
        Map<String, Integer> joyAllCountMap = new HashMap<>();
        if (StringUtils.isNotEmpty(serviceSubtaskCountReq.getConfigValue())) {
            List<Map<String, Object>> joySums = serviceSubtaskMapper.getSfStatisticsScriptJoySum(serviceSubtaskCountReq);
            if (CollectionUtils.isNotEmpty(joySums)) {
                for (Map<String, Object> joySum : joySums) {
                    String joyGroupCode = joySum.get("groupCode") == null ? "Unknown" : joySum.get("groupCode").toString();
                    joyCountMap.put(joyGroupCode, toIntValue(joySum.get("joyCount")));
                    joyAllCountMap.put(joyGroupCode, toIntValue(joySum.get("joyAllCount")));
                }
            }
        }
        //随访率:口径与原 getStatisticCategory 一致(首次率的判断沿用原来的 needFollowUp、再次率沿用原来的分母)
        DecimalFormat percentFormat = new DecimalFormat("##.##%");
        List<String> statisticsGroupKeys = new ArrayList<>();
        for (ServiceSubtaskStatistic statistic : sumStatistics) {
            String statisticGroupCode = statistic.getGroupCode() == null ? "Unknown" : statistic.getGroupCode();
            statistic.setJoyCount(joyCountMap.getOrDefault(statisticGroupCode, 0));
            statistic.setJoyAllCount(joyAllCountMap.getOrDefault(statisticGroupCode, 0));
            //首次随访率:只有该分组存在首次随访记录时才会被赋值(不存在则是 null,与改造前一致)
            if (statistic.getFirstVisitCount() != null && statistic.getFirstVisitCount() > 0) {
                if (statistic.getNeedFollowUp() != null && statistic.getNeedFollowUp() > 0) {
                    double rate = (double) (nvlLong(statistic.getFollowUpSuccess()) + nvlLong(statistic.getFollowUpFail())) / statistic.getNeedFollowUp();
                    statistic.setFollowUpRate(percentFormat.format(rate));
                } else {
                    statistic.setFollowUpRate("0.00%");
                }
            }
            //再次随访率:判断条件与原实现一致(原来判断的是 needFollowUp,这里照搬以免口径变化)
            if (statistic.getAgainVisitCount() != null && statistic.getAgainVisitCount() > 0) {
                if (statistic.getNeedFollowUp() != null && statistic.getNeedFollowUp() > 0) {
                    double rateAgain = (double) (nvlLong(statistic.getFollowUpSuccessAgain()) + nvlLong(statistic.getFollowUpFailAgain())) / nvlLong(statistic.getNeedFollowUpAgain());
                    statistic.setFollowUpRateAgain(percentFormat.format(rateAgain));
                } else {
                    statistic.setFollowUpRateAgain("0.00%");
                }
            }
            statisticsGroupKeys.add(statisticGroupCode);
            serviceSubtaskStatistics.add(statistic);
        }
        /*
         * 病区统计:这一行的病区就是分组键本身,展示名取该组自己的病区名。
         * 聚合 SQL 里原来的 MAX(hospitaldistrictname) 在“一个组混了多个病区”时会挑出
         * 一个毫不相干的病区名(选血液内科病区显示成骨科中心三病区就是这么来的)。
         * 这里把分组键回填到在院/离院两套病区字段,保证前端不论读哪一列都是同一个病区,
         * 也让“查看详情”带上来的病区编码能查回同一批数据。
         */
        if (StringUtils.isNotEmpty(serviceSubtaskCountReq.getDistrictColumn())) {
            for (ServiceSubtaskStatistic statistic : serviceSubtaskStatistics) {
                String districtCode = statistic.getGroupCode() == null ? "Unknown" : statistic.getGroupCode();
                String districtName = StringUtils.isNotEmpty(statistic.getGroupName()) ? statistic.getGroupName() : districtCode;
                statistic.setLeavehospitaldistrictcode(districtCode);
                statistic.setLeavehospitaldistrictname(districtName);
                statistic.setHospitaldistrictcode(districtCode);
                statistic.setHospitaldistrictname(districtName);
            }
        }
        //获取及时率统计合集(一次批量查询,不按分组循环查)
        Map<String, Map<String, Object>> jsRates = getSsRateForSfStatistics(serviceSubtaskCountReq, statisticsGroupKeys);
        for (ServiceSubtaskStatistic statistic : serviceSubtaskStatistics) {
            String statisticGroupCode = statistic.getGroupCode() == null ? "Unknown" : statistic.getGroupCode();
            Map<String, Object> jsRateMap = jsRates == null ? null : jsRates.get(statisticGroupCode);
            BigDecimal jsRate = new BigDecimal("0");
            if (ObjectUtils.isNotEmpty(jsRateMap) && ObjectUtils.isNotEmpty(jsRateMap.get("rate"))) {
                jsRate = new BigDecimal(jsRateMap.get("rate").toString());
            }
            statistic.setRate((jsRate.setScale(4, RoundingMode.HALF_UP)).doubleValue());
        }
        //统计随访题目分类
@@ -3154,7 +4251,9 @@
                    sum = new BigDecimal(serviceSubtaskStatistic.getJoyAllCount());
                }
                if (sum.intValue() != 0) {
                    finishedPercentage = finishedSum.divide(sum, 2, RoundingMode.HALF_UP).doubleValue();
                    // 原来只保留 2 位小数(23634/26106 会被截成 0.91),前端再 ×100
                    // 就显示成 91.00%,与实际的 90.53% 差了半个百分点。这里保留 4 位。
                    finishedPercentage = finishedSum.divide(sum, 4, RoundingMode.HALF_UP).doubleValue();
                }
                serviceSubtaskStatistic.setJoyAllCount(sum.intValue());
                serviceSubtaskStatistic.setJoyCount(finishedSum.intValue());
@@ -3199,8 +4298,7 @@
    }
    @Override
    public Map<String, ServiceSubtaskScriptCount> getSfStatisticsScriptDetails(ServiceSubtaskCountReq serviceSubtaskCountReq) {
        Map<String, ServiceSubtaskScriptCount> result = new HashMap<>();
    public Map<String, Object> getSfStatisticsScriptDetails(ServiceSubtaskCountReq serviceSubtaskCountReq) {
        List<ServiceSubtaskScriptCount> allCounts = new ArrayList<>();
        List<ServiceSubtaskScriptCount> allItemCounts = new ArrayList<>();
        String type = serviceSubtaskCountReq.getType();
@@ -3224,7 +4322,23 @@
            allItemCounts = serviceSubtaskMapper.getSfStatisticsScriptItemDetails(serviceSubtaskCountReq);
        }
        for (ServiceSubtaskScriptCount count : allCounts) {
        //返回结构是 Map,且不传 type 时会执行两条 SQL,PageHelper 没法对整体分页,
        //所以在题目合并完成之后按题目(scriptid)在这里分页,保证 total 和每页数据都是题目维度的。
        allCounts.sort(Comparator
                .comparing(ServiceSubtaskScriptCount::getSubtaskType, Comparator.nullsLast(String::compareTo))
                .thenComparing(ServiceSubtaskScriptCount::getScriptid, Comparator.nullsLast(Long::compareTo)));
        int pageNum = ObjectUtils.isNotEmpty(serviceSubtaskCountReq.getPageNum()) && serviceSubtaskCountReq.getPageNum() > 0
                ? serviceSubtaskCountReq.getPageNum() : 1;
        int pageSize = ObjectUtils.isNotEmpty(serviceSubtaskCountReq.getPageSize()) && serviceSubtaskCountReq.getPageSize() > 0
                ? serviceSubtaskCountReq.getPageSize() : 10;
        int total = allCounts.size();
        int fromIndex = (int) Math.min((long) (pageNum - 1) * pageSize, total);
        int toIndex = Math.min(fromIndex + pageSize, total);
        List<ServiceSubtaskScriptCount> pageCounts = allCounts.subList(fromIndex, toIndex);
        Map<String, ServiceSubtaskScriptCount> pageData = new LinkedHashMap<>();
        for (ServiceSubtaskScriptCount count : pageCounts) {
            Map<String, ServiceSubtaskScriptCount> itemCountMap = new HashMap<>();
            //问卷加上空白选项
            if (ObjectUtils.isNotEmpty(count.getTemplateid()) && ObjectUtils.isNotEmpty(count.getSubtaskType()) && count.getSubtaskType().equals("2")) {
@@ -3255,32 +4369,125 @@
                }
            }
            allItemCounts.forEach(itemCount -> {
                if (ObjectUtils.isNotEmpty(count.getScriptid()) && ObjectUtils.isNotEmpty(itemCount.getScriptid())) {
                    if (count.getScriptid().equals(itemCount.getScriptid())) {
                        //放入统计信息
                        itemCount.setScriptContent(count.getScriptContent());
                        itemCount.setScriptType(count.getScriptType());
                        itemCount.setSubtaskType(count.getSubtaskType());
                        itemCount.setCompletedQuantity(count.getCompletedQuantity());
                        itemCount.setAllQuantity(count.getAllQuantity());
                        itemCount.setCompletedPercentage(count.getCompletedPercentage());
                        itemCount.setTemplateid(count.getTemplateid());
                        //计算选择比例
                        int chosenQuantity = ObjectUtils.isNotEmpty(itemCount.getChosenQuantity()) ? itemCount.getChosenQuantity() : 0;
                        int completedQuantity = itemCount.getCompletedQuantity();
                        if (ObjectUtils.isNotEmpty(completedQuantity) && completedQuantity != 0) {
                            BigDecimal chosenPercentage = (new BigDecimal(chosenQuantity)).divide(new BigDecimal(completedQuantity), 2, RoundingMode.HALF_UP);
                            itemCount.setChosenPercentage(chosenPercentage.toString());
                        }
                        itemCountMap.put(itemCount.getOptionText(), itemCount);
                //明细行已按“题目内容+任务类型”聚合并带出 script_content,不能用 scriptid 匹配:
                //同一道题会被复制到多个任务模板,每个模板的 scriptid 都不同
                boolean sameQuestion = ObjectUtils.isNotEmpty(count.getScriptContent())
                        && ObjectUtils.isNotEmpty(itemCount.getScriptContent())
                        && count.getScriptContent().equals(itemCount.getScriptContent())
                        && Objects.equals(count.getSubtaskType(), itemCount.getSubtaskType());
                if (sameQuestion) {
                    //放入统计信息
                    itemCount.setScriptContent(count.getScriptContent());
                    itemCount.setScriptType(count.getScriptType());
                    itemCount.setSubtaskType(count.getSubtaskType());
                    itemCount.setCompletedQuantity(count.getCompletedQuantity());
                    itemCount.setAllQuantity(count.getAllQuantity());
                    itemCount.setCompletedPercentage(count.getCompletedPercentage());
                    itemCount.setTemplateid(count.getTemplateid());
                    //计算选择比例
                    int chosenQuantity = ObjectUtils.isNotEmpty(itemCount.getChosenQuantity()) ? itemCount.getChosenQuantity() : 0;
                    int completedQuantity = itemCount.getCompletedQuantity();
                    if (ObjectUtils.isNotEmpty(completedQuantity) && completedQuantity != 0) {
                        BigDecimal chosenPercentage = (new BigDecimal(chosenQuantity)).divide(new BigDecimal(completedQuantity), 2, RoundingMode.HALF_UP);
                        itemCount.setChosenPercentage(chosenPercentage.toString());
                    }
                    itemCountMap.put(itemCount.getOptionText(), itemCount);
                }
            });
            List<ServiceSubtaskScriptCount> itemCounts = new ArrayList<>(itemCountMap.values());
            count.setDetails(itemCounts);
            result.put(count.getScriptid().toString(), count);
            pageData.put(count.getScriptid().toString(), count);
        }
        Map<String, Object> result = new HashMap<>();
        // total 是题目总数(已按题目合并),rows/data 是当前页数据
        result.put("total", total);
        result.put("pageNum", pageNum);
        result.put("pageSize", pageSize);
        result.put("rows", new ArrayList<>(pageCounts));
        result.put("data", pageData);
        return result;
    }
    /**
     * 问题选项数字下钻:返回选择了指定选项的 service_subtask 列表。
     * <p>
     * 统计明细中的选项数量来自 service_subtask_detail 的
     * IFNULL(matchedtext, asrtext),因此下钻必须使用同一题目的相同选项进行匹配。
     *
     * @param serviceSubtaskCountReq 下钻条件
     * @return service_subtask 列表
     */
    @Override
    public List<ServiceSubtask> getSfStatisticsScriptDetailsHyperlink(ServiceSubtaskCountReq serviceSubtaskCountReq) {
        log.info("getSfStatisticsScriptDetailsHyperlink的入参为:{}", serviceSubtaskCountReq);
        if (serviceSubtaskCountReq == null) {
            return new ArrayList<>();
        }
        // 兼容前端直接回传统计明细行中的 subtaskType
        if (StringUtils.isBlank(serviceSubtaskCountReq.getType())
                && StringUtils.isNotBlank(serviceSubtaskCountReq.getSubtaskType())) {
            serviceSubtaskCountReq.setType(serviceSubtaskCountReq.getSubtaskType());
        }
        resolveScriptContentAndType(serviceSubtaskCountReq);
        if (StringUtils.isBlank(serviceSubtaskCountReq.getScriptContent())
                && serviceSubtaskCountReq.getScriptid() == null) {
            log.warn("getSfStatisticsScriptDetailsHyperlink-题目scriptid和scriptContent均为空,返回空列表");
            return new ArrayList<>();
        }
        if (serviceSubtaskCountReq.getOptionText() == null) {
            log.warn("getSfStatisticsScriptDetailsHyperlink-选项optionText为空,返回空列表");
            return new ArrayList<>();
        }
        return serviceSubtaskMapper.getSfStatisticsScriptDetailsHyperlink(serviceSubtaskCountReq);
    }
    /**
     * 根据 scriptid 补齐题目内容和任务类型。
     * <p>
     * 统计明细按“题目内容 + 任务类型”合并,同一道题复制到多个任务模板时 scriptid 可能不同,
     * 因此优先按题目内容下钻;只有查不到题目内容时才退化为 scriptid 精确匹配。
     */
    private void resolveScriptContentAndType(ServiceSubtaskCountReq serviceSubtaskCountReq) {
        if (serviceSubtaskCountReq.getScriptid() == null) {
            return;
        }
        // 已有题目内容和任务类型时无需再查模板问题表
        if (StringUtils.isNotBlank(serviceSubtaskCountReq.getScriptContent())
                && StringUtils.isNotBlank(serviceSubtaskCountReq.getType())) {
            return;
        }
        String type = serviceSubtaskCountReq.getType();
        if (StringUtils.isBlank(type) || "1".equals(type)) {
            IvrTaskTemplateScript ivrTaskTemplateScript =
                    iIvrTaskTemplateScriptService.selectIvrTaskTemplateScriptByID(serviceSubtaskCountReq.getScriptid());
            if (ObjectUtils.isNotEmpty(ivrTaskTemplateScript)) {
                if (StringUtils.isBlank(serviceSubtaskCountReq.getType())) {
                    serviceSubtaskCountReq.setType("1");
                }
                if (StringUtils.isBlank(serviceSubtaskCountReq.getScriptContent())) {
                    serviceSubtaskCountReq.setScriptContent(ivrTaskTemplateScript.getScriptContent());
                }
                return;
            }
            // 明确指定了语音类型时不再尝试问卷表
            if (StringUtils.isNotBlank(type)) {
                return;
            }
        }
        if (StringUtils.isBlank(serviceSubtaskCountReq.getType()) || "2".equals(type)) {
            SvyTaskTemplateScript svyTaskTemplateScript =
                    svyTaskTemplateScriptService.selectSvyTaskTemplateScriptByID(serviceSubtaskCountReq.getScriptid());
            if (ObjectUtils.isNotEmpty(svyTaskTemplateScript)) {
                if (StringUtils.isBlank(serviceSubtaskCountReq.getType())) {
                    serviceSubtaskCountReq.setType("2");
                }
                if (StringUtils.isBlank(serviceSubtaskCountReq.getScriptContent())) {
                    serviceSubtaskCountReq.setScriptContent(svyTaskTemplateScript.getScriptContent());
                }
            }
        }
    }
    /**
@@ -3294,6 +4501,8 @@
        // 初始化数值字段
        total.setDischargeCount(0);
        total.setFirstDischargeCount(0L);
        total.setAgainFollowUpPushCount(0L);
        total.setNonFollowUp(0L);
        total.setFilterCount(0L);
        total.setFollowUpNeeded(0L);
@@ -3301,14 +4510,26 @@
        total.setPendingFollowUp(0L);
        total.setFollowUpSuccess(0L);
        total.setFollowUpFail(0L);
        total.setTimelyFollowUp(0L);
        total.setManual(0L);
        total.setSms(0L);
        total.setWeChat(0L);
        total.setAbnormal(0L);
        total.setNeedFollowUpAgain(0L);
        total.setPendingFollowUpAgain(0L);
        total.setManualCallTimeout(0L);
        total.setManualCallTimeoutAgain(0L);
        total.setPendingFollowUpAll(0L);
        total.setPendingFollowUpAllAgain(0L);
        total.setPendingFollowUpWaitSend(0L);
        total.setPendingFollowUpWaitSendAgain(0L);
        total.setPendingFollowUpSent(0L);
        total.setPendingFollowUpSentAgain(0L);
        total.setSent(0L);
        total.setSentAgain(0L);
        total.setFollowUpSuccessAgain(0L);
        total.setFollowUpFailAgain(0L);
        total.setSuggest7CountAgain(0L);
        total.setManualAgain(0L);
        total.setSmsAgain(0L);
        total.setWeChatAgain(0L);
@@ -3325,6 +4546,8 @@
            if (stat.getDischargeCount() != null) {
                total.setDischargeCount(total.getDischargeCount() + stat.getDischargeCount());
            }
            total.setFirstDischargeCount(total.getFirstDischargeCount() + (stat.getFirstDischargeCount() != null ? stat.getFirstDischargeCount() : 0L));
            total.setAgainFollowUpPushCount(total.getAgainFollowUpPushCount() + (stat.getAgainFollowUpPushCount() != null ? stat.getAgainFollowUpPushCount() : 0L));
            total.setNonFollowUp(total.getNonFollowUp() + (stat.getNonFollowUp() != null ? stat.getNonFollowUp() : 0L));
            total.setFilterCount(total.getFilterCount() + (stat.getFilterCount() != null ? stat.getFilterCount() : 0L));
            total.setFollowUpNeeded(total.getFollowUpNeeded() + (stat.getFollowUpNeeded() != null ? stat.getFollowUpNeeded() : 0L));
@@ -3332,14 +4555,26 @@
            total.setPendingFollowUp(total.getPendingFollowUp() + (stat.getPendingFollowUp() != null ? stat.getPendingFollowUp() : 0L));
            total.setFollowUpSuccess(total.getFollowUpSuccess() + (stat.getFollowUpSuccess() != null ? stat.getFollowUpSuccess() : 0L));
            total.setFollowUpFail(total.getFollowUpFail() + (stat.getFollowUpFail() != null ? stat.getFollowUpFail() : 0L));
            total.setTimelyFollowUp(total.getTimelyFollowUp() + (stat.getTimelyFollowUp() != null ? stat.getTimelyFollowUp() : 0L));
            total.setManual(total.getManual() + (stat.getManual() != null ? stat.getManual() : 0L));
            total.setSms(total.getSms() + (stat.getSms() != null ? stat.getSms() : 0L));
            total.setWeChat(total.getWeChat() + (stat.getWeChat() != null ? stat.getWeChat() : 0L));
            total.setAbnormal(total.getAbnormal() + (stat.getAbnormal() != null ? stat.getAbnormal() : 0L));
            total.setNeedFollowUpAgain(total.getNeedFollowUpAgain() + (stat.getNeedFollowUpAgain() != null ? stat.getNeedFollowUpAgain() : 0L));
            total.setPendingFollowUpAgain(total.getPendingFollowUpAgain() + (stat.getPendingFollowUpAgain() != null ? stat.getPendingFollowUpAgain() : 0L));
            total.setManualCallTimeout(total.getManualCallTimeout() + (stat.getManualCallTimeout() != null ? stat.getManualCallTimeout() : 0L));
            total.setManualCallTimeoutAgain(total.getManualCallTimeoutAgain() + (stat.getManualCallTimeoutAgain() != null ? stat.getManualCallTimeoutAgain() : 0L));
            total.setPendingFollowUpAll(total.getPendingFollowUpAll() + (stat.getPendingFollowUpAll() != null ? stat.getPendingFollowUpAll() : 0L));
            total.setPendingFollowUpAllAgain(total.getPendingFollowUpAllAgain() + (stat.getPendingFollowUpAllAgain() != null ? stat.getPendingFollowUpAllAgain() : 0L));
            total.setPendingFollowUpWaitSend(total.getPendingFollowUpWaitSend() + (stat.getPendingFollowUpWaitSend() != null ? stat.getPendingFollowUpWaitSend() : 0L));
            total.setPendingFollowUpWaitSendAgain(total.getPendingFollowUpWaitSendAgain() + (stat.getPendingFollowUpWaitSendAgain() != null ? stat.getPendingFollowUpWaitSendAgain() : 0L));
            total.setPendingFollowUpSent(total.getPendingFollowUpSent() + (stat.getPendingFollowUpSent() != null ? stat.getPendingFollowUpSent() : 0L));
            total.setPendingFollowUpSentAgain(total.getPendingFollowUpSentAgain() + (stat.getPendingFollowUpSentAgain() != null ? stat.getPendingFollowUpSentAgain() : 0L));
            total.setSent(total.getSent() + (stat.getSent() != null ? stat.getSent() : 0L));
            total.setSentAgain(total.getSentAgain() + (stat.getSentAgain() != null ? stat.getSentAgain() : 0L));
            total.setFollowUpSuccessAgain(total.getFollowUpSuccessAgain() + (stat.getFollowUpSuccessAgain() != null ? stat.getFollowUpSuccessAgain() : 0L));
            total.setFollowUpFailAgain(total.getFollowUpFailAgain() + (stat.getFollowUpFailAgain() != null ? stat.getFollowUpFailAgain() : 0L));
            total.setSuggest7CountAgain(total.getSuggest7CountAgain() + (stat.getSuggest7CountAgain() != null ? stat.getSuggest7CountAgain() : 0L));
            total.setManualAgain(total.getManualAgain() + (stat.getManualAgain() != null ? stat.getManualAgain() : 0L));
            total.setSmsAgain(total.getSmsAgain() + (stat.getSmsAgain() != null ? stat.getSmsAgain() : 0L));
            total.setWeChatAgain(total.getWeChatAgain() + (stat.getWeChatAgain() != null ? stat.getWeChatAgain() : 0L));
@@ -3401,7 +4636,7 @@
            diagname.setPhone(subtask.getPhone());
            diagname.setNurseName(subtask.getNurseName());
            diagname.setDrname(subtask.getDrname());
            diagname.setDeptname(subtask.getDeptname());
            diagname.setDeptname(subtask.getLeaveldeptname());
            diagname.setLeavehospitaldistrictname(subtask.getLeavehospitaldistrictname());
            diagname.setTemplatename(subtask.getTemplatename());
            diagname.setPreachform(subtask.getPreachform());
@@ -3413,6 +4648,55 @@
    public Map<String, Object> getCurrentUserServiceSubtaskCount(ServiceSubtaskEntity entity) {
        return serviceSubtaskMapper.getCurrentUserServiceSubtaskCount(entity);
    }
    /**
     * 当前登录人待处理随访任务,按出院年月分组统计(出院随访、门诊随访分开计数)
     * <p>
     * 只返回有数据的月份,前端按「当月 + 前 3 个月」补齐,没有数据显示 0 个。
     */
    @Override
    public Map<String, Object> getPendingManualTaskMonthStats(ServiceSubtaskEntity entity) {
        List<Map<String, Object>> rows = serviceSubtaskMapper.countPendingManualTaskByMonth(entity);
        List<Map<String, Object>> months = new ArrayList<>();
        long total = 0L;
        if (rows != null) {
            for (Map<String, Object> row : rows) {
                if (row == null || row.get("month") == null) {
                    continue;
                }
                long dischargeCount = parsePendingCount(row.get("dischargeCnt"));
                long outpatientCount = parsePendingCount(row.get("outpatientCnt"));
                if (dischargeCount <= 0 && outpatientCount <= 0) {
                    continue;
                }
                Map<String, Object> item = new LinkedHashMap<>();
                item.put("month", row.get("month").toString());
                item.put("dischargeCount", dischargeCount);
                item.put("outpatientCount", outpatientCount);
                months.add(item);
                total += dischargeCount + outpatientCount;
            }
        }
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("total", total);
        result.put("months", months);
        return result;
    }
    /**
     * 统计结果里的数量转 long,解析失败按 0 处理
     */
    private long parsePendingCount(Object value) {
        if (value == null) {
            return 0L;
        }
        try {
            return Long.parseLong(value.toString());
        } catch (NumberFormatException e) {
            log.warn("统计待处理随访任务失败,数量无法解析:{}", value);
            return 0L;
        }
    }
    @Override
@@ -3482,8 +4766,8 @@
    public void setFailSubTaskDefalutVal(Long failDay) {
        //1.先去sys_config中,获取哪些科室或病区需要默认问卷默认值
        IConfigService configService = SpringUtils.getBean(IConfigService.class);
        String deptConfigValue = taskSysConfigPort.selectConfigValue("hosp.default.dept");
        String wardConfigValue = taskSysConfigPort.selectConfigValue("hosp.default.ward");
        String deptConfigValue = sysConfigService.selectConfigByKey("hosp.default.dept");
        String wardConfigValue = sysConfigService.selectConfigByKey("hosp.default.ward");
        List<String> leaveWard = null;
        List<String> leaveDept = null;
        if (deptConfigValue != null && !deptConfigValue.isEmpty()) {
@@ -3508,7 +4792,6 @@
        //3.如果查出来的集合不为空,则进行循环
        for (ServiceSubtask serviceSubtask : serviceSubtasks) {
            Integer preachformSize = 0;
            Boolean flag = false;
            ServiceSubtaskPreachform serviceSubtaskPreachform = new ServiceSubtaskPreachform();
            serviceSubtaskPreachform.setSubid(serviceSubtask.getId());
            List<ServiceSubtaskPreachform> serviceSubtaskPreachforms = taskPreachformPort.selectServiceSubtaskPreachformList(serviceSubtaskPreachform);
@@ -3516,33 +4799,282 @@
                preachformSize = serviceSubtaskPreachforms.size();
            if (serviceSubtask.getUpdateTime() != null && DateUtils.differentDaysByMillisecond(new Date(), serviceSubtask.getUpdateTime()) >= failDay) {
                //3.1 判断当前serviceSubtask的updateTime是不是为空,如果不为空,则判断当前时间是不是与updateTime差距大于等于failDay
                flag = saveServiceSubtaskDetail(serviceSubtask);
                log.debug("【失败任务默认值】子任务 {} 达到失败天数(updateTime)", serviceSubtask.getId());
            } else if (serviceSubtask.getVisitTime() != null && DateUtils.differentDaysByMillisecond(new Date(), serviceSubtask.getVisitTime()) >= failDay + preachformSize) {
                //3.2 如果当前serviceSubtask的updateTime为空,根据当前serviceSubtask的subid去查service_subtask_preachform得到size(如果size为空,则默认0),则根据当前时间是不是与当前serviceSubtask的visitTime差距大于等于failDay + size
                flag = saveServiceSubtaskDetail(serviceSubtask);
                log.debug("【失败任务默认值】子任务 {} 达到失败天数(visitTime+preachformSize)", serviceSubtask.getId());
            } else {
                //3.3 如果2.1且2.2都为false,则continue
                continue;
            }
            //4.如果flag为true,则更新service_subtask表中的sendstate为6,preachform为1
            if (flag) {
                serviceSubtask.setSendstate(6L);
                serviceSubtask.setPreachform("1");
                serviceSubtaskMapper.updateServiceSubtask(serviceSubtask);
            //4. ⚠️ 2026-10-21 加固:先"算完"再"落库",彻底去掉"先插入、失败再删除"的写法。
            //   旧逻辑是边插边判断,只要有一题没配默认值,就把这个 sub_id 下**所有**明细(含患者真实作答、
            //   甚至刚插进去的那批)统统置 del_flag='1' —— nhfy 2026-09-08 那 10 位患者的满意度作答
            //   就是这样被删掉、导致导出「问题详情占比」370 而「随访信息表单」380 的。
            //   现在:① 清单为空(=有题没配默认值)→ 整份跳过,不写也不删;
            //         ② 清单齐全 → 统一落库(存在则更新并复活,不存在则插入)。
            List<ServiceSubtaskDetail> defaultDetails = buildDefaultDetails(serviceSubtask);
            if (CollectionUtils.isEmpty(defaultDetails)) {
                log.info("【失败任务默认值】任务 {} 子任务 {} 有题目未配置默认值,整份跳过(不插入、不删除任何明细)",
                        serviceSubtask.getTaskid(), serviceSubtask.getId());
                continue;
            }
            persistDefaultDetails(defaultDetails);
            //5.全部落库成功后再把任务置为已完成
            serviceSubtask.setSendstate(6L);
            serviceSubtask.setPreachform("1");
            serviceSubtaskMapper.updateServiceSubtask(serviceSubtask);
            followupCompletedEventPublisher.publish(serviceSubtask.getId());
        }
    }
    /**
     * 按模板构造"默认值明细"清单(<b>只计算、不落库</b>)
     * <p>
     * 2026-10-21 新增:把原来的 {@code saveServiceSubtaskDetail}(边算边插、失败再删)拆成
     * 「build(本方法)」+「persist({@link #persistDefaultDetails})」两步,
     * 保证"默认值不全"时**一个字节都不会写、更不会删**。
     *
     * @param serviceSubtask 子任务
     * @return 明细清单;任一题没配默认值(或模板为空)时返回 {@code null},表示整份不处理
     */
    private List<ServiceSubtaskDetail> buildDefaultDetails(ServiceSubtask serviceSubtask) {
        if (serviceSubtask.getType() == null) {
            return null;
        }
        List<ServiceSubtaskDetail> details = new ArrayList<>();
        if (serviceSubtask.getType().equals("1")) {
            //3.3.1 如果type是 1,则用当前serviceSubtask的templateid去ivr_task_temaplate_script中获取问题和选项
            IvrTaskTemplateScript ivrTaskTemplateScript = new IvrTaskTemplateScript();
            ivrTaskTemplateScript.setTemplateID(serviceSubtask.getTemplateid());
            List<IvrTaskTemplateScript> ivrTaskTemplateScripts = iIvrTaskTemplateScriptService.selectIvrTaskTemplateScriptList(ivrTaskTemplateScript);
            if (CollectionUtils.isEmpty(ivrTaskTemplateScripts)) return null;
            for (IvrTaskTemplateScript its : ivrTaskTemplateScripts) {
                IvrTaskTemplateScriptVO ivrTaskTemplateScriptVO = DtoConversionUtils.sourceToTarget(its, IvrTaskTemplateScriptVO.class);
                IvrTaskTemplateTargetoption ivrTaskTemplateTargetoption = new IvrTaskTemplateTargetoption();
                ivrTaskTemplateTargetoption.setScriptid(ivrTaskTemplateScriptVO.getId());
                List<IvrTaskTemplateTargetoption> ivrTaskTemplateTargetoptions = iIvrTaskTemplateTargetoptionService.selectIvrTaskTemplateTargetoptionList(ivrTaskTemplateTargetoption);
                ivrTaskTemplateScriptVO.setIvrTaskScriptTargetoptionList(ivrTaskTemplateTargetoptions);
                ServiceSubtaskDetail detail = buildSFDetail(ivrTaskTemplateScriptVO, serviceSubtask);
                if (detail == null) return null;
                details.add(detail);
            }
        } else if (serviceSubtask.getType().equals("2")) {
            //3.3.2 如果type是 2,则用当前serviceSubtask的templateid去svy_task_temaplate_script中获取问题和选项
            SvyTaskTemplateScript svyTaskTemplateScript = new SvyTaskTemplateScript();
            svyTaskTemplateScript.setTemplateID(serviceSubtask.getTemplateid());
            List<SvyTaskTemplateScript> svyTaskTemplateScripts = svyTaskTemplateScriptService.selectSvyTaskTemplateScriptList(svyTaskTemplateScript);
            if (CollectionUtils.isEmpty(svyTaskTemplateScripts)) return null;
            for (SvyTaskTemplateScript taskTemplateScript : svyTaskTemplateScripts) {
                SvyTaskTemplateScriptVO svyTaskTemplateScriptVO = DtoConversionUtils.sourceToTarget(taskTemplateScript, SvyTaskTemplateScriptVO.class);
                SvyTaskTemplateScriptVO svyTaskTemplateScriptVO1 = svyTaskTemplateScriptService.selectInfoByCondition(svyTaskTemplateScriptVO);
                ServiceSubtaskDetail detail = buildWJDetail(svyTaskTemplateScriptVO1, serviceSubtask);
                if (detail == null) return null;
                details.add(detail);
            }
        } else {
            return null;
        }
        return details;
    }
    /**
     * 落库"默认值明细":存在则更新并复活,不存在则插入(<b>只增不删</b>)
     */
    private void persistDefaultDetails(List<ServiceSubtaskDetail> details) {
        for (ServiceSubtaskDetail detail : details) {
            ServiceSubtaskDetailDTO exist = serviceSubtaskDetailMapper.isExistServiceSubtaskDetail(detail.getSubId(), detail.getScriptid());
            if (ObjectUtils.isNotEmpty(exist)) {
                detail.setId(exist.getId());
                detail.setUpdateTime(new Date());
                serviceSubtaskDetailMapper.updateServiceSubtaskDetail(detail);
                //命中历史被删副本时置回 del_flag='0',避免再插一份新副本
                serviceSubtaskDetailMapper.reviveServiceSubtaskDetail(exist.getId());
            } else {
                //删除误新增detail里的数据
                ServiceSubtaskDetail serviceSubtaskDetail = new ServiceSubtaskDetail();
                serviceSubtaskDetail.setSubId(serviceSubtask.getId());
                List<ServiceSubtaskDetail> serviceSubtaskDetails = serviceSubtaskDetailMapper.selectServiceSubtaskDetailList(serviceSubtaskDetail);
                if (CollectionUtils.isNotEmpty(serviceSubtaskDetails)) {
                    for (ServiceSubtaskDetail serviceSubtaskDetail1 : serviceSubtaskDetails) {
                        serviceSubtaskDetail1.setDelFlag("1");
                        serviceSubtaskDetailMapper.updateServiceSubtaskDetail(serviceSubtaskDetail1);
                    }
                }
                serviceSubtaskDetailMapper.insertServiceSubtaskDetail(detail);
            }
        }
    }
    /**
     * 微信公众号已领取未填写:按题目默认选项补齐当前子任务,并将当前子任务完成。
     * <p>
     * 所有查询、明细写入、状态更新都只使用当前 subid,避免影响其他任务/子任务。
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean completeWechatClaimedByDefault(ServiceSubtask serviceSubtask) {
        if (serviceSubtask == null || serviceSubtask.getId() == null) {
            return false;
        }
        // 重新读取当前子任务,所有判断只针对这一个 subid
        ServiceSubtask current = serviceSubtaskMapper.selectServiceSubtaskById(serviceSubtask.getId());
        if (current == null) {
            return false;
        }
        // 只处理:丽水中医院、问卷、当前方式公众号、已领取、未提交
        // 其他医院 orgid 不匹配时直接返回 false,继续走原补偿逻辑。
        if (!IServiceSubtaskService.WECHAT_DEFAULT_COMPLETE_ORGID.equals(current.getOrgid())
                || !"2".equals(current.getType())
                || !"5".equals(current.getCurrentPreachform())
                || !Long.valueOf(1L).equals(current.getSendstate())
                || Long.valueOf(1L).equals(current.getSubmit())) {
            return false;
        }
        // 查询当前子任务自己的公众号发送方式,必须是已发送(2)或已领取(3)
        ServiceSubtaskPreachform query = new ServiceSubtaskPreachform();
        query.setSubid(current.getId());
        query.setTaskid(current.getTaskid());
        query.setPreachform("5");
        List<ServiceSubtaskPreachform> preachforms = taskPreachformPort.selectServiceSubtaskPreachformList(query);
        if (CollectionUtils.isEmpty(preachforms)) {
            return false;
        }
        ServiceSubtaskPreachform currentPreachform = preachforms.stream()
                .filter(item -> "2".equals(item.getSendstate()) || "3".equals(item.getSendstate()))
                .findFirst().orElse(null);
        if (currentPreachform == null) {
            return false;
        }
        // 只给当前 subid 补齐默认选项;至少有一题默认选项才继续
        int filled = fillWechatDefaultDetailForCurrentSubtask(current);
        if (filled <= 0) {
            log.info("【微信公众号默认完成】subid:{} 没有可填充的默认选项,跳过", current.getId());
            return false;
        }
        // 当前公众号发送方式:已发送/已领取 -> 完成(9)
        ServiceSubtaskPreachform updatePreachform = new ServiceSubtaskPreachform();
        updatePreachform.setId(currentPreachform.getId());
        updatePreachform.setSubid(current.getId());
        updatePreachform.setPreachform("5");
        updatePreachform.setOrgid(current.getOrgid());
        updatePreachform.setRemark("已领取未填写,按默认选项完成");
        int preachformUpdated = taskPreachformPort.updateWechatPreachformComplete(updatePreachform);
        if (preachformUpdated == 0) {
            throw new BaseException("微信公众号默认完成失败:发送方式状态已变化,subid=" + current.getId());
        }
        // 当前子任务:已领取(1) -> 完成(6),只更新这一个 subid
        ServiceSubtask complete = new ServiceSubtask();
        complete.setId(current.getId());
        complete.setOrgid(current.getOrgid());
        complete.setFinishtime(new Date());
        complete.setResult("success");
        complete.setRemark("已领取未填写,按默认选项完成");
        int subtaskUpdated = serviceSubtaskMapper.updateWechatDefaultComplete(complete);
        if (subtaskUpdated == 0) {
            throw new BaseException("微信公众号默认完成失败:子任务状态已变化,subid=" + current.getId());
        }
        followupCompletedEventPublisher.publish(current.getId());
        log.info("【微信公众号默认完成】subid:{},已按默认选项完成,填充题目数:{}", current.getId(), filled);
        return true;
    }
    /**
     * 仅给当前子任务补齐问卷单选/多选题中 default_value=2 的选项。
     * 已有非空答案的题目跳过,不覆盖患者已填写内容。
     *
     * @return 实际填充(或此前已由系统默认填充)的题目数
     */
    private int fillWechatDefaultDetailForCurrentSubtask(ServiceSubtask serviceSubtask) {
        Long templateId = serviceSubtask.getTemplateid();
        ServiceTask serviceTask = serviceTaskService.selectServiceTaskByTaskid(serviceSubtask.getTaskid());
        if (templateId == null && serviceTask != null) {
            templateId = serviceTask.getTemplateid();
        }
        if (templateId == null) {
            return 0;
        }
        SvyTaskTemplateScript scriptQuery = new SvyTaskTemplateScript();
        scriptQuery.setTemplateID(templateId);
        List<SvyTaskTemplateScript> scripts = svyTaskTemplateScriptService.selectSvyTaskTemplateScriptList(scriptQuery);
        if (CollectionUtils.isEmpty(scripts)) {
            return 0;
        }
        int filled = 0;
        for (SvyTaskTemplateScript script : scripts) {
            SvyTaskTemplateScriptVO scriptVO = DtoConversionUtils.sourceToTarget(script, SvyTaskTemplateScriptVO.class);
            SvyTaskTemplateScriptVO scriptWithOptions = svyTaskTemplateScriptService.selectInfoByCondition(scriptVO);
            if (scriptWithOptions == null
                    || scriptWithOptions.getId() == null
                    || CollectionUtils.isEmpty(scriptWithOptions.getSvyTaskTemplateTargetoptions())
                    || (!"1".equals(scriptWithOptions.getScriptType()) && !"2".equals(scriptWithOptions.getScriptType()))) {
                continue;
            }
            List<SvyTaskTemplateTargetoption> options = scriptWithOptions.getSvyTaskTemplateTargetoptions();
            List<SvyTaskTemplateTargetoption> defaultOptions = options.stream()
                    .filter(option -> option.getDefaultValue() != null && option.getDefaultValue() == 2L)
                    .filter(option -> StringUtils.isNotBlank(option.getOptioncontent()))
                    .collect(Collectors.toList());
            if (CollectionUtils.isEmpty(defaultOptions)) {
                continue;
            }
            // 已有非空答案则不覆盖,避免影响患者已填写内容
            ServiceSubtaskDetail detailQuery = new ServiceSubtaskDetail();
            detailQuery.setSubId(serviceSubtask.getId());
            detailQuery.setScriptid(scriptWithOptions.getId());
            List<ServiceSubtaskDetail> existingDetails = serviceSubtaskDetailMapper.selectServiceSubtaskDetailList(detailQuery);
            ServiceSubtaskDetail existing = CollectionUtils.isNotEmpty(existingDetails) ? existingDetails.get(0) : null;
            if (existing != null
                    && (StringUtils.isNotBlank(existing.getAsrtext()) || StringUtils.isNotBlank(existing.getMatchedtext()))) {
                // 之前已由系统默认填充过的,计入已填充,保证重试幂等
                if ("DEFAULT".equals(existing.getOperate())) {
                    filled++;
                }
                continue;
            }
            String targetValue = options.stream()
                    .map(SvyTaskTemplateTargetoption::getOptioncontent)
                    .filter(StringUtils::isNotBlank)
                    .collect(Collectors.joining("&"));
            String defaultValue = defaultOptions.stream()
                    .map(SvyTaskTemplateTargetoption::getOptioncontent)
                    .collect(Collectors.joining("&"));
            ServiceSubtaskDetail detail = new ServiceSubtaskDetail();
            detail.setSubId(serviceSubtask.getId());
            detail.setTaskid(serviceSubtask.getTaskid());
            detail.setPatid(serviceSubtask.getPatid());
            detail.setScriptid(scriptWithOptions.getId());
            detail.setTemplatequestionnum(scriptWithOptions.getId());
            detail.setQuestiontext(scriptWithOptions.getScriptContent());
            detail.setCategoryid(scriptWithOptions.getCategoryid());
            detail.setTargetid(scriptWithOptions.getTargetid() != null ? scriptWithOptions.getTargetid().intValue() : null);
            detail.setValueType(scriptWithOptions.getScriptType());
            detail.setTargetvalue(targetValue);
            detail.setAsrtext(defaultValue);
            detail.setMatchedtext(defaultValue);
            detail.setOperate("DEFAULT");
            detail.setTemplateType(2);
            detail.setLibTemplateid(serviceSubtask.getLibtemplateid() != null ? serviceSubtask.getLibtemplateid().intValue() : null);
            detail.setTemplateid(templateId.toString());
            detail.setGuid(scriptWithOptions.getGuid());
            detail.setOrgid(StringUtils.isNotEmpty(scriptWithOptions.getOrgid()) ? scriptWithOptions.getOrgid()
                    : (serviceTask != null ? serviceTask.getOrgid() : null));
            detail.setCreateTime(new Date());
            detail.setUpdateTime(new Date());
            if (existing != null) {
                detail.setId(existing.getId());
                serviceSubtaskDetailMapper.updateServiceSubtaskDetail(detail);
            } else {
                serviceSubtaskDetailMapper.insertServiceSubtaskDetail(detail);
            }
            filled++;
        }
        return filled;
    }
    @Override
@@ -3613,12 +5145,6 @@
        String subId = rsaPublicKeyExample.encryptedData(serviceSubtask.getId().toString(), pub_key);
        String taskId = rsaPublicKeyExample.encryptedData(serviceSubtask.getTaskid().toString(), pub_key);
        //丽水中医院发送的是微信公众号
        if (StringUtils.isNotEmpty(serviceSubtask.getOrgid()) && serviceSubtask.getOrgid().equals("47231022633110211A2101")) {
            resultMap = wechatSubTask(subid);
            return resultMap;
        }
        //发送数据的封装
        ServiceOutPath serviceOutPath = new ServiceOutPath();
        serviceOutPath.setParam1(taskId);
@@ -3628,7 +5154,7 @@
        serviceOutPath.setCreateTime(new Date());
        serviceOutPath.setOrgid(serviceSubtask.getOrgid());
        iServiceOutPathService.insertServiceOutPath(serviceOutPath);
        String format = String.format("%03X", serviceOutPath.getId());
        String format = ShortCode.encode(serviceOutPath.getId());
        String url = "";
        SendMagParam sendMagParam = new SendMagParam();
        sendMagParam.setType("4");
@@ -3643,8 +5169,8 @@
        // 参数值经端口取用(原为 sysConfigMapper.checkConfigKeyUnique + getConfigValue)。
        // 语义等价:原判定"行存在"、这里判定"值非 null",而 sys_config.config_value 是
        // varchar(500) NOT NULL DEFAULT '' ⇒ 二者不可能不同(详见 TaskSysConfigPort javadoc)。
        String smsContemt = taskSysConfigPort.findConfigValueByKey("wj.sms.template");
        // varchar(500) NOT NULL DEFAULT '' ⇒ 二者不可能不同(详见 ISysConfigService javadoc)。
        String smsContemt = sysConfigService.selectConfigByKey("wj.sms.template");
        if (smsContemt != null) {
            sendMagParam.setContent(smsContemt.replace("${wjurl}", sendMagParam.getUrl()));
@@ -3663,6 +5189,25 @@
                serviceSubtask.setCurrentPreachform("4");
            }
        }
        return resultMap;
    }
    @Override
    public Map<String, Object> weChatSubTask(Long subid) {
        log.info("smsSubTask的入参为:{}", subid);
        Map<String, Object> resultMap = new HashMap<>();
        resultMap.put("code", "500");
        resultMap.put("msg", "发送失败");
        //获取患者的基本信息
        ServiceSubtask serviceSubtask = selectServiceSubtaskById(subid);
        RSAPublicKeyExample rsaPublicKeyExample = new RSAPublicKeyExample();
        String patid = rsaPublicKeyExample.encryptedData(serviceSubtask.getPatid().toString(), pub_key);
        String subId = rsaPublicKeyExample.encryptedData(serviceSubtask.getId().toString(), pub_key);
        String taskId = rsaPublicKeyExample.encryptedData(serviceSubtask.getTaskid().toString(), pub_key);
        //丽水中医院发送的是微信公众号
        resultMap = wechatSubTask(subid);
        return resultMap;
    }
@@ -3693,14 +5238,22 @@
            serviceOutPath.setCreateTime(new Date());
            serviceOutPath.setOrgid(serviceSubtask.getOrgid());
            iServiceOutPathService.insertServiceOutPath(serviceOutPath);
            String format = String.format("%03X", serviceOutPath.getId());
            String format = ShortCode.encode(serviceOutPath.getId());
            serviceOutPath.setRadix(format);
            serviceOutPath.setUpdateTime(new Date());
            serviceOutPath.setUrl(url);
            iServiceOutPathService.updateServiceOutPath(serviceOutPath);
            //his患者id校验(与原实现一致:先校验这一项)
            if (StringUtils.isEmpty(serviceSubtask.getPatidHis())) {
            if (patArchive == null) {
                resultMap.put("code", -1);
                resultMap.put("message", "发送异常:" + "【公众号】未查询到患者档案信息,无法通过公众号发送");
                return resultMap;
            }
            // his系统的患者id取自患者档案pat_archive.patid_his,
            // service_subtask查询未关联该字段(serviceSubtask.getPatidHis()恒为空),不能用于校验
            if (StringUtils.isEmpty(patArchive.getPatidHis())) {
                resultMap.put("code", -1);
                resultMap.put("message", "发送异常:" + "【公众号】公众号发送失败,his系统的患者id为空");
                return resultMap;
@@ -3740,44 +5293,30 @@
        return resultMap;
    }
    private Boolean saveServiceSubtaskDetail(ServiceSubtask serviceSubtask) {
        Boolean result = true;
        //3.4 如果3.1或3.2为true的话,根据type判断什么类型
        if (serviceSubtask.getType().equals("1")) {
            //3.3.1    如果type是 1 ,则用当前serviceSubtask的templateid去ivr_task_temaplate_script中获取问题和选项,将问题和选项的默认值放到service_detail中
            IvrTaskTemplateScript ivrTaskTemplateScript = new IvrTaskTemplateScript();
            ivrTaskTemplateScript.setTemplateID(serviceSubtask.getTemplateid());
            List<IvrTaskTemplateScript> ivrTaskTemplateScripts = iIvrTaskTemplateScriptService.selectIvrTaskTemplateScriptList(ivrTaskTemplateScript);
            for (IvrTaskTemplateScript its : ivrTaskTemplateScripts) {
                IvrTaskTemplateScriptVO ivrTaskTemplateScriptVO = DtoConversionUtils.sourceToTarget(its, IvrTaskTemplateScriptVO.class);
                IvrTaskTemplateTargetoption ivrTaskTemplateTargetoption = new IvrTaskTemplateTargetoption();
                ivrTaskTemplateTargetoption.setScriptid(ivrTaskTemplateScriptVO.getId());
                List<IvrTaskTemplateTargetoption> ivrTaskTemplateTargetoptions = iIvrTaskTemplateTargetoptionService.selectIvrTaskTemplateTargetoptionList(ivrTaskTemplateTargetoption);
                ivrTaskTemplateScriptVO.setIvrTaskScriptTargetoptionList(ivrTaskTemplateTargetoptions);
                Integer integer = setSFDetailInfo(ivrTaskTemplateScriptVO, serviceSubtask);
                if (Objects.isNull(integer) || integer == 0) result = false;
            }
    /*
     * 2026-10-21 已删除 saveServiceSubtaskDetail(...)。
     * 它原来的语义是"边算默认值边落库,最后返回布尔值",调用方在返回 false 时把该 sub_id 下的
     * 全部明细置 del_flag='1'(连患者真实作答一起删)。
     * 现在拆成 buildDefaultDetails(...)(只算不写) + persistDefaultDetails(...)(只增不删),
     * 任何一题缺默认值就整份跳过,不会再产生"删除"动作。
     */
        } else if (serviceSubtask.getType().equals("2")) {
            //3.3.2    如果type是 2,则用当前serviceSubtask的templateid去svy_task_temaplate_script中获取问题和选项,将问题和选项的默认值放到service_detail中
            SvyTaskTemplateScript svyTaskTemplateScript = new SvyTaskTemplateScript();
            svyTaskTemplateScript.setTemplateID(serviceSubtask.getTemplateid());
            List<SvyTaskTemplateScript> svyTaskTemplateScripts = svyTaskTemplateScriptService.selectSvyTaskTemplateScriptList(svyTaskTemplateScript);
            for (SvyTaskTemplateScript taskTemplateScript : svyTaskTemplateScripts) {
                SvyTaskTemplateScriptVO svyTaskTemplateScriptVO = DtoConversionUtils.sourceToTarget(taskTemplateScript, SvyTaskTemplateScriptVO.class);
                SvyTaskTemplateScriptVO svyTaskTemplateScriptVO1 = svyTaskTemplateScriptService.selectInfoByCondition(svyTaskTemplateScriptVO);
                Integer integer = setWJDetailInfo(svyTaskTemplateScriptVO1, serviceSubtask);
                if (Objects.isNull(integer) || integer == 0) result = false;
            }
    /**
     * 按「问卷模板」构造一条"默认值明细"(<b>只计算、不落库</b>)
     * <p>
     * 2026-10-21 由 {@code setWJDetailInfo}(算完就直接 insert/update,失败返回 0)改造而来:
     * 现在只负责把实体拼出来,由 {@link #persistDefaultDetails} 统一落库。
     *
     * @return 明细;该题没配默认值(单/多选题一个默认选项都没有)时返回 {@code null},表示整份问卷不处理
     */
    private ServiceSubtaskDetail buildWJDetail(SvyTaskTemplateScriptVO svyTaskTemplateScriptVO, ServiceSubtask serviceSubtask) {
        if (svyTaskTemplateScriptVO == null) {
            return null;
        }
        return result;
    }
    private Integer setWJDetailInfo(SvyTaskTemplateScriptVO svyTaskTemplateScriptVO, ServiceSubtask serviceSubtask) {
        log.info("-----setWJDetailInfo的入参为:{}", svyTaskTemplateScriptVO);
        log.info("-----buildWJDetail的入参为:{}", svyTaskTemplateScriptVO);
        ServiceSubtaskDetailVO serviceSubtaskDetailVO = new ServiceSubtaskDetailVO();
        if (svyTaskTemplateScriptVO.getScriptType().equals("1") || svyTaskTemplateScriptVO.getScriptType().equals("2")) {
        if ("1".equals(svyTaskTemplateScriptVO.getScriptType()) || "2".equals(svyTaskTemplateScriptVO.getScriptType())) {
            //1、2为单选或多选
            for (SvyTaskTemplateTargetoption svyTaskTemplateTargetoption : svyTaskTemplateScriptVO.getSvyTaskTemplateTargetoptions()) {
                //将问题选项记录下来
@@ -3792,9 +5331,9 @@
                }
            }
            //如果asrtext和matchedtext都为空,则没有默认值,则不进行处理
            //如果asrtext和matchedtext都为空,则没有默认值,则不进行处理(返回 null = 整份问卷跳过)
            if (StringUtils.isEmpty(serviceSubtaskDetailVO.getAsrtext()) && StringUtils.isEmpty(serviceSubtaskDetailVO.getMatchedtext())) {
                return 0;
                return null;
            }
        }
        ServiceTask serviceTask = serviceTaskService.selectServiceTaskByTaskid(serviceSubtask.getTaskid());
@@ -3817,21 +5356,16 @@
        }
        serviceSubtaskDetailVO.setSubId(serviceSubtask.getId());
        serviceSubtaskDetailVO.setTemplatequestionnum(svyTaskTemplateScriptVO.getId());
        ServiceSubtaskDetail serviceSubtaskDetail = DtoConversionUtils.sourceToTarget(serviceSubtaskDetailVO, ServiceSubtaskDetail.class);
        //先用subid和scriptid查询一下是否存在,如果存在则更新,不存在则插入
        ServiceSubtaskDetailDTO existServiceSubtaskDetail = serviceSubtaskDetailMapper.isExistServiceSubtaskDetail(serviceSubtaskDetailVO.getSubId(), serviceSubtaskDetailVO.getScriptid());
        if (ObjectUtils.isNotEmpty(existServiceSubtaskDetail)) {
            serviceSubtaskDetail.setId(existServiceSubtaskDetail.getId());
            serviceSubtaskDetail.setUpdateTime(new Date());
            return serviceSubtaskDetailMapper.updateServiceSubtaskDetail(serviceSubtaskDetail);
        } else {
            return serviceSubtaskDetailMapper.insertServiceSubtaskDetail(serviceSubtaskDetail);
        }
        return DtoConversionUtils.sourceToTarget(serviceSubtaskDetailVO, ServiceSubtaskDetail.class);
    }
    private Integer setSFDetailInfo(IvrTaskTemplateScriptVO ivrTaskTemplateScriptVO, ServiceSubtask serviceSubtask) {
    /**
     * 按「电话模板」构造一条"默认值明细"(<b>只计算、不落库</b>),语义同 {@link #buildWJDetail}
     */
    private ServiceSubtaskDetail buildSFDetail(IvrTaskTemplateScriptVO ivrTaskTemplateScriptVO, ServiceSubtask serviceSubtask) {
        if (ivrTaskTemplateScriptVO == null) {
            return null;
        }
        ServiceSubtaskDetailVO serviceSubtaskDetailVO = new ServiceSubtaskDetailVO();
        if (ivrTaskTemplateScriptVO.getScriptType() != null && (ivrTaskTemplateScriptVO.getScriptType().equals("1") || ivrTaskTemplateScriptVO.getScriptType().equals("2"))) {
            //1、2为单选或多选
@@ -3848,10 +5382,12 @@
                    serviceSubtaskDetailVO.setMatchedtext(StringUtils.isEmpty(serviceSubtaskDetailVO.getMatchedtext()) ? ivrTaskTemplateTargetoption.getTargetvalue() : serviceSubtaskDetailVO.getMatchedtext() + "&" + ivrTaskTemplateTargetoption.getTargetvalue());
                }
            }
            //如果asrtext和matchedtext都为空,则没有默认值,不进行处理
            //如果asrtext和matchedtext都为空,则没有默认值,不进行处理(返回 null = 整份问卷跳过)
            if (StringUtils.isEmpty(serviceSubtaskDetailVO.getAsrtext()) && StringUtils.isEmpty(serviceSubtaskDetailVO.getMatchedtext())) {
                return 0;
                return null;
            }
        } else if (ivrTaskTemplateScriptVO.getScriptType() == null) {
            return null;
        }
        serviceSubtaskDetailVO.setTargetid(ivrTaskTemplateScriptVO.getTargetid() != null ? ivrTaskTemplateScriptVO.getTargetid().intValue() : null);
@@ -3871,17 +5407,9 @@
        serviceSubtaskDetailVO.setTemplateType(1);
        serviceSubtaskDetailVO.setTemplateid(ivrTaskTemplateScriptVO.getTemplateID() == null ? null : ivrTaskTemplateScriptVO.getTemplateID().toString());
        serviceSubtaskDetailVO.setLibTemplateid(serviceSubtask.getLibtemplateid() != null ? serviceSubtask.getLibtemplateid().intValue() : null);
        ServiceSubtaskDetail serviceSubtaskDetail = DtoConversionUtils.sourceToTarget(serviceSubtaskDetailVO, ServiceSubtaskDetail.class);
        //先用subid和scriptid查询一下是否存在,如果存在则更新,不存在则插入
        ServiceSubtaskDetailDTO existServiceSubtaskDetail = serviceSubtaskDetailMapper.isExistServiceSubtaskDetail(serviceSubtaskDetailVO.getSubId(), serviceSubtaskDetailVO.getScriptid());
        if (ObjectUtils.isNotEmpty(existServiceSubtaskDetail)) {
            serviceSubtaskDetail.setId(existServiceSubtaskDetail.getId());
            serviceSubtaskDetail.setUpdateTime(new Date());
            return serviceSubtaskDetailMapper.updateServiceSubtaskDetail(serviceSubtaskDetail);
        } else {
            return serviceSubtaskDetailMapper.insertServiceSubtaskDetail(serviceSubtaskDetail);
        }
        return DtoConversionUtils.sourceToTarget(serviceSubtaskDetailVO, ServiceSubtaskDetail.class);
    }
    public String scanGenerateSubtask(ServiceTask serviceTaskVo) {
        String content = "";
@@ -3925,7 +5453,7 @@
                        serviceOutPath.setCreateTime(new Date());
                        serviceOutPath.setOrgid(serviceTask.getOrgid());
                        iServiceOutPathService.insertServiceOutPath(serviceOutPath);
                        String format = String.format("%03X", serviceOutPath.getId());
                        String format = ShortCode.encode(serviceOutPath.getId());
                        serviceOutPath.setRadix(format);
                        serviceOutPath.setUpdateTime(new Date());
                        String url = StringUtils.isNotEmpty(req_path) ? ip + ":" + req_path + "/wt?p=" + format : ip + "/wt?p=" + format;
@@ -3953,8 +5481,8 @@
//        serviceSubtask.setDrcode(patMedInhosp1.getDrcode());
//        serviceSubtask.setDrname(patMedInhosp1.getDrname());
        if (StringUtils.isNotEmpty(serviceTaskdept.getDeptType()) && serviceTaskdept.getDeptType().equals("1")) {
            serviceSubtask.setDeptcode(serviceTaskdept.getDeptCode());
            serviceSubtask.setDeptname(serviceTaskdept.getDeptName());
            serviceSubtask.setLeaveldeptcode(serviceTaskdept.getDeptCode());
            serviceSubtask.setLeaveldeptname(serviceTaskdept.getDeptName());
        } else {
            serviceSubtask.setLeavehospitaldistrictcode(serviceTaskdept.getDeptCode());
            serviceSubtask.setLeavehospitaldistrictname(serviceTaskdept.getDeptName());
@@ -3990,7 +5518,7 @@
        // 这里用任务配置的病区(其次科室)反查 sys_dept.campusid 落库,保证问卷统计能按院区取到。
        String campusIdByDept = queryCampusIdByDeptCode(serviceSubtask.getLeavehospitaldistrictcode(), serviceTask.getOrgid());
        if (StringUtils.isEmpty(campusIdByDept)) {
            campusIdByDept = queryCampusIdByDeptCode(serviceSubtask.getDeptcode(), serviceTask.getOrgid());
            campusIdByDept = queryCampusIdByDeptCode(serviceSubtask.getLeaveldeptcode(), serviceTask.getOrgid());
        }
        if (StringUtils.isNotEmpty(campusIdByDept)) {
            serviceSubtask.setCampusid(campusIdByDept);
@@ -3999,7 +5527,7 @@
        if (StringUtils.isEmpty(serviceSubtask.getLeavehospitaldistrictcode())
                && ("6".equals(serviceSubtask.getServiceType()) || "14".equals(serviceSubtask.getServiceType()))) {
            log.warn("【扫码生成任务】满意度子任务没有病区,按病区筛选的报表将统计不到该答卷:taskid={}, deptcode={}, campusid={}",
                    serviceSubtask.getTaskid(), serviceSubtask.getDeptcode(), serviceSubtask.getCampusid());
                    serviceSubtask.getTaskid(), serviceSubtask.getLeaveldeptcode(), serviceSubtask.getCampusid());
        }
        return serviceSubtask;
    }
@@ -4012,7 +5540,7 @@
            return null;
        }
        try {
            SysDept sysDept = taskSysDeptPort.selectDeptByCode(deptCode, orgid);
            SysDept sysDept = sysDept2Mapper.selectDeptByCode(deptCode, orgid);
            return sysDept == null ? null : sysDept.getCampusid();
        } catch (Exception e) {
            log.warn("按编码反查院区失败:deptCode={}, orgid={}, err={}", deptCode, orgid, e.getMessage());