package com.ruoyi.satisfaction.service.impl;
import com.ruoyi.satisfaction.domain.ServiceSubtaskDetailRatioExport;
import com.ruoyi.satisfaction.domain.dto.MydTrendReqDTO;
import com.ruoyi.satisfaction.domain.dto.MzMydScoreRankDTO;
import com.ruoyi.satisfaction.domain.dto.QuestionCountKsOrBqDTO;
import com.ruoyi.satisfaction.domain.dto.ServiceSubtaskOptionStatDTO;
import com.ruoyi.satisfaction.domain.vo.MydTrendReqVO;
import com.ruoyi.satisfaction.domain.vo.MzMydScoreRankVO;
import com.ruoyi.satisfaction.domain.vo.QuestionCountKsOrBqVO;
import com.ruoyi.satisfaction.domain.row.SatisfactionScriptQuery;
import com.ruoyi.satisfaction.domain.row.SatisfactionSubtaskQuery;
import com.ruoyi.satisfaction.domain.row.SatisfactionSubtaskRef;
import com.ruoyi.satisfaction.domain.row.SatisfactionIvrScriptRow;
import com.ruoyi.satisfaction.domain.row.SatisfactionSvyScriptRow;
import com.ruoyi.satisfaction.domain.row.SatisfactionTaskRow;
import com.ruoyi.satisfaction.domain.vo.SatisfactionExceptConfigQuestionReqVO;
import com.ruoyi.satisfaction.domain.vo.SltdMydTotalVO;
import com.ruoyi.satisfaction.domain.row.SatisfactionCategoryRow;
import com.ruoyi.satisfaction.mapper.SatisfactionMapper;
import com.ruoyi.satisfaction.mapper.SatisfactionScriptMapper;
import com.ruoyi.satisfaction.service.SatisfactionSubtaskService;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.utils.DtoConversionUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
/**
* 满意度「子任务维度」统计服务。
*
* 迁移说明:本类方法原散落在随访主干的大文件 ServiceSubtaskServiceImpl 中
* (被视为随访主干的大文件),现整段搬入满意度模块,业务逻辑一行未改。
*
* 与 {@link PatSatisfactionServiceImpl} 的分工:
*
* - {@code PatSatisfactionServiceImpl} —— 通用满意度统计(南华与同德共用);
*
- 本类 —— 省立同德导向的统计:选项占比、按科室/病区、趋势、门诊得分排名。
* 这些入口原本挂在随访明细 Controller 上,迁移后仍由满意度模块统一提供。
*
* 本模块自带 SQL,直接查 {@code service_subtask} / {@code service_subtask_detail},
* 不依赖 smartor 的任何实体(调用方只传 id 列表)。
*/
@Slf4j
@Service
public class SatisfactionSubtaskServiceImpl implements SatisfactionSubtaskService {
@Autowired
private SatisfactionMapper satisfactionMapper;
@Autowired
private SatisfactionScriptMapper satisfactionScriptMapper;
/**
* 重复明细清理的“最近一次执行时间”(毫秒)。见 {@link #cleanRepeatDetailsIfDue()}。
*/
private static final AtomicLong LAST_REPEAT_CLEAN_AT = new AtomicLong(0L);
/**
* 重复明细清理的最小间隔:10 分钟。
*/
private static final long REPEAT_CLEAN_INTERVAL_MS = TimeUnit.MINUTES.toMillis(10);
/**
* 清理 service_subtask_detail 里的重复明细(同一 sub_id + questiontext 只留最新一条)。
*
* ⚠️ 为什么要有节流:{@code selectRepeatDetailIds} 是一条
* 「对整张 service_subtask_detail 按 (sub_id, questiontext) 分组 + HAVING COUNT(*) > 1」的
* 全表聚合,且没有任何日期/病区/子任务范围;一旦查出重复还会执行逻辑删除(写操作)。
* 原实现在每次满意度统计请求前都无条件跑一遍,于是:
*
* - 统计接口的性能与用户所选条件完全无关,明细表越大越慢;
* - 一个只读接口在偷偷做 DML。
*
* 而统计 SQL 本身已经用 {@code d.id = (SELECT MAX(...))} 做了“同题只取最新一条”的去重,
* 所以这个前置清理对本次统计结果不是必需的。这里改成“每个实例最多 10 分钟清一次”,
* 既保留清理能力,又不让每个请求都为它买单。
*
* 更彻底的做法是把它挪到定时任务里(只留一个维护入口),需要的话可以再调整。
*/
private void cleanRepeatDetailsIfDue() {
long now = System.currentTimeMillis();
long last = LAST_REPEAT_CLEAN_AT.get();
if (now - last < REPEAT_CLEAN_INTERVAL_MS) {
return;
}
// CAS 抢占:并发请求里只放一个线程去清理,其余直接返回
if (!LAST_REPEAT_CLEAN_AT.compareAndSet(last, now)) {
return;
}
try {
List repeatIds = satisfactionMapper.selectRepeatDetailIds();
if (!CollectionUtils.isEmpty(repeatIds)) {
satisfactionMapper.deleteRepeatDetailByIds(repeatIds);
log.info("满意度统计前置清理:逻辑删除重复明细 {} 条", repeatIds.size());
}
} catch (Exception e) {
// 清理失败不能影响统计本身
log.warn("满意度统计前置清理重复明细失败,已跳过本次清理", e);
}
}
public List statQuestionOption(List taskIds, Date startFinishTime, Date endFinishTime, Date startOutHospTime, Date endOutHospTime, String scriptContent, Double scoreStart, Double scoreEnd, String valueType, List subtaskIds) {
//前置清理重复明细(已节流:每个实例最多 10 分钟一次,不再每个请求都跑全表 GROUP BY)
cleanRepeatDetailsIfDue();
List serviceSubtaskDetailRatioExports = satisfactionMapper.statQuestionOption(taskIds, startOutHospTime, endOutHospTime, startFinishTime, endFinishTime, scoreStart, scoreEnd, scriptContent, valueType, subtaskIds);
// 处理 targetvalue:按 & 分割成多条记录
List result = new ArrayList<>();
for (ServiceSubtaskDetailRatioExport export : serviceSubtaskDetailRatioExports) {
if (StringUtils.isNotEmpty(export.getTargetvalue()) && export.getTargetvalue().contains("&")) {
String[] values = export.getTargetvalue().split("&");
// 如果有多条目标值,拆分成多行
for (String value : values) {
//先去 result 中查一下,有没有这个 questiontext 和对应的 targetvalue
//注意必须带上 taskid 一起匹配:否则会把别的任务的数据填到本任务的行上,
//导致导出的「数量/比例」和该行的「任务名称/任务编号」对不上(同一任务同一题出现两个不同分母)
ServiceSubtaskDetailRatioExport existingExport = result.stream().filter(e -> Objects.equals(e.getTaskid(), export.getTaskid()) && e.getQuestiontext().equals(export.getQuestiontext()) && value.trim().equals(e.getTargetvalue())).findFirst().orElse(null);
if (existingExport != null && existingExport.getRatio().equals("0") && existingExport.getCount().equals("0")) {
//如果存在,则将 result 里的这个问题所对应的 Targetvalue=value 这条数据更新成当前这条数据
if (value.equals(export.getOptionresult())) {
existingExport.setRatio(export.getRatio());
existingExport.setScore(export.getScore());
existingExport.setCount(export.getCount());
existingExport.setOptionresult(export.getOptionresult());
}
} else if (existingExport != null && !existingExport.getRatio().equals("0")) {
continue;
} else {
ServiceSubtaskDetailRatioExport newExport = DtoConversionUtils.sourceToTarget(export, ServiceSubtaskDetailRatioExport.class);
newExport.setTargetvalue(value.trim()); // targetvalue 显示单个选项
if (!value.equals(export.getOptionresult())) {
newExport.setOptionresult(null);
newExport.setCount("0");
newExport.setRatio("0");
}
result.add(newExport);
}
}
} else {
// targetvalue 不包含 &,直接添加到结果
result.add(export);
}
}
return result;
}
public List statMydQuestionOptionForExport(List subtaskIds, String questiontext) {
List result = new ArrayList<>();
if (CollectionUtils.isEmpty(subtaskIds)) {
return result;
}
List statList = satisfactionMapper.statMydQuestionOptionForExport(subtaskIds, questiontext);
if (CollectionUtils.isEmpty(statList)) {
return result;
}
return buildMydOptionRows(statList);
}
/**
* 把「任务+题目+选项」的作答统计拼成导出的「问题详情占比」行
*
* - 选项清单:题目配置过的选项在 targetvalue(用 & 连接)里,全部列出,没人选的显示 0
* - 数量:患者选中的选项在 asrtext 里(多选题是「A&B」),每个选中项各记一次人数,一个患者只算一条明细
* - 比例:数量 / 该题作答患者数,所以单选题各选项数量之和 = 该题作答患者数
* - 分数:该选项明细的 score 之和;多选行按选中项份数均分,保证各选项分数之和 = 明细分数之和
*
*/
private static List buildMydOptionRows(List statList) {
List result = new ArrayList<>();
// 按「任务 + 题目」分组,保持 SQL 的顺序(先题目后任务)
Map> grouped = new LinkedHashMap<>();
for (ServiceSubtaskOptionStatDTO stat : statList) {
String key = stat.getTaskid() + "\u0001" + stat.getQuestiontext();
grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(stat);
}
for (List group : grouped.values()) {
ServiceSubtaskOptionStatDTO first = group.get(0);
// 选项 -> 人数(long[1] 便于累加),顺序即出现顺序
Map countMap = new LinkedHashMap<>();
Map scoreMap = new LinkedHashMap<>();
for (ServiceSubtaskOptionStatDTO stat : group) {
// targetvalue = 本题所有选项的清单,没人选的也要列出来
List options = splitOptions(stat.getTargetvalue());
// asrtext = 患者实际选中的选项,多选题会是「选中项1&选中项2」
List chosenOptions = splitOptions(stat.getOptionresult());
for (String option : options) {
countMap.computeIfAbsent(option, k -> new long[1]);
scoreMap.computeIfAbsent(option, k -> BigDecimal.ZERO);
}
long patientCount = stat.getPatientCount() == null ? 0L : stat.getPatientCount();
BigDecimal scoreSum = stat.getScoreSum() == null ? BigDecimal.ZERO : stat.getScoreSum();
if (!chosenOptions.isEmpty()) {
BigDecimal[] scoreParts = splitScore(scoreSum, chosenOptions.size());
for (int i = 0; i < chosenOptions.size(); i++) {
String chosen = chosenOptions.get(i);
// 选中的选项可能不在配置清单里(历史数据),补一行
countMap.computeIfAbsent(chosen, k -> new long[1]);
scoreMap.computeIfAbsent(chosen, k -> BigDecimal.ZERO);
countMap.get(chosen)[0] += patientCount;
scoreMap.put(chosen, scoreMap.get(chosen).add(scoreParts[i]));
}
} else if (options.size() == 1) {
// 没有 asrtext(未答/无应答)时,只有唯一选项才能确定归属
countMap.get(options.get(0))[0] += patientCount;
scoreMap.put(options.get(0), scoreMap.get(options.get(0)).add(scoreSum));
}
}
long total = 0L;
for (long[] count : countMap.values()) total += count[0];
for (Map.Entry entry : countMap.entrySet()) {
long count = entry.getValue()[0];
ServiceSubtaskDetailRatioExport row = new ServiceSubtaskDetailRatioExport();
row.setQuestiontext(first.getQuestiontext());
row.setOptionresult(entry.getKey());
row.setTargetvalue(entry.getKey());
row.setCount(String.valueOf(count));
row.setRatio(formatRatio(count, total));
row.setScore(formatScore(scoreMap.get(entry.getKey())));
row.setTaskname(first.getTaskName());
row.setTaskid(first.getTaskid());
row.setValueType(first.getValueType());
result.add(row);
}
}
return result;
}
/**
* 把一条明细的分数按选中项份数均分,余数补给第一个,保证各份之和 = 原分数
*/
private static BigDecimal[] splitScore(BigDecimal total, int parts) {
BigDecimal[] result = new BigDecimal[parts];
if (parts <= 0) {
return result;
}
BigDecimal each = total.divide(BigDecimal.valueOf(parts), 2, RoundingMode.HALF_UP);
for (int i = 0; i < parts; i++) {
result[i] = each;
}
result[0] = result[0].add(total.subtract(each.multiply(BigDecimal.valueOf(parts))));
return result;
}
/**
* 拆分题目配置的选项(多个选项用 & 连接),顺带去掉全角/半角空白
*/
private static List splitOptions(String targetvalue) {
List options = new ArrayList<>();
if (StringUtils.isEmpty(targetvalue)) {
return options;
}
for (String part : targetvalue.split("&")) {
String option = trimAll(part);
if (StringUtils.isNotEmpty(option) && !options.contains(option)) {
options.add(option);
}
}
return options;
}
/**
* 比例:数量 / 该题作答患者数,保留两位小数
*/
private static String formatRatio(long count, long total) {
if (total <= 0) {
return "0.00%";
}
return BigDecimal.valueOf(count * 100.0 / total).setScale(2, RoundingMode.HALF_UP).toPlainString() + "%";
}
/**
* 分数之和:去掉多余的小数位(0.00 -> 0)
*/
private static String formatScore(BigDecimal score) {
if (score == null) {
return "0";
}
BigDecimal value = score.stripTrailingZeros();
if (value.scale() < 0) {
value = value.setScale(0);
}
return value.toPlainString();
}
public Map>> statQuestionOptionByKsOrBq(QuestionCountKsOrBqVO vo) {
// 1. 查明细
List sourceList = satisfactionMapper.statQuestionOptionByKsOrBq(vo);
if (CollectionUtils.isEmpty(sourceList)) {
return Collections.emptyMap();
}
// 2. 判断按科室还是按病区(离院 / 在院两组参数都算)
boolean byDept = vo.getLeaveldeptcodes() != null || vo.getDeptcodes() != null;
boolean byDistrict = !byDept
&& (vo.getLeavehospitaldistrictcodes() != null || vo.getHospitaldistrictcodes() != null);
if (!byDept && !byDistrict) {
return Collections.emptyMap();
}
// 3. key = 科室/病区 + 题目 + 选项
Map map = new LinkedHashMap<>();
for (QuestionCountKsOrBqDTO dto : sourceList) {
if (StringUtils.isEmpty(dto.getTargetvalue())) {
continue;
}
// 分组名:科室 或 病区
String groupName = byDept ? dto.getDeptname() : dto.getLeavehospitaldistrictname();
if (StringUtils.isEmpty(groupName)) {
continue;
}
String asrtext = trimAll(dto.getAsrtext()); // 用户实际选中的选项
String questionText = trimAll(dto.getQuestionText()); // 题目
if (StringUtils.isEmpty(questionText)) {
continue;
}
// 拆分 targetvalue,每个选项都要有一条记录
for (String raw : dto.getTargetvalue().split("&")) {
String option = trimAll(raw);
if (option.isEmpty()) {
continue;
}
// key = 科室/病区 + 题目 + 选项
String key = buildKey(groupName, questionText, option);
QuestionCountKsOrBqDTO item = map.get(key);
if (item == null) {
item = DtoConversionUtils.sourceToTarget(dto, QuestionCountKsOrBqDTO.class);
item.setTargetvalue(option);
item.setAsrtext(option);
item.setQuestionText(dto.getQuestionText());
item.setAnswerCount("0");
item.setSingleScore("0");
map.put(key, item);
}
// 只有当前选项 == 用户实际选中的选项,才累加
if (option.equals(asrtext)) {
int count = parseCount(item.getAnswerCount()) + 1;
item.setAnswerCount(String.valueOf(count));
BigDecimal totalScore = new BigDecimal(item.getSingleScore())
.add(new BigDecimal(dto.getSingleScore() == null ? "0" : dto.getSingleScore()));
item.setSingleScore(totalScore.toPlainString());
}
}
}
// 4. 计算平均分
for (QuestionCountKsOrBqDTO dto : map.values()) {
String answerCount = dto.getAnswerCount();
String singleScore = dto.getSingleScore();
BigDecimal avg;
if (StringUtils.isEmpty(answerCount)
|| BigDecimal.ZERO.compareTo(new BigDecimal(answerCount.trim())) == 0) {
avg = BigDecimal.ZERO;
} else {
avg = new BigDecimal(singleScore == null ? "0" : singleScore)
.divide(new BigDecimal(answerCount.trim()), 2, RoundingMode.HALF_UP);
}
dto.setAvgScore(avg.toPlainString());
}
// 5. 转成 Map>>
// 第一层 key:科室/病区
// 第二层 key:题目
// 第二层 value:该题目下的所有选项列表
Map>> result = new LinkedHashMap<>();
for (QuestionCountKsOrBqDTO dto : map.values()) {
// 分组名
String groupName = byDept ? dto.getDeptname() : dto.getLeavehospitaldistrictname();
if (StringUtils.isEmpty(groupName)) {
continue;
}
// 题目名
String questionText = dto.getQuestionText();
if (StringUtils.isEmpty(questionText)) {
continue;
}
// 第一层:科室/病区
Map> questionMap =
result.computeIfAbsent(groupName, k -> new LinkedHashMap<>());
// 第二层:题目
List optionList =
questionMap.computeIfAbsent(questionText, k -> new ArrayList<>());
optionList.add(dto);
}
return result;
}
public List statQuestionOptionBySubtaskIds(List subtaskIds, Date startOutHospTime, Date endOutHospTime, Date startFinishTime, Date endFinishTime, String scriptContent, Double scoreStart, Double scoreEnd, String valueType) {
//前置清理重复明细(已节流:每个实例最多 10 分钟一次,不再每个请求都跑全表 GROUP BY)
cleanRepeatDetailsIfDue();
List serviceSubtaskDetailRatioExports = satisfactionMapper.statQuestionOptionBySubtaskIds(subtaskIds, startOutHospTime, endOutHospTime, startFinishTime, endFinishTime, scoreStart, scoreEnd, scriptContent, valueType);
// SQL 已按「题目 + 选项」汇总数量,这里再按题目合并(不同模板的 targetvalue 选项顺序不同,同一选项会拆成多行),
// 并把没人选的选项补成 0 行、按各选项数量之和重算比例
return mergeMydOptionRows(serviceSubtaskDetailRatioExports);
}
/**
* 把 SQL 返回的「题目 + 选项」明细合并成「一行一个选项」
*
* - 同一题目下相同 optionresult 的数量累加:原实现遇到同名选项直接 continue,
* 每个选项只会保留第一条明细的数量,多任务/多患者的项数全被丢掉,前端「数量」永远只有 1
* - 选项清单取 targetvalue(用 & 连接),没人选的选项补 0 行,前端固定六列才能渲染
* - 比例 = 该选项数量 / 该题各选项数量之和,保证一道题各选项比例合计 100%
*
*
* @param rows SQL 明细
*/
private List mergeMydOptionRows(List rows) {
List result = new ArrayList<>();
if (CollectionUtils.isEmpty(rows)) {
return result;
}
// key -> 选项 -> 数量,用 LinkedHashMap 保序:选项顺序沿用问卷配置 targetvalue 的顺序
Map> countMap = new LinkedHashMap<>();
Map> scoreMap = new LinkedHashMap<>();
Map templateMap = new LinkedHashMap<>();
for (ServiceSubtaskDetailRatioExport row : rows) {
if (row == null) {
continue;
}
String key = row.getQuestiontext();
if (key == null) {
continue;
}
Map counts = countMap.computeIfAbsent(key, k -> new LinkedHashMap<>());
Map scores = scoreMap.computeIfAbsent(key, k -> new LinkedHashMap<>());
templateMap.putIfAbsent(key, row);
// 题目配置的选项清单:没人选的选项也要列出来(数量为 0)
for (String option : splitOptions(row.getTargetvalue())) {
counts.computeIfAbsent(option, k -> new long[1]);
}
// 患者实际选中的选项:多选题 asrtext 是「选项1&选项2」,单选/问答题是整段文本
List chosenOptions = "2".equals(row.getValueType())
? splitOptions(row.getOptionresult())
: singleOption(row.getOptionresult());
if (chosenOptions.isEmpty()) {
// 未作答:不进任何选项,也不进分母
continue;
}
long count = parseCount(row.getCount());
for (String chosen : chosenOptions) {
// 选中的选项可能不在配置清单里(历史数据),补一行,数量不能丢
counts.computeIfAbsent(chosen, k -> new long[1])[0] += count;
if (StringUtils.isNotEmpty(row.getScore()) && StringUtils.isEmpty(scores.get(chosen))) {
scores.put(chosen, row.getScore());
}
}
}
for (Map.Entry> entry : countMap.entrySet()) {
ServiceSubtaskDetailRatioExport template = templateMap.get(entry.getKey());
Map scores = scoreMap.get(entry.getKey());
long total = 0L;
for (long[] count : entry.getValue().values()) {
total += count[0];
}
for (Map.Entry optionEntry : entry.getValue().entrySet()) {
String option = optionEntry.getKey();
long count = optionEntry.getValue()[0];
ServiceSubtaskDetailRatioExport merged = DtoConversionUtils.sourceToTarget(template, ServiceSubtaskDetailRatioExport.class);
merged.setQuestiontext(template.getQuestiontext());
merged.setTargetvalue(option);
// 没人选的选项 optionresult 置空,前端按 targetvalue 兜底展示,保持原有展示口径
merged.setOptionresult(count > 0 ? option : null);
merged.setCount(String.valueOf(count));
merged.setRatio(formatRatio(count, total));
merged.setScore(StringUtils.isNotEmpty(scores.get(option)) ? scores.get(option) : template.getScore());
result.add(merged);
}
}
return result;
}
/**
* 单选/问答题的作答文本:不按 & 拆,避免把问答题的文本答案拆坏
*/
private static List singleOption(String answer) {
List options = new ArrayList<>();
String text = trimAll(answer);
if (StringUtils.isNotEmpty(text)) {
options.add(text);
}
return options;
}
/**
* 标准化问卷答案文本:去掉 A/B/C/D 单字母前缀和后续空格
* 例如 "A 是" -> "是","B 否" -> "否"
*/
private String normalizeOptionAnswer(String answer) {
if (StringUtils.isBlank(answer)) {
return "";
}
String trimmed = answer.trim();
if (trimmed.length() >= 3 && trimmed.charAt(1) == ' ') {
char firstChar = trimmed.charAt(0);
if (firstChar >= 'A' && firstChar <= 'Z') {
return trimmed.substring(2).trim();
}
}
return trimmed;
}
public List mydTrend(MydTrendReqVO mydTrendReqVO) {
List list = satisfactionMapper.mydTrend(mydTrendReqVO.getStartFinishTime(), mydTrendReqVO.getEndFinishTime(), mydTrendReqVO.getType());
if (mydTrendReqVO.getType().equals("week")) list = getWeekDate(mydTrendReqVO, list);
if (mydTrendReqVO.getType().equals("year")) list = getYearDate(mydTrendReqVO, list);
if (mydTrendReqVO.getType().equals("month")) list = getMonthDate(mydTrendReqVO, list);
if (mydTrendReqVO.getType().equals("day")) list = getDayDate(mydTrendReqVO, list);
return list;
}
public List sltdMydTotalByDimension(MydTrendReqVO mydTrendReqVO) {
List list = null;
if (mydTrendReqVO.getDimensionType().equals("1"))
list = satisfactionMapper.sltdMydTotalByDimension(mydTrendReqVO.getStartFinishTime(), mydTrendReqVO.getEndFinishTime(), mydTrendReqVO.getType());
else if (mydTrendReqVO.getDimensionType().equals("2"))
list = satisfactionMapper.sltdMydTotalByYQ(mydTrendReqVO.getStartFinishTime(), mydTrendReqVO.getEndFinishTime(), mydTrendReqVO.getType());
return list;
}
/**
* 问题结果占比
*
* @param sltdMydTotalVO
* @param taskIds 任务 id 列表(随访 serviceType=2 分支使用)
* @param subtaskIds 子任务 id 列表(满意度 serviceType=6/14 分支使用)
* @return
*/
public List getDetailRatio(SltdMydTotalVO sltdMydTotalVO, List subtaskRefs) {
List serviceSubtaskDetailRatioExports = null;
List serviceTypeList = sltdMydTotalVO.getServiceTypeList();
if (CollectionUtils.isEmpty(serviceTypeList)) {
log.warn("getDetailRatio 未传 serviceTypeList,无法统计问题占比");
return serviceSubtaskDetailRatioExports;
}
// 判断口径:2=随访(按 taskid 统计),6/14=满意度(按 subtask id 统计)
boolean hasFollowUp = serviceTypeList.contains("2");
boolean hasSatisfaction = serviceTypeList.contains("6") || serviceTypeList.contains("14");
if (hasFollowUp && hasSatisfaction) {
//serviceType,即有随访又有满意度,暂时没法统计
log.warn("getDetailRatio 暂不支持同时统计随访(2)与满意度(6/14),serviceTypeList={}", serviceTypeList);
return serviceSubtaskDetailRatioExports;
}
if (hasFollowUp) {
//获取serviceSubtaskList中的所有taskid
List taskIds = subtaskRefs.stream().map(SatisfactionSubtaskRef::getTaskid).filter(ObjectUtils::isNotEmpty).distinct().collect(Collectors.toList());
//同时带上本次导出的子任务 id:让「问题详情占比」只统计这批患者,与「患者随访信息表单」口径一致
List subtaskIds = subtaskRefs.stream().map(SatisfactionSubtaskRef::getId).filter(ObjectUtils::isNotEmpty).distinct().collect(Collectors.toList());
serviceSubtaskDetailRatioExports = statQuestionOption(taskIds, sltdMydTotalVO.getStartFinishTime(), sltdMydTotalVO.getEndFinishTime(), sltdMydTotalVO.getStartOutHospTime(), sltdMydTotalVO.getEndOutHospTime(), sltdMydTotalVO.getQuestionContent(), sltdMydTotalVO.getStartScore(), sltdMydTotalVO.getEndScore(), sltdMydTotalVO.getValueType(), subtaskIds);
} else if (hasSatisfaction) {
//满意度统计 (通过 subtask 的 id 去统计)
List subtaskIds = subtaskRefs.stream().map(SatisfactionSubtaskRef::getId).collect(Collectors.toList());
serviceSubtaskDetailRatioExports = statQuestionOptionBySubtaskIds(subtaskIds, sltdMydTotalVO.getStartOutHospTime(), sltdMydTotalVO.getEndOutHospTime(), sltdMydTotalVO.getStartFinishTime(), sltdMydTotalVO.getEndFinishTime(), sltdMydTotalVO.getQuestionContent(), sltdMydTotalVO.getStartScore(), sltdMydTotalVO.getEndScore(), sltdMydTotalVO.getValueType());
} else {
log.warn("getDetailRatio 暂不支持的 serviceTypeList={}(仅支持 2 或 6/14)", serviceTypeList);
}
return serviceSubtaskDetailRatioExports;
}
private static String trimAll(String s) {
if (s == null) {
return "";
}
int start = 0;
int end = s.length();
while (start < end && isBlankChar(s.charAt(start))) {
start++;
}
while (end > start && isBlankChar(s.charAt(end - 1))) {
end--;
}
return s.substring(start, end);
}
private String buildKey(String groupName, String questionText, String option) {
return groupName + "\u0001" + questionText + "\u0001" + option;
}
private static int parseCount(String s) {
if (StringUtils.isEmpty(s)) {
return 0;
}
try {
return Integer.parseInt(s.trim());
} catch (NumberFormatException e) {
return 0;
}
}
private List getWeekDate(MydTrendReqVO mydTrendReqVO, List list) {
// 1. 计算总周数(和 SQL 里的公式保持一致)
long days = (mydTrendReqVO.getEndFinishTime().getTime() - mydTrendReqVO.getStartFinishTime().getTime()) / (1000L * 60 * 60 * 24);
int totalWeeks = (int) (days / 7) + 1;
// 2. 已有数据:period -> totalScore
Map exist = new HashMap<>();
for (MydTrendReqDTO row : list) {
// period 是数字,可能是 Integer / Long / BigDecimal,统一转 int
int p = Integer.parseInt(StringUtils.isNotEmpty(row.getPeriod()) ? row.getPeriod() : "0");
exist.put(p, row.getTotalScore() != null ? row.getTotalScore() : "0");
// 下面这行按你的原逻辑保留(虽然看起来有点多余)
exist.put(p, String.valueOf(row.getTotalScore() != null ? row.getTotalScore() : 0));
}
// 3. 补全 1..totalWeeks
List result = new ArrayList<>(totalWeeks);
// 获取整体的开始时间
long startTimeMillis = mydTrendReqVO.getStartFinishTime().getTime();
long endTimeMillis = mydTrendReqVO.getEndFinishTime().getTime();
long oneWeekMillis = 7L * 24 * 60 * 60 * 1000; // 一周的毫秒数
for (int i = 1; i <= totalWeeks; i++) {
MydTrendReqDTO row = new MydTrendReqDTO();
row.setPeriod(String.valueOf(i));
row.setTotalScore(exist.getOrDefault(i, "0"));
// -------- 新增:设置每周的 startDate 和 endDate --------
// 当前周开始时间 = 整体开始时间 + (i - 1) * 7天
long currentStartMillis = startTimeMillis + (long) (i - 1) * oneWeekMillis;
// 当前周结束时间 = 当前周开始时间 + 7天 - 1毫秒(保证是这一周的最后一刻)
long currentEndMillis = currentStartMillis + oneWeekMillis - 1;
// 如果是最后一周,结束时间不能超过整体的 endTime
if (i == totalWeeks) {
currentEndMillis = endTimeMillis;
}
row.setStartDate(new Date(currentStartMillis));
row.setEndDate(new Date(currentEndMillis));
// ----------------------------------------------------
result.add(row);
}
return result;
}
private List getYearDate(MydTrendReqVO mydTrendReqVO, List list) {
// 获取整体开始和结束时间
Date startTime = mydTrendReqVO.getStartFinishTime();
Date endTime = mydTrendReqVO.getEndFinishTime();
// 1. 计算年份范围
// 使用 Calendar 获取起始年份和结束年份
Calendar cal = Calendar.getInstance();
cal.setTime(startTime);
int startYear = cal.get(Calendar.YEAR);
cal.setTime(endTime);
int endYear = cal.get(Calendar.YEAR);
// 总年数 (包含首尾)
int totalYears = endYear - startYear + 1;
// 2. 已有数据:period -> totalScore (假设 period 存的是年份字符串,如 "2024")
Map exist = new HashMap<>();
for (MydTrendReqDTO row : list) {
String period = row.getPeriod() != null ? row.getPeriod() : "";
exist.put(period, row.getTotalScore() != null ? row.getTotalScore() : "0");
}
// 3. 补全所有年份
List result = new ArrayList<>(totalYears);
for (int i = 0; i < totalYears; i++) {
int currentYear = startYear + i;
String periodKey = String.valueOf(currentYear);
MydTrendReqDTO row = new MydTrendReqDTO();
row.setPeriod(periodKey);
// 如果没有该年份的数据,默认给 "0"
row.setTotalScore(exist.getOrDefault(periodKey, "0"));
// -------- 设置该年份的 startDate 和 endDate --------
Calendar yearCal = Calendar.getInstance();
// 设置为该年的 1月1日 00:00:00
yearCal.set(currentYear, Calendar.JANUARY, 1, 0, 0, 0);
yearCal.set(Calendar.MILLISECOND, 0);
row.setStartDate(yearCal.getTime());
// 设置为该年的 12月31日 23:59:59
yearCal.set(currentYear, Calendar.DECEMBER, 31, 23, 59, 59);
yearCal.set(Calendar.MILLISECOND, 999);
row.setEndDate(yearCal.getTime());
// ----------------------------------------------------
result.add(row);
}
return result;
}
private List getMonthDate(MydTrendReqVO mydTrendReqVO, List list) {
// 获取整体开始和结束时间
Date startTime = mydTrendReqVO.getStartFinishTime();
Date endTime = mydTrendReqVO.getEndFinishTime();
// 1. 计算月份范围
Calendar cal = Calendar.getInstance();
cal.setTime(startTime);
int startYear = cal.get(Calendar.YEAR);
int startMonth = cal.get(Calendar.MONTH); // 注意:Calendar.MONTH 从 0 开始
cal.setTime(endTime);
int endYear = cal.get(Calendar.YEAR);
int endMonth = cal.get(Calendar.MONTH);
// 总月数 = (结束年 - 开始年) * 12 + (结束月 - 开始月) + 1
int totalMonths = (endYear - startYear) * 12 + (endMonth - startMonth) + 1;
// 2. 已有数据:period -> totalScore (period 存的是月份字符串,如 "2024-06")
Map exist = new HashMap<>();
for (MydTrendReqDTO row : list) {
String period = row.getPeriod() != null ? row.getPeriod() : "";
exist.put(period, row.getTotalScore() != null ? row.getTotalScore() : "0");
}
// 3. 补全所有月份
List result = new ArrayList<>(totalMonths);
Calendar monthCal = Calendar.getInstance();
monthCal.set(startYear, startMonth, 1, 0, 0, 0);
monthCal.set(Calendar.MILLISECOND, 0);
for (int i = 0; i < totalMonths; i++) {
int currentYear = monthCal.get(Calendar.YEAR);
int currentMonth = monthCal.get(Calendar.MONTH) + 1; // 转成 1-12 月
// 拼接 period,格式:yyyy-MM,比如 "2024-06"
// 如果你希望是 "2024-6",可以把 String.format 改成 currentMonth
String periodKey = String.format("%04d-%02d", currentYear, currentMonth);
MydTrendReqDTO row = new MydTrendReqDTO();
row.setPeriod(periodKey);
row.setTotalScore(exist.getOrDefault(periodKey, "0"));
// -------- 设置该月的 startDate 和 endDate --------
// 当月开始时间:1号 00:00:00.000
Calendar startCal = (Calendar) monthCal.clone();
row.setStartDate(startCal.getTime());
// 当月结束时间:下个月1号 00:00:00.000 减 1 毫秒
Calendar endCal = (Calendar) monthCal.clone();
endCal.add(Calendar.MONTH, 1);
endCal.add(Calendar.MILLISECOND, -1);
row.setEndDate(endCal.getTime());
// ----------------------------------------------------
result.add(row);
// 进入下一个月
monthCal.add(Calendar.MONTH, 1);
}
return result;
}
private List getDayDate(MydTrendReqVO mydTrendReqVO, List list) {
// 获取整体开始和结束时间
Date startTime = mydTrendReqVO.getStartFinishTime();
Date endTime = mydTrendReqVO.getEndFinishTime();
// 1. 计算总天数
long oneDayMillis = 24L * 60 * 60 * 1000;
// 注意:这里只算日期差,不考虑时分秒,否则可能差一天
Calendar startCal = Calendar.getInstance();
startCal.setTime(startTime);
startCal.set(Calendar.HOUR_OF_DAY, 0);
startCal.set(Calendar.MINUTE, 0);
startCal.set(Calendar.SECOND, 0);
startCal.set(Calendar.MILLISECOND, 0);
Calendar endCal = Calendar.getInstance();
endCal.setTime(endTime);
endCal.set(Calendar.HOUR_OF_DAY, 0);
endCal.set(Calendar.MINUTE, 0);
endCal.set(Calendar.SECOND, 0);
endCal.set(Calendar.MILLISECOND, 0);
long days = (endCal.getTimeInMillis() - startCal.getTimeInMillis()) / oneDayMillis;
int totalDays = (int) days + 1;
// 2. 已有数据:period -> totalScore (period 存的是日期字符串,如 "2024-06-01")
Map exist = new HashMap<>();
for (MydTrendReqDTO row : list) {
String period = row.getPeriod() != null ? row.getPeriod() : "";
exist.put(period, row.getTotalScore() != null ? row.getTotalScore() : "0");
}
// 3. 补全所有日期
List result = new ArrayList<>(totalDays);
Calendar dayCal = (Calendar) startCal.clone();
for (int i = 0; i < totalDays; i++) {
// 拼接 period,格式:yyyy-MM-dd
String periodKey = String.format("%04d-%02d-%02d", dayCal.get(Calendar.YEAR), dayCal.get(Calendar.MONTH) + 1, dayCal.get(Calendar.DAY_OF_MONTH));
MydTrendReqDTO row = new MydTrendReqDTO();
row.setPeriod(periodKey);
row.setTotalScore(exist.getOrDefault(periodKey, "0"));
// -------- 设置该天的 startDate 和 endDate --------
Calendar startOfDay = (Calendar) dayCal.clone();
row.setStartDate(startOfDay.getTime());
Calendar endOfDay = (Calendar) dayCal.clone();
endOfDay.set(Calendar.HOUR_OF_DAY, 23);
endOfDay.set(Calendar.MINUTE, 59);
endOfDay.set(Calendar.SECOND, 59);
endOfDay.set(Calendar.MILLISECOND, 999);
row.setEndDate(endOfDay.getTime());
// ----------------------------------------------------
result.add(row);
// 进入下一天
dayCal.add(Calendar.DAY_OF_MONTH, 1);
}
return result;
}
public List mzMydScoreRank(MzMydScoreRankVO mzMydScoreRankVO) {
if (StringUtils.isEmpty(mzMydScoreRankVO.getDimensionType())) {
mzMydScoreRankVO.setDimensionType("1");
}
if (CollectionUtils.isEmpty(mzMydScoreRankVO.getServiceTypes())) {
List list=new ArrayList<>(Arrays.asList("14"));
mzMydScoreRankVO.setServiceTypes(list);
}
List list = satisfactionMapper.selectMzMydScoreRank(mzMydScoreRankVO);
if (CollectionUtils.isEmpty(list)) {
return new ArrayList<>();
}
// 名次:SQL 已按综合得分降序返回,这里顺序编号
for (int i = 0; i < list.size(); i++) {
list.get(i).setRank(i + 1);
}
return list;
}
/**
* 是否空白字符(含全角空格)。
*
* 迁移说明:原为 ServiceSubtaskServiceImpl 的私有静态方法,满意度方法用到(trimAll),一并搬入。
*/
private static Boolean isBlankChar(char c) {
return c == '\u3000' // 全角空格
|| c == ' ' // 半角空格
|| c == '\t' || c == '\n' || c == '\r' || c == '\f' || Character.isWhitespace(c);
}
/**
* 按机构/院区/服务类型/出院时间/科室病区取子任务引用(只含 id、taskid)。
*
* 原实现走 {@code serviceSubtaskService.getServiceSubtasks(ServiceSubtaskEntity)},
* 该方法位于随访主干 Service 且依赖 patArchiveService,不能搬;
* 这里用满意度专用查询复现其实际用到的条件口径(逐条见 mapper XML 注释)。
*/
@Override
public List getMydSubtaskRefs(SatisfactionSubtaskQuery query) {
if (query == null) {
return new ArrayList<>();
}
List refs = satisfactionMapper.selectMydSubtaskRefs(query);
return refs == null ? new ArrayList<>() : refs;
}
// ==================== 满意度分类(供登录链路,带缓存) ====================
/**
* 满意度分类缓存 key 前缀(与迁移前一致)
*/
private static final String SATISFACTION_CACHE_KEY = "satisfaction:categories:";
/**
* 满意度分类缓存时长(分钟,与迁移前一致)
*/
private static final int SATISFACTION_CACHE_TTL = 30;
@Autowired
private RedisCache redisCache;
/**
* 获取满意度问题分类(语音 + 问卷),带 Redis 缓存。
*
* 迁移说明:原为 {@code smartor.UserExtInfoServiceImpl.getSatisfactionCategories},
* 因属于满意度逻辑,按模块化要求搬入本模块;缓存 key 与 TTL 保持原值。
*/
@Override
@SuppressWarnings("unchecked")
public Map getSatisfactionCategories(String orgid) {
// 先查缓存
String cacheKey = SATISFACTION_CACHE_KEY + orgid;
Map cached = redisCache.getCacheObject(cacheKey);
if (cached != null) {
return cached;
}
Map satisfactionCategories = new HashMap<>();
// 获取语音问题分类
List ivrLibaScriptAssorts =
satisfactionScriptMapper.selectIvrCategoryList(categoryQueryByOrgid(orgid));
List