liusheng
2026-11-03 b5d55e1dd793c01f08786f805f01345bca64c893
代码提交
已修改9个文件
444 ■■■■■ 文件已修改
ruoyi-admin/src/main/resources/application.yml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-inhospvisit/src/main/java/com/ruoyi/inhospvisit/support/VisitSubtaskFactory.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/api/DefaultSatisfactionApi.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-task/src/main/java/com/ruoyi/task/controller/ServiceSubtaskController.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-task/src/main/java/com/ruoyi/task/domain/ServiceSubtaskCountReq.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-task/src/main/java/com/ruoyi/task/domain/ServiceSubtaskStatistic.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-task/src/main/java/com/ruoyi/task/service/impl/ServiceSubtaskServiceImpl.java 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-task/src/main/resources/mapper/task/ServiceSubtaskMapper.xml 230 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/service/impl/ServiceSubtaskAnswerServiceImpl.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/application.yml
@@ -78,7 +78,7 @@
    # 国际化资源文件路径
    basename: i18n/messages
  profiles:
    active: sltd
    active: nhfy
  # 文件上传
  servlet:
ruoyi-inhospvisit/src/main/java/com/ruoyi/inhospvisit/support/VisitSubtaskFactory.java
@@ -49,12 +49,25 @@
 * 实际实现时把它收窄为"纯组装 + 纯工具",因为 {@code writeInSubTask}(232 行)里有大量
 * 写库与去重编排,混在一起会让本类的 A 档逐行等价探针失去意义。
 *
 * <h3>⚠️ 唯一的"非搬运"行:{@code hospno}</h3>
 * {@link #boxedServiceSubtask} 里多了一句 {@code d.setHospno(inhosp.getInhospno())}。
 * 原实现确实<b>没有</b>这一句,所以出院链路的 {@code service_subtask.hospno} 一直落空;
 * 而 {@code ServiceSubtask.hospno} 的语义就是"就诊编号(对应的 outhospno、inhospno)"。
 * 这是<b>有意补齐</b>,不是抄漏 —— 反向探针({@code verify_inhospvisit_boxed_fields.py})
 * 只校验"原 setter 是否都有对应调用",不会因此报错。
 * <h3>⚠️ 两处有意的"非搬运"行</h3>
 * <ol>
 *   <li><b>{@code hospno}</b>:{@link #boxedServiceSubtask} 里多了一句
 *       {@code d.setHospno(inhosp.getInhospno())}。原实现确实<b>没有</b>这一句,
 *       所以出院链路的 {@code service_subtask.hospno} 一直落空;
 *       而 {@code ServiceSubtask.hospno} 的语义就是"就诊编号(对应的 outhospno、inhospno)"。
 *       这是<b>有意补齐</b>,不是抄漏 —— 反向探针({@code verify_inhospvisit_boxed_fields.py})
 *       只校验"原 setter 是否都有对应调用",不会因此报错。</li>
 *   <li><b>campusid 不再被空值覆盖</b>(2026-10-29 修):原实现无条件
 *       {@code d.setCampusid(inhosp.getCampusid())},会把从长期任务搬来的院区抹成 {@code null}。
 *       后果是任务落库 {@code campusid=NULL},而随访列表接口
 *       ({@code ServiceSubtaskController#patItem})强制用登录用户的院区过滤
 *       ({@code and campusid = #{campusid}}),NULL 不匹配任何值 →
 *       <b>任务存在、页面永远查不到</b>。实测南华附一 2026-07 有 894 条这样"生成了但不显示"的
 *       出院随访任务(其中骨科中心一病区 25 条)。现改为"出院记录有院区才覆盖"。
 *       ⚠️ 这是<b>行为变更</b>(不是等价搬迁),触发条件只有"出院记录院区为空"这一种,
 *       其余情况仍与原实现完全一致。历史数据回填脚本:
 *       {@code sql/fix_nhfy_出院随访任务院区病区回填_20261029.sql}。</li>
 * </ol>
 */
@Slf4j
@Component
@@ -157,7 +170,17 @@
        d.setAge(archive.getAge());
        // ---- 原 L1147-1163 ----
        d.setSendstate(2L);
        d.setCampusid(inhosp.getCampusid());
        // ⚠️ 第二处【有意偏离原实现】(2026-10-29,见类注释"campusid 不再被空值覆盖"):
        //   原实现无条件 d.setCampusid(inhosp.getCampusid()),会把上面从长期任务搬来的院区抹成 null。
        //   线上后果:任务落库 campusid=NULL →
        //   ServiceSubtaskController#patItem 强制 setCampusid(登录用户院区),
        //   SQL 条件 `and campusid = #{campusid}` → 该任务对任何人都不显示("生成了但查不到")。
        //   实测南华附一 2026-07:894 条出院随访任务因此在页面上不可见
        //   (其中骨科中心一病区 25 条,正是"出院 475 条 / 随访 444 条"的来源)。
        //   这里改为"出院记录有院区才覆盖",出院记录取不到时保留任务自带的院区。
        if (StringUtils.isNotEmpty(inhosp.getCampusid())) {
            d.setCampusid(inhosp.getCampusid());
        }
        d.setManagementDoctor(inhosp.getManagementDoctor());
        d.setManagementDoctorCode(inhosp.getManagementDoctorCode());
        d.setStarttime(inhosp.getStarttime());
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/api/DefaultSatisfactionApi.java
@@ -3,6 +3,7 @@
import com.ruoyi.satisfaction.domain.ServiceSubtaskDetailRatioExport;
import com.ruoyi.satisfaction.service.SatisfactionSubtaskService;
import com.ruoyi.satisfaction.support.MydOptionRatioChartBuilder;
import com.ruoyi.satisfaction.support.MydOptionRatioPieChartBuilder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@@ -53,8 +54,12 @@
            // 没有行时不给图表构建器:ExcelUtil 会跳过该 Sheet(与原实现"空数据不画图"一致)
            return new SatisfactionExportBundle(rows, ServiceSubtaskDetailRatioExport.class, null);
        }
        // 一个 Sheet 柱状图(原有),一个 Sheet 饼图(按题目一张,带百分比和图例)
        return new SatisfactionExportBundle(rows, ServiceSubtaskDetailRatioExport.class,
                new MydOptionRatioChartBuilder(rows));
                workbook -> {
                    new MydOptionRatioChartBuilder(rows).build(workbook);
                    new MydOptionRatioPieChartBuilder(rows, null).build(workbook);
                });
    }
    @Override
ruoyi-task/src/main/java/com/ruoyi/task/controller/ServiceSubtaskController.java
@@ -887,7 +887,7 @@
     * <p>
     * 口径:service_subtask 中
     * service_type = '2' 出院随访 / '3' 门诊随访
     * + 待随访状态(sendstate in 1,2,3,5,7)
     * + 待随访状态(sendstate in 1,2,3,7,发送失败(5) 不计入)
     * + 出院满 7 天(含「7 天前那一天」出院的患者)
     * + 只看最近 4 个出院月份(含当月);
     * 科室/病区权限取 OR(与随访列表 deptOrDistrict=2 一致)。
@@ -921,9 +921,10 @@
        List<SysUserDept> sysUserWards = sysUserDeptService.selectSysUserDeptList(wardQuery);
        boolean isAdmin = "admin".equals(sysUser.getUserName());
        // 没有科室/病区配置的账号(如部分管理员账号),列表本身是按 机构+院区 查的,
        // 这里也按同样口径统计,否则列表有数据、卡片却全是 0。
        if (!isAdmin && CollectionUtils.isEmpty(sysUserDepts) && CollectionUtils.isEmpty(sysUserWards)) {
            // 既没有科室也没有病区权限时不统计,避免越权看到全机构数据
            return result;
            log.info("[pendingTaskMonthStats] 用户 {} 未配置科室/病区,按机构+院区统计", sysUser.getUserName());
        }
        // sys_user_dept 里同一科室/病区会重复很多行,去重后再拼 IN,避免 SQL 参数过多
@@ -940,6 +941,8 @@
        ServiceSubtaskEntity entity = new ServiceSubtaskEntity();
        entity.setOrgid(sysUser.getOrgid());
        // 与列表一致:按登录会话里的院区过滤(登录页选择的院区)
        entity.setCampusid(sysUser.getCampusid());
        entity.setLeaveldeptcodes(deptCodes);
        entity.setLeavehospitaldistrictcodes(wardCodes);
@@ -1060,9 +1063,9 @@
            userId = loginUser.getUser().getUserId();
        }
        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)
ruoyi-task/src/main/java/com/ruoyi/task/domain/ServiceSubtaskCountReq.java
@@ -183,6 +183,37 @@
    private String groupKey;
    /**
     * 病区统计(离院维度)实际使用的“有效病区”列表达式(仅服务端内部使用)。
     * <p>
     * 满意度子任务(service_type 6/14/15/16)的 {@code leavehospitaldistrictcode} 约三成为空,
     * 旧写法把空值当成“不限”放行({@code xxx IS NULL OR xxx IN (...)}),
     * 结果选中 A 病区时会把空病区的记录全带进来,再按
     * {@code IFNULL(leavehospitaldistrictcode,'Unknown')} 归成一个组,
     * 这一组的病区名又是 {@code MAX(hospitaldistrictname)} 随便挑的。
     * <p>
     * 这里给出“出院病区为空则回退到在院病区”的表达式,过滤与分组都改用它,
     * 空病区的记录因此回到它真正的在院病区,而不是污染用户选中的病区。
     * 为 null 时保持历史行为(兼容其它调用方)。
     */
    @JsonIgnore
    private String districtColumn;
    /**
     * 分页偏移量(仅服务端内部使用)。
     * <p>
     * pageNum 是 1 起始的,而历史写法直接把它当 SQL 的 OFFSET 用
     * ({@code limit 20 OFFSET 1}),第一页会凭空跳掉第一条分组数据。
     */
    @JsonIgnore
    public Integer getPageOffset() {
        if (pageSize == null) {
            return null;
        }
        int current = (pageNum == null || pageNum < 1) ? 1 : pageNum;
        return (current - 1) * pageSize;
    }
    /**
     * 随访统计方式 1-默认统计方式 2-统计时算上不发送的 3-只统计已完成的
     */
    @ApiModelProperty(value = "统计方式")
ruoyi-task/src/main/java/com/ruoyi/task/domain/ServiceSubtaskStatistic.java
@@ -377,6 +377,16 @@
    private String groupCode;
    /**
     * 分组键对应的显示名(病区统计时即该组的病区名)。
     * <p>
     * 仅服务端内部使用:原来病区列取的是 {@code MAX(hospitaldistrictname)},
     * 一个组里混了多个病区时就会随机显示其中一个(选血液内科病区却显示“骨科中心三病区”)。
     * 改成按分组键取名字,再回填到病区 code/name 字段。
     */
    @JsonIgnore
    private String groupName;
    /**
     * 首次随访记录数(visit_count = 1 的行数)。
     * <p>
     * 仅服务端内部使用:原内存统计里“首次随访率”只在该分组存在首次随访记录时才会被赋值
ruoyi-task/src/main/java/com/ruoyi/task/service/impl/ServiceSubtaskServiceImpl.java
@@ -538,9 +538,9 @@
    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)
@@ -809,6 +809,50 @@
    }
    /**
     * 统一「随访病区」口径:一律以【出院记录】{@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());
        }
    }
    /**
     * 新增单一任务(随访)
     *
     * @param serviceSubtask 单一任务(随访)
@@ -850,6 +894,8 @@
            }
            serviceSubtask.setCreateTime(DateUtils.getNowDate());
            // 口径(2026-10-29 业务确认):出院随访的科室/病区一律以出院记录为准
            alignDischargeWardFromInhosp(serviceSubtask);
            serviceSubtaskMapper.insertServiceSubtask(serviceSubtask);
            //修改住院满意度状态(避免重复查询)
            if (serviceSubtask.getServiceType() != null && "6".equals(serviceSubtask.getServiceType())) {
@@ -943,6 +989,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) {
            //新增成功后,需要将之前的任务设置为不要再随访(防止重复点,然后重复生成)
@@ -3206,6 +3259,20 @@
        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);
        /*
@@ -3280,6 +3347,24 @@
            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) {
@@ -3309,7 +3394,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());
ruoyi-task/src/main/resources/mapper/task/ServiceSubtaskMapper.xml
@@ -1861,9 +1861,17 @@
    <select id="getSfStatisticsGroupKey" parameterType="com.ruoyi.task.domain.ServiceSubtaskCountReq"
            resultType="String">
        select
        <if test="groupKey != null and groupKey != ''">
            ${groupKey} as groupKey
        </if>
        <choose>
            <when test="groupColumn != null and groupColumn != ''">
                ${groupColumn} as groupKey
            </when>
            <when test="groupKey != null and groupKey != ''">
                ${groupKey} as groupKey
            </when>
            <otherwise>
                NULL as groupKey
            </otherwise>
        </choose>
        from service_subtask
        where 1=1
        and del_flag = 0
@@ -1877,14 +1885,39 @@
             南华满意度子任务的 leavehospitaldistrictcode 有 83%(3236/3811)为 NULL,
             原来「科室 AND 病区」两个条件永远不可能同时成立 → 整张统计表空。
             判空放行只影响病区为空的记录,有病区的数据行为不变。 -->
        <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0">
            AND (leavehospitaldistrictcode IS NULL OR leavehospitaldistrictcode IN
            <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator=","
                     close=")">
                #{leavehospitaldistrictcode}
            </foreach>
            )
        </if>
        <choose>
            <!-- 满意度统计(getSfStatisticsScript):改用“有效病区”列,
                 出院病区为空则回退到在院病区。筛选因此真正生效,
                 空病区的记录也会归到它真实的在院病区,而不是混进用户选中的病区。 -->
            <when test="districtColumn != null and districtColumn != ''">
                <!-- 出院病区为空回退到在院病区。这里刻意写成 OR 展开而不是 IFNULL(a,b) IN (...):
                     列被函数包住就用不了索引,56 万行明细表上是全表扫(实测 5.1s → 0.7s)。
                     两个分支分别命中 idx_ss_district_pair 的 (leave..., hospital...) 前缀。 -->
                <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0">
                    AND (leavehospitaldistrictcode IN
                    <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator=","
                             close=")">
                        #{leavehospitaldistrictcode}
                    </foreach>
                    OR (leavehospitaldistrictcode IS NULL AND hospitaldistrictcode IN
                    <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator=","
                             close=")">
                        #{leavehospitaldistrictcode}
                    </foreach>
                    ))
                </if>
            </when>
            <otherwise>
                <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0">
                    AND (leavehospitaldistrictcode IS NULL OR leavehospitaldistrictcode IN
                    <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator=","
                             close=")">
                        #{leavehospitaldistrictcode}
                    </foreach>
                    )
                </if>
            </otherwise>
        </choose>
        <if test="leaveldeptcodes != null and leaveldeptcodes.size() > 0">
            AND leaveldeptcode IN
            <foreach collection="leaveldeptcodes" item="leaveldeptcode" open="(" separator=","
@@ -1947,11 +1980,20 @@
        <if test="visitCount != null and visitCount > 1">
            AND visit_count > 1
        </if>
        <if test="groupKey != null and groupKey != ''">
            group by ${groupKey}
        </if>
        <choose>
            <when test="groupColumn != null and groupColumn != ''">
                group by ${groupColumn}
                order by ${groupColumn}
            </when>
            <when test="groupKey != null and groupKey != ''">
                group by ${groupKey}
                order by ${groupKey}
            </when>
        </choose>
        <!-- pageNum 是 1 起始的,OFFSET 必须用 (pageNum-1)*pageSize;
             以前直接写 OFFSET ${pageNum},第一页会跳掉第一条分组。 -->
        <if test="pageSize != null  and pageNum != null">
            limit ${pageSize} OFFSET ${pageNum}
            limit ${pageSize} OFFSET ${pageOffset}
        </if>
    </select>
@@ -1977,6 +2019,8 @@
    -->
    <resultMap type="com.ruoyi.task.domain.ServiceSubtaskStatistic" id="ServiceSubtaskScriptStatisticResult">
        <result property="groupCode" column="group_code"/>
        <result property="groupName" column="group_name"/>
        <result property="dischargeCount" column="discharge_count"/>
        <result property="firstVisitCount" column="first_visit_count"/>
        <result property="againVisitCount" column="again_visit_count"/>
        <result property="drcode" column="drcode"/>
@@ -2025,6 +2069,10 @@
            resultMap="ServiceSubtaskScriptStatisticResult">
        select
        IFNULL(${groupColumn}, 'Unknown') as group_code,
        <!-- 分组键对应的显示名:病区统计时就是这一组的病区名。
             以前这一列是 MAX(hospitaldistrictname),一个组混了多个病区时会随机显示其中一个,
             于是出现“选血液内科病区却显示骨科中心三病区”。 -->
        MAX(IFNULL(leavehospitaldistrictname, hospitaldistrictname)) as group_name,
        MAX(drcode) as drcode,
        MAX(drname) as drname,
        MAX(leaveldeptcode) as deptcode,
@@ -2056,7 +2104,10 @@
        SUM(CASE WHEN visit_count > 1 and sendstate is not null and current_preachform = '3' THEN 1 ELSE 0 END) as voice_again,
        SUM(CASE WHEN visit_count > 1 and sendstate is not null and current_preachform = '4' THEN 1 ELSE 0 END) as sms_again,
        SUM(CASE WHEN visit_count > 1 and sendstate is not null and current_preachform = '5' THEN 1 ELSE 0 END) as we_chat_again,
        SUM(CASE WHEN visit_count > 1 and sendstate is not null and excep = '1' THEN 1 ELSE 0 END) as abnormal_again
        SUM(CASE WHEN visit_count > 1 and sendstate is not null and excep = '1' THEN 1 ELSE 0 END) as abnormal_again,
        <!-- 出院人次:原来的内存实现是 serviceSubtaskList.size(),即分组内命中的子任务条数。
             改成本语句后一直没有聚合这一列,页面上“出院人次”恒为 0,与应随访人次自相矛盾。 -->
        COUNT(*) as discharge_count
        from service_subtask
        where 1=1
        and del_flag = 0
@@ -2066,14 +2117,37 @@
        <if test="campusid != null and campusid != ''">
            and campusid = #{campusid}
        </if>
        <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0">
            AND (leavehospitaldistrictcode IS NULL OR leavehospitaldistrictcode IN
            <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator=","
                     close=")">
                #{leavehospitaldistrictcode}
            </foreach>
            )
        </if>
        <choose>
            <!-- 满意度统计:改用“有效病区”列(出院病区为空回退在院病区),筛选才真正生效 -->
            <when test="districtColumn != null and districtColumn != ''">
                <!-- 出院病区为空回退到在院病区。这里刻意写成 OR 展开而不是 IFNULL(a,b) IN (...):
                     列被函数包住就用不了索引,56 万行明细表上是全表扫(实测 5.1s → 0.7s)。
                     两个分支分别命中 idx_ss_district_pair 的 (leave..., hospital...) 前缀。 -->
                <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0">
                    AND (leavehospitaldistrictcode IN
                    <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator=","
                             close=")">
                        #{leavehospitaldistrictcode}
                    </foreach>
                    OR (leavehospitaldistrictcode IS NULL AND hospitaldistrictcode IN
                    <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator=","
                             close=")">
                        #{leavehospitaldistrictcode}
                    </foreach>
                    ))
                </if>
            </when>
            <otherwise>
                <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0">
                    AND (leavehospitaldistrictcode IS NULL OR leavehospitaldistrictcode IN
                    <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator=","
                             close=")">
                        #{leavehospitaldistrictcode}
                    </foreach>
                    )
                </if>
            </otherwise>
        </choose>
        <if test="deptcodes != null and deptcodes.size() > 0">
            AND leaveldeptcode IN
            <foreach collection="deptcodes" item="deptcode" open="(" separator=","
@@ -2134,8 +2208,10 @@
        <if test="visitCount != null and visitCount > 1">
            AND visit_count > 1
        </if>
        <if test="groupKey != null and groupKey != '' and groupKeyList != null and groupKeyList.size>0">
            AND IFNULL(${groupKey}, 'Unknown') IN
        <!-- 过滤列必须与 GROUP BY 的列一致:都用 groupColumn(满意度病区统计时它是
             IFNULL(出院病区, 在院病区) 这样的表达式),否则聚合出来的分组键过滤不上,题目量会全变 0。 -->
        <if test="groupColumn != null and groupColumn != '' and groupKeyList != null and groupKeyList.size>0">
            AND IFNULL(${groupColumn}, 'Unknown') IN
            <foreach collection="groupKeyList" item="key" open="(" separator=","
                     close=")">
                #{key}
@@ -2153,7 +2229,14 @@
    <select id="getSfStatisticsScriptJoySum" parameterType="com.ruoyi.task.domain.ServiceSubtaskCountReq"
            resultType="map">
        select
        IFNULL(t.${groupColumn}, 'Unknown') as groupCode,
        <choose>
            <when test="districtColumn != null and districtColumn != ''">
                IFNULL(${districtColumn}, 'Unknown') as groupCode,
            </when>
            <otherwise>
                IFNULL(t.${groupColumn}, 'Unknown') as groupCode,
            </otherwise>
        </choose>
        SUM(CASE WHEN a.categoryid IN(${configValue})
        THEN 1 ELSE 0 END) as joyAllCount,
        SUM(CASE WHEN a.categoryid IN(${configValue})
@@ -2171,14 +2254,38 @@
        <if test="campusid != null and campusid != ''">
            and t.campusid = #{campusid}
        </if>
        <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0">
            AND (t.leavehospitaldistrictcode IS NULL OR t.leavehospitaldistrictcode IN
            <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator=","
                     close=")">
                #{leavehospitaldistrictcode}
            </foreach>
            )
        </if>
        <choose>
            <!-- 满意度统计:与聚合主查询一致,用“有效病区”列过滤,
                 否则题目量会和主查询的行对不上(分组键不一样,过滤不上就全变 0)。 -->
            <when test="districtColumn != null and districtColumn != ''">
                <!-- 出院病区为空回退到在院病区。这里刻意写成 OR 展开而不是 IFNULL(a,b) IN (...):
                     列被函数包住就用不了索引,56 万行明细表上是全表扫(实测 5.1s → 0.7s)。
                     两个分支分别命中 idx_ss_district_pair 的 (leave..., hospital...) 前缀。 -->
                <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0">
                    AND (leavehospitaldistrictcode IN
                    <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator=","
                             close=")">
                        #{leavehospitaldistrictcode}
                    </foreach>
                    OR (leavehospitaldistrictcode IS NULL AND hospitaldistrictcode IN
                    <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator=","
                             close=")">
                        #{leavehospitaldistrictcode}
                    </foreach>
                    ))
                </if>
            </when>
            <otherwise>
                <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0">
                    AND (t.leavehospitaldistrictcode IS NULL OR t.leavehospitaldistrictcode IN
                    <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator=","
                             close=")">
                        #{leavehospitaldistrictcode}
                    </foreach>
                    )
                </if>
            </otherwise>
        </choose>
        <if test="deptcodes != null and deptcodes.size() > 0">
            AND t.leaveldeptcode IN
            <foreach collection="deptcodes" item="deptcode" open="(" separator=","
@@ -2239,14 +2346,28 @@
        <if test="visitCount != null and visitCount > 1">
            AND t.visit_count > 1
        </if>
        <if test="groupKey != null and groupKey != '' and groupKeyList != null and groupKeyList.size>0">
        <if test="districtColumn != null and districtColumn != '' and groupKeyList != null and groupKeyList.size>0">
            AND IFNULL(${districtColumn}, 'Unknown') IN
            <foreach collection="groupKeyList" item="key" open="(" separator=","
                     close=")">
                #{key}
            </foreach>
        </if>
        <if test="(districtColumn == null or districtColumn == '') and groupKey != null and groupKey != '' and groupKeyList != null and groupKeyList.size>0">
            AND IFNULL(t.${groupKey}, 'Unknown') IN
            <foreach collection="groupKeyList" item="key" open="(" separator=","
                     close=")">
                #{key}
            </foreach>
        </if>
        group by IFNULL(t.${groupColumn}, 'Unknown')
        <choose>
            <when test="districtColumn != null and districtColumn != ''">
                group by IFNULL(${districtColumn}, 'Unknown')
            </when>
            <otherwise>
                group by IFNULL(t.${groupColumn}, 'Unknown')
            </otherwise>
        </choose>
    </select>
    <select id="getSfStatisticsHyperlink" parameterType="com.ruoyi.task.domain.ServiceSubtaskCountReq"
@@ -2300,13 +2421,20 @@
            and campusid = #{campusid}
        </if>
        <!-- 2026-10-21:病区为空时视为"不限"(南华满意度 83% 子任务无病区),有病区的数据行为不变 -->
        <!-- 与 getSfStatisticsScript 的汇总行保持一致:出院病区为空则回退到在院病区,
             否则点“查看详情”查出来的条数会和汇总行的数字对不上。 -->
        <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0">
            AND (leavehospitaldistrictcode IS NULL OR leavehospitaldistrictcode IN
            AND (leavehospitaldistrictcode IN
            <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator=","
                     close=")">
                #{leavehospitaldistrictcode}
            </foreach>
            )
            OR (leavehospitaldistrictcode IS NULL AND hospitaldistrictcode IN
            <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator=","
                     close=")">
                #{leavehospitaldistrictcode}
            </foreach>
            ))
        </if>
        <if test="deptcodes != null and deptcodes.size() > 0">
            AND leaveldeptcode IN
@@ -2637,11 +2765,17 @@
            AND ss.campusid = #{campusid}
        </if>
        <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0">
            AND ss.leavehospitaldistrictcode IN
            AND (ss.leavehospitaldistrictcode IN
            <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator=","
                     close=")">
                #{leavehospitaldistrictcode}
            </foreach>
            OR (ss.leavehospitaldistrictcode IS NULL AND ss.hospitaldistrictcode IN
            <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator=","
                     close=")">
                #{leavehospitaldistrictcode}
            </foreach>
            ))
        </if>
        <if test="deptcodes != null and deptcodes.size() > 0">
            AND ss.leaveldeptcode IN
@@ -2719,11 +2853,17 @@
            and ss.campusid = #{campusid}
        </if>
        <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0">
            AND ss.leavehospitaldistrictcode IN
            AND (ss.leavehospitaldistrictcode IN
            <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator=","
                     close=")">
                #{leavehospitaldistrictcode}
            </foreach>
            OR (ss.leavehospitaldistrictcode IS NULL AND ss.hospitaldistrictcode IN
            <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator=","
                     close=")">
                #{leavehospitaldistrictcode}
            </foreach>
            ))
        </if>
        <if test="deptcodes != null and deptcodes.size() > 0">
            AND ss.leaveldeptcode IN
@@ -3085,7 +3225,7 @@
        SUM(CASE WHEN sendstate = 1 THEN 1 ELSE 0 END) AS blq,
        SUM(CASE WHEN sendstate = 4 THEN 1 ELSE 0 END) AS wxsf,
        SUM(CASE WHEN sendstate != 4 THEN 1 ELSE 0 END) AS xsf,
        SUM(CASE WHEN (sendstate = 1 OR sendstate = 2 OR sendstate = 3 OR sendstate = 5 OR sendstate = 7 ) THEN 1 ELSE 0
        SUM(CASE WHEN (sendstate = 1 OR sendstate = 2 OR sendstate = 3 OR sendstate = 7 ) THEN 1 ELSE 0
        END) AS dsf,
        SUM(CASE WHEN sendstate != 2 THEN 1 ELSE 0 END) AS yfs,
        SUM(CASE WHEN sendstate = 2 THEN 1 ELSE 0 END) AS wfs,
@@ -3282,7 +3422,8 @@
        当前登录人「待处理」随访任务,按出院年月(endtime)分组统计,出院随访 / 门诊随访分开计数。
        口径(与随访列表「待随访」页签一致):
          1) 出院随访 service_type = '2',门诊随访 service_type = '3'
          2) 待随访(未完成)→ sendstate IN (1,2,3,5,7),排除 4 不执行 / 6 完成
          2) 统计状态 → sendstate IN (1,2,3,7):1 被领取 / 2 待发送 / 3 已发送 / 7 超时
             (不含 4 不执行、5 发送失败、6 已完成)
          3) 出院满 7 天 → endtime < (今天 - 7 天) + 1 天,
             即「7 天前那一天」出院的也计入(用 <= DATE_SUB(...) 只会截到当天 0 点,会漏掉 9 条)
          4) 只看最近 4 个出院月份(含当月)→ endtime >= 当月往前推 3 个月的 1 号
@@ -3302,7 +3443,8 @@
        FROM service_subtask
        WHERE del_flag = '0'
        AND service_type IN ('2', '3')
        AND sendstate IN (1, 2, 3, 5, 7)
        <!-- 浮窗「待处理随访任务」口径:1 被领取 / 2 待发送 / 3 已发送 / 7 超时 -->
        AND sendstate IN (1, 2, 3, 7)
        AND endtime IS NOT NULL
        AND endtime &lt; DATE_ADD(DATE_SUB(CURDATE(), INTERVAL 7 DAY), INTERVAL 1 DAY)
        AND endtime &gt;= DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 3 MONTH), '%Y-%m-01')
smartor/src/main/java/com/smartor/service/impl/ServiceSubtaskAnswerServiceImpl.java
@@ -518,8 +518,29 @@
        }
    }
    /**
     * 同一份问卷的保存锁:按「任务+患者+子任务」加锁,
     * 前端重复点击 / 并发请求进来时串行执行,避免同一份问卷被写两遍。
     * 注意:只是串行化,不阻止再次保存——问卷仍然允许修改后再保存(走更新逻辑)。
     */
    private static final java.util.concurrent.ConcurrentHashMap<String, Object> SAVE_LOCKS =
            new java.util.concurrent.ConcurrentHashMap<>();
    @Override
    public Integer saveQuestionDetail(ServiceSubTaskCacheReq serviceSubTaskCacheReq, Long flag) {
        String lockKey = serviceSubTaskCacheReq.getParam1() + "_"
                + serviceSubTaskCacheReq.getParam2() + "_"
                + serviceSubTaskCacheReq.getParam6();
        Object lock = SAVE_LOCKS.computeIfAbsent(lockKey, k -> new Object());
        if (SAVE_LOCKS.size() > 20000) {
            SAVE_LOCKS.clear();
        }
        synchronized (lock) {
            return doSaveQuestionDetail(serviceSubTaskCacheReq, flag);
        }
    }
    private Integer doSaveQuestionDetail(ServiceSubTaskCacheReq serviceSubTaskCacheReq, Long flag) {
        Long tid = Long.valueOf(serviceSubTaskCacheReq.getParam1());
        Long pid = Long.valueOf(serviceSubTaskCacheReq.getParam2());
        Long subid = Long.valueOf(serviceSubTaskCacheReq.getParam6());