liusheng
2026-10-12 0c1f8c479065739f3c7a5126de8e667035d00b58
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
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.stream.Collectors;
 
/**
 * 满意度「子任务维度」统计服务。
 * <p>
 * 迁移说明:本类方法原散落在随访主干的大文件 ServiceSubtaskServiceImpl 中
 * (被视为随访主干的大文件),现整段搬入满意度模块,业务逻辑一行未改。
 * <p>
 * 与 {@link PatSatisfactionServiceImpl} 的分工:
 * <ul>
 *   <li>{@code PatSatisfactionServiceImpl} —— 通用满意度统计(南华与同德共用);
 *   <li>本类 —— <b>省立同德导向</b>的统计:选项占比、按科室/病区、趋势、门诊得分排名。
 *       这些入口原本挂在随访明细 Controller 上,迁移后仍由满意度模块统一提供。</li>
 * </ul>
 * 本模块自带 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;
 
    public List<ServiceSubtaskDetailRatioExport> statQuestionOption(List<Long> taskIds, Date startFinishTime, Date endFinishTime, Date startOutHospTime, Date endOutHospTime, String scriptContent, Double scoreStart, Double scoreEnd, String valueType, List<Long> subtaskIds) {
        //先清理一下重复数据
        List<Long> repeatIds = satisfactionMapper.selectRepeatDetailIds();
        if (!CollectionUtils.isEmpty(repeatIds)) {
            satisfactionMapper.deleteRepeatDetailByIds(repeatIds);
        }
        List<ServiceSubtaskDetailRatioExport> serviceSubtaskDetailRatioExports = satisfactionMapper.statQuestionOption(taskIds, startOutHospTime, endOutHospTime, startFinishTime, endFinishTime, scoreStart, scoreEnd, scriptContent, valueType, subtaskIds);
 
        // 处理 targetvalue:按 & 分割成多条记录
        List<ServiceSubtaskDetailRatioExport> 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<ServiceSubtaskDetailRatioExport> statMydQuestionOptionForExport(List<Long> subtaskIds, String questiontext) {
        List<ServiceSubtaskDetailRatioExport> result = new ArrayList<>();
        if (CollectionUtils.isEmpty(subtaskIds)) {
            return result;
        }
        List<ServiceSubtaskOptionStatDTO> statList = satisfactionMapper.statMydQuestionOptionForExport(subtaskIds, questiontext);
        if (CollectionUtils.isEmpty(statList)) {
            return result;
        }
        return buildMydOptionRows(statList);
    }
 
    /**
     * 把「任务+题目+选项」的作答统计拼成导出的「问题详情占比」行
     * <ul>
     *     <li>选项清单:题目配置过的选项在 targetvalue(用 & 连接)里,全部列出,没人选的显示 0</li>
     *     <li>数量:患者选中的选项在 asrtext 里(多选题是「A&B」),每个选中项各记一次人数,一个患者只算一条明细</li>
     *     <li>比例:数量 / 该题作答患者数,所以单选题各选项数量之和 = 该题作答患者数</li>
     *     <li>分数:该选项明细的 score 之和;多选行按选中项份数均分,保证各选项分数之和 = 明细分数之和</li>
     * </ul>
     */
    private static List<ServiceSubtaskDetailRatioExport> buildMydOptionRows(List<ServiceSubtaskOptionStatDTO> statList) {
        List<ServiceSubtaskDetailRatioExport> result = new ArrayList<>();
 
        // 按「任务 + 题目」分组,保持 SQL 的顺序(先题目后任务)
        Map<String, List<ServiceSubtaskOptionStatDTO>> grouped = new LinkedHashMap<>();
        for (ServiceSubtaskOptionStatDTO stat : statList) {
            String key = stat.getTaskid() + "\u0001" + stat.getQuestiontext();
            grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(stat);
        }
 
        for (List<ServiceSubtaskOptionStatDTO> group : grouped.values()) {
            ServiceSubtaskOptionStatDTO first = group.get(0);
            // 选项 -> 人数(long[1] 便于累加),顺序即出现顺序
            Map<String, long[]> countMap = new LinkedHashMap<>();
            Map<String, BigDecimal> scoreMap = new LinkedHashMap<>();
 
            for (ServiceSubtaskOptionStatDTO stat : group) {
                // targetvalue = 本题所有选项的清单,没人选的也要列出来
                List<String> options = splitOptions(stat.getTargetvalue());
                // asrtext = 患者实际选中的选项,多选题会是「选中项1&选中项2」
                List<String> 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<String, long[]> 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<String> splitOptions(String targetvalue) {
        List<String> 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<String, Map<String, List<QuestionCountKsOrBqDTO>>> statQuestionOptionByKsOrBq(QuestionCountKsOrBqVO vo) {
 
        // 1. 查明细
        List<QuestionCountKsOrBqDTO> sourceList = satisfactionMapper.statQuestionOptionByKsOrBq(vo);
        if (CollectionUtils.isEmpty(sourceList)) {
            return Collections.emptyMap();
        }
 
        // 2. 判断按科室还是按病区
        boolean byDept = vo.getLeaveldeptcodes() != null;
        boolean byDistrict = !byDept && vo.getLeavehospitaldistrictcodes() != null;
        if (!byDept && !byDistrict) {
            return Collections.emptyMap();
        }
 
        // 3. key = 科室/病区 + 题目 + 选项
        Map<String, QuestionCountKsOrBqDTO> 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<String, Map<String, List<QuestionCountKsOrBqDTO>>>
        //    第一层 key:科室/病区
        //    第二层 key:题目
        //    第二层 value:该题目下的所有选项列表
        Map<String, Map<String, List<QuestionCountKsOrBqDTO>>> 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<String, List<QuestionCountKsOrBqDTO>> questionMap =
                    result.computeIfAbsent(groupName, k -> new LinkedHashMap<>());
 
            // 第二层:题目
            List<QuestionCountKsOrBqDTO> optionList =
                    questionMap.computeIfAbsent(questionText, k -> new ArrayList<>());
 
            optionList.add(dto);
        }
 
        return result;
    }
 
    public List<ServiceSubtaskDetailRatioExport> statQuestionOptionBySubtaskIds(List<Long> subtaskIds, Date startOutHospTime, Date endOutHospTime, Date startFinishTime, Date endFinishTime, String scriptContent, Double scoreStart, Double scoreEnd, String valueType) {
        //先清理一下重复数据
        List<Long> repeatIds = satisfactionMapper.selectRepeatDetailIds();
        if (!CollectionUtils.isEmpty(repeatIds)) {
            satisfactionMapper.deleteRepeatDetailByIds(repeatIds);
        }
 
        List<ServiceSubtaskDetailRatioExport> serviceSubtaskDetailRatioExports = satisfactionMapper.statQuestionOptionBySubtaskIds(subtaskIds, startOutHospTime, endOutHospTime, startFinishTime, endFinishTime, scoreStart, scoreEnd, scriptContent, valueType);
        // SQL 已按「题目 + 选项」汇总数量,这里再按题目合并(不同模板的 targetvalue 选项顺序不同,同一选项会拆成多行),
        // 并把没人选的选项补成 0 行、按各选项数量之和重算比例
        return mergeMydOptionRows(serviceSubtaskDetailRatioExports);
    }
 
    /**
     * 把 SQL 返回的「题目 + 选项」明细合并成「一行一个选项」
     * <ul>
     *     <li>同一题目下相同 optionresult 的数量累加:原实现遇到同名选项直接 continue,
     *         每个选项只会保留第一条明细的数量,多任务/多患者的项数全被丢掉,前端「数量」永远只有 1</li>
     *     <li>选项清单取 targetvalue(用 &amp; 连接),没人选的选项补 0 行,前端固定六列才能渲染</li>
     *     <li>比例 = 该选项数量 / 该题各选项数量之和,保证一道题各选项比例合计 100%</li>
     * </ul>
     *
     * @param rows SQL 明细
     */
    private List<ServiceSubtaskDetailRatioExport> mergeMydOptionRows(List<ServiceSubtaskDetailRatioExport> rows) {
        List<ServiceSubtaskDetailRatioExport> result = new ArrayList<>();
        if (CollectionUtils.isEmpty(rows)) {
            return result;
        }
 
        // key -> 选项 -> 数量,用 LinkedHashMap 保序:选项顺序沿用问卷配置 targetvalue 的顺序
        Map<String, Map<String, long[]>> countMap = new LinkedHashMap<>();
        Map<String, Map<String, String>> scoreMap = new LinkedHashMap<>();
        Map<String, ServiceSubtaskDetailRatioExport> templateMap = new LinkedHashMap<>();
 
        for (ServiceSubtaskDetailRatioExport row : rows) {
            if (row == null) {
                continue;
            }
            String key = row.getQuestiontext();
            if (key == null) {
                continue;
            }
            Map<String, long[]> counts = countMap.computeIfAbsent(key, k -> new LinkedHashMap<>());
            Map<String, String> 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<String> 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<String, Map<String, long[]>> entry : countMap.entrySet()) {
            ServiceSubtaskDetailRatioExport template = templateMap.get(entry.getKey());
            Map<String, String> scores = scoreMap.get(entry.getKey());
            long total = 0L;
            for (long[] count : entry.getValue().values()) {
                total += count[0];
            }
            for (Map.Entry<String, long[]> 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;
    }
 
    /**
     * 单选/问答题的作答文本:不按 &amp; 拆,避免把问答题的文本答案拆坏
     */
    private static List<String> singleOption(String answer) {
        List<String> 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<MydTrendReqDTO> mydTrend(MydTrendReqVO mydTrendReqVO) {
        List<MydTrendReqDTO> 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<MydTrendReqDTO> sltdMydTotalByDimension(MydTrendReqVO mydTrendReqVO) {
        List<MydTrendReqDTO> 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<ServiceSubtaskDetailRatioExport> getDetailRatio(SltdMydTotalVO sltdMydTotalVO, List<SatisfactionSubtaskRef> subtaskRefs) {
        List<ServiceSubtaskDetailRatioExport> serviceSubtaskDetailRatioExports = null;
        List<String> 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<Long> taskIds = subtaskRefs.stream().map(SatisfactionSubtaskRef::getTaskid).filter(ObjectUtils::isNotEmpty).distinct().collect(Collectors.toList());
            //同时带上本次导出的子任务 id:让「问题详情占比」只统计这批患者,与「患者随访信息表单」口径一致
            List<Long> 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<Long> 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<MydTrendReqDTO> getWeekDate(MydTrendReqVO mydTrendReqVO, List<MydTrendReqDTO> 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<Integer, String> 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<MydTrendReqDTO> 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<MydTrendReqDTO> getYearDate(MydTrendReqVO mydTrendReqVO, List<MydTrendReqDTO> 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<String, String> 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<MydTrendReqDTO> 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<MydTrendReqDTO> getMonthDate(MydTrendReqVO mydTrendReqVO, List<MydTrendReqDTO> 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<String, String> 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<MydTrendReqDTO> 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<MydTrendReqDTO> getDayDate(MydTrendReqVO mydTrendReqVO, List<MydTrendReqDTO> 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<String, String> 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<MydTrendReqDTO> 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<MzMydScoreRankDTO> mzMydScoreRank(MzMydScoreRankVO mzMydScoreRankVO) {
        if (StringUtils.isEmpty(mzMydScoreRankVO.getDimensionType())) {
            mzMydScoreRankVO.setDimensionType("1");
        }
        if (CollectionUtils.isEmpty(mzMydScoreRankVO.getServiceTypes())) {
          List<String> list=new ArrayList<>(Arrays.asList("14"));
            mzMydScoreRankVO.setServiceTypes(list);
        }
        List<MzMydScoreRankDTO> 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;
    }
    /**
     * 是否空白字符(含全角空格)。
     * <p>
     * 迁移说明:原为 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)。
     * <p>
     * 原实现走 {@code serviceSubtaskService.getServiceSubtasks(ServiceSubtaskEntity)},
     * 该方法位于随访主干 Service 且依赖 patArchiveService,不能搬;
     * 这里用满意度专用查询复现其<b>实际用到的条件口径</b>(逐条见 mapper XML 注释)。
     */
    @Override
    public List<SatisfactionSubtaskRef> getMydSubtaskRefs(SatisfactionSubtaskQuery query) {
        if (query == null) {
            return new ArrayList<>();
        }
        List<SatisfactionSubtaskRef> 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 缓存。
     * <p>
     * 迁移说明:原为 {@code smartor.UserExtInfoServiceImpl.getSatisfactionCategories},
     * 因属于满意度逻辑,按模块化要求搬入本模块;缓存 key 与 TTL 保持原值。
     */
    @Override
    @SuppressWarnings("unchecked")
    public Map<String, Object> getSatisfactionCategories(String orgid) {
        // 先查缓存
        String cacheKey = SATISFACTION_CACHE_KEY + orgid;
        Map<String, Object> cached = redisCache.getCacheObject(cacheKey);
        if (cached != null) {
            return cached;
        }
 
        Map<String, Object> satisfactionCategories = new HashMap<>();
 
        // 获取语音问题分类
        List<SatisfactionCategoryRow> ivrLibaScriptAssorts =
                satisfactionScriptMapper.selectIvrCategoryList(categoryQueryByOrgid(orgid));
        List<Map<String, Object>> categoryList = new ArrayList<>();
        if (!CollectionUtils.isEmpty(ivrLibaScriptAssorts)) {
            for (SatisfactionCategoryRow assort : ivrLibaScriptAssorts) {
                Map<String, Object> map = new HashMap<>();
                map.put("categoryid", assort.getId());
                map.put("categoryName", assort.getName());
                categoryList.add(map);
            }
        }
        satisfactionCategories.put("voiceCategories", categoryList);
 
        // 获取问卷问题分类
        List<SatisfactionCategoryRow> svyLibScriptCategories =
                satisfactionScriptMapper.selectSvyCategoryList(categoryQueryByOrgid(orgid));
        List<Map<String, Object>> questionnaireCategorys = new ArrayList<>();
        if (!CollectionUtils.isEmpty(svyLibScriptCategories)) {
            for (SatisfactionCategoryRow scriptCategory : svyLibScriptCategories) {
                Map<String, Object> map = new HashMap<>();
                map.put("categoryid", scriptCategory.getId());
                map.put("categoryName", scriptCategory.getName());
                questionnaireCategorys.add(map);
            }
        }
        satisfactionCategorysPut(satisfactionCategories, questionnaireCategorys);
 
        // 写入缓存
        redisCache.setCacheObject(cacheKey, satisfactionCategories, SATISFACTION_CACHE_TTL, TimeUnit.MINUTES);
 
        return satisfactionCategories;
    }
 
    /**
     * 与原文一致:键名为 {@code questionnaireCategorys}
     */
    private void satisfactionCategorysPut(Map<String, Object> target, List<Map<String, Object>> value) {
        target.put("questionnaireCategorys", value);
    }
 
    /**
     * 按机构构造满意度分类查询(orgid + type=myd,与原文一致)
     */
    private SatisfactionScriptQuery categoryQueryByOrgid(String orgid) {
        SatisfactionScriptQuery q = new SatisfactionScriptQuery();
        q.setOrgid(orgid);
        q.setType("myd");
        return q;
    }
 
    // ==================== 满意度异常配置题目 ====================
 
    /**
     * 获取满意度异常配置题目(语音 + 问卷)。
     * <p>
     * 迁移说明:原实现位于 {@code com.smartor.service.impl.ServiceSubtaskDetailTraceServiceImpl},
     * 现整段搬入本模块。原来的调用链是:
     * <pre>
     * 语音分类:IvrLibaScriptAssortServiceImpl.selectIvrLibaScriptAssortList  ← 服务方法,会把分类【拼成树】,只返回顶层
     * 语音题目:IvrLibaTemplateScriptServiceImpl.selectIvrLibaTemplateScriptList(scriptAssortid=X)  ← 逐个分类调用
     * 问卷分类:SvyLibScriptCategoryMapper.selectSvyLibScriptCategoryList     ← 直接 Mapper,返回【全部】分类
     * 问卷题目:SvyLibTemplateScriptServiceImpl.selectSvyLibTemplateScriptList(categoryid=Y)       ← 逐个分类调用
     * </pre>
     * 为不依赖 smartor 的模板/分类实体,这里改成本模块的只读查询复现,并且:
     * <ul>
     *   <li>语音分类:仍然按顶层过滤({@code pid == null || pid == -1}),与原服务方法的结果集一致;</li>
     *   <li>问卷分类:不过滤顶层,取全部,与原 Mapper 调用一致;</li>
     *   <li>题目:原文是按分类逐个查询后累加,两条 list statement 都<b>没有 order by</b>,
     *       所以合并成一次 {@code in (...)} 查询的结果集与逐次查询的并集一致
     *       (集合相同、顺序本来就未定义);</li>
     *   <li>分类 id 列表为空时<b>不查</b>(原文就是在 {@code if (isNotEmpty)} 里才查),
     *       对应本模块 SQL 里"ids 为空则不拼 in 条件"的写法必须靠这个判断兜住,
     *       否则会退化成"全表查询"。</li>
     * </ul>
     */
    @Override
    public Map<String, Object> getSatisfactionExceptConfigQuestion(SatisfactionExceptConfigQuestionReqVO questionReqVO) {
        // 初始化结果集
        Map<String, Object> result = new HashMap<>();
 
        // ---------------------------------------------------------------- 语音
        if (StringUtils.isEmpty(questionReqVO.getScriptType()) || questionReqVO.getScriptType().equals("1")) {
            List<Map<String, Object>> voicetDataList = new ArrayList<>();
 
            // 获取满意度的分类(type 默认 myd,入参有值则覆盖 —— 与原文一致)
            List<Long> assortIdList = selectIvrTopCategoryIds(questionReqVO.getType());
 
            // 用来记录满意度的所有问题
            if (CollectionUtils.isNotEmpty(assortIdList)) {
                SatisfactionScriptQuery scriptQuery = new SatisfactionScriptQuery();
                scriptQuery.setScriptAssortids(assortIdList);
                List<SatisfactionIvrScriptRow> ivrScripts =
                        satisfactionScriptMapper.queryIvrScriptByAssort(scriptQuery);
                if (CollectionUtils.isNotEmpty(ivrScripts)) {
                    for (SatisfactionIvrScriptRow ivrScript : ivrScripts) {
                        Map<String, Object> voicetData = new HashMap<>();
                        voicetData.put("questionId", ivrScript.getId());
                        voicetData.put("libtemplateid", ivrScript.getTemplateid());
                        voicetData.put("questionContent", ivrScript.getScriptContent());
                        voicetData.put("questionTopic", ivrScript.getScriptTopic());
                        voicetData.put("questionType", ivrScript.getScriptType());
                        voicetData.put("categoryName", ivrScript.getScriptAssortname());
                        voicetData.put("categoryId", ivrScript.getScriptAssortid());
                        voicetDataList.add(voicetData);
                    }
                }
            }
            // 语音数据返回集合
            result.put("voiceDataList", voicetDataList);
        }
 
        // ---------------------------------------------------------------- 问卷
        if (StringUtils.isEmpty(questionReqVO.getScriptType()) || questionReqVO.getScriptType().equals("2")) {
            List<Map<String, Object>> questionnaireDataList = new ArrayList<>();
 
            // 获取满意度的分类(问卷侧原文直接查 Mapper,取全部分类,不拼树、不筛顶层)
            List<Long> categoryIdList = selectSvyCategoryIds(questionReqVO.getType());
 
            // 用来记录满意度的所有问题
            if (CollectionUtils.isNotEmpty(categoryIdList)) {
                SatisfactionScriptQuery scriptQuery = new SatisfactionScriptQuery();
                scriptQuery.setScriptCategoryids(categoryIdList);
                List<SatisfactionSvyScriptRow> svyScripts =
                        satisfactionScriptMapper.querySvyScriptByCategory(scriptQuery);
                if (CollectionUtils.isNotEmpty(svyScripts)) {
                    for (SatisfactionSvyScriptRow svyScript : svyScripts) {
                        Map<String, Object> svyData = new HashMap<>();
                        svyData.put("questionId", svyScript.getId());
                        svyData.put("libtemplateid", svyScript.getSvyid());
                        svyData.put("questionContent", svyScript.getScriptContent());
                        svyData.put("questionTopic", svyScript.getScriptTopic());
                        svyData.put("questionType", svyScript.getScriptType());
                        svyData.put("categoryName", svyScript.getCategoryName());
                        svyData.put("categoryId", svyScript.getCategoryid());
                        questionnaireDataList.add(svyData);
                    }
                }
            }
            // 问卷数据返回集合
            result.put("questionnaireDataList", questionnaireDataList);
        }
        return result;
    }
 
    /**
     * 取语音侧满意度分类 id(<b>只取顶层</b>,复现原服务方法拼树后的返回值)。
     *
     * @param type 满意度类型;为空时用 {@code "myd"}
     */
    private List<Long> selectIvrTopCategoryIds(String type) {
        SatisfactionScriptQuery query = new SatisfactionScriptQuery();
        query.setType(StringUtils.isEmpty(type) ? "myd" : type);
        // 原文没有传 orgid,这里保持一致(orgid 为空时 SQL 不拼该条件)
        List<SatisfactionCategoryRow> rows = satisfactionScriptMapper.selectIvrCategoryList(query);
        List<Long> ids = new ArrayList<>();
        if (CollectionUtils.isNotEmpty(rows)) {
            for (SatisfactionCategoryRow row : rows) {
                // 与 IvrLibaScriptAssortServiceImpl 拼树时的顶层判断逐字一致
                if (row.getPid() == null || row.getPid() == -1) {
                    ids.add(row.getId());
                }
            }
        }
        return ids;
    }
 
    /**
     * 取问卷侧满意度分类 id(<b>取全部</b>,复现原 Mapper 调用的返回值)。
     *
     * @param type 满意度类型;为空时用 {@code "myd"}
     */
    private List<Long> selectSvyCategoryIds(String type) {
        SatisfactionScriptQuery query = new SatisfactionScriptQuery();
        query.setType(StringUtils.isEmpty(type) ? "myd" : type);
        List<SatisfactionCategoryRow> rows = satisfactionScriptMapper.selectSvyCategoryList(query);
        List<Long> ids = new ArrayList<>();
        if (CollectionUtils.isNotEmpty(rows)) {
            for (SatisfactionCategoryRow row : rows) {
                ids.add(row.getId());
            }
        }
        return ids;
    }
 
    // ==================== 随访「满意度指数」 ====================
 
    /**
     * 「满意度题目发送量」统计时使用的子任务发送状态(与原实现一致:3、5、6)
     */
    private static final List<Long> SATISFACTION_INDEX_SENDSTATES =
            Collections.unmodifiableList(Arrays.asList(3L, 5L, 6L));
 
    /**
     * 随访统计页的「满意度指数」:满意度题目总量 / 填报量 / 完成比例。
     * <p>
     * 迁移说明:原为 {@code com.smartor.service.impl.ServiceSubtaskServiceImpl#getSatisfactionIndex}
     * 的私有方法,被 {@code getSfStatisticsJoy} 调用。它的取数对象是
     * 「满意度题目」(按 {@code ivrCategoryid} / {@code svyCategoryid} 识别)与
     * 「满意度填报明细」,属于满意度口径,故搬入本模块。
     * <p>
     * <b>与原实现逐条对应:</b>
     * <ol>
     *   <li>{@code type="1"} 用 {@code deptcode} 匹配任务;{@code type="2"} 用
     *       {@code leavehospitaldistrictcode} 匹配任务(互斥,都带 orgid);</li>
     *   <li>语音任务:题目数 = 该模板下 {@code script_assortid = ivrCategoryid} 的题目数;
     *       总量 += 题目数 × 发送量({@code sendstate in (3,5,6)});</li>
     *   <li>问卷任务:同上,用 {@code categoryid = svyCategoryid};</li>
     *   <li>填报量:按 {@code taskid + categoryid(按任务类型取语音/问卷) + orgid} 数明细条数;</li>
     *   <li>完成比例 = 填报量 / 总量(保留 2 位,HALF_UP),总量为 0 时为 {@code 0.0}。</li>
     * </ol>
     *
     * <h3>⚠️ 原实现已知缺陷(本次<b>原样保留</b>,未修复)</h3>
     * 原代码用的是 {@code mydtmzlsum.add(new BigDecimal(n))} ——
     * {@link BigDecimal} 是不可变类,{@code add} 的返回值没有被赋回变量,
     * 因此<b>所有累加都是无效的</b>,本方法实际上<b>恒定返回</b>
     * {@code {mydtmzl: 0, mydtbl: 0, wcbl: 0.0}};
     * 同时因为累加语句仍在循环里,<b>每次调用依旧会真实执行那 5 条查询</b>。
     * <p>
     * 迁移时按"行为不变"原则一字未改(连无效累加也保留):
     * 若直接把查询结果改成常量,虽然返回值相同,但会改变数据库压力;
     * 若把 {@code add} 改成 {@code xxx = xxx.add(...)},则会改变随访统计页的显示值。
     * 两种都超出本次"模块化、不改行为"的范围,故留待单独确认。
     * 详见 {@code doc/满意度模块化_执行进度.md} 的遗留项。
     *
     * @param type            "1"=按科室,"2"=按病区(与原文一致)
     * @param code            科室编码或病区编码
     * @param orgid           机构 id
     * @param ivrCategoryid   语音满意度分类 id(可为 null,为 null 时该条件不生效)
     * @param svyCategoryid   问卷满意度分类 id(可为 null,为 null 时该条件不生效)
     * @return 含 {@code mydtmzl}(Integer) / {@code mydtbl}(Integer) / {@code wcbl}(Double) 的 Map
     */
    @Override
    public Map<String, Object> getSatisfactionIndex(String type, String code, String orgid,
                                                   Long ivrCategoryid, Long svyCategoryid) {
        // 原文是 type.equals("1");调用方只传字面量 "1"/"2",这里用常量在前等价且更安全
        List<SatisfactionTaskRow> serviceTasks = null;
        if ("1".equals(type)) {
            serviceTasks = satisfactionMapper.selectSatisfactionServiceTasks(orgid, code, null);
        } else if ("2".equals(type)) {
            serviceTasks = satisfactionMapper.selectSatisfactionServiceTasks(orgid, null, code);
        }
 
        // 获取满意度题目总量
        BigDecimal mydtmzlsum = new BigDecimal(0);
        // 获取满意度题目填报量
        BigDecimal mydtblsum = new BigDecimal(0);
        if (CollectionUtils.isNotEmpty(serviceTasks)) {
            for (SatisfactionTaskRow serviceTask : serviceTasks) {
                if (serviceTask.getType().equals("1")) {
                    // 语音
                    Long templateid = serviceTask.getTemplateid();
                    int scriptCount = satisfactionMapper.countIvrTaskTemplateScripts(
                            templateid, serviceTask.getOrgid(), ivrCategoryid);
                    if (scriptCount > 0) {
                        // 需要先计算出发送量,再乘以单个问券满意度题目数量,得到满意度题目总量(包含了未做的)
                        int subtaskCount = satisfactionMapper.countSubtaskBySendstates(
                                serviceTask.getTaskid(), SATISFACTION_INDEX_SENDSTATES);
                        if (subtaskCount > 0) {
                            int mydtmzlCount = scriptCount * subtaskCount;
                            // ⚠️ 原实现缺陷:add 的返回值被丢弃(BigDecimal 不可变),此处原样保留
                            mydtmzlsum.add(new BigDecimal(mydtmzlCount));
                        }
                    }
                } else if (serviceTask.getType().equals("2")) {
                    // 问卷
                    int scriptCount = satisfactionMapper.countSvyTaskTemplateScripts(
                            serviceTask.getTemplateid(), serviceTask.getOrgid(), svyCategoryid);
                    if (scriptCount > 0) {
                        int subtaskCount = satisfactionMapper.countSubtaskBySendstates(
                                serviceTask.getTaskid(), SATISFACTION_INDEX_SENDSTATES);
                        if (subtaskCount > 0) {
                            int mydtmzlCount = scriptCount * subtaskCount;
                            // ⚠️ 原实现缺陷:同上,原样保留
                            mydtmzlsum.add(new BigDecimal(mydtmzlCount));
                        }
                    }
                }
 
                // 获取满意度题目填报量
                Long categoryid = null;
                if (serviceTask.getType().equals("1")) categoryid = ivrCategoryid;
                if (serviceTask.getType().equals("2")) categoryid = svyCategoryid;
                int detailCount = satisfactionMapper.countSubtaskDetail(
                        serviceTask.getTaskid(), categoryid, serviceTask.getOrgid());
                if (detailCount > 0) {
                    // ⚠️ 原实现缺陷:同上,原样保留
                    mydtblsum.add(new BigDecimal(detailCount));
                }
            }
        }
 
        Map<String, Object> map = new HashMap<>();
        // 满意度题目总量
        map.put("mydtmzl", mydtmzlsum.intValue());
        map.put("mydtbl", mydtblsum.intValue());
        map.put("wcbl", 0.0);
        if (mydtmzlsum.intValue() != 0) {
            map.put("wcbl", mydtblsum.divide(mydtmzlsum, 2, RoundingMode.HALF_UP).doubleValue());
        }
        return map;
    }
}