liusheng
8 小时以前 c24d16a37523a49080392610357872ce580cbc38
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/service/impl/PatSatisfactionServiceImpl.java
@@ -216,8 +216,10 @@
            //3 一次统计全部问题的发送量/填报量/选项分值(原来是每个问题各查 3 次)
            List<SatScriptRef> scriptRefs = new ArrayList<>();
            for (SatisfactionIvrScriptRow ivrLibaTemplateScript : ivrLibaTemplateScripts) {
                scriptRefs.add(new SatScriptRef(ivrLibaTemplateScript.getTemplateid(), ivrLibaTemplateScript.getScriptid(), ivrLibaTemplateScript.getScriptContent()));
                scriptRefs.add(new SatScriptRef(ivrLibaTemplateScript.getTemplateid(), ivrLibaTemplateScript.getScriptid(), ivrLibaTemplateScript.getScriptContent(), ivrLibaTemplateScript.getDimension()));
            }
            // 只保留本期真正下发过任务的模板:题库里挂着的历史模板(从未下发)不再占行
            scriptRefs = keepActiveTemplates(scriptRefs, activeTemplateIds(patSatisfactionReqVO, "1"));
            buildSatisfactionDetailEntities(patSatisfactionReqVO, "1", true, scriptRefs, patSatisfactionDetailEntities, personTotals);
        }
@@ -231,8 +233,10 @@
            List<SatScriptRef> scriptRefs = new ArrayList<>();
            for (SatisfactionSvyScriptRow svyLibTemplateScript : svyLibTemplateScripts) {
                // 问卷的选项表按 svy_lib_template_script 的主键关联
                scriptRefs.add(new SatScriptRef(svyLibTemplateScript.getSvyid(), svyLibTemplateScript.getId(), svyLibTemplateScript.getScriptContent()));
                scriptRefs.add(new SatScriptRef(svyLibTemplateScript.getSvyid(), svyLibTemplateScript.getId(), svyLibTemplateScript.getScriptContent(), svyLibTemplateScript.getDimension()));
            }
            // 只保留本期真正下发过任务的模板:题库里挂着的历史模板(从未下发)不再占行
            scriptRefs = keepActiveTemplates(scriptRefs, activeTemplateIds(patSatisfactionReqVO, "2"));
            buildSatisfactionDetailEntities(patSatisfactionReqVO, "2", false, scriptRefs, patSatisfactionDetailEntities, personTotals);
        }
        result.setTotalAnswerRate(Arith.div(personTotals[0], personTotals[1]));
@@ -270,8 +274,10 @@
                //3 一次统计全部问题的发送量/填报量/选项分值(原来是每个问题各查 3 次)
                List<SatScriptRef> scriptRefs = new ArrayList<>();
                for (SatisfactionIvrScriptRow ivrLibaTemplateScript : ivrLibaTemplateScripts) {
                    scriptRefs.add(new SatScriptRef(ivrLibaTemplateScript.getTemplateid(), ivrLibaTemplateScript.getScriptid(), ivrLibaTemplateScript.getScriptContent()));
                    scriptRefs.add(new SatScriptRef(ivrLibaTemplateScript.getTemplateid(), ivrLibaTemplateScript.getScriptid(), ivrLibaTemplateScript.getScriptContent(), ivrLibaTemplateScript.getDimension()));
                }
                // 只保留本期真正下发过任务的模板:题库里挂着的历史模板(从未下发)不再占行
                scriptRefs = keepActiveTemplates(scriptRefs, activeTemplateIds(patSatisfactionReqVO, "1"));
                buildSatisfactionDetailEntities(patSatisfactionReqVO, "1", true, scriptRefs, patSatisfactionDetailEntities, personTotals);
            }
        }
@@ -284,8 +290,10 @@
                List<SatScriptRef> scriptRefs = new ArrayList<>();
                for (SatisfactionSvyScriptRow svyLibTemplateScript : svyLibTemplateScripts) {
                    // 问卷的选项表按 svy_lib_template_script 的主键关联
                    scriptRefs.add(new SatScriptRef(svyLibTemplateScript.getSvyid(), svyLibTemplateScript.getId(), svyLibTemplateScript.getScriptContent()));
                    scriptRefs.add(new SatScriptRef(svyLibTemplateScript.getSvyid(), svyLibTemplateScript.getId(), svyLibTemplateScript.getScriptContent(), svyLibTemplateScript.getDimension()));
                }
                // 只保留本期真正下发过任务的模板:题库里挂着的历史模板(从未下发)不再占行
                scriptRefs = keepActiveTemplates(scriptRefs, activeTemplateIds(patSatisfactionReqVO, "2"));
                buildSatisfactionDetailEntities(patSatisfactionReqVO, "2", false, scriptRefs, patSatisfactionDetailEntities, personTotals);
            }
        }
@@ -578,6 +586,8 @@
        statQuery.setEndTime(reqVO.getEndTime());
        statQuery.setDeptcodes(reqVO.getDeptcodes());
        statQuery.setHospitaldistrictcodes(reqVO.getHospitaldistrictcodes());
        statQuery.setLeaveldeptcodes(reqVO.getLeaveldeptcodes());
        statQuery.setLeavehospitaldistrictcodes(reqVO.getLeavehospitaldistrictcodes());
        statQuery.setLibTemplateIds(CollectionUtils.isEmpty(templateIds) ? templateIds
                : templateIds.stream().filter(Objects::nonNull).distinct().collect(Collectors.toList()));
        return statQuery;
@@ -763,31 +773,66 @@
        SatisfactionStatQuery statQuery = buildSatisfactionStatQuery(reqVO, type, templateIds);
        Map<Long, Long> sendCountMap = mapSatisfactionSendCount(statQuery);
        SatisfactionFillStatByMatchedtext fillStat = querySatisfactionFillStatByMatchedtext(statQuery);
        //答题【人数】:按 模板+问题 的 distinct sub_id(与「发送人数」同口径)
        Map<String, Long> answerPersonMap = mapSatisfactionFillPersonCountByQuestion(statQuery);
        Map<Long, List<String>> optionScores = loadTargetOptionScores(voice, scriptRefs);
        for (SatScriptRef scriptRef : scriptRefs) {
            Long templateId = scriptRef.getTemplateId();
            //发送总量(原来是把全部 sub_id 查回 Java 再取 size)
            long sendCount = templateId == null ? 0L : sendCountMap.getOrDefault(templateId, 0L);
            //填报数量(原来是把明细行全部查回 Java 再统计)
            //填报【行数】:用于平均分 / 最高分 / 最低分 / 各选项占比(这些口径本来就按明细行计)
            List<MatchedtextCount> fillRows = fillStat.get(templateId, scriptRef.getScriptContent());
            long fillCount = countFillRowsByMatchedtext(fillRows);
            //答题【人数】:用于 答题人数 / 未答题人数 / 答题率
            long answerPersonCount = answerPersonMap.getOrDefault(
                    statKey(templateId, scriptRef.getScriptContent()), 0L);
            PatSatisfactionDetailEntity detail = new PatSatisfactionDetailEntity();
            detail.setScriptContent(scriptRef.getScriptContent());
            detail.setAnswerPerson((int) fillCount);
            detail.setNoAnswerPerson(fillCount > 0L && sendCount > 0L ? (int) (sendCount - fillCount) : sendCount > 0L ? (int) sendCount : 0);
            //维度:原本查询里已取到,但构造 SatScriptRef 时被丢掉,导致前端「维度」列一直为空
            detail.setDimension(scriptRef.getDimension());
            // ⚠️ 人数口径修正:原来这里用的是 fillCount(明细行数),
            //    而发送人数是 count(distinct id)(人数),两者相减会出现负数(实测 -120 = 15 − 135)。
            //    一条 (sub_id, questiontext) 可能有多行明细,所以答题人数必须取 distinct sub_id。
            detail.setAnswerPerson((int) answerPersonCount);
            detail.setNoAnswerPerson(answerPersonCount > 0L && sendCount > 0L ? (int) (sendCount - answerPersonCount)
                    : sendCount > 0L ? (int) sendCount : 0);
            detail.setAverageScore(String.valueOf(averageScoreByMatchedtext(fillRows)));
            List<String> scores = optionScores.get(scriptRef.getOptionScriptId());
            detail.setMaxScore(String.valueOf(maxScoreOf(scores)));
            detail.setMinScore(String.valueOf(minScoreOf(scores)));
            detail.setAnswerRate(sendCount == 0L ? 0 : fillCount == 0L ? 0 : fillCount * 1.0 / sendCount);
            detail.setAnswerRate(sendCount == 0L ? 0 : answerPersonCount == 0L ? 0 : answerPersonCount * 1.0 / sendCount);
            detail.setMatchedtextStats(buildMatchedtextStats(fillRows, fillCount));
            out.add(detail);
            totals[0] += (int) fillCount;
            totals[0] += (int) answerPersonCount;
            totals[1] += (int) sendCount;
        }
    }
    /**
     * 一次查出「全部 模板+问题」的答题人数({@code count(distinct ssd.sub_id)})。
     * <p>
     * key 用与 {@link #statKey(Long, String)} 相同的规则构造,保证与调用方的查找一致
     * ({@code statKey} 已做 trim + 小写,等价于 MySQL 的忽略大小写与尾部空格比较)。
     */
    private Map<String, Long> mapSatisfactionFillPersonCountByQuestion(SatisfactionStatQuery statQuery) {
        Map<String, Long> answerPersonMap = new HashMap<>();
        if (statQuery == null || CollectionUtils.isEmpty(statQuery.getLibTemplateIds())) return answerPersonMap;
        List<Map<String, Object>> rows = satisfactionMapper.selectSatisfactionFillPersonCountByQuestion(statQuery);
        if (CollectionUtils.isEmpty(rows)) return answerPersonMap;
        for (Map<String, Object> row : rows) {
            Long templateId = toLong(row.get("libtemplateid"));
            if (templateId == null) continue;
            long cnt = toLong(row.get("cnt"), 0L);
            if (cnt <= 0L) continue;
            Object questiontext = row.get("questiontext");
            answerPersonMap.merge(
                    statKey(templateId, questiontext == null ? null : String.valueOf(questiontext)),
                    cnt, Long::sum);
        }
        return answerPersonMap;
    }
    /**
@@ -914,7 +959,7 @@
    /**
     * 获取平均分(聚合版:分值与条数)
     * <p>
     * 口径与 {@link #averageScore(List)} 一致:分子为可解析分值之和,分母为明细行数(含分值为空的行)。
     * 分子为可解析分值之和,分母为明细行数(含分值为空的行)。
     */
    private Double averageScoreByMatchedtext(List<MatchedtextCount> fillRows) {
        if (CollectionUtils.isEmpty(fillRows)) return 0.0;
@@ -932,7 +977,7 @@
    }
    /**
     * 获取最高分(聚合版,口径同 {@link #maxScore(List)}:空列表返回 0.0)
     * 获取最高分(聚合版,空列表返回 0.0)
     */
    private Double maxScoreOf(List<String> scores) {
        if (CollectionUtils.isEmpty(scores)) return 0.0;
@@ -941,7 +986,7 @@
    }
    /**
     * 获取最低分(聚合版,口径同 {@link #minScore(List)}:空列表返回 0.0)
     * 获取最低分(聚合版,口径同 {空列表返回 0.0)
     */
    private Double minScoreOf(List<String> scores) {
        if (CollectionUtils.isEmpty(scores)) return 0.0;
@@ -950,17 +995,19 @@
    }
    /**
     * 满意度统计用的问题引用:模板id、选项表关联的问题id(可能为 null)、问题内容
     * 满意度统计用的问题引用:模板id、选项表关联的问题id(可能为 null)、问题内容、维度
     */
    private static class SatScriptRef {
        private final Long templateId;
        private final Long optionScriptId;
        private final String scriptContent;
        private final String dimension;
        SatScriptRef(Long templateId, Long optionScriptId, String scriptContent) {
        SatScriptRef(Long templateId, Long optionScriptId, String scriptContent, String dimension) {
            this.templateId = templateId;
            this.optionScriptId = optionScriptId;
            this.scriptContent = scriptContent;
            this.dimension = dimension;
        }
        Long getTemplateId() {
@@ -973,6 +1020,10 @@
        String getScriptContent() {
            return scriptContent;
        }
        String getDimension() {
            return dimension;
        }
    }
@@ -1038,19 +1089,74 @@
    // 原实现直接 new smartor 的实体当入参;模块化后统一用 SatisfactionScriptQuery,
    // 这里把三处构造收敛为帮助方法,条件与原来逐字对应。
    /** 满意度分类查询(orgid + type=myd) */
    /**
     * 本期内<b>真正下发过任务</b>的模板 id 集合(空集合表示本次条件下没有任何任务)。
     * <p>
     * 背景:{@code /statistics} 的题目来源是「满意度分类」下的全量题库题目
     * ({@link SatisfactionScriptMapper#querySvyScriptByCategory} /
     * {@link SatisfactionScriptMapper#queryIvrScriptByAssort}),
     * 既不看模板也不看时间。题库里只要挂进过一套历史问卷模板
     * (题目还在、但从来没下发过任务),统计结果就会多出一整批
     * 「平均分 0 / 最高分 0 / 答题人数 0」的行。
     * <p>
     * 实测(丽水 smartor_sltd,某次入参):满意度分类下 6 套模板共 69 道题,
     * 而本期只有 2 套模板共 25 道题真正下发过,于是页面显示 69 行、其中 44 行全 0。
     * 用本方法裁掉无任务模板后,行数回到 25。
     *
     * @param reqVO 原始请求(条件与「有数据」判断同口径:机构、服务类型、科室 OR 病区、时间兜底)
     * @param type  service_subtask.type:1 语音、2 问卷
     */
    private Set<Long> activeTemplateIds(PatSatisfactionReqVO reqVO, String type) {
        List<Long> ids = satisfactionMapper.selectSatisfactionActiveTemplateIds(reqVO, type);
        if (CollectionUtils.isEmpty(ids)) return Collections.emptySet();
        return new HashSet<>(ids);
    }
    /**
     * 只保留本期真正下发过任务的模板的题目(空集合时返回空列表,即「本期没有任务 → 不出行」)。
     * <p>
     * 保留 {@code scriptRefs} 的原有顺序,因为返回顺序就是页面展示顺序。
     */
    private List<SatScriptRef> keepActiveTemplates(List<SatScriptRef> scriptRefs, Set<Long> activeTemplateIds) {
        if (CollectionUtils.isEmpty(scriptRefs) || CollectionUtils.isEmpty(activeTemplateIds)) {
            return new ArrayList<>();
        }
        List<SatScriptRef> kept = new ArrayList<>(scriptRefs.size());
        for (SatScriptRef scriptRef : scriptRefs) {
            if (scriptRef.getTemplateId() != null && activeTemplateIds.contains(scriptRef.getTemplateId())) {
                kept.add(scriptRef);
            }
        }
        return kept;
    }
    /**
     * 满意度分类查询(语音侧)。
     * <p>
     * ⚠️ <b>与原文一致:只传 orgid,<u>不传 type</u></b>。原实现是
     * <pre>
     * IvrLibaScriptAssort e = new IvrLibaScriptAssort();
     * e.setOrgid(reqVO.getOrgid());      // ← 只有 orgid
     * mapper.selectIvrLibaScriptAssortList(e);
     * </pre>
     * 分类的「是不是满意度(myd)」是在 <b>Java 侧</b>用
     * {@code item.getType().contains("myd")} 过滤的(见 {@link #statistics} /
     * {@link #satisfactionGraph})。
     * <p>
     * 如果在这里顺手加上 {@code type="myd"},SQL 就会多出一个
     * {@code type like '%myd%'} 条件 —— 而 MySQL 的 LIKE 默认<b>不区分大小写</b>,
     * 会比 Java 的 {@code contains("myd")} 多命中分类,导致统计结果出现差异。
     */
    private SatisfactionScriptQuery ivrCategoryQuery(PatSatisfactionReqVO reqVO) {
        SatisfactionScriptQuery q = new SatisfactionScriptQuery();
        q.setOrgid(reqVO.getOrgid());
        q.setType("myd");
        return q;
    }
    /** 满意度分类查询(问卷侧,条件同语音侧) */
    /** 满意度分类查询(问卷侧,同样只传 orgid,见 {@link #ivrCategoryQuery}) */
    private SatisfactionScriptQuery svyCategoryQuery(PatSatisfactionReqVO reqVO) {
        SatisfactionScriptQuery q = new SatisfactionScriptQuery();
        q.setOrgid(reqVO.getOrgid());
        q.setType("myd");
        return q;
    }