|
|
@@ -0,0 +1,421 @@
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.DanAssessmentResult;
|
|
|
+import com.etotem.cfc.entity.FiveDimensionScore;
|
|
|
+import com.etotem.cfc.entity.FamilyMember;
|
|
|
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.*;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+@Service
|
|
|
+public class AssessmentComparisonService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private DanAssessmentResultMapper danAssessmentResultMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private FiveDimensionScoreService fiveDimensionScoreService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 维度组定义:dimensionName -> [score fields that belong to this group]
|
|
|
+ */
|
|
|
+ private static final Map<String, List<String>> DIMENSION_GROUPS = new LinkedHashMap<>();
|
|
|
+
|
|
|
+ static {
|
|
|
+ DIMENSION_GROUPS.put("attention", Arrays.asList("attentionScore", "focusScore"));
|
|
|
+ DIMENSION_GROUPS.put("memory", Collections.singletonList("memoryScore"));
|
|
|
+ DIMENSION_GROUPS.put("logic", Collections.singletonList("logicScore"));
|
|
|
+ DIMENSION_GROUPS.put("perception", Arrays.asList("perceptionScore", "spatialScore"));
|
|
|
+ DIMENSION_GROUPS.put("processing", Collections.singletonList("processingSpeedScore"));
|
|
|
+ DIMENSION_GROUPS.put("emotion", Arrays.asList("emotionScore", "resilienceScore",
|
|
|
+ "stressCopingScore", "selfAwarenessScore"));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 单个维度对比结果 */
|
|
|
+ public static class DimensionDelta {
|
|
|
+ public String dimension;
|
|
|
+ public double delta;
|
|
|
+ public String direction; // up / down / unchanged
|
|
|
+ public List<String> drivers; // ["logicScore +18", "memoryScore -5"]
|
|
|
+
|
|
|
+ public DimensionDelta(String dimension, double delta, List<String> drivers) {
|
|
|
+ this.dimension = dimension;
|
|
|
+ this.delta = delta;
|
|
|
+ this.direction = delta > 0.5 ? "up" : (delta < -0.5 ? "down" : "unchanged");
|
|
|
+ this.drivers = drivers;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 单次测评快照 */
|
|
|
+ public static class AssessmentSnapshot {
|
|
|
+ public Long id;
|
|
|
+ public Date date;
|
|
|
+ public Map<String, Integer> scores = new HashMap<>();
|
|
|
+
|
|
|
+ public AssessmentSnapshot(Long id, Date date) {
|
|
|
+ this.id = id;
|
|
|
+ this.date = date;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 完整对比结果 */
|
|
|
+ public static class CompareResult {
|
|
|
+ public AssessmentSnapshot firstAssessment;
|
|
|
+ public AssessmentSnapshot latestAssessment;
|
|
|
+ public List<DimensionDelta> dimensionDeltas = new ArrayList<>();
|
|
|
+ public double overallDelta;
|
|
|
+ public String narrative;
|
|
|
+
|
|
|
+ public CompareResult(AssessmentSnapshot first, AssessmentSnapshot latest) {
|
|
|
+ this.firstAssessment = first;
|
|
|
+ this.latestAssessment = latest;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 对比某个孩子的首次 vs 最新测评
|
|
|
+ */
|
|
|
+ public CompareResult compareByChild(Long childId) {
|
|
|
+ List<DanAssessmentResult> results = danAssessmentResultMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<DanAssessmentResult>()
|
|
|
+ .eq(DanAssessmentResult::getChildId, childId)
|
|
|
+ .eq(DanAssessmentResult::getStatus, "completed")
|
|
|
+ .orderByAsc(DanAssessmentResult::getAssessmentDate)
|
|
|
+ );
|
|
|
+
|
|
|
+ if (results == null || results.size() < 2) {
|
|
|
+ // 尝试从 five_dimension_scores 读取
|
|
|
+ return compareFromFiveDimensionScores(childId);
|
|
|
+ }
|
|
|
+
|
|
|
+ DanAssessmentResult first = results.get(0);
|
|
|
+ DanAssessmentResult latest = results.get(results.size() - 1);
|
|
|
+
|
|
|
+ AssessmentSnapshot firstSnap = toSnapshot(first);
|
|
|
+ AssessmentSnapshot latestSnap = toSnapshot(latest);
|
|
|
+
|
|
|
+ return computeDeltas(firstSnap, latestSnap);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 聚焦单个维度的对比
|
|
|
+ */
|
|
|
+ public DimensionDelta compareByChildAndDimension(Long childId, String dimensionCode) {
|
|
|
+ List<DanAssessmentResult> results = danAssessmentResultMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<DanAssessmentResult>()
|
|
|
+ .eq(DanAssessmentResult::getChildId, childId)
|
|
|
+ .eq(DanAssessmentResult::getStatus, "completed")
|
|
|
+ .orderByAsc(DanAssessmentResult::getAssessmentDate)
|
|
|
+ );
|
|
|
+
|
|
|
+ if (results == null || results.size() < 2) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ DanAssessmentResult first = results.get(0);
|
|
|
+ DanAssessmentResult latest = results.get(results.size() - 1);
|
|
|
+
|
|
|
+ List<String> fieldNames = DIMENSION_GROUPS.get(dimensionCode);
|
|
|
+ if (fieldNames == null) {
|
|
|
+ fieldNames = Collections.singletonList(dimensionCode + "Score");
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<String, Integer> firstScores = extractScores(first, fieldNames);
|
|
|
+ Map<String, Integer> latestScores = extractScores(latest, fieldNames);
|
|
|
+
|
|
|
+ double totalDelta = 0;
|
|
|
+ List<String> drivers = new ArrayList<>();
|
|
|
+ for (String field : fieldNames) {
|
|
|
+ int fVal = firstScores.getOrDefault(field, 0);
|
|
|
+ int lVal = latestScores.getOrDefault(field, 0);
|
|
|
+ int diff = lVal - fVal;
|
|
|
+ totalDelta += diff;
|
|
|
+ if (diff != 0) {
|
|
|
+ drivers.add(field + (diff >= 0 ? " +" + diff : " " + diff));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return new DimensionDelta(dimensionCode, totalDelta, drivers);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生成结构化叙事
|
|
|
+ */
|
|
|
+ public String getProgressNarrative(Long childId) {
|
|
|
+ CompareResult result = compareByChild(childId);
|
|
|
+ if (result == null || result.dimensionDeltas.isEmpty()) {
|
|
|
+ return "暂无足够数据生成进步叙事。需要至少完成两次测评。";
|
|
|
+ }
|
|
|
+
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ double overallImprovement = 0;
|
|
|
+ int improvedCount = 0, declinedCount = 0;
|
|
|
+ List<String> improvedDetails = new ArrayList<>();
|
|
|
+ List<String> declinedDetails = new ArrayList<>();
|
|
|
+
|
|
|
+ for (DimensionDelta d : result.dimensionDeltas) {
|
|
|
+ overallImprovement += d.delta;
|
|
|
+ if ("up".equals(d.direction)) {
|
|
|
+ improvedCount++;
|
|
|
+ improvedDetails.add(String.format(Locale.CHINA, "%s提升 %.1f 分",
|
|
|
+ dimensionLabel(d.dimension), d.delta));
|
|
|
+ } else if ("down".equals(d.direction)) {
|
|
|
+ declinedCount++;
|
|
|
+ declinedDetails.add(String.format(Locale.CHINA, "%s下降 %.1f 分",
|
|
|
+ dimensionLabel(d.dimension), Math.abs(d.delta)));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (improvedCount > 0) {
|
|
|
+ sb.append("进步明显:").append(String.join(",", improvedDetails)).append("。");
|
|
|
+ }
|
|
|
+ if (declinedCount > 0) {
|
|
|
+ sb.append("需关注:").append(String.join(",", declinedDetails)).append("。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 驱动因素分析
|
|
|
+ List<String> allDrivers = result.dimensionDeltas.stream()
|
|
|
+ .flatMap(d -> d.drivers.stream())
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ if (!allDrivers.isEmpty()) {
|
|
|
+ List<String> topGainers = allDrivers.stream()
|
|
|
+ .filter(d -> d.contains("+"))
|
|
|
+ .sorted((a, b) -> {
|
|
|
+ int va = extractDriverValue(a);
|
|
|
+ int vb = extractDriverValue(b);
|
|
|
+ return Integer.compare(vb, va);
|
|
|
+ })
|
|
|
+ .limit(3)
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ if (!topGainers.isEmpty()) {
|
|
|
+ sb.append("主要驱动因素:").append(String.join(",", topGainers)).append("。");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 家庭维度:所有孩子的对比
|
|
|
+ */
|
|
|
+ public List<CompareResult> compareByFamily(Long familyId) {
|
|
|
+ List<FamilyMember> children = familyMemberMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<FamilyMember>()
|
|
|
+ .eq(FamilyMember::getFamilyId, familyId)
|
|
|
+ );
|
|
|
+ List<CompareResult> results = new ArrayList<>();
|
|
|
+ for (FamilyMember child : children) {
|
|
|
+ CompareResult r = compareByChild(child.getId());
|
|
|
+ if (r != null) {
|
|
|
+ results.add(r);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return results;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 内部方法 ====================
|
|
|
+
|
|
|
+ private CompareResult compareFromFiveDimensionScores(Long memberId) {
|
|
|
+ List<FiveDimensionScore> scores = fiveDimensionScoreService.getMemberHistory(
|
|
|
+ memberId, "member", null, null);
|
|
|
+ if (scores == null || scores.size() < 2) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<String, List<FiveDimensionScore>> byDim = scores.stream()
|
|
|
+ .collect(Collectors.groupingBy(FiveDimensionScore::getDimensionCode));
|
|
|
+
|
|
|
+ AssessmentSnapshot firstSnap = new AssessmentSnapshot(null, null);
|
|
|
+ AssessmentSnapshot latestSnap = new AssessmentSnapshot(null, null);
|
|
|
+
|
|
|
+ for (Map.Entry<String, List<FiveDimensionScore>> entry : byDim.entrySet()) {
|
|
|
+ List<FiveDimensionScore> sorted = entry.getValue().stream()
|
|
|
+ .sorted(Comparator.comparing(FiveDimensionScore::getAssessedAt))
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ if (sorted.size() >= 2) {
|
|
|
+ firstSnap.scores.put(entry.getKey(), sorted.get(0).getScore());
|
|
|
+ // update firstSnap date to earliest
|
|
|
+ if (firstSnap.date == null || sorted.get(0).getAssessedAt().before(firstSnap.date)) {
|
|
|
+ firstSnap.date = sorted.get(0).getAssessedAt();
|
|
|
+ firstSnap.id = sorted.get(0).getId();
|
|
|
+ }
|
|
|
+ FiveDimensionScore last = sorted.get(sorted.size() - 1);
|
|
|
+ latestSnap.scores.put(entry.getKey(), last.getScore());
|
|
|
+ if (latestSnap.date == null || last.getAssessedAt().after(latestSnap.date)) {
|
|
|
+ latestSnap.date = last.getAssessedAt();
|
|
|
+ latestSnap.id = last.getId();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (firstSnap.scores.isEmpty() || latestSnap.scores.isEmpty()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return computeDeltas(firstSnap, latestSnap);
|
|
|
+ }
|
|
|
+
|
|
|
+ private CompareResult computeDeltas(AssessmentSnapshot first, AssessmentSnapshot latest) {
|
|
|
+ CompareResult result = new CompareResult(first, latest);
|
|
|
+
|
|
|
+ Set<String> allDimensions = new LinkedHashSet<>(DIMENSION_GROUPS.keySet());
|
|
|
+ // Also add any extra keys from snapshots
|
|
|
+ for (String key : first.scores.keySet()) {
|
|
|
+ if (!DIMENSION_GROUPS.containsKey(key) && !key.endsWith("Score")) {
|
|
|
+ allDimensions.add(key);
|
|
|
+ } else if (!DIMENSION_GROUPS.containsKey(key)) {
|
|
|
+ // raw score field like "logicScore" - skip, will be covered by drivers
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // Add dimension codes from five_dimension_scores
|
|
|
+ for (String key : first.scores.keySet()) {
|
|
|
+ if (Arrays.asList("body", "mind", "wisdom", "action", "wealth").contains(key)) {
|
|
|
+ allDimensions.add(key);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ double totalDelta = 0;
|
|
|
+ int count = 0;
|
|
|
+ for (String dim : allDimensions) {
|
|
|
+ List<String> fieldNames = DIMENSION_GROUPS.getOrDefault(dim, Collections.singletonList(dim));
|
|
|
+ double dimDelta = 0;
|
|
|
+ List<String> drivers = new ArrayList<>();
|
|
|
+ for (String field : fieldNames) {
|
|
|
+ int fVal = first.scores.getOrDefault(field, 0);
|
|
|
+ int lVal = latest.scores.getOrDefault(field, 0);
|
|
|
+ int diff = lVal - fVal;
|
|
|
+ dimDelta += diff;
|
|
|
+ if (diff != 0 && fieldNames.size() > 1) {
|
|
|
+ drivers.add(field + (diff >= 0 ? " +" + diff : " " + diff));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ result.dimensionDeltas.add(new DimensionDelta(dim, dimDelta, drivers));
|
|
|
+ totalDelta += dimDelta;
|
|
|
+ count++;
|
|
|
+ }
|
|
|
+
|
|
|
+ result.overallDelta = count > 0 ? totalDelta / count : 0;
|
|
|
+ result.narrative = buildNarrative(result);
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String buildNarrative(CompareResult result) {
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ int up = 0, down = 0;
|
|
|
+ List<String> upDetails = new ArrayList<>();
|
|
|
+ List<String> downDetails = new ArrayList<>();
|
|
|
+
|
|
|
+ for (DimensionDelta d : result.dimensionDeltas) {
|
|
|
+ if ("up".equals(d.direction)) {
|
|
|
+ up++;
|
|
|
+ upDetails.add(String.format(Locale.CHINA, "%s提升 %.1f 分",
|
|
|
+ dimensionLabel(d.dimension), d.delta));
|
|
|
+ } else if ("down".equals(d.direction)) {
|
|
|
+ down++;
|
|
|
+ downDetails.add(String.format(Locale.CHINA, "%s下降 %.1f 分",
|
|
|
+ dimensionLabel(d.dimension), Math.abs(d.delta)));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (up > 0) {
|
|
|
+ sb.append("可喜进步:").append(String.join(",", upDetails)).append("。");
|
|
|
+ }
|
|
|
+ if (down > 0) {
|
|
|
+ sb.append("需加强:").append(String.join(",", downDetails)).append("。");
|
|
|
+ }
|
|
|
+ if (up == 0 && down == 0) {
|
|
|
+ sb.append("各项指标保持稳定,无明显变化。");
|
|
|
+ }
|
|
|
+
|
|
|
+ double overall = result.overallDelta;
|
|
|
+ if (overall > 5) {
|
|
|
+ sb.append(String.format(Locale.CHINA, "综合来看,该生在测评中表现持续进步,综合提升 %.1f 分。",
|
|
|
+ overall));
|
|
|
+ } else if (overall > 1) {
|
|
|
+ sb.append(String.format(Locale.CHINA, "整体略有进步(+%.1f 分)。", overall));
|
|
|
+ } else if (overall < -5) {
|
|
|
+ sb.append(String.format(Locale.CHINA, "综合评分有所下降(%.1f 分),建议关注并进一步评估。",
|
|
|
+ overall));
|
|
|
+ }
|
|
|
+
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ private AssessmentSnapshot toSnapshot(DanAssessmentResult r) {
|
|
|
+ AssessmentSnapshot snap = new AssessmentSnapshot(r.getId(), r.getAssessmentDate());
|
|
|
+ snap.scores.put("attentionScore", safeVal(r.getAttentionScore()));
|
|
|
+ snap.scores.put("focusScore", safeVal(r.getFocusScore()));
|
|
|
+ snap.scores.put("memoryScore", safeVal(r.getMemoryScore()));
|
|
|
+ snap.scores.put("logicScore", safeVal(r.getLogicScore()));
|
|
|
+ snap.scores.put("perceptionScore", safeVal(r.getPerceptionScore()));
|
|
|
+ snap.scores.put("spatialScore", safeVal(r.getSpatialScore()));
|
|
|
+ snap.scores.put("processingSpeedScore", safeVal(r.getProcessingSpeedScore()));
|
|
|
+ snap.scores.put("emotionScore", safeVal(r.getEmotionScore()));
|
|
|
+ snap.scores.put("resilienceScore", safeVal(r.getResilienceScore()));
|
|
|
+ snap.scores.put("stressCopingScore", safeVal(r.getStressCopingScore()));
|
|
|
+ snap.scores.put("selfAwarenessScore", safeVal(r.getSelfAwarenessScore()));
|
|
|
+ snap.scores.put("overallScore", safeVal(r.getOverallScore()));
|
|
|
+ // Big Five
|
|
|
+ snap.scores.put("opennessScore", safeVal(r.getOpennessScore()));
|
|
|
+ snap.scores.put("conscientiousnessScore", safeVal(r.getConscientiousnessScore()));
|
|
|
+ snap.scores.put("extraversionScore", safeVal(r.getExtraversionScore()));
|
|
|
+ snap.scores.put("agreeablenessScore", safeVal(r.getAgreeablenessScore()));
|
|
|
+ snap.scores.put("neuroticismScore", safeVal(r.getNeuroticismScore()));
|
|
|
+ return snap;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Integer> extractScores(DanAssessmentResult r, List<String> fields) {
|
|
|
+ Map<String, Integer> scores = new HashMap<>();
|
|
|
+ AssessmentSnapshot snap = toSnapshot(r);
|
|
|
+ for (String f : fields) {
|
|
|
+ Integer v = snap.scores.get(f);
|
|
|
+ if (v != null) {
|
|
|
+ scores.put(f, v);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return scores;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static int safeVal(Integer v) {
|
|
|
+ return v != null ? v : 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static int extractDriverValue(String driver) {
|
|
|
+ // "logicScore +18" → 18, "memoryScore -5" → -5
|
|
|
+ String[] parts = driver.split(" ");
|
|
|
+ if (parts.length >= 2) {
|
|
|
+ try {
|
|
|
+ return Integer.parseInt(parts[parts.length - 1].replace("+", ""));
|
|
|
+ } catch (NumberFormatException e) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String dimensionLabel(String dim) {
|
|
|
+ switch (dim) {
|
|
|
+ case "attention": return "注意力";
|
|
|
+ case "memory": return "记忆力";
|
|
|
+ case "logic": return "逻辑思维";
|
|
|
+ case "perception": return "感知能力";
|
|
|
+ case "processing": return "加工速度";
|
|
|
+ case "emotion": return "情绪心理";
|
|
|
+ case "overall": return "综合";
|
|
|
+ case "body": return "身体素质";
|
|
|
+ case "mind": return "心理健康";
|
|
|
+ case "wisdom": return "智慧发展";
|
|
|
+ case "action": return "行动能力";
|
|
|
+ case "wealth": return "财富素养";
|
|
|
+ default: return dim;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|