liusheng
2026-10-12 0c1f8c479065739f3c7a5126de8e667035d00b58
【丽水】满意度模块化:新增 ruoyi-satisfaction 独立模块

- 新增 ruoyi-satisfaction 模块并在根 pom 注册、ruoyi-admin 增加依赖:
满意度 domain / mapper / service / controller / api 门面与 satisfaction 开关
- 满意度按站点口径策略化(common / 省立同德 sltd),接口地址保持不变
- 从 smartor、ruoyi-admin、ruoyi-system、ruoyi-common 中摘除满意度实现
- 新增门诊用药错误记录 PatMedOuthospError(domain / mapper / XML)
- 调整 IVR 模板脚本、随访子任务相关 Mapper 与 XML
已删除2个文件
已重命名20个文件
已修改19个文件
已添加44个文件
9622 ■■■■■ 文件已修改
pom.xml 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/pom.xml 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/smartor/ServiceSubtaskController.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/smartor/ServiceSubtaskDetailController.java 197 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/smartor/ServiceSubtaskDetailTraceController.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/application.yml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-common/src/main/java/com/ruoyi/common/core/service/ISatisfactionCategoryService.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-common/src/main/java/com/ruoyi/common/core/service/IUserExtInfoService.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-quartz/src/main/java/com/ruoyi/quartz/constant/MzGatherConstant.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/pom.xml 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/SatisfactionModule.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/api/DefaultSatisfactionApi.java 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/api/SatisfactionApi.java 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/api/SatisfactionExportBundle.java 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/api/SatisfactionFacade.java 222 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/config/SatisfactionAvailabilityInterceptor.java 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/config/SatisfactionProperties.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/config/SatisfactionSwitch.java 206 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/config/SatisfactionWebConfig.java 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/controller/PatSatisfactionController.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/controller/SatisfactionExceptConfigController.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/controller/SltdSatisfactionController.java 172 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/PatSatisfaction.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/ServiceSubtaskDetailRatioExport.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/dto/MydTrendReqDTO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/dto/MzMydScoreRankDTO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/dto/QuestionCountKsOrBqDTO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/dto/QuestionResultDTO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/dto/ServiceSubtaskOptionStatDTO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/entity/PatSatisfactionDetailEntity.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/entity/SatisfactionStatQuery.java 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/entity/ServiceSubtaskSatisfactionEntity.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/row/SatisfactionCategoryRow.java 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/row/SatisfactionIvrScriptRow.java 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/row/SatisfactionOptionScoreRow.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/row/SatisfactionScriptQuery.java 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/row/SatisfactionSubtaskQuery.java 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/row/SatisfactionSubtaskRef.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/row/SatisfactionSvyScriptRow.java 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/row/SatisfactionTaskRow.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/vo/MydTrendReqVO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/vo/MzMydScoreRankVO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/vo/PatSatisfactionReqVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/vo/PatSatisfactionResVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/vo/QuestionCountKsOrBqVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/vo/SatisfactionExceptConfigQuestionReqVO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/vo/SltdMydTotalVO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/mapper/PatSatisfactionMapper.java 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/mapper/SatisfactionMapper.java 296 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/mapper/SatisfactionScriptMapper.java 146 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/service/IPatSatisfactionService.java 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/service/SatisfactionSubtaskService.java 163 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/service/impl/PatSatisfactionServiceImpl.java 322 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/service/impl/SatisfactionSubtaskServiceImpl.java 1218 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/service/strategy/CommonSatisfactionScoreStrategy.java 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/service/strategy/SatisfactionScoreStrategy.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/service/strategy/SatisfactionScoreStrategyResolver.java 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/service/strategy/SatisfactionSites.java 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/service/strategy/SltdSatisfactionScoreStrategy.java 137 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/support/MydOptionRatioChartBuilder.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/resources/mapper/satisfaction/PatSatisfactionMapper.xml 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/resources/mapper/satisfaction/SatisfactionMapper.xml 986 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/main/resources/mapper/satisfaction/SatisfactionScriptMapper.xml 200 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/test/java/com/ruoyi/satisfaction/api/SatisfactionContextCheck.java 269 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-satisfaction/src/test/java/com/ruoyi/satisfaction/api/SatisfactionSwitchCheck.java 236 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysUserServiceImpl.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/pom.xml 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/domain/PatMedOuthospError.java 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/mapper/IvrLibaTemplateScriptMapper.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/mapper/PatMedOuthospErrorMapper.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/mapper/PatSatisfactionMapper.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/mapper/ServiceSubtaskDetailMapper.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/mapper/ServiceSubtaskMapper.java 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/message/LishuiMessageConfigProviderImpl.java 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/message/WechatMpConfigProviderImpl.java 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/service/IPatSatisfactionService.java 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/service/IServiceSubtaskDetailTraceService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/service/IServiceSubtaskService.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/service/impl/ServiceSubtaskDetailTraceServiceImpl.java 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/service/impl/ServiceSubtaskServiceImpl.java 945 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/service/impl/UserExtInfoServiceImpl.java 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/java/com/smartor/statemachine/README.java 258 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/resources/mapper/smartor/PatMedOuthospErrorMapper.xml 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/resources/mapper/smartor/ServiceSubtaskDetailMapper.xml 212 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
smartor/src/main/resources/mapper/smartor/ServiceSubtaskMapper.xml 749 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pom.xml
@@ -192,6 +192,13 @@
                <version>${ruoyi.version}</version>
            </dependency>
            <!-- æ»¡æ„åº¦æ¨¡å— -->
            <dependency>
                <groupId>com.ruoyi</groupId>
                <artifactId>ruoyi-satisfaction</artifactId>
                <version>${ruoyi.version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>
@@ -203,6 +210,7 @@
        <module>ruoyi-generator</module>
        <module>ruoyi-common</module>
        <module>ruoyi-message</module>
        <module>ruoyi-satisfaction</module>
        <module>smartor</module>
    </modules>
    <packaging>pom</packaging>
ruoyi-admin/pom.xml
@@ -92,6 +92,11 @@
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-message</artifactId>
        </dependency>
        <!-- æ»¡æ„åº¦æ¨¡å—(Controller / Service / Mapper / SQL / domain å…¨éƒ¨åœ¨æœ¬æ¨¡å—内) -->
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-satisfaction</artifactId>
        </dependency>
        <!-- https://mvnrepository.com/artifact/net.glxn.qrgen/javase -->
        <dependency>
            <groupId>net.glxn.qrgen</groupId>
ruoyi-admin/src/main/java/com/ruoyi/web/controller/smartor/ServiceSubtaskController.java
@@ -23,9 +23,9 @@
import com.ruoyi.quartz.task.RyTask;
import com.ruoyi.system.service.ISysConfigService;
import com.ruoyi.system.service.ISysUserDeptService;
import com.smartor.common.MydOptionRatioChartBuilder;
import com.ruoyi.satisfaction.api.SatisfactionExportBundle;
import com.ruoyi.satisfaction.api.SatisfactionFacade;
import com.smartor.domain.*;
import com.smartor.domain.DTO.QuestionResultDTO;
import com.smartor.domain.VO.*;
import com.smartor.domain.entity.ServiceSubtaskEntity;
import com.smartor.service.IServiceSubtaskRecordService;
@@ -69,6 +69,15 @@
    @Autowired
    private IServiceSubtaskService serviceSubtaskService;
    /**
     * æ»¡æ„åº¦æ¨¡å—对外门面(常驻 Bean)。
     * <p>
     * æ»¡æ„åº¦ç›¸å…³æ•°æ®ï¼ˆé—®é¢˜è¯¦æƒ…占比)只能通过它获取;满意度模块关闭时
     * é—¨é¢è¿”回空结果并打 WARN,<b>不会</b>影响随访导出的其它内容。
     */
    @Autowired
    private SatisfactionFacade satisfactionFacade;
    @Autowired
    private IServiceTaskService serviceTaskService;
@@ -205,7 +214,8 @@
            serviceSubtaskList = serviceSubtaskService.patItem(serviceSubtaskEntity);
        }
        List<ServiceSubtaskExport> serviceSubtaskExports = null;
        List<ServiceSubtaskDetailRatioExport> serviceSubtaskDetailRatioExports = null;
        //满意度占比导出包:默认空包(没有可导出的患者时保持为空),有数据时由满意度门面填充
        SatisfactionExportBundle ratioBundle = SatisfactionExportBundle.empty();
        if (!CollectionUtils.isEmpty(serviceSubtaskList)) {
            serviceSubtaskExports = DtoConversionUtils.sourceToTarget(serviceSubtaskList, ServiceSubtaskExport.class);
            for (ServiceSubtaskExport serviceSubtaskExport : serviceSubtaskExports) {
@@ -223,21 +233,23 @@
                }
            }
            //问题详情占比:只统计本次导出的这批患者,数量=选该选项的患者数,比例=占该题作答人数
            //满意度模块化后:只通过满意度模块暴露的 api åŒ…取数(导出包 = è¡Œ + è¡Œç±»åž‹ + å›¾è¡¨æž„建器);
            //模块关闭(satisfaction.enabled=false)时门面返回空导出包,导出照常完成(只是没有占比 Sheet)。
            List<Long> subtaskIds = new ArrayList<>();
            for (ServiceSubtaskRes serviceSubtaskRes : serviceSubtaskList) {
                if (serviceSubtaskRes.getId() != null) {
                    subtaskIds.add(serviceSubtaskRes.getId());
                }
            }
            serviceSubtaskDetailRatioExports = serviceSubtaskService.statMydQuestionOptionForExport(subtaskIds, null);
            ratioBundle = satisfactionFacade.getQuestionOptionRatioExport(subtaskIds, null);
        }
        ExcelUtil<ServiceSubtaskExport> util = new ExcelUtil<ServiceSubtaskExport>(ServiceSubtaskExport.class);
        //第三个Sheet:问题详情占比图表(按任务名称分组,一个任务一张柱状图)
        util.exportExcelTwoSheetWithChart(response, serviceSubtaskExports, "患者随访信息表单",
                serviceSubtaskDetailRatioExports, "问题详情占比", ServiceSubtaskDetailRatioExport.class,
                new MydOptionRatioChartBuilder(serviceSubtaskDetailRatioExports));
                ratioBundle.getRows(), "问题详情占比", ratioBundle.getRowClass(),
                ratioBundle.getChartBuilder());
    }
    /**
ruoyi-admin/src/main/java/com/ruoyi/web/controller/smartor/ServiceSubtaskDetailController.java
@@ -10,12 +10,7 @@
import com.ruoyi.common.utils.DtoConversionUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.system.service.ISysConfigService;
import com.smartor.domain.DTO.QuestionResultDTO;
import com.smartor.domain.*;
import com.smartor.domain.VO.MydTrendReqVO;
import com.smartor.domain.VO.MzMydScoreRankVO;
import com.smartor.domain.VO.QuestionCountKsOrBqVO;
import com.smartor.domain.VO.SltdMydTotalVO;
import com.smartor.domain.entity.ServiceSubtaskEntity;
import com.smartor.service.IServiceSubtaskDetailService;
import com.smartor.service.IServiceSubtaskService;
@@ -202,194 +197,4 @@
        map = ServiceSubtaskDetailService.getServiceSubtaskDetails(serviceSubtaskDetailVO);
        return map;
    }
    /**
     * çœç«‹åŒå¾·æ»¡æ„åº¦æ•°æ®ç»Ÿè®¡ï¼ˆæŒ‰é¢˜ç›®ç»Ÿè®¡é€‰é¡¹æ•°é‡/占比,并给出该题的综合得分)
     * <p>
     * ç»¼åˆæ»¡æ„åº¦ = (很满意项数×1 + æ»¡æ„é¡¹æ•°Ã—0.8 + ä¸€èˆ¬é¡¹æ•°Ã—0.6 + ä¸æ»¡æ„é¡¹æ•°Ã—0.4 + å¾ˆä¸æ»¡æ„é¡¹æ•°Ã—0.2)
     * / (很满意项数 + æ»¡æ„é¡¹æ•° + ä¸€èˆ¬é¡¹æ•° + ä¸æ»¡æ„é¡¹æ•° + å¾ˆä¸æ»¡æ„é¡¹æ•°) Ã— 100%;
     * ã€Œæœªç»åŽ†ã€ã€Œä¸é€‚ç”¨ã€ç­‰ä¸åœ¨äº”æ¡£é‡Œçš„é€‰é¡¹æ—¢ä¸è¿›åˆ†å­ä¹Ÿä¸è¿›åˆ†æ¯ã€‚
     */
    @ApiOperation("省立同德满意度数据统计")
    @AddOrgId(field = "orgid", paramIndex = 0, campusField = "campusid")
    @PostMapping("/sltdMydTotalByScore")
    public Map<String, Object> sltdMydTotalByScore(@RequestBody SltdMydTotalVO sltdMydTotalVO) {
        List<ServiceSubtaskRes> serviceSubtaskResList = null;
        if (CollectionUtils.isNotEmpty(sltdMydTotalVO.getServiceTypeList())) {
            //serviceType可能会出现多个,不想多写方法了,一个一个查询吧
            for (String st : sltdMydTotalVO.getServiceTypeList()) {
                ServiceSubtaskEntity serviceSubtaskEntity = new ServiceSubtaskEntity();
                serviceSubtaskEntity.setServiceType(st);
                serviceSubtaskEntity.setEndOutHospTime(sltdMydTotalVO.getEndOutHospTime());
                serviceSubtaskEntity.setStartOutHospTime(sltdMydTotalVO.getStartOutHospTime());
                serviceSubtaskEntity.setDeptOrDistrict(sltdMydTotalVO.getDeptOrDistrict());
                serviceSubtaskEntity.setLeavehospitaldistrictcodes(sltdMydTotalVO.getLeavehospitaldistrictcodes());
                serviceSubtaskEntity.setLeaveldeptcodes(sltdMydTotalVO.getLeaveldeptcodes());
                //机构/院区由 @AddOrgId æ³¨å…¥ï¼Œè½åˆ°æŸ¥è¯¢æ¡ä»¶ä¸Šï¼Œé¿å…è·¨æœºæž„取数
                serviceSubtaskEntity.setOrgid(sltdMydTotalVO.getOrgid());
                serviceSubtaskEntity.setCampusid(sltdMydTotalVO.getCampusid());
                if (serviceSubtaskEntity != null) {
                    List<ServiceSubtask> serviceSubtasks = serviceSubtaskService.getServiceSubtasks(serviceSubtaskEntity);
                    List<ServiceSubtaskRes> serviceSubtaskRes = DtoConversionUtils.sourceToTarget(serviceSubtasks, ServiceSubtaskRes.class);
                    if (CollectionUtils.isNotEmpty(serviceSubtaskResList)) {
                        serviceSubtaskResList.addAll(serviceSubtaskRes);
                    } else {
                        if (CollectionUtils.isEmpty(serviceSubtaskResList)) serviceSubtaskResList = serviceSubtaskRes;
                    }
                }
            }
        }
        // æŒ‰ questiontext åˆ†ç»„后转换为 QuestionResultVO
        List<QuestionResultDTO> result = new ArrayList<>();
        if (CollectionUtils.isNotEmpty(serviceSubtaskResList)) {
            List<ServiceSubtaskDetailRatioExport> detailList = serviceSubtaskService.getDetailRatio(sltdMydTotalVO, serviceSubtaskResList);
            if (detailList == null) {
                //serviceType ç»„合不在支持范围内(如同时含随访 2 å’Œæ»¡æ„åº¦ 6/14),给明确提示而不是悄悄返回空数组
                return error("当前 serviceType ç»„合暂不支持统计问题占比,请分开查询(仅支持随访 2 æˆ–满意度 6/14)");
            }
            if (CollectionUtils.isNotEmpty(detailList)) {
                Map<String, List<ServiceSubtaskDetailRatioExport>> groupedMap = detailList.stream().collect(Collectors.groupingBy(ServiceSubtaskDetailRatioExport::getQuestiontext));
                groupedMap.forEach((questionText, exportList) -> {
                    QuestionResultDTO questionResultDTO = new QuestionResultDTO();
                    questionResultDTO.setQeustionText(questionText);
                    questionResultDTO.setSubtaskDetailRatioExportList(exportList);
                    //综合得分:(很满意×1 + æ»¡æ„Ã—0.8 + ä¸€èˆ¬Ã—0.6 + ä¸æ»¡æ„Ã—0.4 + å¾ˆä¸æ»¡æ„Ã—0.2) / äº”档项数之和 Ã— 100%
                    questionResultDTO.setAvgScore(calcMydScore(exportList));
                    result.add(questionResultDTO);
                });
            }
        }
        return success(result);
    }
    @ApiOperation("省立同德满意度数据统计通过科室或病区")
    @PostMapping("/statQuestionOptionByKsOrBq")
    public Map<String, Object> statQuestionOptionByKsOrBq(@RequestBody QuestionCountKsOrBqVO questionResultVO) {
        return success(serviceSubtaskService.statQuestionOptionByKsOrBq(questionResultVO));
    }
    @ApiOperation("省立同德患者满意度趋势")
    @PostMapping("/mydTrend")
    public Map<String, Object> mydTrend(@RequestBody MydTrendReqVO mydTrendReqVO) {
        return success(serviceSubtaskService.mydTrend(mydTrendReqVO));
    }
    @ApiOperation("省立同德患者满意度维护")
    @PostMapping("/sltdMydTotalByDimension")
    public Map<String, Object> sltdMydTotalByDimension(@RequestBody MydTrendReqVO mydTrendReqVO) {
        return success(serviceSubtaskService.sltdMydTotalByDimension(mydTrendReqVO));
    }
    @ApiOperation("门诊满意度综合得分排名(按科室或病区)")
    @AddOrgId(field = "orgid", paramIndex = 0, campusField = "campusid")
    @PostMapping("/mzMydScoreRank")
    public Map<String, Object> mzMydScoreRank(@RequestBody MzMydScoreRankVO mzMydScoreRankVO) {
        return success(serviceSubtaskService.mzMydScoreRank(mzMydScoreRankVO));
    }
    /**
     * å¤šé€‰é¢˜çš„问题类型
     */
    private static final String VALUE_TYPE_MULTIPLE = "2";
    /**
     * æ»¡æ„åº¦äº”档选项(顺序与权重顺序一致)
     */
    private static final String[] MYD_SCORE_OPTIONS = {"很满意", "满意", "一般", "不满意", "很不满意"};
    /**
     * æ»¡æ„åº¦äº”档权重:很满意 1、满意 0.8、一般 0.6、不满意 0.4、很不满意 0.2
     */
    private static final BigDecimal[] MYD_SCORE_WEIGHTS = {
            new BigDecimal("1"), new BigDecimal("0.8"), new BigDecimal("0.6"), new BigDecimal("0.4"), new BigDecimal("0.2")};
    /**
     * è®¡ç®—一道题的满意度综合得分(百分制,保留两位小数)
     * <p>
     * ç»¼åˆæ»¡æ„åº¦ = (很满意项数×1 + æ»¡æ„é¡¹æ•°Ã—0.8 + ä¸€èˆ¬é¡¹æ•°Ã—0.6 + ä¸æ»¡æ„é¡¹æ•°Ã—0.4 + å¾ˆä¸æ»¡æ„é¡¹æ•°Ã—0.2)
     * / (很满意项数 + æ»¡æ„é¡¹æ•° + ä¸€èˆ¬é¡¹æ•° + ä¸æ»¡æ„é¡¹æ•° + å¾ˆä¸æ»¡æ„é¡¹æ•°) Ã— 100%
     * <p>
     * æƒé‡æŒ‰é€‰é¡¹æ–‡æ¡ˆåˆ¤å®šï¼ˆè§ {@link #mydScoreOptionIndex(String)}),不再依赖明细的 score å­—段:
     * åŽ†å²æ•°æ®é‡Œ score æœ‰ 0、「0000000001」这类脏值,按 score ç®—会把得分固定成 0 æˆ– 100。
     * ã€Œæœªç»åŽ†ã€ã€Œä¸é€‚ç”¨ã€ç­‰ä¸åœ¨äº”æ¡£é‡Œçš„é€‰é¡¹æ—¢ä¸è¿›åˆ†å­ä¹Ÿä¸è¿›åˆ†æ¯ã€‚
     *
     * @param exportList åŒä¸€é“题目的选项统计明细
     * @return ç™¾åˆ†åˆ¶ç»¼åˆå¾—分,无有效项数时返回 "0.00"
     */
    private String calcMydScore(List<ServiceSubtaskDetailRatioExport> exportList) {
        if (CollectionUtils.isEmpty(exportList)) return "0.00";
        BigDecimal scoreSum = BigDecimal.ZERO;   //分子:Σ(档位权重 Ã— é¡¹æ•°)
        BigDecimal countSum = BigDecimal.ZERO;   //分母:五档项数之和
        for (ServiceSubtaskDetailRatioExport detail : exportList) {
            //多选题一行是多个选项的分值之和,分母只算 1 é¡¹ï¼Œä¼šæŠŠå¾—分算高,不计入综合得分
            if (VALUE_TYPE_MULTIPLE.equals(detail.getValueType())) continue;
            //「不适用」不在公式的五档里,不计入综合得分
            if (isNotApplicable(detail)) continue;
            int optionIndex = mydScoreOptionIndex(detail.getOptionresult());
            BigDecimal count = parseDecimal(detail.getCount());
            if (optionIndex < 0 || count == null || count.signum() <= 0) continue;
            scoreSum = scoreSum.add(MYD_SCORE_WEIGHTS[optionIndex].multiply(count));
            countSum = countSum.add(count);
        }
        if (countSum.signum() <= 0) return "0.00";
        return scoreSum.divide(countSum, 6, RoundingMode.HALF_UP)
                .multiply(BigDecimal.valueOf(100))
                .setScale(2, RoundingMode.HALF_UP)
                .toPlainString();
    }
    /**
     * é€‰é¡¹æ–‡æ¡ˆ â†’ æ»¡æ„åº¦äº”档下标(很满意 0、满意 1、一般 2、不满意 3、很不满意 4),不在五档里返回 -1
     */
    private int mydScoreOptionIndex(String optionresult) {
        if (StringUtils.isEmpty(optionresult)) return -1;
        String text = optionresult.trim();
        for (int i = 0; i < MYD_SCORE_OPTIONS.length; i++) {
            if (MYD_SCORE_OPTIONS[i].equals(text)) return i;
        }
        //兼容「A å¾ˆæ»¡æ„ã€ã€ŒB.满意」这类带选项序号前缀的历史数据
        if (text.length() > 2 && (text.charAt(1) == ' ' || text.charAt(1) == '.' || text.charAt(1) == '、')) {
            String stripped = text.substring(2).trim();
            for (int i = 0; i < MYD_SCORE_OPTIONS.length; i++) {
                if (MYD_SCORE_OPTIONS[i].equals(stripped)) return i;
            }
        }
        return -1;
    }
    /**
     * æ˜¯å¦ã€Œä¸é€‚用」选项
     */
    private boolean isNotApplicable(ServiceSubtaskDetailRatioExport detail) {
        return containsNotApplicable(detail.getOptionresult()) || containsNotApplicable(detail.getTargetvalue());
    }
    private boolean containsNotApplicable(String optionText) {
        if (StringUtils.isEmpty(optionText)) return false;
        String text = optionText.trim();
        return text.contains("不适用") || text.contains("未答") || text.contains("拒答");
    }
    /**
     * å­—符串转 BigDecimal,空值/非法值返回 null
     */
    private BigDecimal parseDecimal(String value) {
        if (StringUtils.isEmpty(value)) return null;
        try {
            return new BigDecimal(value.trim());
        } catch (NumberFormatException e) {
            return null;
        }
    }
}
}
ruoyi-admin/src/main/java/com/ruoyi/web/controller/smartor/ServiceSubtaskDetailTraceController.java
@@ -9,7 +9,6 @@
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.smartor.domain.ServiceSubtaskDetailTrace;
import com.smartor.domain.VO.DetailTraceDealVO;
import com.smartor.domain.VO.SatisfactionExceptConfigQuestionReqVO;
import com.smartor.domain.VO.ServiceSubtaskDetailTraceVO;
import com.smartor.service.IServiceSubtaskDetailTraceService;
import io.swagger.annotations.Api;
@@ -72,17 +71,6 @@
        Object questionTotal = result.get("questionTotal");
        long total = questionTotal == null ? 0L : Long.parseLong(String.valueOf(questionTotal));
        return getDataTable3(total, result);
    }
    /**
     * èŽ·å–æ»¡æ„åº¦å¼‚å¸¸é…ç½®é¢˜ç›®
     */
    @ApiOperation("获取满意度异常配置题目")
    @PostMapping("/getSatisfactionExceptConfigQuestion")
    public Map<String, Object> getSatisfactionExceptConfigQuestion(@RequestBody SatisfactionExceptConfigQuestionReqVO questionReqVO) {
        log.info("获取满意度异常配置题目的入参: {}", questionReqVO);
        Map<String, Object> satisfactionExceptConfigQuestion = serviceSubtaskDetailTraceService.getSatisfactionExceptConfigQuestion(questionReqVO);
        return satisfactionExceptConfigQuestion;
    }
    /**
ruoyi-admin/src/main/resources/application.yml
@@ -158,6 +158,8 @@
#不需要进行长期任务的机构
noLongTask:
#省立同德取数据公共接口
sltd_pub_path: "http://open-tdyy.cfuture.shop/kapi/gw-api/"
ruoyi-common/src/main/java/com/ruoyi/common/core/service/ISatisfactionCategoryService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
package com.ruoyi.common.core.service;
import java.util.Map;
/**
 * æ»¡æ„åº¦åˆ†ç±»æŸ¥è¯¢æŽ¥å£ï¼ˆä¾› ruoyi-system ç­‰æ¨¡å—通过 Spring å®¹å™¨è°ƒç”¨æ»¡æ„åº¦æ¨¡å—的实现)。
 * <p>
 * <b>为什么单独拆出这个接口</b>:满意度分类原本挂在 {@link IUserExtInfoService} ä¸Šï¼Œ
 * ç”± smartor æ¨¡å—çš„ {@code UserExtInfoServiceImpl} å®žçŽ°ã€‚æ»¡æ„åº¦æ¨¡å—åŒ–åŽï¼Œ
 * è¯¥æŸ¥è¯¢å·²æ•´æ®µæ¬å…¥ ruoyi-satisfaction;为了不让调用方(登录链路)与满意度模块直接耦合,
 * è¿™é‡ŒæŠŠ"满意度分类"这一能力单独抽成接口放在 ruoyi-common:
 * <ul>
 *   <li>接口在 ruoyi-common â€”— è°ƒç”¨æ–¹åªä¾èµ–接口,不依赖满意度模块;</li>
 *   <li>实现由满意度模块的常驻门面 {@code SatisfactionFacade} æä¾›ï¼Œ
 *       è¯¥ Bean <b>永远存在</b>,因此调用方不会因为满意度模块被关闭而启动失败;</li>
 *   <li>满意度模块关闭({@code satisfaction.enabled=false})或该模块整体不在依赖里时,
 *       è°ƒç”¨æ–¹æ‹¿ä¸åˆ°å®žçŽ°ï¼Œåº”é™çº§ä¸ºç©ºç»“æžœï¼ˆè§ {@code SysUserServiceImpl#loadSatisfactionCategories})。</li>
 * </ul>
 */
public interface ISatisfactionCategoryService {
    /**
     * èŽ·å–æ»¡æ„åº¦é—®é¢˜åˆ†ç±»ï¼ˆè¯­éŸ³ + é—®å·ï¼‰ï¼Œå¸¦ç¼“存。
     *
     * @param orgid æœºæž„ID
     * @return åŒ…含 {@code voiceCategories} ä¸Ž {@code questionnaireCategorys} çš„ Map;
     * æ»¡æ„åº¦æ¨¡å—关闭或不可用时返回空 Map
     */
    Map<String, Object> getSatisfactionCategories(String orgid);
}
ruoyi-common/src/main/java/com/ruoyi/common/core/service/IUserExtInfoService.java
@@ -4,6 +4,10 @@
/**
 * ç”¨æˆ·æ‰©å±•信息服务接口(供 ruoyi-system é€šè¿‡ SpringUtils è°ƒç”¨ smartor å®žçŽ°ï¼‰
 * <p>
 * æ³¨æ„ï¼šåŽŸ {@code getSatisfactionCategories} å·²éšæ»¡æ„åº¦æ¨¡å—化迁出,
 * æ”¹ç”± {@link ISatisfactionCategoryService} æä¾›ï¼ˆå®žçŽ°åœ¨ ruoyi-satisfaction æ¨¡å—),
 * è¿™æ ·ç™»å½•取满意度分类的链路不再依赖 smartor çš„æ¨¡æ¿/分类 Mapper。
 */
public interface IUserExtInfoService {
@@ -16,12 +20,4 @@
     * @return åŒ…含 orgid å’Œ campusid çš„ Map,找不到时返回空 Map
     */
    Map<String, String> getUserOrgInfo(String userId, String orgid, String campusid);
    /**
     * èŽ·å–æ»¡æ„åº¦é—®é¢˜åˆ†ç±»ï¼ˆè¯­éŸ³ + é—®å·ï¼‰
     *
     * @param orgid æœºæž„ID
     * @return åŒ…含 voiceCategories å’Œ questionnaireCategorys çš„ Map
     */
    Map<String, Object> getSatisfactionCategories(String orgid);
}
ruoyi-quartz/src/main/java/com/ruoyi/quartz/constant/MzGatherConstant.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
package com.ruoyi.quartz.constant;
import java.time.format.DateTimeFormatter;
/**
 * çœç«‹åŒå¾·é—¨æ€¥è¯Šé‡‡é›†å›ºå®šå¸¸é‡ï¼ˆä¸éœ€è¦åœ¨ sys_config é‡Œé…çš„那些)
 *
 * @author smartor
 */
public class MzGatherConstant {
    private MzGatherConstant() {
    }
    /** çœç«‹åŒå¾·æœºæž„ID */
    public static final String ORG_ID = "20001001";
    /** æ–­ç‚¹ç­‰ç¼“存保留时长(小时),默认 30 å¤© */
    public static final long CACHE_TTL_HOURS = 24L * 30L;
    /** å•页在本轮内的重试次数 */
    public static final int PAGE_RETRY_TIMES = 3;
    /** å•页重试间隔(毫秒) */
    public static final long PAGE_RETRY_SLEEP_MS = 2000L;
    /** è¿žç»­å¤šå°‘页接口失败就判定"接口整体不可用",本轮提前结束,不再空转后面几十页 */
    public static final int MAX_CONSECUTIVE_PAGE_FAIL = 3;
    /** æ¥æœªæ¥æŽ¥å£åªæ”¯æŒæŒ‰å¤©æŸ¥ï¼Œæ—¶é—´å‚æ•°ä¼  yyyy-MM-dd */
    public static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
    /** æ—¥å¿—与断点里用的时间格式 */
    public static final DateTimeFormatter MINUTE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
}
ruoyi-satisfaction/pom.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>ruoyi</artifactId>
        <groupId>com.ruoyi</groupId>
        <version>3.8.5</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>
    <artifactId>ruoyi-satisfaction</artifactId>
    <description>
        æ»¡æ„åº¦æ¨¡å—:满意度统计/查询/任务新增的唯一实现处。
        æœ¬æ¨¡å—自带 Mapper ä¸Ž SQL,可直接查询 service_subtask、service_subtask_detail åŠæ¨¡æ¿åˆ†ç±»è¡¨ï¼›
        å…¶å®ƒæ¨¡å—需要满意度数据只能通过 com.ruoyi.satisfaction.api è°ƒç”¨ã€‚
        æ”¯æŒé€šè¿‡ sys_config å‚数表里的 satisfaction.enabled æ•´ä½“关闭(运行时读取,改完最多 10 ç§’生效)。
    </description>
    <dependencies>
        <!-- é€šç”¨å·¥å…·ï¼šBaseController / ExcelUtil / PageUtils / Arith / SecurityUtils / DtoConversionUtils
             åŒæ—¶ä¼ é€’带入 pagehelper(含 mybatis)、swagger-annotations、commons-lang3、jackson ç­‰ -->
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-common</artifactId>
        </dependency>
        <!-- lombok:ruoyi-common é‡Œæ˜¯ç›´æŽ¥ä¾èµ–,此处显式声明版本,避免注解处理器缺失 -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.34</version>
        </dependency>
        <!-- MyBatis:Mapper æŽ¥å£ä¸Ž XML(pagehelper å·²ä¼ é€’带入,这里只是显式化意图) -->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.2.2</version>
        </dependency>
        <!-- Servlet API:HttpServletResponse(Controller å¯¼å‡ºç”¨ï¼‰ -->
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>javax.servlet-api</artifactId>
            <version>4.0.1</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>
</project>
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/SatisfactionModule.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package com.ruoyi.satisfaction;
/**
 * æ»¡æ„åº¦æ¨¡å—包根。
 * <p>
 * ç›®å½•约定:
 * <ul>
 *   <li>{@code com.ruoyi.satisfaction.api} â€”— <b>唯一对外包</b>,其它模块只能依赖这个包;</li>
 *   <li>{@code controller} / {@code service} / {@code mapper} / {@code domain} â€”— æ¨¡å—内部实现,
 *       å…¶å®ƒæ¨¡å—不应直接引用。</li>
 * </ul>
 * æ¨¡å—可通过 {@code sys_config} å‚数表的 {@code satisfaction.enabled=false} æ•´ä½“关闭
 * ï¼ˆè¿è¡Œæ—¶è¯»å–,改完最多 10 ç§’生效);关闭后
 * {@code com.ruoyi.satisfaction.api.SatisfactionFacade} ä»å­˜åœ¨ï¼Œä½†æ‰€æœ‰è°ƒç”¨é™çº§ä¸ºç©ºå€¼/全零;
 * ç›´æŽ¥è®¿é—®æ»¡æ„åº¦ç«¯ç‚¹çš„请求会被拦截器拦下并返回明确的"未启用"业务错误。
 */
public final class SatisfactionModule {
    /**
     * æ¨¡å—配置前缀(仅作为 yml å…œåº•配置的键前缀;主开关在 sys_config å‚数表里)
     */
    public static final String PROPERTIES_PREFIX = "satisfaction";
    private SatisfactionModule() {
    }
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/api/DefaultSatisfactionApi.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,80 @@
package com.ruoyi.satisfaction.api;
import com.ruoyi.satisfaction.domain.ServiceSubtaskDetailRatioExport;
import com.ruoyi.satisfaction.service.SatisfactionSubtaskService;
import com.ruoyi.satisfaction.support.MydOptionRatioChartBuilder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
 * {@link SatisfactionApi} çš„内部实现(<b>常驻 Bean</b>)。
 * <p>
 * å¼€å…³å·²æ”¹ä¸ºå­˜æ”¾åœ¨ {@code sys_config} å‚数表的 {@code satisfaction.enabled},
 * <b>运行时热读</b>,因此本 Bean æ— æ¡ä»¶åˆ›å»ºï¼š
 * <ul>
 *   <li>参数为 {@code true}(默认)→ {@link SatisfactionFacade} æ­£å¸¸è½¬å‘到本实现;</li>
 *   <li>参数为 {@code false} â†’ æœ¬ Bean ä»ç„¶å­˜åœ¨ï¼Œä½†é—¨é¢åœ¨
 *       {@link com.ruoyi.satisfaction.config.SatisfactionSwitch} åˆ¤æ–­ä¸ºå…³é—­åŽ
 *       ä¸å†è½¬å‘,而是返回安全空值/全零。</li>
 * </ul>
 * ä¹‹æ‰€ä»¥ä¸å†ç”¨ {@code @ConditionalOnProperty}:那个注解只在<b>启动时</b>求值一次,
 * ä¸Ž"改完参数配置即时生效"的诉求冲突。
 * <p>
 * è°ƒç”¨æ–¹<b>不要</b>直接注入本类,应注入 {@link SatisfactionFacade}。
 */
@Slf4j
@Component
public class DefaultSatisfactionApi implements SatisfactionApi {
    private final SatisfactionSubtaskService satisfactionSubtaskService;
    public DefaultSatisfactionApi(SatisfactionSubtaskService satisfactionSubtaskService) {
        this.satisfactionSubtaskService = satisfactionSubtaskService;
    }
    @Override
    public boolean isEnabled() {
        return true;
    }
    @Override
    public Map<String, Object> getSatisfactionCategories(String orgid) {
        return satisfactionSubtaskService.getSatisfactionCategories(orgid);
    }
    @Override
    public SatisfactionExportBundle getQuestionOptionRatioExport(List<Long> subtaskIds, String questiontext) {
        List<ServiceSubtaskDetailRatioExport> rows =
                satisfactionSubtaskService.statMydQuestionOptionForExport(subtaskIds, questiontext);
        if (rows == null || rows.isEmpty()) {
            // æ²¡æœ‰è¡Œæ—¶ä¸ç»™å›¾è¡¨æž„建器:ExcelUtil ä¼šè·³è¿‡è¯¥ Sheet(与原实现"空数据不画图"一致)
            return new SatisfactionExportBundle(rows, ServiceSubtaskDetailRatioExport.class, null);
        }
        return new SatisfactionExportBundle(rows, ServiceSubtaskDetailRatioExport.class,
                new MydOptionRatioChartBuilder(rows));
    }
    @Override
    public boolean hasSatisfactionCategory(String orgid) {
        Map<String, Object> categories = satisfactionSubtaskService.getSatisfactionCategories(orgid);
        if (categories == null || categories.isEmpty()) {
            return false;
        }
        Object voice = categories.get("voiceCategories");
        Object questionnaire = categories.get("questionnaireCategorys");
        return isNotEmpty(voice) || isNotEmpty(questionnaire);
    }
    private boolean isNotEmpty(Object value) {
        return value instanceof java.util.Collection && !((java.util.Collection<?>) value).isEmpty();
    }
    @Override
    public Map<String, Object> getSatisfactionIndex(String type, String code, String orgid,
                                                    Long ivrCategoryid, Long svyCategoryid) {
        return satisfactionSubtaskService.getSatisfactionIndex(type, code, orgid, ivrCategoryid, svyCategoryid);
    }
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/api/SatisfactionApi.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,87 @@
package com.ruoyi.satisfaction.api;
import java.util.List;
import java.util.Map;
/**
 * æ»¡æ„åº¦æ¨¡å—<b>对外接口</b>(唯一允许其它模块依赖的入口)。
 * <p>
 * è®¾è®¡çº¦å®šï¼š
 * <ul>
 *   <li>只暴露 DTO / åŸºæœ¬ç±»åž‹ï¼Œ<b>不暴露</b>满意度模块内部的实体与 Mapper;</li>
 *   <li>本接口由 {@link SatisfactionFacade} ç»Ÿä¸€è½¬å‡ºï¼Œé—¨é¢<b>永远存在</b>,
 *       å³ä½¿æ»¡æ„åº¦æ¨¡å—被关闭({@code sys_config} å‚æ•° {@code satisfaction.enabled=false})
 *       ä¹Ÿä¸ä¼šå¯¼è‡´è°ƒç”¨æ–¹å¯åŠ¨å¤±è´¥ï¼›</li>
 *   <li>关闭时各方法返回 {@code false} / ç©ºé›†åˆ / {@code null},并打 WARN æ—¥å¿—,
 *       <b>不抛异常</b>,以免影响调用方主流程(如随访导出、登录)。</li>
 * </ul>
 */
public interface SatisfactionApi {
    /**
     * æ»¡æ„åº¦æ¨¡å—是否已启用
     */
    boolean isEnabled();
    /**
     * èŽ·å–æ»¡æ„åº¦é—®é¢˜åˆ†ç±»ï¼ˆè¯­éŸ³ + é—®å·ï¼‰ï¼Œå¸¦ç¼“存。
     * <p>
     * ä¾›ç™»å½•链路使用(原 {@code IUserExtInfoService.getSatisfactionCategories})。
     * <p>
     * è¿”回结构(与迁移前完全一致):
     * <pre>
     * {
     *   "voiceCategories":        [ {categoryid, categoryName}, ... ],
     *   "questionnaireCategorys": [ {categoryid, categoryName}, ... ]
     * }
     * </pre>
     *
     * @param orgid æœºæž„ id
     * @return åˆ†ç±»ç»“构;模块关闭时返回空 Map
     */
    Map<String, Object> getSatisfactionCategories(String orgid);
    /**
     * é—®é¢˜è¯¦æƒ…占比导出包(供随访导出使用)。
     * <p>
     * å¯¹åº”原 {@code IServiceSubtaskService.statMydQuestionOptionForExport}。
     * <p>
     * è¿”回 {@link SatisfactionExportBundle}:行列表 + è¡Œç±»åž‹ + å›¾è¡¨æž„建器一次给齐,
     * è°ƒç”¨æ–¹ï¼ˆéšè®¿å¯¼å‡ºï¼‰å› æ­¤<b>只需 import api åŒ…</b>即可把结果交给
     * {@code ExcelUtil.exportExcelTwoSheetWithChart},
     * ä¸éœ€è¦æŽ¥è§¦æœ¬æ¨¡å—çš„ {@code domain} / {@code support} åŒ…。
     *
     * @param subtaskIds   æœ¬æ¬¡å¯¼å‡ºçš„子任务 id
     * @param questiontext é¢˜ç›®å†…容(可选,模糊匹配)
     * @return å¯¼å‡ºåŒ…;模块关闭时返回 {@link SatisfactionExportBundle#empty()}(导出照常完成,只是没有占比 Sheet)
     */
    SatisfactionExportBundle getQuestionOptionRatioExport(List<Long> subtaskIds, String questiontext);
    /**
     * åˆ¤æ–­è¯¥æœºæž„是否存在满意度配置(可选能力,供调用方做前置校验)
     *
     * @param orgid æœºæž„ id
     * @return æœ‰è¯­éŸ³æˆ–问卷满意度分类时返回 true
     */
    boolean hasSatisfactionCategory(String orgid);
    /**
     * éšè®¿ç»Ÿè®¡é¡µçš„「满意度指数」:满意度题目总量 / å¡«æŠ¥é‡ / å®Œæˆæ¯”例。
     * <p>
     * å¯¹åº”原 {@code IServiceSubtaskService#getSfStatisticsJoy} å†…部私有的
     * {@code getSatisfactionIndex}(现已搬入本模块)。
     * <p>
     * âš ï¸ å‡ºäºŽ"不改行为"的要求,被迁移实现里的原缺陷<b>原样保留</b>:
     * æœ¬æ–¹æ³•恒定返回 {@code {mydtmzl: 0, mydtbl: 0, wcbl: 0.0}}。
     * å› æ­¤æ¨¡å—关闭时返回同样的全零 Map,调用方得到的显示结果与开启时一致。
     *
     * @param type          "1"=按科室,"2"=按病区
     * @param code          ç§‘室/病区编码
     * @param orgid         æœºæž„ id
     * @param ivrCategoryid è¯­éŸ³æ»¡æ„åº¦åˆ†ç±» id(可为 null)
     * @param svyCategoryid é—®å·æ»¡æ„åº¦åˆ†ç±» id(可为 null)
     * @return {@code mydtmzl}(Integer) / {@code mydtbl}(Integer) / {@code wcbl}(Double)
     */
    Map<String, Object> getSatisfactionIndex(String type, String code, String orgid,
                                            Long ivrCategoryid, Long svyCategoryid);
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/api/SatisfactionExportBundle.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,82 @@
package com.ruoyi.satisfaction.api;
import com.ruoyi.common.utils.poi.ExcelUtil;
import java.util.Collections;
import java.util.List;
/**
 * ã€Œé—®é¢˜è¯¦æƒ…占比」导出包(满意度模块对外暴露的导出载体)。
 * <p>
 * <b>为什么需要它</b>:随访导出({@code ServiceSubtaskController#patItemExport})需要把满意度占比行
 * äº¤ç»™ {@code ExcelUtil.exportExcelTwoSheetWithChart(...)},而这个方法要求同时给出
 * ã€Œè¡Œåˆ—表 + è¡Œç±»åž‹ {@code Class} + å›¾è¡¨æž„建器」。如果让调用方自己拿这三样,
 * å®ƒå°±å¿…é¡» import æ»¡æ„åº¦æ¨¡å—内部的 {@code domain} ä¸Ž {@code support} åŒ…,
 * è¿åã€Œå…¶å®ƒæ¨¡å—只能用本模块暴露的 api」的约束。
 * <p>
 * å› æ­¤è¿™é‡ŒæŠŠä¸‰æ ·ä¸œè¥¿æ‰“包成一个 <b>api å±‚的值对象</b>:
 * <ul>
 *   <li>{@link #getRows()} â€”— å æ¯”行(调用方只当作 {@code List<?>} é€ä¼ ï¼Œä¸éœ€è¦çŸ¥é“具体类型);</li>
 *   <li>{@link #getRowClass()} â€”— è¡Œç±»åž‹ï¼ˆåŒæ ·åªæ˜¯é€ä¼ ç»™ {@code ExcelUtil});</li>
 *   <li>{@link #getChartBuilder()} â€”— æŸ±çŠ¶å›¾æž„å»ºå™¨ï¼ˆä»¥ ruoyi-common çš„
 *       {@link ExcelUtil.ChartSheetBuilder} æŽ¥å£ç±»åž‹æš´éœ²ï¼Œå®žçŽ°åœ¨æ¨¡å—å†…éƒ¨ï¼Œ
 *       æ¨¡å—关闭时为 {@code null})。</li>
 * </ul>
 * è°ƒç”¨æ–¹åªéœ€ import æœ¬ç±»ä¸Ž {@link SatisfactionFacade} å³å¯å®Œæˆæ•´ä¸ªå¯¼å‡ºã€‚
 */
public class SatisfactionExportBundle {
    /**
     * æ¨¡å—关闭时使用的空导出包(无行、无图表构建器)
     */
    private static final SatisfactionExportBundle EMPTY =
            new SatisfactionExportBundle(Collections.emptyList(), null, null);
    private final List<?> rows;
    private final Class<?> rowClass;
    private final ExcelUtil.ChartSheetBuilder chartBuilder;
    public SatisfactionExportBundle(List<?> rows, Class<?> rowClass, ExcelUtil.ChartSheetBuilder chartBuilder) {
        this.rows = rows == null ? Collections.emptyList() : rows;
        this.rowClass = rowClass;
        this.chartBuilder = chartBuilder;
    }
    /**
     * æ¨¡å—关闭时的空包(行列表为空、行类型与图表构建器为 null)。
     * <p>配合 {@code ExcelUtil} çš„"行列表为空则跳过该 Sheet"行为,导出可以照常完成。
     */
    public static SatisfactionExportBundle empty() {
        return EMPTY;
    }
    /**
     * å æ¯”行(调用方只作透传,不建议自行解析其字段语义)
     */
    public List<?> getRows() {
        return rows;
    }
    /**
     * å æ¯”行的类型(透传给 {@code ExcelUtil} ç”¨ï¼›æ¨¡å—关闭时为 null)
     */
    public Class<?> getRowClass() {
        return rowClass;
    }
    /**
     * æŸ±çж图 Sheet æž„建器(透传给 {@code ExcelUtil} ç”¨ï¼›æ— æ•°æ®æˆ–模块关闭时为 null)
     */
    public ExcelUtil.ChartSheetBuilder getChartBuilder() {
        return chartBuilder;
    }
    /**
     * æ˜¯å¦æ²¡æœ‰å¯å¯¼å‡ºçš„行
     */
    public boolean isEmpty() {
        return rows.isEmpty();
    }
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/api/SatisfactionFacade.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,222 @@
package com.ruoyi.satisfaction.api;
import com.ruoyi.common.core.service.ISatisfactionCategoryService;
import com.ruoyi.satisfaction.config.SatisfactionProperties;
import com.ruoyi.satisfaction.config.SatisfactionSwitch;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
 * æ»¡æ„åº¦æ¨¡å—对外门面(<b>常驻 Bean</b>)。
 * <p>
 * è¿™æ˜¯å…¶å®ƒæ¨¡å—访问满意度能力的<b>唯一入口</b>。
 * <p>
 * ä¸Ž {@link SatisfactionApi} å®žçŽ°ç±»çš„å…³ç³»ï¼š
 * <ul>
 *   <li>实现类 {@link DefaultSatisfactionApi} <b>常驻</b>(不再受启动期条件装配控制);</li>
 *   <li>开关存放在 {@code sys_config} å‚数表的 {@code satisfaction.enabled},
 *       ç”± {@link SatisfactionSwitch} <b>运行时</b>读取(10 ç§’进程内缓存),
 *       æ”¹å®Œå‚数设置无需重启;</li>
 *   <li>本门面<b>永远</b>是 Spring Bean,因此调用方(随访导出、登录链路)
 *       çš„ {@code @Autowired} åœ¨ä»»ä½•开关状态下都<b>不会启动失败</b>;</li>
 *   <li>关闭时各方法返回失败/空值并打 WARN,<b>不抛异常</b>,
 *       ä¿è¯è°ƒç”¨æ–¹ä¸»æµç¨‹ï¼ˆå¦‚导出、登录)照常完成。</li>
 * </ul>
 *
 * <h3>使用示例</h3>
 * <pre>
 * &#64;Autowired
 * private SatisfactionFacade satisfactionFacade;
 *
 * if (satisfactionFacade.isEnabled()) {
 *     Map&lt;String, Object&gt; categories = satisfactionFacade.getSatisfactionCategories(orgid);
 * }
 * </pre>
 */
@Slf4j
@Component
public class SatisfactionFacade implements SatisfactionApi, ISatisfactionCategoryService {
    private final SatisfactionProperties properties;
    /**
     * è¿è¡Œæ—¶å¼€å…³ï¼ˆ{@code sys_config} ä¼˜å…ˆï¼Œ{@link SatisfactionProperties} å…œåº•)
     */
    private final SatisfactionSwitch satisfactionSwitch;
    /**
     * å†…部实现;模块关闭时为 {@code null}。
     * <p>
     * ç”¨ {@code ObjectProvider} æƒ°æ€§èŽ·å–ï¼šå³ä½¿å®žçŽ° Bean ä¸å­˜åœ¨ä¹Ÿä¸ä¼šå¯¼è‡´æ³¨å…¥å¤±è´¥ã€‚
     */
    private final ObjectProvider<SatisfactionApi> apiProvider;
    /**
     * Spring ä½¿ç”¨çš„æž„造器。
     * <p>
     * âš ï¸ <b>必须显式标注 {@code @Autowired}</b>:本类有两个公开构造器
     * ï¼ˆå¦ä¸€ä¸ªæ˜¯ç»™æµ‹è¯•/非 Spring çŽ¯å¢ƒç”¨çš„ï¼‰ï¼Œè‹¥ä¸æ ‡æ³¨ï¼ŒSpring ä¼šå› ä¸º"构造器不唯一"
     * è€Œé€€å›žæŸ¥æ‰¾æ— å‚构造器,从而在启动时报
     * {@code No default constructor found}。已由
     * {@code sql/_probe/verify_spring_beans.py} é™æ€æŠŠå…³ã€‚
     */
    @Autowired
    public SatisfactionFacade(SatisfactionProperties properties,
                              SatisfactionSwitch satisfactionSwitch,
                              ObjectProvider<SatisfactionApi> apiProvider) {
        this.properties = properties;
        this.satisfactionSwitch = satisfactionSwitch;
        this.apiProvider = apiProvider;
    }
    /**
     * ä¾›<b>测试/非 Spring çŽ¯å¢ƒ</b>直接构造(直接给出内部实现,可为 null)。
     * <p>
     * å¼€å…³å–自 {@code sys_config} çš„æ¨¡æ‹Ÿé”®å€¼ï¼ˆè§ {@link SatisfactionSwitch#SatisfactionSwitch}),
     * ä»Žè€Œä¿è¯"开关校验"脚本无需 Spring/数据库即可运行。
     */
    public SatisfactionFacade(SatisfactionProperties properties, SatisfactionApi delegate, String sysConfigValue) {
        this(properties, new SatisfactionSwitch(properties, sysConfigValue), new FixedProvider(delegate));
    }
    /**
     * å–运行时开关(供拦截器复用同一个实例、也供测试断言)
     */
    public SatisfactionSwitch getSatisfactionSwitch() {
        return satisfactionSwitch;
    }
    /**
     * å–内部实现;模块关闭、实现缺失、或只找到自己时返回 {@code null}。
     * <p>
     * âš ï¸ <b>必须排除"自己"</b>:本类自身也实现了 {@link SatisfactionApi},而 Spring åœ¨
     * æŒ‰ç±»åž‹æŸ¥æ‰¾å€™é€‰ Bean æ—¶ï¼Œè‹¥ç¬¬ä¸€è½®æ²¡æ‰¾åˆ°åˆ«çš„实现(例如 ruoyi-satisfaction
     * æ²¡æœ‰è¢«æ‰“进应用,或将来又有人用条件装配把它摘掉),会<b>把自引用当作兜底候选</b>返回。
     * é‚£æ · {@code delegateOrNull()} ä¼šæ‹¿åˆ° {@code this},随后
     * {@code getSatisfactionCategories()} é‡Œ {@code delegate.getXxx()} å°±å˜æˆæ— é™é€’å½’
     * ï¼ˆStackOverflowError)。这里显式过滤掉自己,把"没有实现"如实视为不可用。
     * <p>
     * âš ï¸ <b>本方法不能替代开关判断</b>:开关({@code sys_config: satisfaction.enabled})
     * å…³é—­æ—¶ {@link DefaultSatisfactionApi} ä¾ç„¶å­˜åœ¨ï¼Œæ‰€ä»¥è°ƒç”¨ç‚¹ä¸€å¾‹å…ˆåˆ¤ {@link #isEnabled()}。
     * <p>
     * ç”¨ {@code stream()} è€Œä¸æ˜¯ {@code getIfAvailable()}:后者在候选多于一个时抛
     * {@code NoUniqueBeanDefinitionException}(它继承自 {@code NoSuchBeanDefinitionException}),
     * æœ€ç»ˆä¼šè¢«åžæˆ null,掩盖"有人又加了一个实现"这种配置问题。
     */
    private SatisfactionApi delegateOrNull() {
        if (apiProvider == null) {
            return null;
        }
        try {
            return apiProvider.stream()
                    .filter(api -> api != this)
                    .findFirst()
                    .orElse(null);
        } catch (Exception e) {
            // å®¹å™¨çŠ¶æ€å¼‚å¸¸æ—¶æŒ‰"未启用"处理,绝不让调用方(登录、随访导出)因为满意度而失败
            log.warn("【满意度】获取内部实现失败,按未启用处理:{}", e.toString());
            return null;
        }
    }
    @Override
    public boolean isEnabled() {
        return satisfactionSwitch.isEnabled() && delegateOrNull() != null;
    }
    @Override
    public Map<String, Object> getSatisfactionCategories(String orgid) {
        if (!isEnabled()) {
            log.warn("【满意度】模块未启用(参数配置 satisfaction.enabled=false),getSatisfactionCategories è¿”回空结果。orgid={}", orgid);
            return Collections.emptyMap();
        }
        return delegateOrNull().getSatisfactionCategories(orgid);
    }
    @Override
    public SatisfactionExportBundle getQuestionOptionRatioExport(List<Long> subtaskIds, String questiontext) {
        if (!isEnabled()) {
            log.warn("【满意度】模块未启用(参数配置 satisfaction.enabled=false),getQuestionOptionRatioExport è¿”回空导出包。subtaskIds æ•°é‡={}",
                    subtaskIds == null ? 0 : subtaskIds.size());
            return SatisfactionExportBundle.empty();
        }
        return delegateOrNull().getQuestionOptionRatioExport(subtaskIds, questiontext);
    }
    @Override
    public boolean hasSatisfactionCategory(String orgid) {
        if (!isEnabled()) {
            return false;
        }
        return delegateOrNull().hasSatisfactionCategory(orgid);
    }
    @Override
    public Map<String, Object> getSatisfactionIndex(String type, String code, String orgid,
                                                    Long ivrCategoryid, Long svyCategoryid) {
        if (!isEnabled()) {
            log.warn("【满意度】模块未启用(参数配置 satisfaction.enabled=false),getSatisfactionIndex è¿”回全零结果。type={}, code={}",
                    type, code);
            // è¿”回与"启用时"完全相同的全零 Map(被迁移实现因原缺陷也恒定返回全零),
            // è¿™æ ·éšè®¿ç»Ÿè®¡é¡µåœ¨æ¨¡å—关闭前后的显示结果一致。
            return emptySatisfactionIndex();
        }
        return delegateOrNull().getSatisfactionIndex(type, code, orgid, ivrCategoryid, svyCategoryid);
    }
    /**
     * å…¨é›¶çš„æ»¡æ„åº¦æŒ‡æ•°ï¼ˆé”®åä¸Žç±»åž‹ä¸Žè¢«è¿ç§»å®žçŽ°ä¸€è‡´ï¼š{@code mydtmzl}(Integer)、
     * {@code mydtbl}(Integer)、{@code wcbl}(Double))
     */
    public static Map<String, Object> emptySatisfactionIndex() {
        Map<String, Object> map = new HashMap<>();
        map.put("mydtmzl", 0);
        map.put("mydtbl", 0);
        map.put("wcbl", 0.0);
        return map;
    }
    /**
     * å›ºå®šçš„ ObjectProvider å®žçŽ°ï¼šä»…ç”¨äºŽéž Spring çŽ¯å¢ƒ/测试。
     */
    private static final class FixedProvider implements ObjectProvider<SatisfactionApi> {
        private final SatisfactionApi api;
        private FixedProvider(SatisfactionApi api) {
            this.api = api;
        }
        @Override
        public SatisfactionApi getObject(Object... args) {
            return api;
        }
        @Override
        public SatisfactionApi getIfAvailable() {
            return api;
        }
        @Override
        public SatisfactionApi getIfUnique() {
            return api;
        }
        @Override
        public SatisfactionApi getObject() {
            return api;
        }
        @Override
        public java.util.stream.Stream<SatisfactionApi> stream() {
            return api == null ? java.util.stream.Stream.empty() : java.util.stream.Stream.of(api);
        }
    }
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/config/SatisfactionAvailabilityInterceptor.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,65 @@
package com.ruoyi.satisfaction.config;
import com.ruoyi.common.exception.ServiceException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
 * æ»¡æ„åº¦æ¨¡å—开关拦截器。
 * <p>
 * <b>作用</b>:当开关关闭({@code sys_config} ä¸­ {@code satisfaction.enabled=false})时,
 * å‘½ä¸­æ»¡æ„åº¦ç«¯ç‚¹çš„请求会被立即拦下并抛出 {@link ServiceException},
 * ç”± RuoYi çš„全局异常处理器返回<b>明确的业务错误提示</b>(而不是 404 æˆ–静默返回空数据)。
 * <p>
 * å¼€å…³å€¼ç”± {@link SatisfactionSwitch} åœ¨è¿è¡Œæ—¶è¯»å–({@code sys_config},10 ç§’进程内缓存),
 * å› æ­¤æ”¹å®Œå‚数设置<b>不需要重启</b>,最多 10 ç§’即生效。
 * <p>
 * <b>为什么不用 {@code @ConditionalOnProperty} è®© Controller ç›´æŽ¥ä¸æ³¨å†Œ</b>:
 * <ul>
 *   <li>那样调用方只会拿到 HTTP 404 â€”— è™½ç„¶ä¹Ÿæ˜¯å¤±è´¥ï¼Œä½†ä¸æ˜¯"明确错误",
 *       å‰ç«¯/外链无法区分"模块被关闭"和"地址写错了";</li>
 *   <li>而且 {@code @ConditionalOnProperty} åªåœ¨<b>启动时</b>求值一次,
 *       åšä¸åˆ°"改完参数配置就生效",与参数表开关的诉求冲突。</li>
 * </ul>
 * <p>
 * <b>为什么不会影响其它业务与启动</b>:
 * <ul>
 *   <li>本拦截器只登记在 <b>17 ä¸ªæ»¡æ„åº¦ç«¯ç‚¹</b>的精确路径上
 *       ï¼ˆè§ {@link SatisfactionWebConfig}),不会碰到随访/问卷/其它接口;</li>
 *   <li>模块关闭时,Controller ä¸Ž Service ä¾æ—§æ˜¯æ™®é€š Bean(启动不受影响);
 *       å…¶å®ƒæ¨¡å—通过 {@code SatisfactionFacade} å–数时拿到的是安全空值/全零,
 *       åªæœ‰"专门调满意度端点"的请求才会收到错误。</li>
 * </ul>
 */
@Slf4j
@Component
public class SatisfactionAvailabilityInterceptor implements HandlerInterceptor {
    /**
     * æ¨¡å—被关闭时返回给调用方的错误提示(前端可直接展示)
     */
    public static final String DISABLED_MESSAGE =
            "满意度功能未启用(参数配置 satisfaction.enabled=false),该接口不可用";
    private final SatisfactionSwitch satisfactionSwitch;
    public SatisfactionAvailabilityInterceptor(SatisfactionSwitch satisfactionSwitch) {
        this.satisfactionSwitch = satisfactionSwitch;
    }
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        if (!satisfactionSwitch.isEnabled()) {
            // request å…è®¸ä¸º null(单元测试直接调 preHandle æ—¶ä¸æž„造请求对象)
            String method = request == null ? "-" : request.getMethod();
            String uri = request == null ? "-" : request.getRequestURI();
            log.warn("【满意度】模块已关闭(参数配置 satisfaction.enabled=false),拒绝访问 {} {}", method, uri);
            throw new ServiceException(DISABLED_MESSAGE);
        }
        return true;
    }
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/config/SatisfactionProperties.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,49 @@
package com.ruoyi.satisfaction.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
 * æ»¡æ„åº¦æ¨¡å—配置(前缀 {@code satisfaction})。
 * <p>
 * âš ï¸ <b>这不是主开关</b>。满意度模块的开关<b>存在 {@code sys_config} å‚数配置表里</b>:
 * <pre>
 * å‚数键名:satisfaction.enabled
 * å‚数键值:true(启用,默认)/ false(关闭)
 * </pre>
 * è§ {@link SatisfactionSwitch}。本类的 {@link #isEnabled()} åªä½œä¸º<b>兜底</b>:
 * å½“ {@code sys_config} é‡Œæ²¡æœ‰è¿™ä¸€è¡Œã€æˆ–非 Spring çŽ¯å¢ƒå–ä¸åˆ°é…ç½®æœåŠ¡æ—¶æ‰ä¼šç”¨åˆ°ã€‚
 * <p>
 * å¦‚需在 yml ä¸­å…œåº•(一般不必要,正常应在参数设置页维护):
 * <pre>
 * satisfaction:
 *   enabled: false
 * </pre>
 * <p>
 * <b>关闭后的行为</b>:
 * <ul>
 *   <li>本模块的 <b>17 ä¸ªæ»¡æ„åº¦ç«¯ç‚¹</b>({@code /smartor/satisfaction/**}、
 *       {@code /smartor/serviceSubtaskDetail} ä¸‹çš„ 5 ä¸ªã€{@code /smartor/trace/getSatisfactionExceptConfigQuestion})
 *       ä»ä¼šæ³¨å†Œï¼Œä½†è¯·æ±‚会在进入方法前被
 *       {@link SatisfactionAvailabilityInterceptor} æ‹¦ä¸‹å¹¶æŠ›å‡º
 *       {@code ServiceException("满意度功能未启用…")} â€”— è°ƒç”¨æ–¹æ‹¿åˆ°çš„æ˜¯
 *       <b>明确的业务错误提示</b>,而不是 404,便于区分"模块被关闭"与"地址写错";</li>
 *   <li>{@code com.ruoyi.satisfaction.api.SatisfactionFacade} ä»ç„¶å­˜åœ¨ï¼Œ
 *       å…¶å®ƒæ¨¡å—(随访导出、随访统计、登录链路)通过它取数时会拿到安全空值/全零,
 *       <b>不影响调用方启动与主流程</b>;</li>
 *   <li>随访、问卷、电话等其它业务完全不受影响(拦截器只挂在上述 17 ä¸ªè·¯å¾„上)。</li>
 * </ul>
 */
@Data
@Component
@ConfigurationProperties(prefix = "satisfaction")
public class SatisfactionProperties {
    /**
     * æ˜¯å¦å¯ç”¨æ»¡æ„åº¦æ¨¡å—(默认启用)。
     * <p>
     * ä»…当 {@code sys_config} ä¸­æ²¡æœ‰ {@code satisfaction.enabled} æ—¶ç”Ÿæ•ˆï¼Œè§ {@link SatisfactionSwitch}。
     */
    private boolean enabled = true;
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/config/SatisfactionSwitch.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,206 @@
package com.ruoyi.satisfaction.config;
import com.ruoyi.common.core.service.IConfigService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
 * æ»¡æ„åº¦æ¨¡å—<b>总开关</b>:值存放在 {@code sys_config} å‚数配置表中,<b>运行时读取、可热切换</b>。
 *
 * <h3>参数配置</h3>
 * <pre>
 * å‚数键名:satisfaction.enabled
 * å‚数键值:true  = å¯ç”¨ï¼ˆé»˜è®¤ï¼‰
 *          false = å…³é—­
 * </pre>
 * ä½ç½®ï¼šç³»ç»Ÿç®¡ç† â†’ å‚数设置 â†’ æ–°å¢ž/修改。
 * <pre>
 * insert into sys_config (config_name, config_key, config_value, config_type, create_by, create_time, remark)
 * values ('满意度模块开关', 'satisfaction.enabled', 'true', 'Y', 'admin', sysdate(), 'true启用,false关闭(默认true)');
 * </pre>
 *
 * <h3>取值优先级</h3>
 * <ol>
 *   <li>{@code sys_config} é‡Œ {@code satisfaction.enabled} çš„值(<b>主用</b>,
 *       æ¥è‡ªæœ¬é¡¹ç›® {@code IConfigService} æŽ¥å£ï¼Œå®žçŽ°åœ¨ ruoyi-system çš„ {@code SysConfigServiceImpl});</li>
 *   <li>{@link SatisfactionProperties#isEnabled()}(<b>兜底</b>:仅当 sys_config é‡Œæ²¡æœ‰è¿™ä¸€è¡Œã€
 *       æˆ–非 Spring çŽ¯å¢ƒå–ä¸åˆ°é…ç½®æœåŠ¡æ—¶ä½¿ç”¨ï¼Œé»˜è®¤ true)。</li>
 * </ol>
 *
 * <h3>为什么默认 true</h3>
 * åŽ†å²è¡Œä¸ºå°±æ˜¯"默认开启"。参数行缺失时按 true å¤„理,保证升级到本版本后
 * æ»¡æ„åº¦åŠŸèƒ½ä¸ä¼šå› ä¸ºå°‘äº†ä¸€è¡Œé…ç½®è€Œçªç„¶ä¸å¯ç”¨ï¼ˆè¦å…³é—­å¿…é¡»æ˜¾å¼é…ç½® false)。
 *
 * <h3>为什么读不到配置时也按 true(fail-open)</h3>
 * è¯» {@code sys_config} éœ€è¦èµ°æ•°æ®åº“,数据库抖动时如果 fail-closed ä¼šæŠŠæ•´ä¸ªæ»¡æ„åº¦ä¸šåŠ¡æ‰“æ­»ã€‚
 * è¿™é‡Œé€‰æ‹©ä¸Ž"参数行缺失"一致的处理:只打 WARN å¹¶æ”¾è¡Œï¼Œå®å¯å¤šå¼€ä¸€ä¼šå„¿ï¼Œä¹Ÿä¸åšæ•°æ®åº“抖动放大器。
 *
 * <h3>为什么有 10 ç§’缓存</h3>
 * {@code SysConfigServiceImpl#selectConfigByKey} å½“前<b>每次都查库</b>(Redis ç¼“存那段被注释掉了),
 * è€Œæœ¬å¼€å…³åœ¨æ¯æ¬¡æ»¡æ„åº¦è¯·æ±‚、每次登录取分类时都会被读到。这里加一个进程内 TTL ç¼“存,
 * æŠŠæŸ¥åº“降到"每个实例每 10 ç§’最多一次"。代价是参数改动最多 10 ç§’生效(多实例部署时各自计时)。
 */
@Slf4j
@Component
public class SatisfactionSwitch {
    /**
     * sys_config é‡Œçš„参数键名
     */
    public static final String CONFIG_KEY = "satisfaction.enabled";
    /**
     * è¿›ç¨‹å†…缓存有效期(毫秒)
     */
    private static final long CACHE_TTL_MILLIS = 10_000L;
    private final SatisfactionProperties satisfactionProperties;
    /**
     * å‚数配置服务(ruoyi-common çš„æŽ¥å£ï¼Œå®žçŽ°åœ¨ ruoyi-system)。
     * <p>
     * ç”¨ {@code ObjectProvider} æƒ°æ€§å–:本模块只依赖 ruoyi-common,
     * åœ¨å•元测试/独立部署等"容器里没有实现"的场景下不能因此启动失败。
     */
    private final ObjectProvider<IConfigService> configServiceProvider;
    /**
     * æœ€è¿‘一次解析结果(含是否来自 sys_config)
     */
    private volatile Boolean cachedEnabled;
    /**
     * æœ€è¿‘一次解析的时间戳
     */
    private volatile long cachedAt;
    /**
     * Spring ä½¿ç”¨çš„æž„造器。
     * <p>
     * âš ï¸ <b>必须显式标注 {@code @Autowired}</b>:本类有两个公开构造器
     * ï¼ˆå¦ä¸€ä¸ªç»™æµ‹è¯•/非 Spring çŽ¯å¢ƒç”¨ï¼‰ï¼Œä¸æ ‡æ³¨çš„è¯ Spring ä¼šå› "构造器不唯一"
     * é€€å›žæŸ¥æ‰¾æ— å‚构造器,启动即报 {@code No default constructor found}。
     */
    @Autowired
    public SatisfactionSwitch(SatisfactionProperties satisfactionProperties,
                              ObjectProvider<IConfigService> configServiceProvider) {
        this.satisfactionProperties = satisfactionProperties;
        this.configServiceProvider = configServiceProvider;
    }
    /**
     * ä¾›<b>测试/非 Spring çŽ¯å¢ƒ</b>直接构造:{@code configValue} å°±æ˜¯ sys_config é‡Œçš„键值,
     * ä¼  {@code null} è¡¨ç¤º"参数行不存在"(此时回落到 {@link SatisfactionProperties})。
     */
    public SatisfactionSwitch(SatisfactionProperties satisfactionProperties, final String configValue) {
        this(satisfactionProperties, new FixedProvider(configValue));
        // éž Spring çŽ¯å¢ƒä¸‹æ²¡æœ‰"改配置后等 10 ç§’"的概念,直接禁用缓存,保证断言即时可见
        this.cachedEnabled = resolve();
        this.cachedAt = System.currentTimeMillis() + CACHE_TTL_MILLIS * 1000L;
    }
    /**
     * æ»¡æ„åº¦åŠŸèƒ½æ˜¯å¦å¯ç”¨ï¼ˆè¿è¡Œæ—¶çƒ­è¯»ï¼Œæœ€å¤š 10 ç§’延迟)
     */
    public boolean isEnabled() {
        long now = System.currentTimeMillis();
        Boolean cached = this.cachedEnabled;
        if (cached != null && now - this.cachedAt < CACHE_TTL_MILLIS) {
            return cached;
        }
        boolean resolved = resolve();
        this.cachedEnabled = resolved;
        this.cachedAt = now;
        return resolved;
    }
    /**
     * æ¸…掉进程内缓存,下次调用立即重新读 sys_config(供测试/需要立即生效的场景)
     */
    public void invalidateCache() {
        this.cachedEnabled = null;
    }
    /**
     * è§£æžå½“前开关值:sys_config ä¼˜å…ˆï¼Œå…¶æ¬¡ yml å…œåº•
     */
    private boolean resolve() {
        String value = readFromSysConfig();
        if (value != null) {
            if (!"true".equalsIgnoreCase(value) && !"false".equalsIgnoreCase(value)) {
                log.warn("【满意度】sys_config ä¸­ {} çš„值不是 true/false(实际=「{}」),将按「不是 false å³å¯ç”¨ã€å¤„理",
                        CONFIG_KEY, value);
            }
            boolean enabled = !"false".equalsIgnoreCase(value);
            log.debug("【满意度】开关取自 sys_config:{}={} -> enabled={}", CONFIG_KEY, value, enabled);
            return enabled;
        }
        boolean enabled = satisfactionProperties.isEnabled();
        log.debug("【满意度】sys_config ä¸­æœªé…ç½® {},回落 satisfaction.enabled={}", CONFIG_KEY, enabled);
        return enabled;
    }
    /**
     * è¯» sys_config çš„原始键值;没有实现、没有这一行、或读库失败都返回 {@code null}
     */
    private String readFromSysConfig() {
        if (configServiceProvider == null) {
            return null;
        }
        try {
            IConfigService configService = configServiceProvider.stream().findFirst().orElse(null);
            if (configService == null) {
                return null;
            }
            String value = configService.selectConfigByKey(CONFIG_KEY);
            if (value == null || value.trim().isEmpty()) {
                return null;
            }
            return value.trim();
        } catch (Exception e) {
            // å®¹å™¨æ²¡æœ‰å®žçŽ°ã€æ•°æ®æºå¼‚å¸¸ã€SQL æŠ¥é”™â€¦â€¦ä¸€å¾‹æŒ‰"参数缺失"处理(fail-open),绝不影响主流程
            log.warn("【满意度】读取 sys_config çš„ {} å¤±è´¥ï¼Œæœ¬æ¬¡æŒ‰æœªé…ç½®å¤„理(fail-open):{}",
                    CONFIG_KEY, e.toString());
            return null;
        }
    }
    /**
     * å›ºå®šå€¼çš„ {@code ObjectProvider}:只用于非 Spring çŽ¯å¢ƒ/测试
     */
    private static final class FixedProvider implements ObjectProvider<IConfigService> {
        private final IConfigService configService;
        private FixedProvider(final String configValue) {
            this.configService = configValue == null ? null : configKey -> configValue;
        }
        @Override
        public IConfigService getObject(Object... args) {
            return configService;
        }
        @Override
        public IConfigService getIfAvailable() {
            return configService;
        }
        @Override
        public IConfigService getIfUnique() {
            return configService;
        }
        @Override
        public IConfigService getObject() {
            return configService;
        }
        @Override
        public java.util.stream.Stream<IConfigService> stream() {
            return configService == null ? java.util.stream.Stream.empty() : java.util.stream.Stream.of(configService);
        }
    }
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/config/SatisfactionWebConfig.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,81 @@
package com.ruoyi.satisfaction.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
 * æ»¡æ„åº¦æ¨¡å—çš„ MVC é…ç½®ï¼šæŠŠ {@link SatisfactionAvailabilityInterceptor} åªæŒ‚到
 * <b>21 ä¸ªæ»¡æ„åº¦æŽ¥å£ä¸­å±žäºŽæœ¬æ¨¡å—çš„ 17 ä¸ªç«¯ç‚¹</b>上。
 * <p>
 * <b>为什么逐个列路径而不是用 {@code /smartor/**} ä¹‹ç±»çš„前缀</b>:
 * <ul>
 *   <li>{@code /smartor/serviceSubtaskDetail/**} ä¸‹æ—¢æœ‰æ»¡æ„åº¦ç«¯ç‚¹ï¼ˆ5 ä¸ªï¼‰ï¼Œ
 *       ä¹Ÿæœ‰éšè®¿æ˜Žç»†ç«¯ç‚¹ï¼ˆå…¶å®ƒï¼‰ï¼Œç”¨å‰ç¼€ä¼šè¯¯ä¼¤ï¼›</li>
 *   <li>{@code /smartor/trace/**} ä¸‹åªæœ‰ 1 ä¸ªæ»¡æ„åº¦ç«¯ç‚¹ï¼Œå…¶ä½™æ˜¯å¼‚常跟踪业务;</li>
 *   <li>因此这里显式枚举,保证"只拦满意度",绝不影响其它业务。</li>
 * </ul>
 * å¦å¤– 4 ä¸ªä½¿ç”¨æ»¡æ„åº¦æ•°æ®çš„随访接口({@code patItemExport} / {@code getSfStatisticsJoy} /
 * {@code getSfStatisticsJoydetails} / {@code saveMYDQuestionAnswer})<b>不在这里</b>:
 * å®ƒä»¬å±žäºŽéšè®¿ä¸šåŠ¡ï¼Œæ¨¡å—å…³é—­æ—¶å¿…é¡»ç»§ç»­å¯ç”¨ï¼ˆæ»¡æ„åº¦éƒ¨åˆ†é™çº§ä¸ºç©º/全零)。
 */
@Configuration
public class SatisfactionWebConfig implements WebMvcConfigurer {
    /**
     * æ‚£è€…满意度(11 ä¸ªï¼ŒåŽŸ {@code PatSatisfactionController})
     */
    private static final String PAT_SATISFACTION_PATTERN = "/smartor/satisfaction/**";
    /**
     * çœç«‹åŒå¾· / é—¨è¯Šæ»¡æ„åº¦ç‹¬å çš„ 5 ä¸ªç«¯ç‚¹ï¼ˆåŽŸæŒ‚åœ¨éšè®¿æ˜Žç»† Controller ä¸Šï¼‰
     */
    private static final List<String> SLTD_ENDPOINTS = Arrays.asList(
            "/smartor/serviceSubtaskDetail/sltdMydTotalByScore",
            "/smartor/serviceSubtaskDetail/statQuestionOptionByKsOrBq",
            "/smartor/serviceSubtaskDetail/mydTrend",
            "/smartor/serviceSubtaskDetail/sltdMydTotalByDimension",
            "/smartor/serviceSubtaskDetail/mzMydScoreRank");
    /**
     * æ»¡æ„åº¦å¼‚常配置题目(1 ä¸ªï¼ŒåŽŸæŒ‚åœ¨éšè®¿å¼‚å¸¸è·Ÿè¸ª Controller ä¸Šï¼‰
     */
    private static final String EXCEPT_CONFIG_ENDPOINT = "/smartor/trace/getSatisfactionExceptConfigQuestion";
    @Autowired
    private SatisfactionAvailabilityInterceptor satisfactionAvailabilityInterceptor;
    /**
     * ä¾›æµ‹è¯•/非 Spring çŽ¯å¢ƒç›´æŽ¥æž„é€ 
     */
    public SatisfactionWebConfig() {
    }
    public SatisfactionWebConfig(SatisfactionAvailabilityInterceptor interceptor) {
        this.satisfactionAvailabilityInterceptor = interceptor;
    }
    /**
     * æœ¬æ¨¡å—需要"关闭即明确报错"的 17 ä¸ªç«¯ç‚¹ï¼ˆä¾›æ ¡éªŒè„šæœ¬/单元测试断言)
     */
    public static List<String> guardedPathPatterns() {
        List<String> patterns = new ArrayList<>();
        patterns.add(PAT_SATISFACTION_PATTERN);
        patterns.addAll(SLTD_ENDPOINTS);
        patterns.add(EXCEPT_CONFIG_ENDPOINT);
        return patterns;
    }
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(satisfactionAvailabilityInterceptor)
                .addPathPatterns(guardedPathPatterns())
                // æ‹¦æˆªå™¨æœ¬èº«å¾ˆè½»ï¼ˆåªè¯»ä¸€ä¸ª boolean),order ç»™ä¸ªå°å€¼è®©å®ƒåœ¨ä¸šåŠ¡æ‹¦æˆªå™¨ä¹‹å‰æ‰§è¡Œ
                .order(-100);
    }
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/controller/PatSatisfactionController.java
ÎļþÃû´Ó ruoyi-admin/src/main/java/com/ruoyi/web/controller/smartor/PatSatisfactionController.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.ruoyi.web.controller.smartor;
package com.ruoyi.satisfaction.controller;
import java.util.List;
import java.util.Map;
@@ -8,8 +8,8 @@
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.utils.PageUtils;
import com.smartor.domain.VO.PatSatisfactionReqVO;
import com.smartor.domain.VO.PatSatisfactionResVO;
import com.ruoyi.satisfaction.domain.vo.PatSatisfactionReqVO;
import com.ruoyi.satisfaction.domain.vo.PatSatisfactionResVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
@@ -27,13 +27,23 @@
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.smartor.domain.PatSatisfaction;
import com.smartor.service.IPatSatisfactionService;
import com.ruoyi.satisfaction.domain.PatSatisfaction;
import com.ruoyi.satisfaction.service.IPatSatisfactionService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
/**
 * æ‚£è€…满意度Controller
 * æ‚£è€…满意度 Controller(已迁入 ruoyi-satisfaction æ¨¡å—)。
 * <p>
 * âš ï¸ <b>URL ä¸Žå‡ºå…¥å‚一字未改</b>:{@code @RequestMapping("/smartor/satisfaction")} ä¸Žå…¨éƒ¨
 * {@code @PostMapping}/{@code @GetMapping} è·¯å¾„保持原样,前端无需任何改动。
 * è¿ç§»åªæ”¹äº†ç±»æ‰€åœ¨çš„ Maven æ¨¡å—与包名。
 * <p>
 * <b>开关</b>:本 Controller å§‹ç»ˆæ³¨å†Œï¼ˆä¸éš {@code satisfaction.enabled} æ¶ˆå¤±ï¼‰ã€‚
 * æ¨¡å—被关闭时由 {@code SatisfactionAvailabilityInterceptor}
 * ï¼ˆè§ {@code config/SatisfactionWebConfig})在进入方法前抛出 {@code ServiceException},
 * è°ƒç”¨æ–¹ä¼šæ‹¿åˆ°<b>明确的错误提示</b>(而不是 404,能区分"模块被关闭"与"地址写错")。
 * æ‹¦æˆªå™¨åªæŒ‚在本模块的 17 ä¸ªæ»¡æ„åº¦ç«¯ç‚¹ä¸Šï¼Œéšè®¿/问卷等其它业务完全不受影响。
 *
 * @author lihu
 * @date 2025-06-30
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/controller/SatisfactionExceptConfigController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,64 @@
package com.ruoyi.satisfaction.controller;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.satisfaction.domain.vo.SatisfactionExceptConfigQuestionReqVO;
import com.ruoyi.satisfaction.service.SatisfactionSubtaskService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
 * æ»¡æ„åº¦å¼‚常配置题目 Controller(已迁入 ruoyi-satisfaction æ¨¡å—)。
 * <p>
 * âš ï¸ <b>URL ä¸Žå‡ºå…¥å‚一字未改</b>:该端点原本挂在
 * {@code com.ruoyi.web.controller.smartor.ServiceSubtaskDetailTraceController}
 * ï¼ˆç±»çº§ {@code @RequestMapping("/smartor/trace")})下,迁移后
 * <b>类级路径与原方法路径都保持原样</b>,最终对外地址仍是
 * {@code POST /smartor/trace/getSatisfactionExceptConfigQuestion},前端无需任何改动。
 * <p>
 * <b>为什么类级路径要沿用 {@code /smartor/trace}:</b>
 * è¯¥ç«¯ç‚¹çš„业务是"随访任务异常跟踪页面里选择满意度异常题目",
 * ä½†å®ƒçš„æ•°æ®æ¥æºä¸Žç­›é€‰é€»è¾‘(满意度分类 + æ¨¡æ¿é¢˜ç›®ï¼‰å®Œå…¨å±žäºŽæ»¡æ„åº¦åŸŸï¼Œ
 * æŒ‰æ¨¡å—化要求必须搬入本模块。为了做到"URL ä¸å˜",这里刻意沿用原类级前缀,
 * è€Œä¸æ˜¯æ”¹åˆ° {@code /smartor/satisfaction} ä¸‹ã€‚
 * <p>
 * <b>开关</b>:本 Controller å§‹ç»ˆæ³¨å†Œï¼ˆä¸éš {@code satisfaction.enabled} æ¶ˆå¤±ï¼‰ã€‚
 * æ¨¡å—被关闭时由 {@code SatisfactionAvailabilityInterceptor}
 * ï¼ˆè§ {@code config/SatisfactionWebConfig})在进入方法前抛出 {@code ServiceException},
 * è°ƒç”¨æ–¹ä¼šæ‹¿åˆ°<b>明确的错误提示</b>(而不是 404)。
 * æ³¨æ„ï¼šåŒä¸€å‰ç¼€ {@code /smartor/trace} ä¸‹çš„<b>异常跟踪</b>端点不受影响
 * â€”— æ‹¦æˆªå™¨åªæŒ‚在本类的这 1 ä¸ªè·¯å¾„上。
 */
@Slf4j
@Api(description = "满意度异常配置题目")
@RestController
@RequestMapping("/smartor/trace")
public class SatisfactionExceptConfigController extends BaseController {
    @Autowired
    private SatisfactionSubtaskService satisfactionSubtaskService;
    /**
     * èŽ·å–æ»¡æ„åº¦å¼‚å¸¸é…ç½®é¢˜ç›®
     * <p>
     * è¿ç§»è¯´æ˜Žï¼šå…¥å‚、返回结构、日志格式与被迁移的实现完全一致;
     * åŽŸå®žçŽ°é‡Œçš„æŸ¥è¯¢é€»è¾‘æ•´æ®µæ¬åˆ°äº†
     * {@link SatisfactionSubtaskService#getSatisfactionExceptConfigQuestion}。
     */
    @ApiOperation("获取满意度异常配置题目")
    @PostMapping("/getSatisfactionExceptConfigQuestion")
    public Map<String, Object> getSatisfactionExceptConfigQuestion(
            @RequestBody SatisfactionExceptConfigQuestionReqVO questionReqVO) {
        log.info("获取满意度异常配置题目的入参: {}", questionReqVO);
        Map<String, Object> satisfactionExceptConfigQuestion =
                satisfactionSubtaskService.getSatisfactionExceptConfigQuestion(questionReqVO);
        return satisfactionExceptConfigQuestion;
    }
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/controller/SltdSatisfactionController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,172 @@
package com.ruoyi.satisfaction.controller;
import com.ruoyi.common.annotation.AddOrgId;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.satisfaction.domain.ServiceSubtaskDetailRatioExport;
import com.ruoyi.satisfaction.domain.dto.QuestionResultDTO;
import com.ruoyi.satisfaction.domain.row.SatisfactionSubtaskQuery;
import com.ruoyi.satisfaction.domain.row.SatisfactionSubtaskRef;
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.vo.SltdMydTotalVO;
import com.ruoyi.satisfaction.service.SatisfactionSubtaskService;
import com.ruoyi.satisfaction.service.strategy.SatisfactionScoreStrategyResolver;
import com.ruoyi.satisfaction.service.strategy.SatisfactionSites;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
 * çœç«‹åŒå¾· / é—¨è¯Šæ»¡æ„åº¦ç»Ÿè®¡ Controller(已迁入 ruoyi-satisfaction æ¨¡å—)。
 * <p>
 * âš ï¸ <b>URL ä¸Žå‡ºå…¥å‚一字未改</b>:这些端点原本挂在
 * {@code ServiceSubtaskDetailController}({@code /smartor/serviceSubtaskDetail})下,
 * è¿ç§»åŽ {@code @RequestMapping} ä¸Žå…¨éƒ¨ {@code @PostMapping} è·¯å¾„保持原样,前端无需任何改动。
 * <p>
 * è¿ç§»æ—¶å”¯ä¸€è°ƒæ•´çš„一处:{@code sltdMydTotalByScore} åŽŸæœ¬è°ƒç”¨
 * {@code serviceSubtaskService.getServiceSubtasks(...)}(随访主干 Service,不能搬),
 * çŽ°æ”¹ä¸ºè°ƒç”¨æ»¡æ„åº¦æ¨¡å—è‡ªå¸¦çš„ {@link SatisfactionSubtaskService#getMydSubtaskRefs};
 * æŸ¥è¯¢æ¡ä»¶ä¸ŽåŽç»­ç»Ÿè®¡å£å¾„逐字保持一致(见各方法内注释)。
 * <p>
 * <b>开关</b>:本 Controller å§‹ç»ˆæ³¨å†Œï¼ˆä¸éš {@code satisfaction.enabled} æ¶ˆå¤±ï¼‰ã€‚
 * æ¨¡å—被关闭时由 {@code SatisfactionAvailabilityInterceptor}
 * ï¼ˆè§ {@code config/SatisfactionWebConfig})在进入方法前抛出 {@code ServiceException},
 * è°ƒç”¨æ–¹ä¼šæ‹¿åˆ°<b>明确的错误提示</b>(而不是 404)。
 * æ³¨æ„ï¼šåŒä¸€å‰ç¼€ {@code /smartor/serviceSubtaskDetail} ä¸‹çš„<b>随访明细</b>端点不受影响
 * â€”— æ‹¦æˆªå™¨åªæŒ‚在本类的 5 ä¸ªæ»¡æ„åº¦è·¯å¾„上。
 */
@Api(description = "省立同德满意度统计")
@RestController
@RequestMapping("/smartor/serviceSubtaskDetail")
public class SltdSatisfactionController extends BaseController {
    /**
     * ç«™ç‚¹å£å¾„策略解析器:综合满意度得分按站点(省立同德)取策略计算,
     * ä¸å†æŠŠç®—法与常量写在 Controller é‡Œã€‚
     */
    @Autowired
    private SatisfactionScoreStrategyResolver satisfactionScoreStrategyResolver;
    @Autowired
    private SatisfactionSubtaskService satisfactionSubtaskService;
    // ================================================================ ç«¯ç‚¹ï¼ˆURL åŽŸæ ·ï¼‰
    /**
     * çœç«‹åŒå¾·æ»¡æ„åº¦æ•°æ®ç»Ÿè®¡ï¼ˆæŒ‰é¢˜ç›®ç»Ÿè®¡é€‰é¡¹æ•°é‡/占比,并给出该题的综合得分)
     * <p>
     * ç»¼åˆæ»¡æ„åº¦ = (很满意项数×1 + æ»¡æ„é¡¹æ•°Ã—0.8 + ä¸€èˆ¬é¡¹æ•°Ã—0.6 + ä¸æ»¡æ„é¡¹æ•°Ã—0.4 + å¾ˆä¸æ»¡æ„é¡¹æ•°Ã—0.2)
     * / (很满意项数 + æ»¡æ„é¡¹æ•° + ä¸€èˆ¬é¡¹æ•° + ä¸æ»¡æ„é¡¹æ•° + å¾ˆä¸æ»¡æ„é¡¹æ•°) Ã— 100%;
     * ã€Œæœªç»åŽ†ã€ã€Œä¸é€‚ç”¨ã€ç­‰ä¸åœ¨äº”æ¡£é‡Œçš„é€‰é¡¹æ—¢ä¸è¿›åˆ†å­ä¹Ÿä¸è¿›åˆ†æ¯ã€‚
     */
    @ApiOperation("省立同德满意度数据统计")
    @AddOrgId(field = "orgid", paramIndex = 0, campusField = "campusid")
    @PostMapping("/sltdMydTotalByScore")
    public Map<String, Object> sltdMydTotalByScore(@RequestBody SltdMydTotalVO sltdMydTotalVO) {
        List<SatisfactionSubtaskRef> subtaskRefs = null;
        if (CollectionUtils.isNotEmpty(sltdMydTotalVO.getServiceTypeList())) {
            //serviceType可能会出现多个,不想多写方法了,一个一个查询吧
            for (String st : sltdMydTotalVO.getServiceTypeList()) {
                SatisfactionSubtaskQuery query = new SatisfactionSubtaskQuery();
                query.setServiceType(st);
                query.setEndOutHospTime(sltdMydTotalVO.getEndOutHospTime());
                query.setStartOutHospTime(sltdMydTotalVO.getStartOutHospTime());
                query.setDeptOrDistrict(sltdMydTotalVO.getDeptOrDistrict());
                query.setLeavehospitaldistrictcodes(sltdMydTotalVO.getLeavehospitaldistrictcodes());
                query.setLeaveldeptcodes(sltdMydTotalVO.getLeaveldeptcodes());
                //机构/院区由 @AddOrgId æ³¨å…¥ï¼Œè½åˆ°æŸ¥è¯¢æ¡ä»¶ä¸Šï¼Œé¿å…è·¨æœºæž„取数
                query.setOrgid(sltdMydTotalVO.getOrgid());
                query.setCampusid(sltdMydTotalVO.getCampusid());
                List<SatisfactionSubtaskRef> refs = satisfactionSubtaskService.getMydSubtaskRefs(query);
                if (CollectionUtils.isNotEmpty(subtaskRefs)) {
                    subtaskRefs.addAll(refs);
                } else {
                    if (CollectionUtils.isEmpty(subtaskRefs)) subtaskRefs = refs;
                }
            }
        }
        // æŒ‰ questiontext åˆ†ç»„后转换为 QuestionResultVO
        List<QuestionResultDTO> result = new ArrayList<>();
        if (CollectionUtils.isNotEmpty(subtaskRefs)) {
            List<ServiceSubtaskDetailRatioExport> detailList =
                    satisfactionSubtaskService.getDetailRatio(sltdMydTotalVO, subtaskRefs);
            if (detailList == null) {
                //serviceType ç»„合不在支持范围内(如同时含随访 2 å’Œæ»¡æ„åº¦ 6/14),给明确提示而不是悄悄返回空数组
                return error("当前 serviceType ç»„合暂不支持统计问题占比,请分开查询(仅支持随访 2 æˆ–满意度 6/14)");
            }
            if (CollectionUtils.isNotEmpty(detailList)) {
                Map<String, List<ServiceSubtaskDetailRatioExport>> groupedMap =
                        detailList.stream().collect(Collectors.groupingBy(ServiceSubtaskDetailRatioExport::getQuestiontext));
                groupedMap.forEach((questionText, exportList) -> {
                    QuestionResultDTO questionResultDTO = new QuestionResultDTO();
                    questionResultDTO.setQeustionText(questionText);
                    questionResultDTO.setSubtaskDetailRatioExportList(exportList);
                    //综合得分:(很满意×1 + æ»¡æ„Ã—0.8 + ä¸€èˆ¬Ã—0.6 + ä¸æ»¡æ„Ã—0.4 + å¾ˆä¸æ»¡æ„Ã—0.2) / äº”档项数之和 Ã— 100%
                    questionResultDTO.setAvgScore(calcMydScore(exportList));
                    result.add(questionResultDTO);
                });
            }
        }
        return success(result);
    }
    @ApiOperation("省立同德满意度数据统计通过科室或病区")
    @PostMapping("/statQuestionOptionByKsOrBq")
    public Map<String, Object> statQuestionOptionByKsOrBq(@RequestBody QuestionCountKsOrBqVO questionResultVO) {
        return success(satisfactionSubtaskService.statQuestionOptionByKsOrBq(questionResultVO));
    }
    @ApiOperation("省立同德患者满意度趋势")
    @PostMapping("/mydTrend")
    public Map<String, Object> mydTrend(@RequestBody MydTrendReqVO mydTrendReqVO) {
        return success(satisfactionSubtaskService.mydTrend(mydTrendReqVO));
    }
    @ApiOperation("省立同德患者满意度维护")
    @PostMapping("/sltdMydTotalByDimension")
    public Map<String, Object> sltdMydTotalByDimension(@RequestBody MydTrendReqVO mydTrendReqVO) {
        return success(satisfactionSubtaskService.sltdMydTotalByDimension(mydTrendReqVO));
    }
    @ApiOperation("门诊满意度综合得分排名(按科室或病区)")
    @AddOrgId(field = "orgid", paramIndex = 0, campusField = "campusid")
    @PostMapping("/mzMydScoreRank")
    public Map<String, Object> mzMydScoreRank(@RequestBody MzMydScoreRankVO mzMydScoreRankVO) {
        return success(satisfactionSubtaskService.mzMydScoreRank(mzMydScoreRankVO));
    }
    // ================================================================ ç»¼è¯„计算(改走站点策略)
    /**
     * è®¡ç®—一道题的满意度综合得分(百分制,保留两位小数)。
     * <p>
     * <b>策略化改造</b>:算法本身已原样搬到
     * {@link com.ruoyi.satisfaction.service.strategy.SltdSatisfactionScoreStrategy}
     * ï¼ˆçœç«‹åŒå¾·å£å¾„),本方法只负责"按站点取策略 + æŠŠ null å‘ˆçŽ°ä¸º 0.00"。
     * è®¡ç®—过程与返回值与迁移前<b>完全一致</b>。
     *
     * @param exportList åŒä¸€é“题目的选项统计明细
     * @return ç™¾åˆ†åˆ¶ç»¼åˆå¾—分,无有效项数时返回 "0.00"
     */
    private String calcMydScore(List<ServiceSubtaskDetailRatioExport> exportList) {
        BigDecimal score = satisfactionScoreStrategyResolver
                .resolve(SatisfactionSites.SLTD)
                .comprehensiveScore(exportList);
        return score == null ? "0.00" : score.toPlainString();
    }
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/PatSatisfaction.java
ÎļþÃû´Ó smartor/src/main/java/com/smartor/domain/PatSatisfaction.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.smartor.domain;
package com.ruoyi.satisfaction.domain;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.annotation.Excel;
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/ServiceSubtaskDetailRatioExport.java
ÎļþÃû´Ó smartor/src/main/java/com/smartor/domain/ServiceSubtaskDetailRatioExport.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.smartor.domain;
package com.ruoyi.satisfaction.domain;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.annotation.Excel;
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/dto/MydTrendReqDTO.java
ÎļþÃû´Ó smartor/src/main/java/com/smartor/domain/DTO/MydTrendReqDTO.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.smartor.domain.DTO;
package com.ruoyi.satisfaction.domain.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/dto/MzMydScoreRankDTO.java
ÎļþÃû´Ó smartor/src/main/java/com/smartor/domain/DTO/MzMydScoreRankDTO.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.smartor.domain.DTO;
package com.ruoyi.satisfaction.domain.dto;
import com.ruoyi.common.core.domain.BaseEntity;
import io.swagger.annotations.ApiModel;
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/dto/QuestionCountKsOrBqDTO.java
ÎļþÃû´Ó smartor/src/main/java/com/smartor/domain/DTO/QuestionCountKsOrBqDTO.java ÐÞ¸Ä
@@ -1,7 +1,7 @@
package com.smartor.domain.DTO;
package com.ruoyi.satisfaction.domain.dto;
import com.ruoyi.common.core.domain.BaseEntity;
import com.smartor.domain.ServiceSubtaskDetailRatioExport;
import com.ruoyi.satisfaction.domain.ServiceSubtaskDetailRatioExport;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/dto/QuestionResultDTO.java
ÎļþÃû´Ó smartor/src/main/java/com/smartor/domain/DTO/QuestionResultDTO.java ÐÞ¸Ä
@@ -1,7 +1,7 @@
package com.smartor.domain.DTO;
package com.ruoyi.satisfaction.domain.dto;
import com.ruoyi.common.core.domain.BaseEntity;
import com.smartor.domain.ServiceSubtaskDetailRatioExport;
import com.ruoyi.satisfaction.domain.ServiceSubtaskDetailRatioExport;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/dto/ServiceSubtaskOptionStatDTO.java
ÎļþÃû´Ó smartor/src/main/java/com/smartor/domain/DTO/ServiceSubtaskOptionStatDTO.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.smartor.domain.DTO;
package com.ruoyi.satisfaction.domain.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/entity/PatSatisfactionDetailEntity.java
ÎļþÃû´Ó smartor/src/main/java/com/smartor/domain/entity/PatSatisfactionDetailEntity.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.smartor.domain.entity;
package com.ruoyi.satisfaction.domain.entity;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/entity/SatisfactionStatQuery.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,60 @@
package com.ruoyi.satisfaction.domain.entity;
import com.ruoyi.common.core.domain.BaseEntity;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.Date;
import java.util.List;
/**
 * æ»¡æ„åº¦ç»Ÿè®¡èšåˆæŸ¥è¯¢å…¥å‚
 * <p>
 * ç”¨äºŽ satisfactionGraph ä¸€æ¬¡æ€§æŸ¥å‡ºã€Œæœ¬æ¬¡è¯·æ±‚涉及的所有模板」的发送量与填报量,
 * æ›¿ä»£åŽŸæ¥ã€Œæ¯ä¸ªæ¨¡æ¿é—®é¢˜å„æŸ¥ä¸¤æ¬¡ã€çš„å†™æ³•ã€‚
 * orgid / campusid å¤ç”¨ BaseEntity å­—段(与原 ServiceSubtaskSatisfactionEntity å–值来源一致)。
 *
 * @author smartor
 */
@ApiModel(value = "SatisfactionStatQuery", description = "满意度统计聚合查询入参")
@Data
public class SatisfactionStatQuery extends BaseEntity {
    private static final long serialVersionUID = 1L;
    /**
     * ä»»åŠ¡ç±»åž‹ï¼ˆservice_subtask.type),字符串比较,避免 varchar ä¸Žæ•°å­—隐式转换
     */
    @ApiModelProperty(value = "任务类型(1、语音;2、问卷表;)")
    private String type;
    /**
     * æŸ¥è¯¢å¼€å§‹æ—¶é—´
     */
    @ApiModelProperty(value = "查询开始时间")
    private Date startTime;
    /**
     * æŸ¥è¯¢ç»“束时间
     */
    @ApiModelProperty(value = "查询结束时间")
    private Date endTime;
    /**
     * éƒ¨é—¨é›†åˆ
     */
    @ApiModelProperty(value = "部门集合")
    private List<String> deptcodes;
    /**
     * ç—…区编号
     */
    @ApiModelProperty(value = "病区集合")
    private List<String> hospitaldistrictcodes;
    /**
     * åŸºç¡€æ¨¡æ¿Ids(ivr ç”¨ templateid,问卷用 svyid)
     */
    @ApiModelProperty(value = "基础模板Ids")
    private List<Long> libTemplateIds;
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/entity/ServiceSubtaskSatisfactionEntity.java
ÎļþÃû´Ó smartor/src/main/java/com/smartor/domain/entity/ServiceSubtaskSatisfactionEntity.java ÐÞ¸Ä
@@ -1,9 +1,8 @@
package com.smartor.domain.entity;
package com.ruoyi.satisfaction.domain.entity;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
import com.smartor.domain.TaskSendTimeVO;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/row/SatisfactionCategoryRow.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,54 @@
package com.ruoyi.satisfaction.domain.row;
import lombok.Data;
/**
 * æ»¡æ„åº¦åˆ†ç±»åªè¯»è§†å›¾ï¼ˆè¯­éŸ³ä¾§ä¸Žé—®å·ä¾§å…±ç”¨ï¼‰ã€‚
 * <p>
 * ä¸ºä»€ä¹ˆå…±ç”¨ä¸€ä¸ªç±»ï¼šè¯­éŸ³çš„ {@code ivr_liba_script_assort} ä¸Žé—®å·çš„
 * {@code svy_lib_script_category} åœ¨æ»¡æ„åº¦ç»Ÿè®¡é‡Œè¯­ä¹‰å®Œå…¨ä¸€è‡´â€”—
 * éƒ½æ˜¯ã€Œä¸€ä¸ªæ»¡æ„åº¦åˆ†ç±»ï¼šid + åç§° + type」,调用方只用来
 * ã€ŒæŒ‰ {@code type.contains("myd")} è¿‡æ»¤å‡ºåˆ†ç±» id、并按名称分组」。
 * <p>
 * ä¸¤è¡¨çš„原始列名不同,SQL é‡Œå·²åšåˆ«åå½’一:
 * <ul>
 *   <li>语音:{@code index_assort_name as name}</li>
 *   <li>问卷:{@code name}(列名本来就是 name)</li>
 * </ul>
 * <p>
 * âš ï¸ è¿™æ˜¯<b>只读</b>视图。
 */
@Data
public class SatisfactionCategoryRow {
    /**
     * åˆ†ç±» id
     */
    private Long id;
    /**
     * åˆ†ç±»åç§°
     * <p>注意:分类口径统计是按「模板上的分类名」({@code script_assortname} /
     * {@code categoryName})分组,而不是用本字段;本字段主要用于建分类 id â†’ åç§°çš„æ˜ å°„。
     */
    private String name;
    /**
     * åˆ†ç±»ç±»åž‹ï¼ˆæ»¡æ„åº¦å›ºå®šä¸º myd ç›¸å…³ï¼Œè°ƒç”¨æ–¹æŒ‰ contains("myd") è¿‡æ»¤ï¼‰
     */
    private String type;
    /**
     * å¯¹åº”列 {@code pid}:父分类 id(顶层为 {@code null} æˆ– {@code -1})。
     * <p>
     * ç”¨é€”:{@code /smartor/trace/getSatisfactionExceptConfigQuestion} èµ°çš„æ˜¯
     * è¯­éŸ³åˆ†ç±»çš„<b>服务方法</b> {@code IvrLibaScriptAssortServiceImpl.selectIvrLibaScriptAssortList},
     * è¯¥æ–¹æ³•会把分类拼成树、<b>只返回顶层节点</b>({@code pid == null || pid == -1}),
     * æ‰€ä»¥è°ƒç”¨æ–¹å¿…须用本字段做同样的顶层筛选才能与原文一致。
     * <p>
     * è¯­éŸ³ä¾§ä¸Žé—®å·ä¾§éƒ½å–了本列,但<b>只有语音侧</b>需要按它筛顶层——
     * é—®å·ä¾§åŽŸæ–‡ç›´æŽ¥ç”¨ {@code svyLibScriptCategoryMapper} å–全部分类(不拼树),
     * ä¸¤ä¾§ä¸ä¸€è‡´æ˜¯åŽŸæ–‡è¡Œä¸ºï¼Œå¿…é¡»ä¿ç•™ã€‚
     */
    private Long pid;
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/row/SatisfactionIvrScriptRow.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,78 @@
package com.ruoyi.satisfaction.domain.row;
import lombok.Data;
/**
 * æ»¡æ„åº¦ç»Ÿè®¡ç”¨çš„「语音模板问题」只读视图。
 * <p>
 * ä¸ºä»€ä¹ˆéœ€è¦è¿™ä¸ªç±»ï¼ˆè€Œä¸æ˜¯ç›´æŽ¥ç”¨ {@code com.smartor.domain.IvrLibaTemplateScript}):
 * <ul>
 *   <li>那个实体属于 smartor çš„**语音模板管理**业务,被 9 ä¸ªæ–‡ä»¶ä½¿ç”¨ï¼ˆå«æ¨¡æ¿å¢žåˆ æ”¹æŸ¥ï¼‰ï¼Œä¸èƒ½æ¬è¿›æœ¬æ¨¡å—ï¼›</li>
 *   <li>满意度统计其实只用到它的 5 ä¸ªå­—段(见下),按需取列比整套实体更清晰;</li>
 *   <li>本模块要求「不依赖 smartor」,所以不能 import å®ƒã€‚</li>
 * </ul>
 * å­—段与源表 {@code ivr_liba_template_script} çš„列一一对应,口径与原实现完全一致。
 * <p>
 * âš ï¸ è¿™æ˜¯<b>只读</b>视图,本模块不会对它做任何写操作。
 */
@Data
public class SatisfactionIvrScriptRow {
    /**
     * å¯¹åº”列 {@code templateid}:语音模板 id。
     * <p>聚合统计(发送量/回收人数/填报量)按它分组。
     */
    private Long templateid;
    /**
     * å¯¹åº”列 {@code scriptid}:选项表 {@code ivr_liba_template_targetoption} å…³è”的问题 id。
     * <p>用于取该问题的选项分值(算最高/最低分)。<b>可能为 null</b>,
     * æ­¤æ—¶åŽŸå®žçŽ°çš„ SQL ä¸æ‹¼ scriptid æ¡ä»¶ï¼ˆç­‰äºŽå–该表全部分值)。
     */
    private Long scriptid;
    /**
     * å¯¹åº”列 {@code script_content}:问题内容。
     * <p>用于与填报明细的 {@code questiontext} åšåŒ¹é…ï¼ˆMySQL çš„ {@code =} å¿½ç•¥å¤§å°å†™ä¸Žå°¾éƒ¨ç©ºæ ¼ï¼‰ã€‚
     */
    private String scriptContent;
    /**
     * å¯¹åº”列 {@code script_assortname}:语音满意度分类名称。
     * <p>分类口径统计({@code satisfactionGraph})按它分组。
     */
    private String scriptAssortname;
    /**
     * å¯¹åº”列 {@code dimension}:维度。
     * <p>维度口径统计({@code satisfactionGraphDimension})按它分组。
     */
    private String dimension;
    // ==================== ä»…「满意度异常配置题目」接口使用 ====================
    // ä¸‹é¢ 3 ä¸ªå­—段只有 /smartor/trace/getSatisfactionExceptConfigQuestion éœ€è¦ï¼Œ
    // ç»Ÿè®¡å£å¾„(satisfactionGraph / satisfactionGraphDimension)的查询不取这些列,保持为 null。
    /**
     * å¯¹åº”列 {@code id}:{@code ivr_liba_template_script} ä¸»é”®ã€‚
     * <p>「满意度异常配置题目」接口把它作为返回里的 {@code questionId}。
     */
    private Long id;
    /**
     * å¯¹åº”列 {@code script_topic}:题目主题。
     * <p>「满意度异常配置题目」接口把它作为返回里的 {@code questionTopic}。
     */
    private String scriptTopic;
    /**
     * å¯¹åº”列 {@code script_type}:题目类型。
     * <p>「满意度异常配置题目」接口把它作为返回里的 {@code questionType}。
     */
    private String scriptType;
    /**
     * å¯¹åº”列 {@code script_assortid}:所属语音分类 id。
     * <p>「满意度异常配置题目」接口把它作为返回里的 {@code categoryId}。
     */
    private Long scriptAssortid;
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/row/SatisfactionOptionScoreRow.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
package com.ruoyi.satisfaction.domain.row;
import lombok.Data;
/**
 * é€‰é¡¹åˆ†å€¼åªè¯»è§†å›¾ï¼ˆåªå– scriptid ä¸Ž score ä¸¤åˆ—)。
 * <p>
 * ç”¨é€”:满意度明细统计里算每个问题的**最高分 / æœ€ä½Žåˆ†**。
 * åŽŸå®žçŽ°å¯¹åº”
 * {@code IvrLibaTemplateTargetoptionMapper.selectIvrLibaTargetoptionScores} ä¸Ž
 * {@code SvyLibTemplateTargetoptionMapper.selectSvyLibTargetoptionScores},
 * ä¸¤è€…列与语义完全一致(分别来自 {@code ivr_liba_template_targetoption} ä¸Ž
 * {@code svy_lib_template_targetoption}),所以共用一个类。
 * <p>
 * âš ï¸ {@code score} ç”¨ {@code String} æ‰¿è½½ï¼šåŽ†å²æ•°æ®é‡Œå­˜åœ¨
 * {@code 0}、{@code 0000000001} è¿™ç±»è„å€¼ï¼ŒåŽŸå®žçŽ°ä¹Ÿæ˜¯æŒ‰å­—ç¬¦ä¸²è§£æžï¼Œ
 * <b>不能</b>改成数字类型,否则解析行为会变。
 * <p>
 * âš ï¸ è¿™æ˜¯<b>只读</b>视图。
 */
@Data
public class SatisfactionOptionScoreRow {
    /**
     * å¯¹åº”列 {@code scriptid}:问题 id(关联 {@code *_lib_template_script})
     */
    private Long scriptid;
    /**
     * å¯¹åº”列 {@code score}:选项分值(字符串,保留脏值原样)
     */
    private String score;
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/row/SatisfactionScriptQuery.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,52 @@
package com.ruoyi.satisfaction.domain.row;
import lombok.Data;
import java.util.List;
/**
 * æ»¡æ„åº¦ç»Ÿè®¡å–「满意度分类 / æ¨¡æ¿é—®é¢˜ã€æ—¶çš„æŸ¥è¯¢æ¡ä»¶ã€‚
 * <p>
 * ä¸ºä»€ä¹ˆå•独定义:原实现的入参复用了 smartor çš„实体({@code IvrLibaScriptAssort} /
 * {@code SvyLibScriptCategory} / {@code IvrLibaTemplateScript} / {@code SvyLibTemplateScriptVO}),
 * è¿™äº›å®žä½“不能搬进本模块。满意度实际只用到下面这几个条件,故收敛为一个查询对象。
 * <p>
 * å„条件与原来逐字对应:
 * <ul>
 *   <li>{@code orgid} â€”— æŒ‰æœºæž„过滤分类({@code and orgid = #{orgid}});</li>
 *   <li>{@code type} â€”— æŒ‰åˆ†ç±»ç±»åž‹è¿‡æ»¤ï¼ŒåŽŸå®žçŽ°æ˜¯ {@code like concat('%', type, '%')},
 *       æ»¡æ„åº¦ä¼  {@code "myd"};</li>
 *   <li>{@code scriptAssortids} â€”— æŒ‰è¯­éŸ³åˆ†ç±» id å–问题({@code script_assortid in (...)});</li>
 *   <li>{@code scriptCategoryids} â€”— æŒ‰é—®å·åˆ†ç±» id å–问题({@code categoryid in (...)});</li>
 *   <li>{@code dimensionList} â€”— æŒ‰ç»´åº¦å–问题。原实现:非空时 {@code dimension in (...)},
 *       <b>为空时</b>语音侧拼 {@code dimension IS NOT NULL}、问卷侧不拼该条件(口径不同,必须保留)。</li>
 * </ul>
 */
@Data
public class SatisfactionScriptQuery {
    /**
     * æœºæž„ id
     */
    private String orgid;
    /**
     * åˆ†ç±»ç±»åž‹ï¼ˆæ»¡æ„åº¦å›ºå®šä¼  "myd")
     */
    private String type;
    /**
     * è¯­éŸ³åˆ†ç±» id åˆ—表({@code ivr_liba_script_assort.id})
     */
    private List<Long> scriptAssortids;
    /**
     * é—®å·åˆ†ç±» id åˆ—表({@code svy_lib_script_category.id})
     */
    private List<Long> scriptCategoryids;
    /**
     * ç»´åº¦åˆ—表
     */
    private List<String> dimensionList;
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/row/SatisfactionSubtaskQuery.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,70 @@
package com.ruoyi.satisfaction.domain.row;
import lombok.Data;
import java.util.Date;
import java.util.List;
/**
 * æ»¡æ„åº¦ç»Ÿè®¡å–「子任务」时的查询条件。
 * <p>
 * å¯¹åº”原实现 {@code ServiceSubtaskServiceImpl.getServiceSubtasks(ServiceSubtaskEntity)}
 * é‡Œ {@code sltdMydTotalByScore} å®žé™…用到的条件。原方法还支持近百个其它字段,
 * ä½†æ»¡æ„åº¦åªç”¨åˆ°ä¸‹é¢è¿™äº›ï¼Œæ•…收敛为本查询对象。
 * <p>
 * æ¯ä¸ªå­—段与原来的 SQL æ¡ä»¶<b>逐字对应</b>(见 {@code SatisfactionMapper.xml}):
 * <ul>
 *   <li>{@code orgid}:{@code and orgid = #{orgid}}(非空才拼)</li>
 *   <li>{@code campusid}:{@code and campusid = #{campusid}}(非空才拼)</li>
 *   <li>{@code serviceType}:{@code and service_type = #{serviceType}}(非空才拼;调用方逐个 serviceType æŸ¥è¯¢å†åˆå¹¶ï¼‰</li>
 *   <li>{@code startOutHospTime} / {@code endOutHospTime}:按 {@code endtime} çš„ yymmdd æ¯”较</li>
 *   <li>{@code deptOrDistrict} ä¸º {@code 1} æ—¶ï¼ŒæŒ‰ {@code leavehospitaldistrictcodes} è¿‡æ»¤ç—…区</li>
 *   <li>{@code leaveldeptcodes}:按 {@code deptcode} è¿‡æ»¤ç§‘室</li>
 * </ul>
 */
@Data
public class SatisfactionSubtaskQuery {
    /**
     * æœºæž„ id
     */
    private String orgid;
    /**
     * é™¢åŒº id
     */
    private String campusid;
    /**
     * æœåŠ¡ç±»åž‹ï¼ˆæ»¡æ„åº¦ä¸º 6 ä½é™¢ / 14 é—¨è¯Šï¼‰
     */
    private String serviceType;
    /**
     * å‡ºé™¢å¼€å§‹æ—¶é—´ï¼ˆæŒ‰ endtime è¿‡æ»¤ï¼‰
     */
    private Date startOutHospTime;
    /**
     * å‡ºé™¢ç»“束时间(按 endtime è¿‡æ»¤ï¼‰
     */
    private Date endOutHospTime;
    /**
     * ç»´åº¦æ ‡è¯†ï¼š{@code 1} è¡¨ç¤ºæŒ‰ç—…区过滤
     * <p>
     * âš ï¸ åŽŸ SQL ç”¨çš„æ˜¯ {@code <if test="deptOrDistrict==1">}(与数字比较),
     * å› æ­¤è¿™é‡Œæ˜¯ {@code Integer},取值语义与原来一致。
     */
    private String deptOrDistrict = "1";
    /**
     * ç—…区代码列表(仅在 {@code deptOrDistrict == 1} æ—¶ç”Ÿæ•ˆï¼‰
     */
    private List<String> leavehospitaldistrictcodes;
    /**
     * ç§‘室代码列表
     */
    private List<String> leaveldeptcodes;
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/row/SatisfactionSubtaskRef.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
package com.ruoyi.satisfaction.domain.row;
import lombok.Data;
/**
 * æ»¡æ„åº¦ç»Ÿè®¡ç”¨çš„「子任务引用」只读视图:只取 id ä¸Ž taskid。
 * <p>
 * ä¸ºä»€ä¹ˆéœ€è¦å®ƒï¼š
 * <ul>
 *   <li>{@code sltdMydTotalByScore} åŽŸæœ¬æŠŠæŸ¥è¯¢ç»“æžœè½¬æˆ smartor çš„ {@code ServiceSubtaskRes},
 *       ä½†åŽç»­åªç”¨åˆ° {@code getId()} ä¸Ž {@code getTaskid()} ä¸¤ä¸ªå­—段
 *       ï¼ˆä¾› {@code getDetailRatio} æŠ½å‡º subtaskIds / taskIds);</li>
 *   <li>本模块要求不依赖 smartor çš„实体,故用这个极简只读视图承载;</li>
 *   <li>只 select ä¸¤åˆ—,避免像原实现那样把接近全表的字段查回 Java。</li>
 * </ul>
 * âš ï¸ è¿™æ˜¯<b>只读</b>视图。
 */
@Data
public class SatisfactionSubtaskRef {
    /**
     * å¯¹åº”列 {@code id}:子任务 id(满意度 6/14 åˆ†æ”¯ç”¨å®ƒç»Ÿè®¡é€‰é¡¹å æ¯”)
     */
    private Long id;
    /**
     * å¯¹åº”列 {@code taskid}:任务 id(随访 2 åˆ†æ”¯ç”¨å®ƒç»Ÿè®¡é€‰é¡¹å æ¯”)
     */
    private Long taskid;
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/row/SatisfactionSvyScriptRow.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,77 @@
package com.ruoyi.satisfaction.domain.row;
import lombok.Data;
/**
 * æ»¡æ„åº¦ç»Ÿè®¡ç”¨çš„「问卷模板问题」只读视图。
 * <p>
 * ä¸ºä»€ä¹ˆéœ€è¦è¿™ä¸ªç±»ï¼ˆè€Œä¸æ˜¯ç›´æŽ¥ç”¨ {@code com.smartor.domain.SvyLibTemplateScript}):
 * <ul>
 *   <li>那个实体属于 smartor çš„**问卷模板管理**业务,被 13 ä¸ªæ–‡ä»¶ä½¿ç”¨ï¼ˆå«æ¨¡æ¿å¢žåˆ æ”¹æŸ¥ï¼‰ï¼Œä¸èƒ½æ¬è¿›æœ¬æ¨¡å—ï¼›</li>
 *   <li>满意度统计只用到它的 5 ä¸ªå­—段(见下);</li>
 *   <li>本模块要求「不依赖 smartor」,所以不能 import å®ƒã€‚</li>
 * </ul>
 * <p>
 * âš ï¸ é‡è¦å£å¾„差异(原实现就有,必须保留):
 * <ul>
 *   <li>{@code querySvyLibTemplateIds}(按分类取问题):聚合统计用的模板 id æ˜¯ {@code svyid} åˆ—,
 *       è€Œå–选项分值用的关联 id æ˜¯ {@code id} åˆ—({@code svy_lib_template_script} ä¸»é”®ï¼‰ï¼›</li>
 *   <li>{@code selectSLTScriptListBydimension}(按维度取问题):两者都用 {@code svyid}。</li>
 * </ul>
 * ä¸ºä¿æŒä¸Žè¢«è¿ç§»å®žçŽ°å®Œå…¨ä¸€è‡´ï¼Œæœ¬ç±»<b>同时</b>保留 {@code svyid} ä¸Ž {@code id},
 * ç”±å„查询自行决定取哪一列(与原来逐字对应)。
 * <p>
 * âš ï¸ è¿™æ˜¯<b>只读</b>视图,本模块不会对它做任何写操作。
 */
@Data
public class SatisfactionSvyScriptRow {
    /**
     * å¯¹åº”列 {@code svyid}:问卷模板 id。
     */
    private Long svyid;
    /**
     * å¯¹åº”列 {@code id}:{@code svy_lib_template_script} ä¸»é”®ã€‚
     * <p>按分类查询时,选项表 {@code svy_lib_template_targetoption} æŒ‰å®ƒå…³è”。
     */
    private Long id;
    /**
     * å¯¹åº”列 {@code script_content}:问题内容。
     */
    private String scriptContent;
    /**
     * å¯¹åº”列 {@code categoryName}:问卷满意度分类名称。
     * <p>分类口径统计({@code satisfactionGraph})按它分组。
     */
    private String categoryName;
    /**
     * å¯¹åº”列 {@code dimension}:维度。
     */
    private String dimension;
    // ==================== ä»…「满意度异常配置题目」接口使用 ====================
    // ä¸‹é¢ 3 ä¸ªå­—段只有 /smartor/trace/getSatisfactionExceptConfigQuestion éœ€è¦ï¼Œ
    // ç»Ÿè®¡å£å¾„(satisfactionGraph / satisfactionGraphDimension)的查询不取这些列,保持为 null。
    /**
     * å¯¹åº”列 {@code script_topic}:题目主题。
     * <p>「满意度异常配置题目」接口把它作为返回里的 {@code questionTopic}。
     */
    private String scriptTopic;
    /**
     * å¯¹åº”列 {@code script_type}:题目类型。
     * <p>「满意度异常配置题目」接口把它作为返回里的 {@code questionType}。
     */
    private String scriptType;
    /**
     * å¯¹åº”列 {@code categoryid}:所属问卷分类 id。
     * <p>「满意度异常配置题目」接口把它作为返回里的 {@code categoryId}。
     */
    private Long categoryid;
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/row/SatisfactionTaskRow.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,47 @@
package com.ruoyi.satisfaction.domain.row;
import lombok.Data;
/**
 * ã€Œéšè®¿æ»¡æ„åº¦æŒ‡æ•°ã€ç»Ÿè®¡ç”¨çš„ {@code service_task} åªè¯»è§†å›¾ã€‚
 * <p>
 * ä¸ºä»€ä¹ˆéœ€è¦è¿™ä¸ªç±»ï¼ˆè€Œä¸æ˜¯ç›´æŽ¥ç”¨ {@code com.smartor.domain.ServiceTask}):
 * <ul>
 *   <li>那个实体属于 smartor çš„**随访任务管理**业务(被大量文件使用,含增删改查),不能搬进本模块;</li>
 *   <li>满意度指数统计只用到它的 4 ä¸ªå­—段(见下);</li>
 *   <li>本模块要求「不依赖 smartor」,所以不能 import å®ƒã€‚</li>
 * </ul>
 * <p>
 * å¯¹åº”原实现:{@code ServiceTaskMapper.selectServiceTaskList}(经
 * {@code ServiceTaskServiceImpl.selectServiceTaskList} è°ƒç”¨ï¼‰ï¼Œ
 * è°ƒç”¨æ–¹åªå– {@code getType()} / {@code getTemplateid()} / {@code getTaskid()} / {@code getOrgid()}。
 * <p>
 * âš ï¸ è¿™æ˜¯<b>只读</b>视图,本模块不会对它做任何写操作。
 */
@Data
public class SatisfactionTaskRow {
    /**
     * å¯¹åº”列 {@code taskid}:随访任务 id。
     * <p>用于按 {@code taskid} ç»Ÿè®¡å‘送量({@code service_subtask})与填报量({@code service_subtask_detail})。
     */
    private Long taskid;
    /**
     * å¯¹åº”列 {@code templateid}:任务所用模板 id。
     * <p>用于查该任务模板下的满意度题目数量(语音 {@code ivr_task_template_script} /
     * é—®å· {@code svy_task_template_script})。
     */
    private Long templateid;
    /**
     * å¯¹åº”列 {@code type}:任务类型({@code "1"}=语音,{@code "2"}=问卷)。
     */
    private String type;
    /**
     * å¯¹åº”列 {@code orgid}:机构 id。
     * <p>原实现把它透传给后续 3 æ¡æŸ¥è¯¢çš„ {@code orgid} æ¡ä»¶ã€‚
     */
    private String orgid;
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/vo/MydTrendReqVO.java
ÎļþÃû´Ó smartor/src/main/java/com/smartor/domain/VO/MydTrendReqVO.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.smartor.domain.VO;
package com.ruoyi.satisfaction.domain.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.annotation.Excel;
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/vo/MzMydScoreRankVO.java
ÎļþÃû´Ó smartor/src/main/java/com/smartor/domain/VO/MzMydScoreRankVO.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.smartor.domain.VO;
package com.ruoyi.satisfaction.domain.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/vo/PatSatisfactionReqVO.java
ÎļþÃû´Ó smartor/src/main/java/com/smartor/domain/VO/PatSatisfactionReqVO.java ÐÞ¸Ä
@@ -1,8 +1,8 @@
package com.smartor.domain.VO;
package com.ruoyi.satisfaction.domain.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
import com.smartor.domain.entity.PatSatisfactionDetailEntity;
import com.ruoyi.satisfaction.domain.entity.PatSatisfactionDetailEntity;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/vo/PatSatisfactionResVO.java
ÎļþÃû´Ó smartor/src/main/java/com/smartor/domain/VO/PatSatisfactionResVO.java ÐÞ¸Ä
@@ -1,8 +1,8 @@
package com.smartor.domain.VO;
package com.ruoyi.satisfaction.domain.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
import com.smartor.domain.entity.PatSatisfactionDetailEntity;
import com.ruoyi.satisfaction.domain.entity.PatSatisfactionDetailEntity;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/vo/QuestionCountKsOrBqVO.java
ÎļþÃû´Ó smartor/src/main/java/com/smartor/domain/VO/QuestionCountKsOrBqVO.java ÐÞ¸Ä
@@ -1,9 +1,9 @@
package com.smartor.domain.VO;
package com.ruoyi.satisfaction.domain.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
import com.smartor.domain.ServiceSubtaskDetailRatioExport;
import com.ruoyi.satisfaction.domain.ServiceSubtaskDetailRatioExport;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/vo/SatisfactionExceptConfigQuestionReqVO.java
ÎļþÃû´Ó smartor/src/main/java/com/smartor/domain/VO/SatisfactionExceptConfigQuestionReqVO.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.smartor.domain.VO;
package com.ruoyi.satisfaction.domain.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.annotation.Excel;
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/domain/vo/SltdMydTotalVO.java
ÎļþÃû´Ó smartor/src/main/java/com/smartor/domain/VO/SltdMydTotalVO.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.smartor.domain.VO;
package com.ruoyi.satisfaction.domain.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.annotation.Excel;
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/mapper/PatSatisfactionMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,67 @@
package com.ruoyi.satisfaction.mapper;
import com.ruoyi.satisfaction.domain.PatSatisfaction;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
 * æ‚£è€…满意度 Mapper æŽ¥å£ã€‚
 * <p>
 * è¿ç§»è¯´æ˜Žï¼šåŽŸä¸º {@code com.smartor.mapper.PatSatisfactionMapper},整体搬入满意度模块。
 * å¯¹åº” XML:{@code ruoyi-satisfaction/src/main/resources/mapper/satisfaction/PatSatisfactionMapper.xml}
 *
 * @author lihu
 * @date 2025-06-30
 */
@Mapper
public interface PatSatisfactionMapper {
    /**
     * æŸ¥è¯¢æ‚£è€…满意度
     *
     * @param id æ‚£è€…满意度主键
     * @return æ‚£è€…满意度
     */
    PatSatisfaction selectPatSatisfactionById(Long id);
    /**
     * æŸ¥è¯¢æ‚£è€…满意度列表
     *
     * @param patSatisfaction æ‚£è€…满意度
     * @return æ‚£è€…满意度集合
     */
    List<PatSatisfaction> selectPatSatisfactionList(PatSatisfaction patSatisfaction);
    /**
     * æ–°å¢žæ‚£è€…满意度
     *
     * @param patSatisfaction æ‚£è€…满意度
     * @return ç»“æžœ
     */
    int insertPatSatisfaction(PatSatisfaction patSatisfaction);
    /**
     * ä¿®æ”¹æ‚£è€…满意度
     *
     * @param patSatisfaction æ‚£è€…满意度
     * @return ç»“æžœ
     */
    int updatePatSatisfaction(PatSatisfaction patSatisfaction);
    /**
     * åˆ é™¤æ‚£è€…满意度
     *
     * @param id æ‚£è€…满意度主键
     * @return ç»“æžœ
     */
    int deletePatSatisfactionById(Long id);
    /**
     * æ‰¹é‡åˆ é™¤æ‚£è€…满意度
     *
     * @param ids éœ€è¦åˆ é™¤çš„æ•°æ®ä¸»é”®é›†åˆ
     * @return ç»“æžœ
     */
    int deletePatSatisfactionByIds(Long[] ids);
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/mapper/SatisfactionMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,296 @@
package com.ruoyi.satisfaction.mapper;
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.entity.SatisfactionStatQuery;
import com.ruoyi.satisfaction.domain.row.SatisfactionSubtaskQuery;
import com.ruoyi.satisfaction.domain.row.SatisfactionSubtaskRef;
import com.ruoyi.satisfaction.domain.row.SatisfactionTaskRow;
import com.ruoyi.satisfaction.domain.vo.MzMydScoreRankVO;
import com.ruoyi.satisfaction.domain.vo.PatSatisfactionReqVO;
import com.ruoyi.satisfaction.domain.vo.QuestionCountKsOrBqVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
 * æ»¡æ„åº¦ç»Ÿè®¡ Mapper æŽ¥å£ã€‚
 * <p>
 * è¿ç§»è¯´æ˜Žï¼šæ–¹æ³•原本散落在 {@code com.smartor.mapper.ServiceSubtaskMapper} ä¸Ž
 * {@code com.smartor.mapper.ServiceSubtaskDetailMapper} ä¸­ï¼Œæ»¡æ„åº¦æ¨¡å—化后集中到此处。
 * <p>
 * æœ¬æ¨¡å—自带 SQL,<b>直接查询</b> {@code service_subtask} / {@code service_subtask_detail},
 * ä»¥åŠæ¨¡æ¿åˆ†ç±»è¡¨ï¼›ä¸ä¾èµ–任何其它模块的 Mapper。
 * <p>
 * å¯¹åº” XML:{@code ruoyi-satisfaction/src/main/resources/mapper/satisfaction/SatisfactionMapper.xml}
 * <p>
 * å·²åˆ é™¤çš„æ­»ä»£ç ï¼ˆæ¬è¿æ—¶ç¡®è®¤é›¶è°ƒç”¨ç‚¹ï¼‰ï¼š
 * <ul>
 *   <li>{@code selectSatisfactionSubtaskList} â€”— è¢« {@code selectSatisfactionSubtaskTypes} æ›¿ä»£ï¼›</li>
 *   <li>{@code querySendCount} â€”— è¢« {@code selectSatisfactionSendCount} æ›¿ä»£ã€‚</li>
 * </ul>
 */
@Mapper
public interface SatisfactionMapper {
    // ================================================================ å‘送量统计
    /**
     * æ»¡æ„åº¦æŸ±çŠ¶å›¾ï¼šä¸€æ¬¡æ€§ç»Ÿè®¡æœ¬æ¬¡è¯·æ±‚å…¨éƒ¨æ¨¡æ¿çš„å‘é€é‡ï¼ˆæŒ‰ libtemplateid åˆ†ç»„)。
     *
     * @param query èšåˆæŸ¥è¯¢æ¡ä»¶
     * @return æ¯è¡Œå« libtemplateid、cnt
     */
    List<Map<String, Object>> selectSatisfactionSendCount(SatisfactionStatQuery query);
    /**
     * æ»¡æ„åº¦ç»Ÿè®¡ï¼šåªæŸ¥æœ¬æ¬¡æ¡ä»¶ä¸‹å®žé™…存在哪些 type(1 è¯­éŸ³ / 2 é—®å·ï¼‰ã€‚
     *
     * @param patSatisfactionReqVO æŸ¥è¯¢æ¡ä»¶
     * @return åŽ»é‡åŽçš„ type åˆ—表(可能为 null)
     */
    List<String> selectSatisfactionSubtaskTypes(PatSatisfactionReqVO patSatisfactionReqVO);
    // ================================================================ å¡«æŠ¥é‡ç»Ÿè®¡
    /**
     * æ»¡æ„åº¦æŸ±çŠ¶å›¾ï¼šä¸€æ¬¡æ€§ç»Ÿè®¡æœ¬æ¬¡è¯·æ±‚å…¨éƒ¨æ¨¡æ¿çš„å¡«æŠ¥é‡ï¼ˆæŒ‰ æ¨¡æ¿+问题+score åˆ†ç»„)。
     *
     * @param query èšåˆæŸ¥è¯¢æ¡ä»¶
     * @return æ¯è¡Œå« libtemplateid、questiontext、score、cnt
     */
    List<Map<String, Object>> selectSatisfactionFillStat(SatisfactionStatQuery query);
    /**
     * æ»¡æ„åº¦ç»Ÿè®¡ï¼šä¸€æ¬¡æ€§ç»Ÿè®¡æœ¬æ¬¡è¯·æ±‚全部问题的填报量(按 æ¨¡æ¿+问题+matchedtext+score åˆ†ç»„)。
     *
     * @param query èšåˆæŸ¥è¯¢æ¡ä»¶
     * @return æ¯è¡Œå« libtemplateid、questiontext、matchedtext、score、cnt、targetvalue
     */
    List<Map<String, Object>> selectSatisfactionFillStatByMatchedtext(SatisfactionStatQuery query);
    /**
     * æ»¡æ„åº¦æŸ±çŠ¶å›¾ï¼šä¸€æ¬¡æ€§ç»Ÿè®¡æœ¬æ¬¡è¯·æ±‚å…¨éƒ¨æ¨¡æ¿çš„ã€Œå›žæ”¶äººæ•°ã€ã€‚
     * <p>
     * å›žæ”¶äººæ•°å¿…须是人数口径(count distinct sub_id),不能按题目汇总答案行数(题量口径)。
     *
     * @param query èšåˆæŸ¥è¯¢æ¡ä»¶
     * @return æ¯è¡Œå« libtemplateid、cnt
     */
    List<Map<String, Object>> selectSatisfactionFillPersonCount(SatisfactionStatQuery query);
    // ================================================================ çœç«‹åŒå¾·ï¼šè¶‹åŠ¿ä¸Žç»´åº¦
    /**
     * çœç«‹åŒå¾·æ‚£è€…满意度趋势。
     *
     * @param startFinishTime å¼€å§‹æ—¶é—´
     * @param endFinishTime   ç»“束时间
     * @param type            ç²’度:week / month / year / day
     * @return è¶‹åŠ¿æ•°æ®
     */
    List<MydTrendReqDTO> mydTrend(@Param("startFinishTime") Date startFinishTime,
                                  @Param("endFinishTime") Date endFinishTime,
                                  @Param("type") String type);
    /**
     * çœç«‹åŒå¾·æ‚£è€…满意度——按科室维度统计。
     */
    List<MydTrendReqDTO> sltdMydTotalByDimension(@Param("startFinishTime") Date startFinishTime,
                                                 @Param("endFinishTime") Date endFinishTime,
                                                 @Param("type") String type);
    /**
     * çœç«‹åŒå¾·æ‚£è€…满意度——按院区维度统计。
     */
    List<MydTrendReqDTO> sltdMydTotalByYQ(@Param("startFinishTime") Date startFinishTime,
                                          @Param("endFinishTime") Date endFinishTime,
                                          @Param("type") String type);
    // ================================================================ é€‰é¡¹å æ¯”
    /**
     * ç»Ÿè®¡æ»¡æ„åº¦é€‰é¡¹çš„百分比。
     *
     * @param taskIds         ä»»åŠ¡ ID åˆ—表
     * @param subtaskIds      å­ä»»åŠ¡ ID åˆ—表(非空时只统计这些子任务;为空则统计这些任务下的全部子任务)
     * @param startOutHospTime å‡ºé™¢å¼€å§‹æ—¶é—´
     * @param endOutHospTime   å‡ºé™¢ç»“束时间
     * @param startFinishTime  å®Œæˆå¼€å§‹æ—¶é—´
     * @param endFinishTime    å®Œæˆç»“束时间
     * @param scoreStart       åˆ†å€¼ä¸‹é™
     * @param scoreEnd         åˆ†å€¼ä¸Šé™
     * @param questiontext     é¢˜ç›®å†…容(模糊匹配)
     * @param valueType        é¢˜åž‹ï¼ˆ2=多选)
     */
    List<ServiceSubtaskDetailRatioExport> statQuestionOption(
            @Param("taskIds") List<Long> taskIds,
            @Param("startOutHospTime") Date startOutHospTime,
            @Param("endOutHospTime") Date endOutHospTime,
            @Param("startFinishTime") Date startFinishTime,
            @Param("endFinishTime") Date endFinishTime,
            @Param("scoreStart") Double scoreStart,
            @Param("scoreEnd") Double scoreEnd,
            @Param("questiontext") String questiontext,
            @Param("valueType") String valueType,
            @Param("subtaskIds") List<Long> subtaskIds);
    /**
     * æ»¡æ„åº¦å¯¼å‡ºï¼šé€‰é¡¹å æ¯”。
     * <p>
     * é¡¹æ•° = é€‰äº†è¯¥é€‰é¡¹çš„æ‚£è€…数(同一人同一题只取最新一条明细);分数 = è¯¥é€‰é¡¹æ‰€æœ‰æ˜Žç»† score ä¹‹å’Œã€‚
     *
     * @param subtaskIds   æœ¬æ¬¡å¯¼å‡ºçš„子任务 ID
     * @param questiontext é¢˜ç›®å†…容(可选,模糊匹配)
     */
    List<ServiceSubtaskOptionStatDTO> statMydQuestionOptionForExport(
            @Param("subtaskIds") List<Long> subtaskIds,
            @Param("questiontext") String questiontext);
    /**
     * æ ¹æ®å­ä»»åŠ¡ ID ç»Ÿè®¡æ»¡æ„åº¦é€‰é¡¹çš„百分比(用于满意度统计 serviceType=6/14)。
     */
    List<ServiceSubtaskDetailRatioExport> statQuestionOptionBySubtaskIds(
            @Param("subtaskIds") List<Long> subtaskIds,
            @Param("startOutHospTime") Date startOutHospTime,
            @Param("endOutHospTime") Date endOutHospTime,
            @Param("startFinishTime") Date startFinishTime,
            @Param("endFinishTime") Date endFinishTime,
            @Param("scoreStart") Double scoreStart,
            @Param("scoreEnd") Double scoreEnd,
            @Param("questiontext") String questiontext,
            @Param("valueType") String valueType);
    /**
     * æŒ‰ç§‘室/病区对满意度题目做归类统计。
     */
    List<QuestionCountKsOrBqDTO> statQuestionOptionByKsOrBq(QuestionCountKsOrBqVO questionCountKsOrBqVO);
    // ================================================================ ç»¼åˆå¾—分排名
    /**
     * é—¨è¯Šæ»¡æ„åº¦ç»¼åˆå¾—分排名(按科室或病区)。
     * <p>
     * ç»¼åˆæ»¡æ„åº¦ = Î£(评分明细权重) / è¯„分明细条数 Ã— 100%;权重只取 1、0.8、0.6、0.4、0.2 äº”档。
     *
     * @param mzMydScoreRankVO æŸ¥è¯¢æ¡ä»¶ï¼ˆdimensionType、serviceType、时间区间、科室/病区编号、orgid/campusid)
     * @return æ¯è¡Œç§‘室/病区的综合得分,已按综合得分降序
     */
    List<MzMydScoreRankDTO> selectMzMydScoreRank(MzMydScoreRankVO mzMydScoreRankVO);
    // ================================================================ é‡å¤æ˜Žç»†æ¸…理(满意度统计前置)
    /**
     * æ‰¾å‡ºã€ŒåŒä¸€ sub_id + questiontext」下除最新一条之外的重复明细 id。
     * <p>
     * åŽŸå±ž {@code ServiceSubtaskDetailMapper},只有满意度统计调用(已核实),故一并搬入。
     * <p>
     * âš ï¸ æ³¨æ„ï¼šè°ƒç”¨å®ƒçš„ {@code statQuestionOption} / {@code statQuestionOptionBySubtaskIds}
     * ä¼šå…ˆæ‰§è¡Œæœ¬æŸ¥è¯¢å†æ‰§è¡Œ {@link #deleteRepeatDetailByIds},<b>这是会写库的行为</b>(原实现即如此)。
     *
     * @return éœ€è¦æ¸…理的明细 id
     */
    List<Long> selectRepeatDetailIds();
    /**
     * æŒ‰ id é€»è¾‘删除重复明细({@code del_flag = 1})。
     */
    Integer deleteRepeatDetailByIds(@Param("ids") List<Long> ids);
    // ================================================================ å­ä»»åŠ¡ï¼ˆæ»¡æ„åº¦ç»Ÿè®¡å–æ•°ï¼‰
    /**
     * æŒ‰æœºæž„/院区/服务类型/出院时间/科室病区取子任务,<b>只返回 id ä¸Ž taskid</b>。
     * <p>
     * å¯¹åº”原实现 {@code ServiceSubtaskServiceImpl.getServiceSubtasks(ServiceSubtaskEntity)}
     * ä¸­ {@code sltdMydTotalByScore} å®žé™…用到的口径。原方法基于
     * {@code selectServiceSubtaskList}(近百个可选条件 + è¿”回全表字段),
     * æ»¡æ„åº¦åªéœ€è¦ä¸Šè¿°æ¡ä»¶ä¸Žä¸¤ä¸ª id åˆ—,因此这里专写一条精简查询。
     * <p>
     * <b>条件与原文逐字一致</b>(均「非空才拼」):
     * {@code del_flag = 0}、{@code orgid}、{@code campusid}、{@code service_type}、
     * {@code endtime} çš„æ—¥æœŸåŒºé—´ã€{@code deptOrDistrict==1} æ—¶çš„病区 IN、
     * {@code leaveldeptcodes} çš„科室 IN。
     * <p>
     * âš ï¸ åŽŸæ–‡æ— é»˜è®¤æŽ’åºã€æ— é»˜è®¤åˆ†é¡µï¼ˆ{@code sort}/{@code pageSize} æœªè®¾ç½®å³ä¸ç”Ÿæ•ˆï¼‰ï¼Œ
     * æœ¬æŸ¥è¯¢åŒæ ·ä¸åŠ æŽ’åºï¼Œä¿è¯ç»“æžœé¡ºåºè¡Œä¸ºä¸€è‡´ã€‚
     */
    List<SatisfactionSubtaskRef> selectMydSubtaskRefs(@Param("q") SatisfactionSubtaskQuery query);
    // ================================================================ éšè®¿ã€Œæ»¡æ„åº¦æŒ‡æ•°ã€å–æ•°
    // ä»¥ä¸‹ 5 ä¸ªæ–¹æ³•服务于 getSatisfactionIndex(随访统计页的「满意度题目总量/填报量/完成比例」),
    // åŽŸæœ¬æ•£è½åœ¨ smartor çš„ ServiceTaskMapper / IvrTaskTemplateScriptMapper /
    // SvyTaskTemplateScriptMapper / ServiceSubtaskMapper / ServiceSubtaskDetailMapper ä¸­ï¼Œ
    // ç”± ServiceSubtaskServiceImpl#getSatisfactionIndex é€ä¸ªè°ƒç”¨ã€‚
    /**
     * æŒ‰ç§‘室 <b>或</b> ç—…区取随访任务(只取统计用到的 4 åˆ—)。
     * <p>
     * å¯¹åº”原实现 {@code ServiceTaskMapper.selectServiceTaskList},调用方按 type äºŒé€‰ä¸€ä¼ å‚:
     * <ul>
     *   <li>{@code deptcode} éžç©º â†’ æ‹¼ {@code deptcode REGEXP CONCAT('(^|,)', #{deptcode}, '(,|$)')};</li>
     *   <li>{@code leavehospitaldistrictcode} éžç©º â†’ æ‹¼åŒå½¢æ€çš„ REGEXP;</li>
     * </ul>
     * ä¸¤è€…只会有一个非空,因此本方法一条 SQL å³å¯è¦†ç›–原实现的两个分支。
     * <p>
     * <b>口径逐字一致</b>:{@code del_flag = 0}、{@code orgid}、{@code order by update_time desc, taskid desc}。
     */
    List<SatisfactionTaskRow> selectSatisfactionServiceTasks(
            @Param("orgid") String orgid,
            @Param("deptcode") String deptcode,
            @Param("leavehospitaldistrictcode") String leavehospitaldistrictcode);
    /**
     * ç»Ÿè®¡æŸæ¨¡æ¿ä¸‹çš„语音满意度题目数量。
     * <p>
     * å¯¹åº”原实现 {@code IvrTaskTemplateScriptMapper.selectIvrTaskTemplateScriptList}
     * åœ¨ {@code templateID + orgid + scriptAssortid} ä¸‰ä¸ªæ¡ä»¶ä¸‹çš„ {@code size()}。
     * <p>
     * <b>口径注意</b>:{@code scriptAssortid} ä¸º {@code null} æ—¶<b>不拼</b>该条件
     * ï¼ˆç­‰äºŽç»Ÿè®¡è¯¥æ¨¡æ¿å…¨éƒ¨é¢˜ç›®ï¼‰â€”—与原文 {@code <if test="scriptAssortid != null">} ä¸€è‡´ã€‚
     */
    int countIvrTaskTemplateScripts(@Param("templateID") Long templateID,
                                    @Param("orgid") String orgid,
                                    @Param("scriptAssortid") Long scriptAssortid);
    /**
     * ç»Ÿè®¡æŸæ¨¡æ¿ä¸‹çš„问卷满意度题目数量。
     * <p>
     * å¯¹åº”原实现 {@code SvyTaskTemplateScriptMapper.selectSvyTaskTemplateScriptList}
     * åœ¨ {@code templateID + orgid + categoryid} ä¸‰ä¸ªæ¡ä»¶ä¸‹çš„ {@code size()}。
     * <p>
     * <b>口径注意</b>:{@code categoryid} ä¸º {@code null} æ—¶<b>不拼</b>该条件(与原文一致)。
     */
    int countSvyTaskTemplateScripts(@Param("templateID") Long templateID,
                                    @Param("orgid") String orgid,
                                    @Param("categoryid") Long categoryid);
    /**
     * ç»Ÿè®¡æŸä»»åŠ¡ä¸‹æŒ‡å®šå‘é€çŠ¶æ€çš„å­ä»»åŠ¡æ•°é‡ã€‚
     * <p>
     * å¯¹åº”原实现 {@code ServiceSubtaskMapper.selectServiceSubtaskBySendstate}
     * åœ¨ {@code taskid + sendstates(3,5,6)} ä¸¤ä¸ªæ¡ä»¶ä¸‹çš„ {@code size()}。
     * åŽŸè°ƒç”¨æ²¡æœ‰è®¾ç½® {@code orgid},故本方法也不含该条件。
     * <p>
     * <b>口径注意</b>:含 {@code del_flag = 0};{@code sendstates} ä¸º {@code null} æ—¶ä¸æ‹¼ IN æ¡ä»¶ã€‚
     */
    int countSubtaskBySendstates(@Param("taskid") Long taskid,
                                @Param("sendstates") List<Long> sendstates);
    /**
     * ç»Ÿè®¡æŸä»»åŠ¡ä¸‹çš„æ»¡æ„åº¦æ˜Žç»†æ•°é‡ã€‚
     * <p>
     * å¯¹åº”原实现 {@code ServiceSubtaskDetailMapper.selectServiceSubtaskDetailList}
     * åœ¨ {@code taskid + categoryid + orgid} ä¸‰ä¸ªæ¡ä»¶ä¸‹çš„ {@code size()}。
     * <p>
     * <b>口径注意</b>:原文<b>没有</b> {@code del_flag} æ¡ä»¶ï¼Œæœ¬æŸ¥è¯¢åŒæ ·ä¸åŠ ï¼›
     * {@code categoryid} ä¸º {@code null} æ—¶ä¸æ‹¼è¯¥æ¡ä»¶ï¼ˆä¿ç•™åŽŸæ–‡çš„"按任务类型决定是否设 categoryid"行为)。
     */
    int countSubtaskDetail(@Param("taskid") Long taskid,
                           @Param("categoryid") Long categoryid,
                           @Param("orgid") String orgid);
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/mapper/SatisfactionScriptMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,146 @@
package com.ruoyi.satisfaction.mapper;
import com.ruoyi.satisfaction.domain.row.SatisfactionCategoryRow;
import com.ruoyi.satisfaction.domain.row.SatisfactionIvrScriptRow;
import com.ruoyi.satisfaction.domain.row.SatisfactionOptionScoreRow;
import com.ruoyi.satisfaction.domain.row.SatisfactionScriptQuery;
import com.ruoyi.satisfaction.domain.row.SatisfactionSvyScriptRow;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
/**
 * æ»¡æ„åº¦ç»Ÿè®¡å–「满意度分类 / æ¨¡æ¿é—®é¢˜ / é€‰é¡¹åˆ†å€¼ã€çš„只读 Mapper。
 * <p>
 * <b>为什么不复用 smartor çš„æ¨¡æ¿/分类 Mapper:</b>
 * <ul>
 *   <li>{@code com.smartor.domain.IvrLibaScriptAssort} / {@code IvrLibaTemplateScript} /
 *       {@code SvyLibScriptCategory} / {@code SvyLibTemplateScript} è¿™ 4 ä¸ªå®žä½“属于
 *       smartor çš„**模板与分类管理**业务(分别被 11/9/12/13 ä¸ªæ–‡ä»¶ä½¿ç”¨ï¼Œå«å¢žåˆ æ”¹æŸ¥ï¼‰ï¼Œ
 *       ä¸èƒ½æ¬è¿›æœ¬æ¨¡å—ï¼›</li>
 *   <li>本模块要求「不依赖 smartor,其它模块要用只能调本模块 api」,所以不能 import å®ƒä»¬ï¼›</li>
 *   <li>原文这些查询依赖 `resultMap` + å…±äº« `<sql>` ç‰‡æ®µï¼Œè€Œç‰‡æ®µè¢«åŒæ–‡ä»¶é‡Œå…¶å®ƒ
 *       <b>非满意度</b>查询共用。直接复制片段会造成「同一份 SQL ä¸¤å¤„维护」的隐患,
 *       å› æ­¤è¿™é‡Œ<b>改写为显式列清单</b>,只取满意度真正用到的字段。</li>
 * </ul>
 * <p>
 * âš ï¸ æœ¬ Mapper å…¨éƒ¨æ˜¯<b>只读查询</b>,不会对 {@code ivr_liba_*} / {@code svy_lib_*} åšä»»ä½•写操作。
 * <p>
 * å¯¹åº” XML:{@code mapper/satisfaction/SatisfactionScriptMapper.xml}
 */
@Mapper
public interface SatisfactionScriptMapper {
    // ================================================================ æ»¡æ„åº¦åˆ†ç±»
    /**
     * æŸ¥è¯¢æŒ‡å®šæœºæž„下的满意度分类(语音侧)。
     * <p>
     * åŽŸå®žçŽ°ï¼š{@code IvrLibaScriptAssortMapper.selectIvrLibaScriptAssortList}
     * <pre>
     * select id, index_assort_name, type from ivr_liba_script_assort
     * where del_flag = 0 and orgid = ? and type like '%myd%' order by seqno asc
     * </pre>
     * è°ƒç”¨æ–¹å†æŒ‰ {@code type.contains("myd")} è¿‡æ»¤å‡ºæ»¡æ„åº¦åˆ†ç±» id。
     *
     * @param query orgid ä¸Ž type(type ä¼  "myd")
     * @return æ¯è¡Œå« id、index_assort_name、type
     */
    List<SatisfactionCategoryRow> selectIvrCategoryList(@Param("q") SatisfactionScriptQuery query);
    /**
     * æŸ¥è¯¢æŒ‡å®šæœºæž„下的满意度分类(问卷侧)。
     * <p>
     * åŽŸå®žçŽ°ï¼š{@code SvyLibScriptCategoryMapper.selectSvyLibScriptCategoryList}
     * ï¼ˆæ³¨æ„ï¼šè¯¥ statement å®šä¹‰åœ¨ {@code SvyLibTopicCategoryMapper.xml} é‡Œï¼Œnamespace ä¸Žæ–‡ä»¶åä¸ä¸€è‡´ï¼‰
     * <pre>
     * select id, name, type from svy_lib_script_category
     * where del_flag = 0 and orgid = ? and type like '%myd%' order by seqno asc
     * </pre>
     *
     * @param query orgid ä¸Ž type(type ä¼  "myd")
     * @return æ¯è¡Œå« id、name、type
     */
    List<SatisfactionCategoryRow> selectSvyCategoryList(@Param("q") SatisfactionScriptQuery query);
    // ================================================================ æ¨¡æ¿é—®é¢˜ï¼ˆæŒ‰åˆ†ç±»ï¼‰
    /**
     * æŒ‰è¯­éŸ³åˆ†ç±» id å–问题。
     * <p>
     * åŽŸå®žçŽ°ï¼š{@code IvrLibaTemplateScriptMapper.queryLibTemplateIds}
     * <pre>
     * select templateid, scriptid, script_content, script_assortname, dimension
     * from ivr_liba_template_script
     * where del_flag = 0 [and script_assortid in (...)]
     * </pre>
     * <b>口径注意</b>:{@code scriptAssortids} ä¸ºç©ºæ—¶ä¸å†æ‹¼è¯¥æ¡ä»¶ï¼ˆåŽŸå®žçŽ°æ˜¯
     * {@code if test="scriptAssortids != null and scriptAssortids.size > 0"})。
     */
    List<SatisfactionIvrScriptRow> queryIvrScriptByAssort(@Param("q") SatisfactionScriptQuery query);
    /**
     * æŒ‰é—®å·åˆ†ç±» id å–问题。
     * <p>
     * åŽŸå®žçŽ°ï¼š{@code SvyLibTemplateScriptMapper.querySvyLibTemplateIds}
     * <pre>
     * select id, svyid, script_content, categoryName, dimension
     * from svy_lib_template_script
     * where del_flag = 0 [and categoryid in (...)]
     * </pre>
     * <b>口径注意</b>:原实现取选项分值时用的是 {@code id}(主键),
     * è€Œèšåˆç»Ÿè®¡çš„æ¨¡æ¿ id ç”¨çš„æ˜¯ {@code svyid}——两者都保留在结果行里。
     */
    List<SatisfactionSvyScriptRow> querySvyScriptByCategory(@Param("q") SatisfactionScriptQuery query);
    // ================================================================ æ¨¡æ¿é—®é¢˜ï¼ˆæŒ‰ç»´åº¦ï¼‰
    /**
     * æŒ‰ç»´åº¦å–问题(语音侧)。
     * <p>
     * åŽŸå®žçŽ°ï¼š{@code IvrLibaTemplateScriptMapper.selectILTSBydimension}
     * <pre>
     * ... where del_flag = 0
     *     [and dimension in (...)]
     *     [dimension IS NOT NULL]   â† dimensionList ä¸ºç©ºæ—¶æ‹¼è¿™ä¸ª
     * </pre>
     * <b>口径注意</b>:{@code dimensionList} ä¸ºç©ºæ—¶ï¼Œè¯­éŸ³ä¾§ä¼šæ‹¼
     * {@code dimension IS NOT NULL}(问卷侧不会)——这是原实现的行为,必须保留。
     * åŒæ—¶åŽŸå®žçŽ°è¿˜æ”¯æŒ duty/report ç›¸å…³è¿‡æ»¤ï¼Œæ»¡æ„åº¦è°ƒç”¨æ—¶ä¸ä¼ ï¼Œæ•…不再暴露。
     */
    List<SatisfactionIvrScriptRow> selectIvrScriptByDimension(@Param("q") SatisfactionScriptQuery query);
    /**
     * æŒ‰ç»´åº¦å–问题(问卷侧)。
     * <p>
     * åŽŸå®žçŽ°ï¼š{@code SvyLibTemplateScriptMapper.selectSLTScriptListBydimension}
     * <pre>
     * ... where del_flag = 0 [and dimension in (...)]
     * </pre>
     * <b>口径注意</b>:与语音侧不同,{@code dimensionList} ä¸ºç©ºæ—¶<b>不拼</b>
     * {@code dimension IS NOT NULL}(等于取该表全部 del_flag=0 çš„问题)。
     * å¦å¤–此处聚合统计用的模板 id ä¸Žå–选项分值用的关联 id <b>都是 {@code svyid}</b>。
     */
    List<SatisfactionSvyScriptRow> selectSvyScriptByDimension(@Param("q") SatisfactionScriptQuery query);
    // ================================================================ é€‰é¡¹åˆ†å€¼
    /**
     * æŒ‰é—®é¢˜ id æ‰¹é‡å–语音选项分值(只取 scriptid、score ä¸¤åˆ—)。
     * <p>
     * åŽŸå®žçŽ°ï¼š{@code IvrLibaTemplateTargetoptionMapper.selectIvrLibaTargetoptionScores}
     * <p>
     * <b>口径注意</b>:{@code scriptIds} ä¸º null æ—¶åŽŸå®žçŽ°ä¸æ‹¼ scriptid æ¡ä»¶
     * ï¼ˆç­‰äºŽå–该表全部分值),调用方用 null ä½œ key å­˜è¿™ä¸€ä»½ã€‚
     */
    List<SatisfactionOptionScoreRow> selectIvrTargetOptionScores(@Param("scriptIds") List<Long> scriptIds);
    /**
     * æŒ‰é—®é¢˜ id æ‰¹é‡å–问卷选项分值(只取 scriptid、score ä¸¤åˆ—)。
     * <p>
     * åŽŸå®žçŽ°ï¼š{@code SvyLibTemplateTargetoptionMapper.selectSvyLibTargetoptionScores}
     */
    List<SatisfactionOptionScoreRow> selectSvyTargetOptionScores(@Param("scriptIds") List<Long> scriptIds);
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/service/IPatSatisfactionService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,92 @@
package com.ruoyi.satisfaction.service;
import com.ruoyi.satisfaction.domain.PatSatisfaction;
import com.ruoyi.satisfaction.domain.vo.PatSatisfactionReqVO;
import com.ruoyi.satisfaction.domain.vo.PatSatisfactionResVO;
import java.util.List;
import java.util.Map;
/**
 * æ‚£è€…满意度 Service æŽ¥å£ã€‚
 * <p>
 * è¿ç§»è¯´æ˜Žï¼šåŽŸä¸º {@code com.smartor.service.IPatSatisfactionService},整体搬入满意度模块。
 * æ–¹æ³•签名与语义<b>一字未改</b>,Controller çš„ URL ä¸Žå‡ºå…¥å‚也因此保持不变。
 * <p>
 * âš ï¸ æœ¬æŽ¥å£åªåº”在满意度模块内部使用;其它模块需要满意度数据请使用
 * {@code com.ruoyi.satisfaction.api} ä¸‹çš„门面接口。
 *
 * @author lihu
 * @date 2025-06-30
 */
public interface IPatSatisfactionService {
    // ================================================================ pat_satisfaction è¡¨ CRUD
    /**
     * æŸ¥è¯¢æ‚£è€…满意度
     *
     * @param id æ‚£è€…满意度主键
     * @return æ‚£è€…满意度
     */
    PatSatisfaction selectPatSatisfactionById(Long id);
    /**
     * æŸ¥è¯¢æ‚£è€…满意度列表
     *
     * @param patSatisfaction æ‚£è€…满意度
     * @return æ‚£è€…满意度集合
     */
    List<PatSatisfaction> selectPatSatisfactionList(PatSatisfaction patSatisfaction);
    /**
     * æ–°å¢žæ‚£è€…满意度
     */
    int insertPatSatisfaction(PatSatisfaction patSatisfaction);
    /**
     * æ‰¹é‡æ–°å¢žæ‚£è€…满意度
     */
    int batchInsert(List<PatSatisfaction> patSatisfactions);
    /**
     * ä¿®æ”¹æ‚£è€…满意度
     */
    int updatePatSatisfaction(PatSatisfaction patSatisfaction);
    /**
     * æ‰¹é‡åˆ é™¤æ‚£è€…满意度
     */
    int deletePatSatisfactionByIds(Long[] ids);
    /**
     * åˆ é™¤æ‚£è€…满意度信息
     */
    int deletePatSatisfactionById(Long id);
    // ================================================================ ç»Ÿè®¡
    /**
     * æ‚£è€…满意度统计(明细口径:逐问题返回答题人数、平均分、最高/最低分、答题率、选项占比)
     *
     * @param patSatisfactionReqVO ç»Ÿè®¡æ¡ä»¶
     * @return ç»Ÿè®¡ç»“æžœ
     */
    PatSatisfactionResVO statistics(PatSatisfactionReqVO patSatisfactionReqVO);
    /**
     * æ‚£è€…满意度统计(维度口径,与 {@link #statistics} çš„差异仅在「问题来源」:
     * å‰è€…按满意度分类取,本方法按入参 dimensionList å–)
     */
    PatSatisfactionResVO statisticsByDimension(PatSatisfactionReqVO patSatisfactionReqVO);
    /**
     * æ‚£è€…满意度柱状图信息(分类口径:按分类汇总发送量、回收人数、回收率、平均分)
     */
    Map<String, Object> satisfactionGraph(PatSatisfactionReqVO patSatisfactionReqVO);
    /**
     * æ‚£è€…满意度柱状图信息(维度口径)
     */
    Map<String, Object> satisfactionGraphDimension(PatSatisfactionReqVO patSatisfactionReqVO);
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/service/SatisfactionSubtaskService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,163 @@
package com.ruoyi.satisfaction.service;
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.vo.MydTrendReqVO;
import com.ruoyi.satisfaction.domain.vo.MzMydScoreRankVO;
import com.ruoyi.satisfaction.domain.vo.QuestionCountKsOrBqVO;
import com.ruoyi.satisfaction.domain.row.SatisfactionCategoryRow;
import com.ruoyi.satisfaction.domain.row.SatisfactionIvrScriptRow;
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.SatisfactionSvyScriptRow;
import com.ruoyi.satisfaction.domain.vo.SatisfactionExceptConfigQuestionReqVO;
import com.ruoyi.satisfaction.domain.vo.SltdMydTotalVO;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
 * æ»¡æ„åº¦ã€Œå­ä»»åŠ¡ç»´åº¦ã€ç»Ÿè®¡æœåŠ¡ã€‚
 * <p>
 * è¿ç§»è¯´æ˜Žï¼šè¿™äº›æ–¹æ³•原属 {@code com.smartor.service.IServiceSubtaskService},
 * ä½†åªæœ‰æ»¡æ„åº¦ç›¸å…³å…¥å£åœ¨ç”¨ï¼ˆå·²é€ä¸ªæ ¸å®žæ— å…¶å®ƒè°ƒç”¨æ–¹ï¼‰ï¼Œæ•…整批搬入满意度模块。
 * æ–¹æ³•签名除 {@link #getDetailRatio} å¤–均<b>一字未改</b>。
 * <p>
 * âš ï¸ æœ¬æŽ¥å£åªåº”在满意度模块内部使用;其它模块需要满意度数据请用
 * {@code com.ruoyi.satisfaction.api} ä¸‹çš„门面接口。
 */
public interface SatisfactionSubtaskService {
    // ================================================================ é€‰é¡¹å æ¯”
    /**
     * ç»Ÿè®¡æ»¡æ„åº¦é€‰é¡¹çš„百分比。
     * <p>
     * æ³¨æ„ï¼šæœ¬æ–¹æ³•开头会先清理重复明细({@code selectRepeatDetailIds} / {@code deleteRepeatDetailByIds}),
     * è¿™æ˜¯åŽŸå®žçŽ°å°±æœ‰çš„è¡Œä¸ºã€‚
     */
    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<ServiceSubtaskDetailRatioExport> statMydQuestionOptionForExport(
            List<Long> subtaskIds, String questiontext);
    /**
     * æ ¹æ®å­ä»»åŠ¡ ID ç»Ÿè®¡æ»¡æ„åº¦é€‰é¡¹çš„百分比(满意度 serviceType=6/14 ç”¨ï¼‰ã€‚
     */
    List<ServiceSubtaskDetailRatioExport> statQuestionOptionBySubtaskIds(
            List<Long> subtaskIds, Date startOutHospTime, Date endOutHospTime,
            Date startFinishTime, Date endFinishTime, String scriptContent,
            Double scoreStart, Double scoreEnd, String valueType);
    /**
     * æŒ‰ç§‘室/病区对满意度题目做归类统计。
     */
    Map<String, Map<String, List<QuestionCountKsOrBqDTO>>> statQuestionOptionByKsOrBq(QuestionCountKsOrBqVO vo);
    /**
     * é—®é¢˜ç»“果占比(省立同德满意度统计用)。
     * <p>
     * <b>与原文的差异(仅签名)</b>:原文入参是 {@code List<ServiceSubtaskRes>},
     * æ–¹æ³•内部从中抽出 taskIds ä¸Ž subtaskIds。为让满意度模块不依赖 smartor çš„实体,
     * æ”¹ä¸ºç›´æŽ¥æŽ¥æ”¶è¿™ä¸¤ä¸ª id åˆ—表;<b>口径与分支判断完全不变</b>:
     * <ul>
     *   <li>serviceTypeList åŒæ—¶å« 2 ä¸Ž 6/14 â†’ è¿”回 {@code null}(原文即如此,调用方据此提示"暂不支持");</li>
     *   <li>含 2 â†’ èµ° {@link #statQuestionOption}(按 taskid ç»Ÿè®¡ï¼‰ï¼›</li>
     *   <li>含 6/14 â†’ èµ° {@link #statQuestionOptionBySubtaskIds}(按 subtask id ç»Ÿè®¡ï¼‰ã€‚</li>
     * </ul>
     *
     * @param taskIds    ä»»åŠ¡ id åˆ—表(随访分支用;由调用方从查询结果抽出)
     * @param subtaskIds å­ä»»åŠ¡ id åˆ—表(满意度分支用;由调用方从查询结果抽出)
     */
    List<ServiceSubtaskDetailRatioExport> getDetailRatio(
            SltdMydTotalVO sltdMydTotalVO, List<SatisfactionSubtaskRef> subtaskRefs);
    // ================================================================ çœç«‹åŒå¾·ï¼šè¶‹åŠ¿ä¸Žç»´åº¦
    /**
     * çœç«‹åŒå¾·æ‚£è€…满意度趋势(按周/月/å¹´/日补零)。
     */
    List<MydTrendReqDTO> mydTrend(MydTrendReqVO mydTrendReqVO);
    /**
     * çœç«‹åŒå¾·æ‚£è€…满意度——按维度(科室/院区)统计。
     */
    List<MydTrendReqDTO> sltdMydTotalByDimension(MydTrendReqVO mydTrendReqVO);
    // ================================================================ é—¨è¯Šå¾—分排名
    /**
     * é—¨è¯Šæ»¡æ„åº¦ç»¼åˆå¾—分排名(按科室或病区)。
     * <p>
     * å…¥å‚缺省处理保留原实现:{@code dimensionType} ä¸ºç©ºåˆ™ç½® {@code "1"};
     * {@code serviceTypes} ä¸ºç©ºåˆ™ç½® {@code ["14"]}(门诊满意度)。
     */
    List<MzMydScoreRankDTO> mzMydScoreRank(MzMydScoreRankVO mzMydScoreRankVO);
    /**
     * æŒ‰æœºæž„/院区/服务类型/出院时间/科室病区取子任务引用(只含 id、taskid)。
     * <p>
     * ä¾› {@code /sltdMydTotalByScore} ä½¿ç”¨ï¼šåŽŸå®žçŽ°é€ä¸ª serviceType è°ƒç”¨
     * {@code getServiceSubtasks} å†è½¬æˆ {@code ServiceSubtaskRes},现改为本方法。
     */
    List<SatisfactionSubtaskRef> getMydSubtaskRefs(SatisfactionSubtaskQuery query);
    /**
     * èŽ·å–æ»¡æ„åº¦é—®é¢˜åˆ†ç±»ï¼ˆè¯­éŸ³ + é—®å·ï¼‰ï¼Œå¸¦ç¼“存。
     * <p>
     * ä¾›ç™»å½•链路使用(原 {@code smartor.UserExtInfoServiceImpl.getSatisfactionCategories})。
     */
    Map<String, Object> getSatisfactionCategories(String orgid);
    /**
     * èŽ·å–æ»¡æ„åº¦å¼‚å¸¸é…ç½®é¢˜ç›®ï¼ˆè¯­éŸ³ + é—®å·ï¼‰ï¼Œç”¨äºŽã€Œæ»¡æ„åº¦å¼‚常配置」页面。
     * <p>
     * è¿ç§»è¯´æ˜Žï¼šåŽŸä¸º {@code com.smartor.service.IServiceSubtaskDetailTraceService#getSatisfactionExceptConfigQuestion},
     * æœåŠ¡å®žçŽ°è½åœ¨éšè®¿å¼‚å¸¸è·Ÿè¸ª Service é‡Œï¼Œä½†é€»è¾‘完全是满意度分类/题目的查询,
     * æ•…按模块化要求搬入本模块。<b>返回结构、字段名、筛选条件均与原文一致</b>:
     * <pre>
     * {
     *   "voiceDataList":         [ {questionId, libtemplateid, questionContent, questionTopic, questionType, categoryName, categoryId}, ... ],
     *   "questionnaireDataList": [ åŒä¸Š ]
     * }
     * </pre>
     * <b>保留的原文不对称行为</b>(不要"统一"):
     * <ul>
     *   <li>{@code scriptType} ä¸ºç©ºæ—¶ï¼Œè¯­éŸ³ä¸Žé—®å·<b>两段都执行</b>,两个 key éƒ½ä¼šå‡ºçŽ°ï¼›</li>
     *   <li>语音侧的分类取的是服务方法(拼树后<b>只返回顶层分类</b>),问卷侧用的是 Mapper(取<b>全部</b>分类);</li>
     *   <li>{@code type} ä¸ºç©ºæ—¶æŒ‰ {@code "myd"} è¿‡æ»¤ï¼Œå…¥å‚有值时覆盖。</li>
     * </ul>
     *
     * @param questionReqVO å…¥å‚(scriptType:1 è¯­éŸ³ / 2 é—®å·ï¼Œä¸ºç©ºå–两者;type:满意度类型,默认 myd)
     * @return è¯­éŸ³ä¸Žé—®å·çš„题目列表
     */
    Map<String, Object> getSatisfactionExceptConfigQuestion(SatisfactionExceptConfigQuestionReqVO questionReqVO);
    /**
     * éšè®¿ç»Ÿè®¡é¡µçš„「满意度指数」:满意度题目总量 / å¡«æŠ¥é‡ / å®Œæˆæ¯”例。
     * <p>
     * è¿ç§»è¯´æ˜Žï¼šåŽŸä¸º {@code com.smartor.service.impl.ServiceSubtaskServiceImpl#getSatisfactionIndex}
     * ï¼ˆç§æœ‰æ–¹æ³•,被 {@code getSfStatisticsJoy} è°ƒç”¨ï¼‰ã€‚
     * <p>
     * âš ï¸ <b>原实现缺陷已原样保留</b>:由于 {@code BigDecimal.add(...)} çš„返回值被丢弃,
     * æœ¬æ–¹æ³•实际上恒定返回 {@code {mydtmzl:0, mydtbl:0, wcbl:0.0}}(但查询照旧执行)。
     * è¯¦è§å®žçŽ°ç±»ä¸Šçš„æ³¨é‡Šã€‚
     *
     * @param type          "1"=按科室,"2"=按病区
     * @param code          ç§‘室/病区编码
     * @param orgid         æœºæž„ id
     * @param ivrCategoryid è¯­éŸ³æ»¡æ„åº¦åˆ†ç±» id(可为 null)
     * @param svyCategoryid é—®å·æ»¡æ„åº¦åˆ†ç±» id(可为 null)
     * @return {@code mydtmzl}(Integer) / {@code mydtbl}(Integer) / {@code wcbl}(Double)
     */
    Map<String, Object> getSatisfactionIndex(String type, String code, String orgid,
                                            Long ivrCategoryid, Long svyCategoryid);
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/service/impl/PatSatisfactionServiceImpl.java
ÎļþÃû´Ó smartor/src/main/java/com/smartor/service/impl/PatSatisfactionServiceImpl.java ÐÞ¸Ä
@@ -1,4 +1,4 @@
package com.smartor.service.impl;
package com.ruoyi.satisfaction.service.impl;
import java.math.BigDecimal;
import java.math.RoundingMode;
@@ -12,18 +12,27 @@
import com.ruoyi.common.utils.ArrayListUtils;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.DtoConversionUtils;
import com.smartor.domain.*;
import com.smartor.domain.DTO.ServiceSubtaskDetailDTO;
import com.smartor.domain.VO.PatSatisfactionReqVO;
import com.smartor.domain.VO.PatSatisfactionResVO;
import com.smartor.domain.entity.PatSatisfactionDetailEntity;
import com.smartor.domain.entity.SatisfactionStatQuery;
import com.smartor.domain.entity.ServiceSubtaskSatisfactionEntity;
import com.smartor.mapper.*;
import com.ruoyi.satisfaction.domain.vo.PatSatisfactionReqVO;
import com.ruoyi.satisfaction.domain.vo.PatSatisfactionResVO;
import com.ruoyi.satisfaction.domain.entity.PatSatisfactionDetailEntity;
import com.ruoyi.satisfaction.domain.entity.SatisfactionStatQuery;
import com.ruoyi.satisfaction.domain.entity.ServiceSubtaskSatisfactionEntity;
import com.ruoyi.satisfaction.mapper.PatSatisfactionMapper;
import com.ruoyi.satisfaction.mapper.SatisfactionMapper;
import com.ruoyi.satisfaction.mapper.SatisfactionScriptMapper;
import com.ruoyi.satisfaction.domain.row.SatisfactionCategoryRow;
import com.ruoyi.satisfaction.domain.row.SatisfactionIvrScriptRow;
import com.ruoyi.satisfaction.domain.row.SatisfactionOptionScoreRow;
import com.ruoyi.satisfaction.domain.row.SatisfactionScriptQuery;
import com.ruoyi.satisfaction.domain.row.SatisfactionSvyScriptRow;
import com.ruoyi.satisfaction.domain.ServiceSubtaskDetailRatioExport;
import com.ruoyi.satisfaction.service.strategy.SatisfactionScoreStrategyResolver;
import com.ruoyi.satisfaction.service.strategy.SatisfactionSites;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.smartor.service.IPatSatisfactionService;
import com.ruoyi.satisfaction.domain.PatSatisfaction;
import com.ruoyi.satisfaction.service.IPatSatisfactionService;
import static com.ruoyi.common.utils.SecurityUtils.getLoginUser;
@@ -39,28 +48,21 @@
    private PatSatisfactionMapper patSatisfactionMapper;
    @Autowired
    private ServiceSubtaskMapper serviceSubtaskMapper;
    private SatisfactionMapper satisfactionMapper;
    @Autowired
    private ServiceSubtaskDetailMapper serviceSubtaskDetailMapper;
    private SatisfactionScriptMapper satisfactionScriptMapper;
    /**
     * ç«™ç‚¹å£å¾„策略解析器。
     * <p>
     * æœ¬ç±»çš„ {@code averageScoreByStat} å°±æ˜¯ã€Œé€šç”¨å£å¾„(南华)」的综合满意度得分口径,
     * çŽ°æ”¹ä¸ºå§”æ‰˜ {@link com.ruoyi.satisfaction.service.strategy.CommonSatisfactionScoreStrategy},
     * ä¸Žçœç«‹åŒå¾·å£å¾„({@code SltdSatisfactionScoreStrategy})通过同一策略接口并列,
     * é¿å…åŒä¸€ä¸ªæ¦‚念在两处各写一份实现。
     */
    @Autowired
    private IvrLibaTemplateScriptMapper ivrLibaTemplateScriptMapper;
    @Autowired
    private IvrLibaScriptAssortMapper ivrLibaScriptAssortMapper;
    @Autowired
    private SvyLibScriptCategoryMapper svyLibScriptCategoryMapper;
    @Autowired
    private IvrLibaTemplateTargetoptionMapper ivrLibaTemplateTargetoptionMapper;
    @Autowired
    private SvyLibTemplateTargetoptionMapper svyLibTemplateTargetoptionMapper;
    @Autowired
    private SvyLibTemplateScriptMapper svyLibTemplateScriptMapper;
    private SatisfactionScoreStrategyResolver satisfactionScoreStrategyResolver;
    /**
     * æŸ¥è¯¢æ‚£è€…满意度
@@ -201,41 +203,33 @@
        int[] personTotals = new int[2];
        // åªæŸ¥æœ¬æ¬¡æ¡ä»¶ä¸‹å®žé™…存在哪些 type(原来是把接近全表的上百个字段查回来,仅用于判断下面两个分支是否为空)
        List<String> types = serviceSubtaskMapper.selectSatisfactionSubtaskTypes(patSatisfactionReqVO);
        List<String> types = satisfactionMapper.selectSatisfactionSubtaskTypes(patSatisfactionReqVO);
        boolean hasVoice = types != null && types.contains("1");
        boolean hasQuestionnaire = types != null && types.contains("2");
        if (hasVoice) {
            //1. å…ˆèŽ·å–ivr_liba_script_assort的满意度分类ID
            IvrLibaScriptAssort ivrLibaScriptAssort = new IvrLibaScriptAssort();
            ivrLibaScriptAssort.setOrgid(patSatisfactionReqVO.getOrgid());
            List<IvrLibaScriptAssort> ivrLibaScriptAssorts = ivrLibaScriptAssortMapper.selectIvrLibaScriptAssortList(ivrLibaScriptAssort);
            List<Long> mydIdList = ivrLibaScriptAssorts.stream().filter(item -> item.getType() != null && item.getType().contains("myd")).map(IvrLibaScriptAssort::getId).collect(Collectors.toList());
            List<SatisfactionCategoryRow> ivrLibaScriptAssorts = satisfactionScriptMapper.selectIvrCategoryList(ivrCategoryQuery(patSatisfactionReqVO));
            List<Long> mydIdList = ivrLibaScriptAssorts.stream().filter(item -> item.getType() != null && item.getType().contains("myd")).map(SatisfactionCategoryRow::getId).collect(Collectors.toList());
            //2 å†èŽ·å–è¯¥é—®é¢˜æ»¡æ„åº¦åˆ†ç±»ä¸‹çš„IvrLibaTemplateScript的集合
            List<IvrLibaTemplateScript> ivrLibaTemplateScripts = ivrLibaTemplateScriptMapper.queryLibTemplateIds(mydIdList);
            List<SatisfactionIvrScriptRow> ivrLibaTemplateScripts = satisfactionScriptMapper.queryIvrScriptByAssort(assortQuery(mydIdList));
            //3 ä¸€æ¬¡ç»Ÿè®¡å…¨éƒ¨é—®é¢˜çš„发送量/填报量/选项分值(原来是每个问题各查 3 æ¬¡ï¼‰
            List<SatScriptRef> scriptRefs = new ArrayList<>();
            for (IvrLibaTemplateScript ivrLibaTemplateScript : ivrLibaTemplateScripts) {
            for (SatisfactionIvrScriptRow ivrLibaTemplateScript : ivrLibaTemplateScripts) {
                scriptRefs.add(new SatScriptRef(ivrLibaTemplateScript.getTemplateid(), ivrLibaTemplateScript.getScriptid(), ivrLibaTemplateScript.getScriptContent()));
            }
            buildSatisfactionDetailEntities(patSatisfactionReqVO, "1", true, scriptRefs, patSatisfactionDetailEntities, personTotals);
        }
        if (hasQuestionnaire) {
            //1. å…ˆèŽ·å–svy_lib_script_category的满意度分类ID
            SvyLibScriptCategory svyLibScriptCategory = new SvyLibScriptCategory();
            svyLibScriptCategory.setOrgid(patSatisfactionReqVO.getOrgid());
            List<SvyLibScriptCategory> svyLibScriptCategories = svyLibScriptCategoryMapper.selectSvyLibScriptCategoryList(svyLibScriptCategory);
            List<Long> mydIdList = svyLibScriptCategories.stream().filter(item -> item.getType() != null && item.getType().contains("myd")).map(SvyLibScriptCategory::getId).collect(Collectors.toList());
            List<SatisfactionCategoryRow> svyLibScriptCategories = satisfactionScriptMapper.selectSvyCategoryList(svyCategoryQuery(patSatisfactionReqVO));
            List<Long> mydIdList = svyLibScriptCategories.stream().filter(item -> item.getType() != null && item.getType().contains("myd")).map(SatisfactionCategoryRow::getId).collect(Collectors.toList());
            //2 å†èŽ·å–è¯¥é—®é¢˜æ»¡æ„åº¦åˆ†ç±»ä¸‹çš„IvrLibaTemplateScript的集合
            List<SvyLibTemplateScript> svyLibTemplateScripts = svyLibTemplateScriptMapper.querySvyLibTemplateIds(mydIdList);
            List<SatisfactionSvyScriptRow> svyLibTemplateScripts = satisfactionScriptMapper.querySvyScriptByCategory(categoryQuery(mydIdList));
            //3 ä¸€æ¬¡ç»Ÿè®¡å…¨éƒ¨é—®é¢˜çš„发送量/填报量/选项分值(原来是每个问题各查 3 æ¬¡ï¼‰
            List<SatScriptRef> scriptRefs = new ArrayList<>();
            for (SvyLibTemplateScript svyLibTemplateScript : svyLibTemplateScripts) {
            for (SatisfactionSvyScriptRow svyLibTemplateScript : svyLibTemplateScripts) {
                // é—®å·çš„选项表按 svy_lib_template_script çš„主键关联
                scriptRefs.add(new SatScriptRef(svyLibTemplateScript.getSvyid(), svyLibTemplateScript.getId(), svyLibTemplateScript.getScriptContent()));
            }
@@ -264,21 +258,18 @@
        int[] personTotals = new int[2];
        // åªæŸ¥æœ¬æ¬¡æ¡ä»¶ä¸‹å®žé™…存在哪些 type(原来是把接近全表的上百个字段查回来,仅用于判断下面两个分支是否为空)
        List<String> types = serviceSubtaskMapper.selectSatisfactionSubtaskTypes(patSatisfactionReqVO);
        List<String> types = satisfactionMapper.selectSatisfactionSubtaskTypes(patSatisfactionReqVO);
        boolean hasVoice = types != null && types.contains("1");
        boolean hasQuestionnaire = types != null && types.contains("2");
        if (hasVoice) {
            //1. å…ˆèŽ·å–å…¥å‚ä¸­çš„ç»´æŠ¤é›†åˆ
            IvrLibaTemplateScript iltscript = new IvrLibaTemplateScript();
            iltscript.setDimensionList(patSatisfactionReqVO.getDimensionList());
            //2 å†èŽ·å–è¯¥é—®é¢˜æ»¡æ„åº¦åˆ†ç±»ä¸‹çš„IvrLibaTemplateScript的集合
            List<IvrLibaTemplateScript> ivrLibaTemplateScripts = ivrLibaTemplateScriptMapper.selectILTSBydimension(iltscript);
            List<SatisfactionIvrScriptRow> ivrLibaTemplateScripts = satisfactionScriptMapper.selectIvrScriptByDimension(dimensionQuery(patSatisfactionReqVO));
            if (CollectionUtils.isNotEmpty(ivrLibaTemplateScripts)) {
                //3 ä¸€æ¬¡ç»Ÿè®¡å…¨éƒ¨é—®é¢˜çš„发送量/填报量/选项分值(原来是每个问题各查 3 æ¬¡ï¼‰
                List<SatScriptRef> scriptRefs = new ArrayList<>();
                for (IvrLibaTemplateScript ivrLibaTemplateScript : ivrLibaTemplateScripts) {
                for (SatisfactionIvrScriptRow ivrLibaTemplateScript : ivrLibaTemplateScripts) {
                    scriptRefs.add(new SatScriptRef(ivrLibaTemplateScript.getTemplateid(), ivrLibaTemplateScript.getScriptid(), ivrLibaTemplateScript.getScriptContent()));
                }
                buildSatisfactionDetailEntities(patSatisfactionReqVO, "1", true, scriptRefs, patSatisfactionDetailEntities, personTotals);
@@ -287,14 +278,11 @@
        if (hasQuestionnaire) {
            //1. å…ˆèŽ·å–å…¥å‚ä¸­çš„æ»¡æ„åº¦ç»´åº¦
            SvyLibTemplateScriptVO svyLibTemplateScriptVO = new SvyLibTemplateScriptVO();
            svyLibTemplateScriptVO.setDimensionList(patSatisfactionReqVO.getDimensionList());
            //2 å†èŽ·å–è¯¥é—®é¢˜æ»¡æ„åº¦åˆ†ç±»ä¸‹çš„IvrLibaTemplateScript的集合
            List<SvyLibTemplateScript> svyLibTemplateScripts = svyLibTemplateScriptMapper.selectSLTScriptListBydimension(svyLibTemplateScriptVO);
            List<SatisfactionSvyScriptRow> svyLibTemplateScripts = satisfactionScriptMapper.selectSvyScriptByDimension(dimensionQuery(patSatisfactionReqVO));
            if (CollectionUtils.isNotEmpty(svyLibTemplateScripts)) {
                //3 ä¸€æ¬¡ç»Ÿè®¡å…¨éƒ¨é—®é¢˜çš„发送量/填报量/选项分值(原来是每个问题各查 3 æ¬¡ï¼‰
                List<SatScriptRef> scriptRefs = new ArrayList<>();
                for (SvyLibTemplateScript svyLibTemplateScript : svyLibTemplateScripts) {
                for (SatisfactionSvyScriptRow svyLibTemplateScript : svyLibTemplateScripts) {
                    // é—®å·çš„选项表按 svy_lib_template_script çš„主键关联
                    scriptRefs.add(new SatScriptRef(svyLibTemplateScript.getSvyid(), svyLibTemplateScript.getId(), svyLibTemplateScript.getScriptContent()));
                }
@@ -317,24 +305,20 @@
        Map<String, List<ScoreCount>> assortNameFillCountMap = new HashMap<>();
        if (patSatisfactionReqVO.getType() == null || patSatisfactionReqVO.getType() == 1) {
            //1. å…ˆèŽ·å–ivr_liba_script_assort的满意度分类ID
            IvrLibaScriptAssort ivrLibaScriptAssort = new IvrLibaScriptAssort();
            ivrLibaScriptAssort.setOrgid(patSatisfactionReqVO.getOrgid());
            List<IvrLibaScriptAssort> ivrLibaScriptAssorts = ivrLibaScriptAssortMapper.selectIvrLibaScriptAssortList(ivrLibaScriptAssort);
            List<Long> mydIdList = ivrLibaScriptAssorts.stream().filter(item -> item.getType() != null && item.getType().contains("myd")).map(IvrLibaScriptAssort::getId).collect(Collectors.toList());
            List<SatisfactionCategoryRow> ivrLibaScriptAssorts = satisfactionScriptMapper.selectIvrCategoryList(ivrCategoryQuery(patSatisfactionReqVO));
            List<Long> mydIdList = ivrLibaScriptAssorts.stream().filter(item -> item.getType() != null && item.getType().contains("myd")).map(SatisfactionCategoryRow::getId).collect(Collectors.toList());
            //2 å†èŽ·å–è¯¥é—®é¢˜æ»¡æ„åº¦åˆ†ç±»ä¸‹çš„IvrLibaTemplateScript的集合
            List<IvrLibaTemplateScript> ivrLibaTemplateScripts = ivrLibaTemplateScriptMapper.queryLibTemplateIds(mydIdList);
            List<SatisfactionIvrScriptRow> ivrLibaTemplateScripts = satisfactionScriptMapper.queryIvrScriptByAssort(assortQuery(mydIdList));
            //2.1 ä¸€æ¬¡æ€§æŠŠæœ¬æ¬¡è¯·æ±‚全部模板的发送量、回收人数查回来(原来是每个模板问题各查两次)
            SatisfactionStatQuery statQuery = buildSatisfactionStatQuery(patSatisfactionReqVO, "1",
                    ivrLibaTemplateScripts.stream().map(IvrLibaTemplateScript::getTemplateid).collect(Collectors.toList()));
                    ivrLibaTemplateScripts.stream().map(SatisfactionIvrScriptRow::getTemplateid).collect(Collectors.toList()));
            Map<Long, Long> sendCountMap = mapSatisfactionSendCount(statQuery);
            SatisfactionFillStat fillStat = querySatisfactionFillStat(statQuery);
            Map<Long, Long> fillPersonCountMap = mapSatisfactionFillPersonCount(statQuery);
            //3 æŒ‰scriptAssortname分组,并获取每个集合进行遍历,计算每个分类的总人数和填报人数
            Map<String, List<IvrLibaTemplateScript>> scriptGroupByAssortName = ivrLibaTemplateScripts.stream().filter(item -> item.getScriptAssortname() != null).collect(Collectors.groupingBy(IvrLibaTemplateScript::getScriptAssortname));
            Map<String, List<SatisfactionIvrScriptRow>> scriptGroupByAssortName = ivrLibaTemplateScripts.stream().filter(item -> item.getScriptAssortname() != null).collect(Collectors.groupingBy(SatisfactionIvrScriptRow::getScriptAssortname));
            if (scriptGroupByAssortName != null && !scriptGroupByAssortName.isEmpty()) {
                for (String assortNameKey : scriptGroupByAssortName.keySet()) {
                    // è¯¥åˆ†ç±»æ¶‰åŠçš„去重模板:发送人数/回收人数都按模板统计,
@@ -343,8 +327,8 @@
                    //获取该分类的填报总数量(只用于算平均分,按题目聚合的行数)
                    List<ScoreCount> allFillCountList = new ArrayList<>();
                    List<IvrLibaTemplateScript> ivrLibaTemplateScriptList = scriptGroupByAssortName.get(assortNameKey);
                    for (IvrLibaTemplateScript ivrLibaTemplateScript : ivrLibaTemplateScriptList) {
                    List<SatisfactionIvrScriptRow> ivrLibaTemplateScriptList = scriptGroupByAssortName.get(assortNameKey);
                    for (SatisfactionIvrScriptRow ivrLibaTemplateScript : ivrLibaTemplateScriptList) {
                        Long templateId = ivrLibaTemplateScript.getTemplateid();
                        if (templateId != null) {
                            categoryTemplateIds.add(templateId);
@@ -375,24 +359,20 @@
        }
        if (patSatisfactionReqVO.getType() == null || patSatisfactionReqVO.getType() == 2) {
            //1. å…ˆèŽ·å–svy_lib_script_category的满意度分类ID
            SvyLibScriptCategory svyLibScriptCategory = new SvyLibScriptCategory();
            svyLibScriptCategory.setOrgid(patSatisfactionReqVO.getOrgid());
            List<SvyLibScriptCategory> svyLibScriptCategories = svyLibScriptCategoryMapper.selectSvyLibScriptCategoryList(svyLibScriptCategory);
            List<Long> mydIdList = svyLibScriptCategories.stream().filter(item -> item.getType() != null && item.getType().contains("myd")).map(SvyLibScriptCategory::getId).collect(Collectors.toList());
            List<SatisfactionCategoryRow> svyLibScriptCategories = satisfactionScriptMapper.selectSvyCategoryList(svyCategoryQuery(patSatisfactionReqVO));
            List<Long> mydIdList = svyLibScriptCategories.stream().filter(item -> item.getType() != null && item.getType().contains("myd")).map(SatisfactionCategoryRow::getId).collect(Collectors.toList());
            //2 å†èŽ·å–è¯¥é—®é¢˜æ»¡æ„åº¦åˆ†ç±»ä¸‹çš„IvrLibaTemplateScript的集合
            List<SvyLibTemplateScript> svyLibTemplateScripts = svyLibTemplateScriptMapper.querySvyLibTemplateIds(mydIdList);
            List<SatisfactionSvyScriptRow> svyLibTemplateScripts = satisfactionScriptMapper.querySvyScriptByCategory(categoryQuery(mydIdList));
            //2.1 ä¸€æ¬¡æ€§æŠŠæœ¬æ¬¡è¯·æ±‚全部模板的发送量、回收人数查回来(原来是每个模板问题各查两次)
            SatisfactionStatQuery statQuery = buildSatisfactionStatQuery(patSatisfactionReqVO, "2",
                    svyLibTemplateScripts.stream().map(SvyLibTemplateScript::getSvyid).collect(Collectors.toList()));
                    svyLibTemplateScripts.stream().map(SatisfactionSvyScriptRow::getSvyid).collect(Collectors.toList()));
            Map<Long, Long> sendCountMap = mapSatisfactionSendCount(statQuery);
            SatisfactionFillStat fillStat = querySatisfactionFillStat(statQuery);
            Map<Long, Long> fillPersonCountMap = mapSatisfactionFillPersonCount(statQuery);
            //3 æŒ‰scriptAssortname分组,并获取每个集合进行遍历,计算每个分类的总人数和填报人数
            Map<String, List<SvyLibTemplateScript>> scriptGroupByatCegoryName = svyLibTemplateScripts.stream().filter(item -> item.getCategoryName() != null).collect(Collectors.groupingBy(SvyLibTemplateScript::getCategoryName));
            Map<String, List<SatisfactionSvyScriptRow>> scriptGroupByatCegoryName = svyLibTemplateScripts.stream().filter(item -> item.getCategoryName() != null).collect(Collectors.groupingBy(SatisfactionSvyScriptRow::getCategoryName));
            if (scriptGroupByatCegoryName != null && !scriptGroupByatCegoryName.isEmpty()) {
                for (String categoryNameKey : scriptGroupByatCegoryName.keySet()) {
                    // è¯¥åˆ†ç±»æ¶‰åŠçš„去重模板:发送人数/回收人数都按模板统计,
@@ -401,8 +381,8 @@
                    //获取该分类的填报总数量(只用于算平均分,按题目聚合的行数)
                    List<ScoreCount> allFillCountList = new ArrayList<>();
                    List<SvyLibTemplateScript> svyLibTemplateScriptList = scriptGroupByatCegoryName.get(categoryNameKey);
                    for (SvyLibTemplateScript svyLibTemplateScript : svyLibTemplateScriptList) {
                    List<SatisfactionSvyScriptRow> svyLibTemplateScriptList = scriptGroupByatCegoryName.get(categoryNameKey);
                    for (SatisfactionSvyScriptRow svyLibTemplateScript : svyLibTemplateScriptList) {
                        Long templateId = svyLibTemplateScript.getSvyid();
                        if (templateId != null) {
                            categoryTemplateIds.add(templateId);
@@ -419,7 +399,6 @@
                        subidAll += sendCountMap.getOrDefault(templateId, 0L);
                        fillCountAll += fillPersonCountMap.getOrDefault(templateId, 0L);
                    }
                    // è¿™é‡Œå…ˆè®°å½•一下每个分类的总人数和填报人数
                    Map<String, Object> personCount = (Map<String, Object>) zzt.get(categoryNameKey);
@@ -449,7 +428,6 @@
        return zzt;
    }
    @Override
    public Map<String, Object> satisfactionGraphDimension(PatSatisfactionReqVO patSatisfactionReqVO) {
        //用来记录柱状图信息(key=满意度维度名称,value=该维度下的总人数和填报人数)
@@ -459,21 +437,17 @@
        Map<String, List<ScoreCount>> assortNameFillCountMap = new HashMap<>();
        if (patSatisfactionReqVO.getType() == null || patSatisfactionReqVO.getType() == 1) {
            //1. ä»Žå…¥å‚中获取满意度维维度
            IvrLibaTemplateScript ivrLibaTemplateScript2 = new IvrLibaTemplateScript();
            ivrLibaTemplateScript2.setDimensionList(patSatisfactionReqVO.getDimensionList());
            //2 å†èŽ·å–è¯¥é—®é¢˜æ»¡æ„åº¦åˆ†ç±»ä¸‹çš„IvrLibaTemplateScript的集合
            List<IvrLibaTemplateScript> ivrLibaTemplateScripts = ivrLibaTemplateScriptMapper.selectILTSBydimension(ivrLibaTemplateScript2);
            List<SatisfactionIvrScriptRow> ivrLibaTemplateScripts = satisfactionScriptMapper.selectIvrScriptByDimension(dimensionQuery(patSatisfactionReqVO));
            if (ivrLibaTemplateScripts != null && !ivrLibaTemplateScripts.isEmpty()) {
                //3 æŒ‰dimension维度分组,并获取每个集合进行遍历,计算每个分类的总人数和填报人数
                List<IvrLibaTemplateScript> dimensionScripts = ivrLibaTemplateScripts.stream().filter(item -> item.getDimension() != null).collect(Collectors.toList());
                Map<String, List<IvrLibaTemplateScript>> groupByDimension = dimensionScripts.stream().collect(Collectors.groupingBy(IvrLibaTemplateScript::getDimension));
                List<SatisfactionIvrScriptRow> dimensionScripts = ivrLibaTemplateScripts.stream().filter(item -> item.getDimension() != null).collect(Collectors.toList());
                Map<String, List<SatisfactionIvrScriptRow>> groupByDimension = dimensionScripts.stream().collect(Collectors.groupingBy(SatisfactionIvrScriptRow::getDimension));
                if (groupByDimension != null && !groupByDimension.isEmpty()) {
                    //3.1 ä¸€æ¬¡æ€§æŠŠæœ¬æ¬¡è¯·æ±‚全部模板的发送量、回收人数查回来(原来是每个模板问题各查两次)
                    SatisfactionStatQuery statQuery = buildSatisfactionStatQuery(patSatisfactionReqVO, "1",
                            dimensionScripts.stream().map(IvrLibaTemplateScript::getTemplateid).collect(Collectors.toList()));
                            dimensionScripts.stream().map(SatisfactionIvrScriptRow::getTemplateid).collect(Collectors.toList()));
                    Map<Long, Long> sendCountMap = mapSatisfactionSendCount(statQuery);
                    SatisfactionFillStat fillStat = querySatisfactionFillStat(statQuery);
                    Map<Long, Long> fillPersonCountMap = mapSatisfactionFillPersonCount(statQuery);
@@ -484,8 +458,8 @@
                        //获取该维度的填报分值分布(只用于算平均分)
                        List<ScoreCount> allFillCountList = new ArrayList<>();
                        List<IvrLibaTemplateScript> ivrLibaTemplateScriptList = groupByDimension.get(dimension);
                        for (IvrLibaTemplateScript ivrLibaTemplateScript : ivrLibaTemplateScriptList) {
                        List<SatisfactionIvrScriptRow> ivrLibaTemplateScriptList = groupByDimension.get(dimension);
                        for (SatisfactionIvrScriptRow ivrLibaTemplateScript : ivrLibaTemplateScriptList) {
                            Long templateId = ivrLibaTemplateScript.getTemplateid();
                            if (templateId != null) {
                                dimensionTemplateIds.add(templateId);
@@ -518,19 +492,16 @@
        if (patSatisfactionReqVO.getType() == null || patSatisfactionReqVO.getType() == 2) {
            //1.  ä»Žå…¥å‚中获取满意度维维度
            SvyLibTemplateScriptVO svyLibTemplateScriptVO = new SvyLibTemplateScriptVO();
            svyLibTemplateScriptVO.setDimensionList(patSatisfactionReqVO.getDimensionList());
            //2 å†èŽ·å–è¯¥é—®é¢˜æ»¡æ„åº¦åˆ†ç±»ä¸‹çš„IvrLibaTemplateScript的集合
            List<SvyLibTemplateScript> svyLibTemplateScripts = svyLibTemplateScriptMapper.selectSLTScriptListBydimension(svyLibTemplateScriptVO);
            List<SatisfactionSvyScriptRow> svyLibTemplateScripts = satisfactionScriptMapper.selectSvyScriptByDimension(dimensionQuery(patSatisfactionReqVO));
            if (svyLibTemplateScripts != null && !svyLibTemplateScripts.isEmpty()) {
                //3 æŒ‰dimension维度分组,并获取每个集合进行遍历,计算每个分类的总人数和填报人数
                List<SvyLibTemplateScript> dimensionScripts = svyLibTemplateScripts.stream().filter(item -> item.getDimension() != null).collect(Collectors.toList());
                Map<String, List<SvyLibTemplateScript>> groupByDimension = dimensionScripts.stream().collect(Collectors.groupingBy(SvyLibTemplateScript::getDimension));
                List<SatisfactionSvyScriptRow> dimensionScripts = svyLibTemplateScripts.stream().filter(item -> item.getDimension() != null).collect(Collectors.toList());
                Map<String, List<SatisfactionSvyScriptRow>> groupByDimension = dimensionScripts.stream().collect(Collectors.groupingBy(SatisfactionSvyScriptRow::getDimension));
                if (groupByDimension != null && !groupByDimension.isEmpty()) {
                    //3.1 ä¸€æ¬¡æ€§æŠŠæœ¬æ¬¡è¯·æ±‚全部模板的发送量、回收人数查回来(原来是每个模板问题各查两次)
                    SatisfactionStatQuery statQuery = buildSatisfactionStatQuery(patSatisfactionReqVO, "2",
                            dimensionScripts.stream().map(SvyLibTemplateScript::getSvyid).collect(Collectors.toList()));
                            dimensionScripts.stream().map(SatisfactionSvyScriptRow::getSvyid).collect(Collectors.toList()));
                    Map<Long, Long> sendCountMap = mapSatisfactionSendCount(statQuery);
                    SatisfactionFillStat fillStat = querySatisfactionFillStat(statQuery);
                    Map<Long, Long> fillPersonCountMap = mapSatisfactionFillPersonCount(statQuery);
@@ -541,8 +512,8 @@
                        //获取该分类的填报分值分布(只用于算平均分)
                        List<ScoreCount> allFillCountList = new ArrayList<>();
                        List<SvyLibTemplateScript> svyLibTemplateScriptList = groupByDimension.get(dimension);
                        for (SvyLibTemplateScript svyLibTemplateScript : svyLibTemplateScriptList) {
                        List<SatisfactionSvyScriptRow> svyLibTemplateScriptList = groupByDimension.get(dimension);
                        for (SatisfactionSvyScriptRow svyLibTemplateScript : svyLibTemplateScriptList) {
                            Long templateId = svyLibTemplateScript.getSvyid();
                            if (templateId != null) {
                                dimensionTemplateIds.add(templateId);
@@ -559,7 +530,6 @@
                            subidAll += sendCountMap.getOrDefault(templateId, 0L);
                            fillCountAll += fillPersonCountMap.getOrDefault(templateId, 0L);
                        }
                        // è¿™é‡Œå…ˆè®°å½•一下每个分类的总人数和填报人数
                        Map<String, Object> personCount = (Map<String, Object>) zzt.get(dimension);
@@ -590,53 +560,7 @@
        return zzt;
    }
    /**
     * èŽ·å–æœ€é«˜åˆ†
     *
     * @param options
     * @return
     */
    private Double maxScore(List<IvrLibaTemplateTargetoption> options) {
        if (CollectionUtils.isEmpty(options)) return 0.0;
        Double maxScore = options.stream()
                .map(IvrLibaTemplateTargetoption::getScore)
                .filter(scoreStr -> scoreStr != null && !scoreStr.isEmpty())
                .mapToDouble(Double::parseDouble)
                .max()
                .orElse(0.0);
        return maxScore;
    }
    /**
     * èŽ·å–æœ€ä½Žåˆ†
     *
     * @param options
     * @return
     */
    private Double minScore(List<IvrLibaTemplateTargetoption> options) {
        if (CollectionUtils.isEmpty(options)) return 0.0;
        Double minScore = options.stream()
                .map(IvrLibaTemplateTargetoption::getScore)
                .filter(scoreStr -> scoreStr != null && !scoreStr.isEmpty())
                .mapToDouble(Double::parseDouble)
                .min()
                .orElse(0.0);
        return minScore;
    }
    /**
     * èŽ·å–å¹³å‡åˆ†
     *
     * @param fillCount
     * @return
     */
    private Double averageScore(List<ServiceSubtaskDetailDTO> fillCount) {
        if (CollectionUtils.isEmpty(fillCount)) return 0.0;
        double sum = fillCount.stream().filter(dto -> dto.getScore() != null && !dto.getScore().isEmpty()).mapToDouble(dto -> Double.parseDouble(dto.getScore())).sum();
        return new BigDecimal(sum / fillCount.size()).setScale(1, RoundingMode.HALF_UP).doubleValue();
    }
    // ==================== satisfactionGraph èšåˆç»Ÿè®¡ï¼ˆæ›¿ä»£é€ä¸ªæ¨¡æ¿æŸ¥åº“) ====================
                // ==================== satisfactionGraph èšåˆç»Ÿè®¡ï¼ˆæ›¿ä»£é€ä¸ªæ¨¡æ¿æŸ¥åº“) ====================
    /**
     * æž„建聚合统计入参(取值来源与原 DtoConversionUtils.sourceToTarget(patSatisfactionReqVO, ServiceSubtaskSatisfactionEntity.class) ä¸€è‡´ï¼‰
@@ -667,7 +591,7 @@
    private Map<Long, Long> mapSatisfactionSendCount(SatisfactionStatQuery statQuery) {
        Map<Long, Long> sendCountMap = new HashMap<>();
        if (statQuery == null || CollectionUtils.isEmpty(statQuery.getLibTemplateIds())) return sendCountMap;
        List<Map<String, Object>> rows = serviceSubtaskMapper.selectSatisfactionSendCount(statQuery);
        List<Map<String, Object>> rows = satisfactionMapper.selectSatisfactionSendCount(statQuery);
        if (CollectionUtils.isEmpty(rows)) return sendCountMap;
        for (Map<String, Object> row : rows) {
            Long templateId = toLong(row.get("libtemplateid"));
@@ -685,7 +609,7 @@
    private Map<Long, Long> mapSatisfactionFillPersonCount(SatisfactionStatQuery statQuery) {
        Map<Long, Long> fillPersonCountMap = new HashMap<>();
        if (statQuery == null || CollectionUtils.isEmpty(statQuery.getLibTemplateIds())) return fillPersonCountMap;
        List<Map<String, Object>> rows = serviceSubtaskDetailMapper.selectSatisfactionFillPersonCount(statQuery);
        List<Map<String, Object>> rows = satisfactionMapper.selectSatisfactionFillPersonCount(statQuery);
        if (CollectionUtils.isEmpty(rows)) return fillPersonCountMap;
        for (Map<String, Object> row : rows) {
            Long templateId = toLong(row.get("libtemplateid"));
@@ -701,7 +625,7 @@
    private SatisfactionFillStat querySatisfactionFillStat(SatisfactionStatQuery statQuery) {
        SatisfactionFillStat fillStat = new SatisfactionFillStat();
        if (statQuery == null || CollectionUtils.isEmpty(statQuery.getLibTemplateIds())) return fillStat;
        List<Map<String, Object>> rows = serviceSubtaskDetailMapper.selectSatisfactionFillStat(statQuery);
        List<Map<String, Object>> rows = satisfactionMapper.selectSatisfactionFillStat(statQuery);
        if (CollectionUtils.isEmpty(rows)) return fillStat;
        for (Map<String, Object> row : rows) {
            Long templateId = toLong(row.get("libtemplateid"));
@@ -719,22 +643,31 @@
    /**
     * èŽ·å–å¹³å‡åˆ†ï¼ˆèšåˆç‰ˆï¼šåˆ†å€¼ä¸Žæ¡æ•°ï¼‰
     * <p>
     * å£å¾„与 {@link #averageScore(List)} å®Œå…¨ä¸€è‡´ï¼šåˆ†å­ä¸ºå¯è§£æžåˆ†å€¼ä¹‹å’Œï¼Œåˆ†æ¯ä¸ºæ˜Žç»†è¡Œæ•°ï¼ˆåŒ…含分值为空的行)。
     * å£å¾„与原 {@code averageScore(List)} å®Œå…¨ä¸€è‡´ï¼šåˆ†å­ä¸ºå¯è§£æžåˆ†å€¼ä¹‹å’Œï¼Œåˆ†æ¯ä¸ºæ˜Žç»†è¡Œæ•°ï¼ˆåŒ…含分值为空的行)。
     * æ³¨æ„ä¸èƒ½æ”¹æˆ sql çš„ avg() æˆ–「有分值的行数」做分母,否则返回的 averageScore ä¼šå˜ã€‚
     * <p>
     * <b>策略化(2026)</b>:真正的算法已收敛到「通用口径(南华)」策略
     * {@link com.ruoyi.satisfaction.service.strategy.CommonSatisfactionScoreStrategy},
     * æœ¬æ–¹æ³•只做入参适配(把内部 {@code ScoreCount} è½¬æˆç»Ÿä¸€çš„占比行)。
     * <b>计算结果与迁移前逐位相同</b>(保留 1 ä½ã€HALF_UP;无行时 0.0)。
     */
    private Double averageScoreByStat(List<ScoreCount> fillCount) {
        if (CollectionUtils.isEmpty(fillCount)) return 0.0;
        double sum = 0.0;
        long rows = 0L;
        // é€‚配成策略的统一入参:只带 score ä¸Ž count ä¸¤ä¸ªå­—段(策略只用到这两个)
        List<ServiceSubtaskDetailRatioExport> rows = new ArrayList<>(fillCount.size());
        for (ScoreCount scoreCount : fillCount) {
            if (scoreCount.getCount() <= 0L) continue;
            rows += scoreCount.getCount();
            if (scoreCount.getScore() != null && !scoreCount.getScore().isEmpty()) {
                sum += Double.parseDouble(scoreCount.getScore()) * scoreCount.getCount();
            }
            if (scoreCount.getCount() <= 0L) continue;   // ä¸ŽåŽŸæ–‡ä¸€è‡´ï¼šéžæ­£æ•°çš„èšåˆè¡Œä¸å‚ä¸Ž
            ServiceSubtaskDetailRatioExport row = new ServiceSubtaskDetailRatioExport();
            row.setScore(scoreCount.getScore());
            row.setCount(String.valueOf(scoreCount.getCount()));
            rows.add(row);
        }
        if (rows == 0L) return 0.0;
        return new BigDecimal(sum / rows).setScale(1, RoundingMode.HALF_UP).doubleValue();
        BigDecimal average = satisfactionScoreStrategyResolver
                .resolve(SatisfactionSites.COMMON)
                .comprehensiveScore(rows);
        return average == null ? 0.0 : average.doubleValue();
    }
    /**
@@ -863,7 +796,7 @@
    private SatisfactionFillStatByMatchedtext querySatisfactionFillStatByMatchedtext(SatisfactionStatQuery statQuery) {
        SatisfactionFillStatByMatchedtext fillStat = new SatisfactionFillStatByMatchedtext();
        if (statQuery == null || CollectionUtils.isEmpty(statQuery.getLibTemplateIds())) return fillStat;
        List<Map<String, Object>> rows = serviceSubtaskDetailMapper.selectSatisfactionFillStatByMatchedtext(statQuery);
        List<Map<String, Object>> rows = satisfactionMapper.selectSatisfactionFillStatByMatchedtext(statQuery);
        if (CollectionUtils.isEmpty(rows)) return fillStat;
        for (Map<String, Object> row : rows) {
            Long templateId = toLong(row.get("libtemplateid"));
@@ -894,34 +827,33 @@
                .filter(Objects::nonNull).distinct().collect(Collectors.toList());
        if (CollectionUtils.isNotEmpty(scriptIds)) {
            addTargetOptionScores(optionScores, voice
                    ? ivrLibaTemplateTargetoptionMapper.selectIvrLibaTargetoptionScores(scriptIds)
                    : svyLibTemplateTargetoptionMapper.selectSvyLibTargetoptionScores(scriptIds));
                    ? satisfactionScriptMapper.selectIvrTargetOptionScores(scriptIds)
                    : satisfactionScriptMapper.selectSvyTargetOptionScores(scriptIds));
        }
        if (scriptRefs.stream().anyMatch(ref -> ref.getOptionScriptId() == null)) {
            optionScores.put(null, collectTargetOptionScores(voice
                    ? ivrLibaTemplateTargetoptionMapper.selectIvrLibaTargetoptionScores(null)
                    : svyLibTemplateTargetoptionMapper.selectSvyLibTargetoptionScores(null)));
                    ? satisfactionScriptMapper.selectIvrTargetOptionScores(null)
                    : satisfactionScriptMapper.selectSvyTargetOptionScores(null)));
        }
        return optionScores;
    }
    private void addTargetOptionScores(Map<Long, List<String>> optionScores, List<Map<String, Object>> rows) {
    private void addTargetOptionScores(Map<Long, List<String>> optionScores,
                                      List<SatisfactionOptionScoreRow> rows) {
        if (CollectionUtils.isEmpty(rows)) return;
        for (Map<String, Object> row : rows) {
            Long scriptId = toLong(row.get("scriptid"));
        for (SatisfactionOptionScoreRow row : rows) {
            Long scriptId = row.getScriptid();
            if (scriptId == null) continue;
            Object score = row.get("score");
            optionScores.computeIfAbsent(scriptId, key -> new ArrayList<>())
                    .add(score == null ? null : String.valueOf(score));
                    .add(row.getScore());
        }
    }
    private List<String> collectTargetOptionScores(List<Map<String, Object>> rows) {
    private List<String> collectTargetOptionScores(List<SatisfactionOptionScoreRow> rows) {
        List<String> scores = new ArrayList<>();
        if (CollectionUtils.isEmpty(rows)) return scores;
        for (Map<String, Object> row : rows) {
            Object score = row.get("score");
            scores.add(score == null ? null : String.valueOf(score));
        for (SatisfactionOptionScoreRow row : rows) {
            scores.add(row.getScore());
        }
        return scores;
    }
@@ -1101,4 +1033,46 @@
            return byTemplateQuestion.get(statKey(templateId, scriptContent));
        }
    }
    // ==================== æ»¡æ„åº¦æ¨¡å—化:查询对象构造 ====================
    // åŽŸå®žçŽ°ç›´æŽ¥ new smartor çš„实体当入参;模块化后统一用 SatisfactionScriptQuery,
    // è¿™é‡ŒæŠŠä¸‰å¤„构造收敛为帮助方法,条件与原来逐字对应。
    /** æ»¡æ„åº¦åˆ†ç±»æŸ¥è¯¢ï¼ˆorgid + type=myd) */
    private SatisfactionScriptQuery ivrCategoryQuery(PatSatisfactionReqVO reqVO) {
        SatisfactionScriptQuery q = new SatisfactionScriptQuery();
        q.setOrgid(reqVO.getOrgid());
        q.setType("myd");
        return q;
    }
    /** æ»¡æ„åº¦åˆ†ç±»æŸ¥è¯¢ï¼ˆé—®å·ä¾§ï¼Œæ¡ä»¶åŒè¯­éŸ³ä¾§ï¼‰ */
    private SatisfactionScriptQuery svyCategoryQuery(PatSatisfactionReqVO reqVO) {
        SatisfactionScriptQuery q = new SatisfactionScriptQuery();
        q.setOrgid(reqVO.getOrgid());
        q.setType("myd");
        return q;
    }
    /** æŒ‰è¯­éŸ³åˆ†ç±» id å–问题 */
    private SatisfactionScriptQuery assortQuery(List<Long> scriptAssortids) {
        SatisfactionScriptQuery q = new SatisfactionScriptQuery();
        q.setScriptAssortids(scriptAssortids);
        return q;
    }
    /** æŒ‰é—®å·åˆ†ç±» id å–问题 */
    private SatisfactionScriptQuery categoryQuery(List<Long> scriptCategoryids) {
        SatisfactionScriptQuery q = new SatisfactionScriptQuery();
        q.setScriptCategoryids(scriptCategoryids);
        return q;
    }
    /** æŒ‰ç»´åº¦å–问题(语音/问卷共用;两侧「dimensionList ä¸ºç©ºæ—¶æ˜¯å¦æ‹¼ dimension IS NOT NULL」的差异在 SQL é‡Œä¿ç•™ï¼‰ */
    private SatisfactionScriptQuery dimensionQuery(PatSatisfactionReqVO reqVO) {
        SatisfactionScriptQuery q = new SatisfactionScriptQuery();
        q.setDimensionList(reqVO.getDimensionList());
        return q;
    }
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/service/impl/SatisfactionSubtaskServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,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;
    }
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/service/strategy/CommonSatisfactionScoreStrategy.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,82 @@
package com.ruoyi.satisfaction.service.strategy;
import com.ruoyi.satisfaction.domain.ServiceSubtaskDetailRatioExport;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
/**
 * é€šç”¨å£å¾„(南华使用)的综合满意度得分策略。
 * <p>
 * <b>口径与原实现完全一致</b>(等于 {@code PatSatisfactionServiceImpl#averageScoreByStat}):
 * <pre>
 *   åˆ†å­ = Î£(可解析的 score Ã— è¯¥åˆ†å€¼çš„æ˜Žç»†è¡Œæ•°)
 *   åˆ†æ¯ = å…¨éƒ¨æ˜Žç»†è¡Œæ•°ï¼ˆ<b>包含分值为空的行</b>)
 *   ç»“æžœ = åˆ†å­ / åˆ†æ¯ï¼Œä¿ç•™ 1 ä½ã€HALF_UP;无行时 0.0
 * </pre>
 * âš ï¸ ä¸¤ä¸ª"不能改"的点(原注释已强调,这里原样保留):
 * <ol>
 *   <li>分母是<b>明细行数</b>,不是"有分值的行数" â€”— æ”¹æˆåŽè€…会改变 {@code averageScore};</li>
 *   <li>分值解析用 {@code Double.parseDouble},脏值会抛 {@code NumberFormatException}
 *       ï¼ˆåŽŸå®žçŽ°å³å¦‚æ­¤ï¼ŒæœªåŠ  try/catch,保持一致)。</li>
 * </ol>
 */
@Component
public class CommonSatisfactionScoreStrategy implements SatisfactionScoreStrategy {
    /**
     * é€šç”¨å£å¾„的保留位数(1 ä½ï¼Œä¸Ž {@code averageScoreByStat} ä¸€è‡´ï¼‰
     */
    private static final int SCALE = 1;
    @Override
    public String site() {
        return SatisfactionSites.COMMON;
    }
    @Override
    public String siteName() {
        return "通用口径(南华)";
    }
    @Override
    public BigDecimal comprehensiveScore(List<ServiceSubtaskDetailRatioExport> rows) {
        if (CollectionUtils.isEmpty(rows)) {
            return BigDecimal.ZERO.setScale(SCALE);
        }
        double sum = 0.0;
        long totalRows = 0L;
        for (ServiceSubtaskDetailRatioExport row : rows) {
            long count = parseCount(row.getCount());
            // ä¸ŽåŽŸæ–‡ä¸€è‡´ï¼šéžæ­£æ•°çš„èšåˆè¡Œç›´æŽ¥è·³è¿‡
            if (count <= 0L) continue;
            totalRows += count;
            String score = row.getScore();
            if (score != null && !score.isEmpty()) {
                sum += Double.parseDouble(score) * count;
            }
        }
        if (totalRows == 0L) {
            return BigDecimal.ZERO.setScale(SCALE);
        }
        return new BigDecimal(sum / totalRows).setScale(SCALE, RoundingMode.HALF_UP);
    }
    /**
     * æ˜Žç»†è¡Œæ•°è§£æžï¼šä¸Ž {@code ScoreCount.getCount()} ç­‰ä»·ï¼ˆåŽŸå§‹æ˜¯ long,这里从字符串取)
     */
    private long parseCount(String count) {
        if (count == null || count.isEmpty()) {
            return 0L;
        }
        try {
            return Long.parseLong(count.trim());
        } catch (NumberFormatException e) {
            return 0L;
        }
    }
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/service/strategy/SatisfactionScoreStrategy.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,44 @@
package com.ruoyi.satisfaction.service.strategy;
import com.ruoyi.satisfaction.domain.ServiceSubtaskDetailRatioExport;
import java.math.BigDecimal;
import java.util.List;
/**
 * æ»¡æ„åº¦ã€Œç«™ç‚¹å£å¾„」策略:<b>一个站点一套综合满意度得分口径</b>。
 * <p>
 * è¿™æ˜¯æœ¬æ¨¡å—用来区分「南华」与「省立同德」两套满意度口径的<b>策略接口</b>:
 * <ul>
 *   <li>{@link CommonSatisfactionScoreStrategy}({@link SatisfactionSites#COMMON})—— é€šç”¨/南华口径;</li>
 *   <li>{@link SltdSatisfactionScoreStrategy}({@link SatisfactionSites#SLTD})—— çœç«‹åŒå¾·å£å¾„。</li>
 * </ul>
 * ç”± {@link SatisfactionScoreStrategyResolver} æŒ‰ç«™ç‚¹æ ‡è¯†é€‰æ‹©å®žçŽ°ï¼›è°ƒç”¨æ–¹ï¼ˆController / Service)
 * åªä¾èµ–本接口 + è§£æžå™¨ï¼Œä¸å†è‡ªå·±å†™ç«™ç‚¹åˆ†æ”¯ â€”— ä»¥åŽæ–°å¢žç«™ç‚¹åªéœ€æ–°å¢žä¸€ä¸ªå®žçŽ°ç±»ã€‚
 * <p>
 * <b>入参统一为</b>{@code List<ServiceSubtaskDetailRatioExport>}("问题详情占比"行:选项 + æ•°é‡ + åˆ†å€¼ï¼‰ï¼Œ
 * å› ä¸ºä¸¤ä¸ªç«™ç‚¹çš„综评入口拿到的都是这个结构。
 */
public interface SatisfactionScoreStrategy {
    /**
     * ç«™ç‚¹æ ‡è¯†ï¼ˆè§„范值,取自 {@link SatisfactionSites})
     */
    String site();
    /**
     * ç«™ç‚¹åç§°ï¼ˆä¸­æ–‡ï¼Œç”¨äºŽæ–‡æ¡£ä¸Žæ—¥å¿—)
     */
    String siteName();
    /**
     * è®¡ç®—「综合满意度得分」。
     * <p>
     * è¿”回值语义由各站点口径自行定义(通用口径是<b>平均分</b>保留 1 ä½ï¼›
     * åŒå¾·å£å¾„是<b>百分制加权得分</b>保留 2 ä½ï¼‰ï¼Œè°ƒç”¨æ–¹æŒ‰ç«™ç‚¹æ—¢æœ‰çº¦å®šå‘ˆçŽ°ã€‚
     *
     * @param rows åŒä¸€é“题(或同一批)的选项占比行
     * @return å¾—分;<b>无法计算时返回 {@code null}</b>(调用方按站点既有约定回退,例如同德回退为 "0.00")
     */
    BigDecimal comprehensiveScore(List<ServiceSubtaskDetailRatioExport> rows);
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/service/strategy/SatisfactionScoreStrategyResolver.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,71 @@
package com.ruoyi.satisfaction.service.strategy;
import com.ruoyi.common.exception.ServiceException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
/**
 * æ»¡æ„åº¦ç«™ç‚¹å£å¾„策略解析器。
 * <p>
 * ç”± Spring æ³¨å…¥å…¨éƒ¨ {@link SatisfactionScoreStrategy} å®žçŽ°ï¼ŒæŒ‰ç«™ç‚¹æ ‡è¯†é€‰æ‹©ï¼›
 * ç«™ç‚¹ä¸ºç©ºæˆ–未知时<b>回落到通用口径</b>并打 WARN(保证调用方永远不会因为
 * "传了个没见过的站点"而失败)。
 * <p>
 * ç”¨æ³•:
 * <pre>
 * BigDecimal score = resolver.resolve(SatisfactionSites.SLTD).comprehensiveScore(rows);
 * </pre>
 * ä»¥åŽæ–°å¢žç«™ç‚¹ï¼šåªåŠ ä¸€ä¸ª {@code SatisfactionScoreStrategy} å®žçŽ°ç±»å³å¯ï¼Œæœ¬ç±»æ— éœ€æ”¹åŠ¨ã€‚
 */
@Slf4j
@Component
public class SatisfactionScoreStrategyResolver {
    private final Map<String, SatisfactionScoreStrategy> strategies;
    private final SatisfactionScoreStrategy fallback;
    public SatisfactionScoreStrategyResolver(List<SatisfactionScoreStrategy> strategyList) {
        Map<String, SatisfactionScoreStrategy> map = new LinkedHashMap<>();
        if (strategyList != null) {
            for (SatisfactionScoreStrategy strategy : strategyList) {
                map.put(SatisfactionSites.normalize(strategy.site()), strategy);
            }
        }
        this.strategies = Collections.unmodifiableMap(map);
        this.fallback = map.get(SatisfactionSites.COMMON);
        if (this.fallback == null) {
            // åªå¯èƒ½åœ¨é…ç½®é”™è¯¯æ—¶å‘生(通用口径实现被误删),尽早暴露
            throw new ServiceException("满意度模块缺少通用口径策略实现(site=" + SatisfactionSites.COMMON + ")");
        }
    }
    /**
     * æŒ‰ç«™ç‚¹å–策略;未知站点回落到通用口径并打 WARN
     *
     * @param site ç«™ç‚¹æ ‡è¯†ï¼ˆ{@link SatisfactionSites} ä¸­çš„常量,或其别名)
     */
    public SatisfactionScoreStrategy resolve(String site) {
        String key = SatisfactionSites.normalize(site);
        SatisfactionScoreStrategy strategy = strategies.get(key);
        if (strategy == null) {
            log.warn("【满意度】未找到站点口径策略 site={},回落到通用口径({})", site, SatisfactionSites.COMMON);
            return fallback;
        }
        return strategy;
    }
    /**
     * å·²æ³¨å†Œçš„站点标识(供校验/文档使用)
     */
    public Set<String> supportedSites() {
        return Collections.unmodifiableSet(new TreeSet<>(strategies.keySet()));
    }
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/service/strategy/SatisfactionSites.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,73 @@
package com.ruoyi.satisfaction.service.strategy;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
/**
 * æ»¡æ„åº¦ã€Œç«™ç‚¹å£å¾„」标识。
 * <p>
 * æœ¬æ¨¡å—用<b>策略</b>区分两套满意度口径(见 {@link SatisfactionScoreStrategy}):
 * <table border="1">
 *   <tr><th>站点</th><th>标识</th><th>口径</th></tr>
 *   <tr>
 *     <td>通用(南华使用;同德也复用其中的 4 ä¸ªé€šç”¨ç«¯ç‚¹ï¼‰</td>
 *     <td>{@link #COMMON}({@code "common"},别名 {@code "nanhua"})</td>
 *     <td>{@link CommonSatisfactionScoreStrategy}:按明细的 {@code score} å­—段求平均(分母含分值为空的行)</td>
 *   </tr>
 *   <tr>
 *     <td>省立同德</td>
 *     <td>{@link #SLTD}({@code "sltd"})</td>
 *     <td>{@link SltdSatisfactionScoreStrategy}:按选项< b>文案</b>判定五档并加权(1/0.8/0.6/0.4/0.2),百分制</td>
 *   </tr>
 * </table>
 * <p>
 * <b>为什么两套口径必须分开</b>:同德的历史数据里 {@code score} æœ‰ {@code 0}、{@code 0000000001}
 * è¿™ç±»è„å€¼ï¼ŒæŒ‰ {@code score} æ±‚平均会把得分固定成 0 æˆ– 100,所以同德只能按选项文案判档;
 * è€Œé€šç”¨å£å¾„的南华数据没有这个问题,改用文案判档反而会让结果与迁移前不一致。
 * å› æ­¤è¿™é‡Œä¸åš"统一算法",而是用策略显式区分。
 */
public final class SatisfactionSites {
    /**
     * é€šç”¨å£å¾„(南华使用,同德的 4 ä¸ªé€šç”¨ç«¯ç‚¹ä¹Ÿèµ°å®ƒï¼‰
     */
    public static final String COMMON = "common";
    /**
     * çœç«‹åŒå¾·å£å¾„
     */
    public static final String SLTD = "sltd";
    /**
     * ç«™ç‚¹åˆ«å â†’ è§„范站点标识(南华没有独占口径,归到通用口径)
     */
    private static final Map<String, String> ALIASES;
    static {
        Map<String, String> aliases = new LinkedHashMap<>();
        aliases.put("nanhua", COMMON);
        aliases.put("通用", COMMON);
        aliases.put("同德", SLTD);
        aliases.put("sld", SLTD);
        ALIASES = Collections.unmodifiableMap(aliases);
    }
    private SatisfactionSites() {
    }
    /**
     * å½’一化站点标识:去空白、转小写、按别名映射;空值返回 {@link #COMMON}
     */
    public static String normalize(String site) {
        if (site == null) {
            return COMMON;
        }
        String key = site.trim().toLowerCase(java.util.Locale.ROOT);
        if (key.isEmpty()) {
            return COMMON;
        }
        String mapped = ALIASES.get(key);
        return mapped == null ? key : mapped;
    }
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/service/strategy/SltdSatisfactionScoreStrategy.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,137 @@
package com.ruoyi.satisfaction.service.strategy;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.satisfaction.domain.ServiceSubtaskDetailRatioExport;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
/**
 * çœç«‹åŒå¾·å£å¾„的综合满意度得分策略(五档加权,百分制)。
 * <p>
 * ç®—法从原 {@code ServiceSubtaskDetailController#calcMydScore} åŠå…¶ 4 ä¸ªç§æœ‰åŠ©æ‰‹
 * <b>原样搬入</b>(一行未改,仅把返回值由 {@code String} æ”¹ä¸º {@link BigDecimal} ä»¥ä¾¿ç»Ÿä¸€ç­–略签名;
 * "无有效项数"这类情况由返回 {@code null} è¡¨è¾¾ï¼Œè°ƒç”¨æ–¹ä»å›žé€€ä¸º {@code "0.00"},对外结果不变)。
 * <p>
 * å£å¾„:
 * <pre>
 *   ç»¼åˆæ»¡æ„åº¦ = (很满意数×1 + æ»¡æ„æ•°Ã—0.8 + ä¸€èˆ¬æ•°Ã—0.6 + ä¸æ»¡æ„æ•°Ã—0.4 + å¾ˆä¸æ»¡æ„æ•°Ã—0.2)
 *              / (五档项数之和) Ã— 100%,保留 2 ä½
 * </pre>
 * <b>为什么按选项文案判档、而不是用明细的 {@code score} å­—段</b>:
 * åŽ†å²æ•°æ®é‡Œ {@code score} æœ‰ {@code 0}、{@code 0000000001} è¿™ç±»è„å€¼ï¼Œ
 * æŒ‰ {@code score} ç®—会把得分固定成 0 æˆ– 100。
 * <p>
 * è·³è¿‡çš„行:多选行({@code valueType=2})、以及「不适用 / æœªç­” / æ‹’答」(不在五档里,
 * æ—¢ä¸è¿›åˆ†å­ä¹Ÿä¸è¿›åˆ†æ¯ï¼‰ã€‚
 */
@Component
public class SltdSatisfactionScoreStrategy implements SatisfactionScoreStrategy {
    /**
     * å¤šé€‰é¢˜çš„问题类型
     */
    private static final String VALUE_TYPE_MULTIPLE = "2";
    /**
     * æ»¡æ„åº¦äº”档选项(顺序与权重顺序一致)
     */
    private static final String[] MYD_SCORE_OPTIONS = {"很满意", "满意", "一般", "不满意", "很不满意"};
    /**
     * æ»¡æ„åº¦äº”档权重:很满意 1、满意 0.8、一般 0.6、不满意 0.4、很不满意 0.2
     */
    private static final BigDecimal[] MYD_SCORE_WEIGHTS = {
            new BigDecimal("1"), new BigDecimal("0.8"), new BigDecimal("0.6"), new BigDecimal("0.4"), new BigDecimal("0.2")};
    @Override
    public String site() {
        return SatisfactionSites.SLTD;
    }
    @Override
    public String siteName() {
        return "省立同德口径";
    }
    /**
     * è®¡ç®—一道题的满意度综合得分(百分制,保留两位小数)。
     *
     * @param exportList åŒä¸€é“题目的选项统计明细
     * @return ç™¾åˆ†åˆ¶ç»¼åˆå¾—分;无有效项数时返回 {@code null}(调用方呈现为 "0.00")
     */
    @Override
    public BigDecimal comprehensiveScore(List<ServiceSubtaskDetailRatioExport> exportList) {
        if (CollectionUtils.isEmpty(exportList)) return null;
        BigDecimal scoreSum = BigDecimal.ZERO;   //分子:Σ(档位权重 Ã— é¡¹æ•°)
        BigDecimal countSum = BigDecimal.ZERO;   //分母:五档项数之和
        for (ServiceSubtaskDetailRatioExport detail : exportList) {
            //多选题一行是多个选项的分值之和,分母只算 1 é¡¹ï¼Œä¼šæŠŠå¾—分算高,不计入综合得分
            if (VALUE_TYPE_MULTIPLE.equals(detail.getValueType())) continue;
            //「不适用」不在公式的五档里,不计入综合得分
            if (isNotApplicable(detail)) continue;
            int optionIndex = mydScoreOptionIndex(detail.getOptionresult());
            BigDecimal count = parseDecimal(detail.getCount());
            if (optionIndex < 0 || count == null || count.signum() <= 0) continue;
            scoreSum = scoreSum.add(MYD_SCORE_WEIGHTS[optionIndex].multiply(count));
            countSum = countSum.add(count);
        }
        if (countSum.signum() <= 0) return null;
        return scoreSum.divide(countSum, 6, RoundingMode.HALF_UP)
                .multiply(BigDecimal.valueOf(100))
                .setScale(2, RoundingMode.HALF_UP);
    }
    /**
     * é€‰é¡¹æ–‡æ¡ˆ â†’ æ»¡æ„åº¦äº”档下标(很满意 0、满意 1、一般 2、不满意 3、很不满意 4),不在五档里返回 -1
     */
    private int mydScoreOptionIndex(String optionresult) {
        if (StringUtils.isEmpty(optionresult)) return -1;
        String text = optionresult.trim();
        for (int i = 0; i < MYD_SCORE_OPTIONS.length; i++) {
            if (MYD_SCORE_OPTIONS[i].equals(text)) return i;
        }
        //兼容「A å¾ˆæ»¡æ„ã€ã€ŒB.满意」这类带选项序号前缀的历史数据
        if (text.length() > 2 && (text.charAt(1) == ' ' || text.charAt(1) == '.' || text.charAt(1) == '、')) {
            String stripped = text.substring(2).trim();
            for (int i = 0; i < MYD_SCORE_OPTIONS.length; i++) {
                if (MYD_SCORE_OPTIONS[i].equals(stripped)) return i;
            }
        }
        return -1;
    }
    /**
     * æ˜¯å¦ã€Œä¸é€‚用」选项
     */
    private boolean isNotApplicable(ServiceSubtaskDetailRatioExport detail) {
        return containsNotApplicable(detail.getOptionresult()) || containsNotApplicable(detail.getTargetvalue());
    }
    private boolean containsNotApplicable(String optionText) {
        if (StringUtils.isEmpty(optionText)) return false;
        String text = optionText.trim();
        return text.contains("不适用") || text.contains("未答") || text.contains("拒答");
    }
    /**
     * å­—符串转 BigDecimal,空值/非法值返回 null
     */
    private BigDecimal parseDecimal(String value) {
        if (StringUtils.isEmpty(value)) return null;
        try {
            return new BigDecimal(value.trim());
        } catch (NumberFormatException e) {
            return null;
        }
    }
}
ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/support/MydOptionRatioChartBuilder.java
ÎļþÃû´Ó smartor/src/main/java/com/smartor/common/MydOptionRatioChartBuilder.java ÐÞ¸Ä
@@ -1,7 +1,7 @@
package com.smartor.common;
package com.ruoyi.satisfaction.support;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.smartor.domain.ServiceSubtaskDetailRatioExport;
import com.ruoyi.satisfaction.domain.ServiceSubtaskDetailRatioExport;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.util.CellRangeAddress;
import org.apache.poi.xddf.usermodel.chart.AxisCrosses;
@@ -30,9 +30,18 @@
import java.util.Map;
/**
 * å¯¼å‡ºã€Œé—®é¢˜è¯¦æƒ…占比图表」Sheet çš„æž„建器
 * å¯¼å‡ºã€Œé—®é¢˜è¯¦æƒ…占比图表」Sheet çš„æž„建器。
 * <p>
 * æŒ‰ä»»åŠ¡åç§°åˆ†ç»„ï¼šæ¯ä¸ªä»»åŠ¡å†™ä¸€å—æ•°æ®ï¼ˆè¡Œ=题目,列=选项,值=数量),并在数据右侧画一张柱状图。
 * <p>
 * <b>迁移说明</b>:原类为 {@code com.smartor.common.MydOptionRatioChartBuilder}。
 * å®ƒå¤„理的数据是满意度模块的 {@link ServiceSubtaskDetailRatioExport}
 * ï¼ˆå³"问题详情占比"结果行),属于满意度导出链路的一部分,
 * å› æ­¤éšæ»¡æ„åº¦ä¸€èµ·æ¬å…¥æœ¬æ¨¡å—。搬家后<b>逻辑一行未改</b>,只调整了包名与 import。
 * <p>
 * è°ƒç”¨æ–¹ï¼ˆéšè®¿å¯¼å‡ºçš„ {@code ServiceSubtaskController#patItemExport})通过
 * {@code SatisfactionFacade#getQuestionOptionRatio} æ‹¿åˆ°æ•°æ®åŽå†äº¤ç»™æœ¬ç±»ï¼Œ
 * ä¸ç›´æŽ¥æŽ¥è§¦æ»¡æ„åº¦æ¨¡å—内部的 Service / Mapper。
 *
 * @author smartor
 */
ruoyi-satisfaction/src/main/resources/mapper/satisfaction/PatSatisfactionMapper.xml
ÎļþÃû´Ó smartor/src/main/resources/mapper/smartor/PatSatisfactionMapper.xml ÐÞ¸Ä
@@ -2,9 +2,9 @@
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.smartor.mapper.PatSatisfactionMapper">
<mapper namespace="com.ruoyi.satisfaction.mapper.PatSatisfactionMapper">
    <resultMap type="com.smartor.domain.PatSatisfaction" id="PatSatisfactionResult">
    <resultMap type="com.ruoyi.satisfaction.domain.PatSatisfaction" id="PatSatisfactionResult">
        <result property="id" column="id"/>
        <result property="patName" column="pat_name"/>
        <result property="patCode" column="pat_code"/>
@@ -55,7 +55,7 @@
        from pat_satisfaction
    </sql>
    <select id="selectPatSatisfactionList" parameterType="com.smartor.domain.PatSatisfaction"
    <select id="selectPatSatisfactionList" parameterType="com.ruoyi.satisfaction.domain.PatSatisfaction"
            resultMap="PatSatisfactionResult">
        <include refid="selectPatSatisfactionVo"/>
        where 1=1
@@ -129,7 +129,7 @@
        where id = #{id}
    </select>
    <insert id="insertPatSatisfaction" parameterType="com.smartor.domain.PatSatisfaction">
    <insert id="insertPatSatisfaction" parameterType="com.ruoyi.satisfaction.domain.PatSatisfaction">
        insert into pat_satisfaction
        <trim prefix="(" suffix=")" suffixOverrides=",">
            <if test="id != null">id,
@@ -225,7 +225,7 @@
        </trim>
    </insert>
    <update id="updatePatSatisfaction" parameterType="com.smartor.domain.PatSatisfaction">
    <update id="updatePatSatisfaction" parameterType="com.ruoyi.satisfaction.domain.PatSatisfaction">
        update pat_satisfaction
        <trim prefix="SET" suffixOverrides=",">
            <if test="patName != null">pat_name =
ruoyi-satisfaction/src/main/resources/mapper/satisfaction/SatisfactionMapper.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,986 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.satisfaction.mapper.SatisfactionMapper">
    <!--
      æ»¡æ„åº¦ç»Ÿè®¡æŸ¥è¯¢ï¼ˆå—华 + åŒå¾·å…±ç”¨çš„æ•°æ®è®¿é—®å±‚)。
      åŽŸæœ¬æ•£è½åœ¨ smartor çš„ ServiceSubtaskMapper.xml é‡Œï¼Œæ»¡æ„åº¦æ¨¡å—化后集中到此处。
      æ³¨æ„ï¼š
      1) è¿™äº› SQL åªè¯» service_subtask / service_subtask_detail / service_task,不做任何写操作;
      2) ä¸€å¾‹ä½¿ç”¨å…¨é™å®šç±»åï¼Œä¸ä¾èµ– typeAliasesPackage åˆ«åï¼›
      3) åŽŸæœ¬ç”¨äºŽæ‰¿æŽ¥ ServiceSubtask å®žä½“çš„ selectSatisfactionSubtaskList ä¸Ž querySendCount å·²ç¡®è®¤
         ä¸ºæ­»ä»£ç ï¼ˆé›¶è°ƒç”¨ç‚¹ï¼‰å¹¶åˆ é™¤ï¼Œå› æ­¤æœ¬æ–‡ä»¶ä¸ä¾èµ–任何共享 <sql> ç‰‡æ®µã€‚
    -->
    <select id="selectSatisfactionSendCount" parameterType="com.ruoyi.satisfaction.domain.entity.SatisfactionStatQuery"
            resultType="java.util.Map">
        select libtemplateid as libtemplateid, count(distinct id) as cnt
        from service_subtask
        where del_flag = 0
        <if test="orgid != null and orgid != ''">
            and orgid = #{orgid}
        </if>
        <if test="campusid != null and campusid != ''">
            and campusid = #{campusid}
        </if>
        <if test="type != null and type != ''">and type = #{type}</if>
        <if test="startTime != null">
            and visit_time &gt;= #{startTime}
        </if>
        <if test="endTime != null">
            and visit_time &lt; date_add(#{endTime}, interval 1 day)
        </if>
        <if test="libTemplateIds != null and libTemplateIds.size() > 0">
            and libtemplateid IN
            <foreach collection="libTemplateIds" item="libTemplateId" open="(" separator="," close=")">
                #{libTemplateId}
            </foreach>
        </if>
        <if test="(hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0) or (deptcodes != null and deptcodes.size() > 0)">
            AND (
            <if test="hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0">
                leavehospitaldistrictcode IN
                <foreach collection="hospitaldistrictcodes" item="hospitaldistrictcode" open="("
                         separator=","
                         close=")">
                    #{hospitaldistrictcode}
                </foreach>
            </if>
            <if test="hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0 and deptcodes != null and deptcodes.size() > 0">
                OR
            </if>
            <if test="deptcodes != null and deptcodes.size() > 0">
                deptcode IN
                <foreach collection="deptcodes" item="deptcode" open="(" separator="," close=")">
                    #{deptcode}
                </foreach>
            </if>
            )
        </if>
        group by libtemplateid
    </select>
    <select id="selectSatisfactionSubtaskTypes" parameterType="com.ruoyi.satisfaction.domain.vo.PatSatisfactionReqVO"
            resultType="java.lang.String">
        select distinct type
        from service_subtask
        where del_flag = 0
        <if test="questionType != null">and type = #{questionType}</if>
        <if test="hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0">
            AND leavehospitaldistrictcode IN
            <foreach collection="hospitaldistrictcodes" item="hospitaldistrictcode" open="(" separator=","
                     close=")">
                #{hospitaldistrictcode}
            </foreach>
        </if>
        <if test="deptcodes != null and deptcodes.size() > 0">
            AND deptcode IN
            <foreach collection="deptcodes" item="deptcode" open="(" separator="," close=")">
                #{deptcode}
            </foreach>
        </if>
        <if test="serviceTypes != null and serviceTypes.size() > 0">
            AND service_type IN
            <foreach collection="serviceTypes" item="serviceType" open="(" separator="," close=")">
                #{serviceType}
            </foreach>
        </if>
        <if test="startTime != null and endTime != null">
            AND visit_time &gt;= #{startTime}
            AND visit_time &lt; date_add(#{endTime}, interval 1 day)
        </if>
    </select>
    <select id="mydTrend" resultType="com.ruoyi.satisfaction.domain.dto.MydTrendReqDTO">
        SELECT CASE #{type}
                   WHEN 'year' THEN DATE_FORMAT(d.create_time, '%Y')
                   WHEN 'quarter' THEN CONCAT(YEAR(d.create_time), '-Q', QUARTER(d.create_time))
                   WHEN 'month' THEN DATE_FORMAT(d.create_time, '%Y-%m')
                   WHEN 'week' THEN FLOOR(DATEDIFF(d.create_time, #{startFinishTime}) / 7) + 1
                   ELSE DATE_FORMAT(d.create_time, '%Y-%m-%d')
                   END                AS period,
               ROUND(avg(d.score), 2) AS totalScore
        FROM service_subtask_detail d
                 JOIN service_task t ON d.taskid = t.taskid
        WHERE t.service_type IN (14)
          AND t.del_flag = 0
          AND d.del_flag = 0
          AND d.create_time >= #{startFinishTime}
          AND d.create_time &lt; DATE_ADD(#{endFinishTime}, INTERVAL 1 DAY)
        GROUP BY period
        ORDER BY period;
    </select>
    <select id="sltdMydTotalByDimension" resultType="com.ruoyi.satisfaction.domain.dto.MydTrendReqDTO">
        SELECT
        p.period,
        dim.dimension,
        IFNULL(ROUND(SUM(d.score), 2), 0) AS totalScore
        FROM (
        <!-- ç”ŸæˆæŸ¥è¯¢èŒƒå›´å†…实际出现过的时间段 -->
        SELECT DISTINCT
        CASE #{type}
        WHEN 'year' THEN DATE_FORMAT(d.create_time, '%Y')
        WHEN 'quarter' THEN CONCAT(YEAR(d.create_time), '-Q', QUARTER(d.create_time))
        WHEN 'month' THEN DATE_FORMAT(d.create_time, '%Y-%m')
        WHEN 'week' THEN FLOOR(DATEDIFF(d.create_time, #{startFinishTime}) / 7) + 1
        ELSE DATE_FORMAT(d.create_time, '%Y-%m-%d')
        END AS period
        FROM service_subtask_detail d
        WHERE d.del_flag = 0
        AND d.create_time >= #{startFinishTime}
        AND d.create_time &lt; #{endFinishTime}
        ) p
        CROSS JOIN (
        <!-- æ‰€æœ‰éœ€è¦å±•示的 dimension -->
        SELECT DISTINCT dimension
        FROM service_subtask_detail
        WHERE del_flag = 0
        AND dimension IS NOT NULL
        ) dim
        LEFT JOIN service_task t
        ON t.service_type IN (6, 14)
        AND t.del_flag = 0
        LEFT JOIN service_subtask_detail d
        ON d.taskid = t.taskid
        AND d.del_flag = 0
        AND d.dimension = dim.dimension
        AND CASE #{type}
        WHEN 'year' THEN DATE_FORMAT(d.create_time, '%Y')
        WHEN 'quarter' THEN CONCAT(YEAR(d.create_time), '-Q', QUARTER(d.create_time))
        WHEN 'month' THEN DATE_FORMAT(d.create_time, '%Y-%m')
        WHEN 'week' THEN FLOOR(DATEDIFF(d.create_time, #{startFinishTime}) / 7) + 1
        ELSE DATE_FORMAT(d.create_time, '%Y-%m-%d')
        END = p.period
        AND d.create_time >= #{startFinishTime}
        AND d.create_time &lt; #{endFinishTime}
        GROUP BY p.period, dim.dimension
        ORDER BY p.period, dim.dimension
    </select>
    <select id="sltdMydTotalByYQ" resultType="com.ruoyi.satisfaction.domain.dto.MydTrendReqDTO">
        SELECT p.period,
               c.campusid,
               IFNULL(ROUND(AVG(d.score), 2), 0) AS totalScore
        FROM (
                 -- ç”ŸæˆæŸ¥è¯¢èŒƒå›´å†…实际出现过的时间段
                 SELECT DISTINCT CASE #{type}
                                     WHEN 'year' THEN DATE_FORMAT(d.create_time, '%Y')
                                     WHEN 'quarter' THEN CONCAT(YEAR(d.create_time), '-Q', QUARTER(d.create_time))
                                     WHEN 'month' THEN DATE_FORMAT(d.create_time, '%Y-%m')
                                     WHEN 'week' THEN FLOOR(DATEDIFF(d.create_time, #{startFinishTime}) / 7) + 1
                                     ELSE DATE_FORMAT(d.create_time, '%Y-%m-%d')
                                     END AS period
                 FROM service_subtask_detail d
                 WHERE d.del_flag = 0
                   AND d.create_time >= #{startFinishTime}
                   AND d.create_time &lt; #{endFinishTime}) p
                 CROSS JOIN (
            -- æ‰€æœ‰éœ€è¦å±•示的院区
            SELECT DISTINCT campusid
            FROM service_task
            WHERE service_type IN (6, 14)
              AND del_flag = 0
              AND campusid IS NOT NULL) c
                 LEFT JOIN service_task t
                           ON t.campusid = c.campusid
                               AND t.service_type IN (6, 14)
                               AND t.del_flag = 0
                 LEFT JOIN service_subtask_detail d
                           ON d.taskid = t.taskid
                               AND d.del_flag = 0
                               AND CASE #{type}
                                       WHEN 'year' THEN DATE_FORMAT(d.create_time, '%Y')
                                       WHEN 'quarter' THEN CONCAT(YEAR(d.create_time), '-Q', QUARTER(d.create_time))
                                       WHEN 'month' THEN DATE_FORMAT(d.create_time, '%Y-%m')
                                       WHEN 'week' THEN FLOOR(DATEDIFF(d.create_time, #{startFinishTime}) / 7) + 1
                                       ELSE DATE_FORMAT(d.create_time, '%Y-%m-%d')
                                       END = p.period
                               AND d.create_time >= #{startFinishTime}
                               AND d.create_time &lt; #{endFinishTime}
        GROUP BY p.period, c.campusid
        ORDER BY p.period, c.campusid;
    </select>
    <select id="statMydQuestionOptionForExport" resultType="com.ruoyi.satisfaction.domain.dto.ServiceSubtaskOptionStatDTO">
        SELECT
        t.taskid AS taskid,
        t.task_name AS taskName,
        d.questiontext AS questiontext,
        d.asrtext AS optionresult,
        d.targetvalue AS targetvalue,
        d.value_type AS valueType,
        COUNT(DISTINCT s.id) AS patientCount,
        SUM(COALESCE(CAST(d.score AS DECIMAL(18,2)), 0)) AS scoreSum
        FROM
        service_task t
        INNER JOIN service_subtask s ON s.taskid = t.taskid
        INNER JOIN service_subtask_detail d ON d.sub_id = s.id
        WHERE 1=1
        AND s.del_flag = '0'
        AND d.del_flag = 0
        <if test="subtaskIds != null and subtaskIds.size() > 0">
            AND s.id IN
            <foreach collection="subtaskIds" item="subtaskId" open="(" separator="," close=")">
                #{subtaskId}
            </foreach>
        </if>
        <!-- ä¸ä¼ å­ä»»åŠ¡æ—¶ç›´æŽ¥æŸ¥ä¸åˆ°æ•°æ®ï¼Œé¿å…è¯¯ç»Ÿè®¡å…¨é‡ -->
        <if test="subtaskIds == null or subtaskIds.size() == 0">
            AND 1 = 0
        </if>
        <if test="questiontext != null and questiontext != ''">
            AND d.questiontext LIKE CONCAT('%', #{questiontext}, '%')
        </if>
        <!-- åŒä¸€æ‚£è€…同一题目只保留最新一条,重答不重复计入数量 -->
        AND d.id = (
        SELECT MAX(d2.id)
        FROM service_subtask_detail d2
        WHERE d2.sub_id = d.sub_id
        AND d2.questiontext &lt;=&gt; d.questiontext
        AND d2.del_flag = '0'
        )
        GROUP BY
        t.taskid,
        t.task_name,
        d.questiontext,
        d.asrtext,
        d.targetvalue,
        d.value_type
        <!-- æŒ‰ä»»åŠ¡åç§°åˆ†ç»„å±•ç¤ºï¼šå…ˆä»»åŠ¡åç§°ã€å†ä»»åŠ¡ç¼–å·ï¼ŒåŒä¸€ä»»åŠ¡çš„é¢˜ç›®/选项连续排列 -->
        ORDER BY
        t.task_name,
        t.taskid,
        d.questiontext
    </select>
    <select id="statQuestionOption" resultType="com.ruoyi.satisfaction.domain.ServiceSubtaskDetailRatioExport">
        SELECT
        d.questiontext AS questiontext,
        t.taskid AS taskid,
        t.task_name AS taskName,
        d.asrtext AS optionresult,
        d.score AS score,
        d.targetvalue AS targetvalue,
        d.value_type AS valueType,
        COUNT(*) AS count,
        CONCAT(ROUND(COUNT(*) * 100.0 / t2.total_count, 2), '%') AS ratio
        FROM
        service_task t
        INNER JOIN service_subtask s ON s.taskid = t.taskid
        INNER JOIN service_subtask_detail d ON d.sub_id = s.id
        INNER JOIN (
        SELECT
        t.taskid,
        d.questiontext,
        COUNT(*) AS total_count
        FROM
        service_task t
        INNER JOIN service_subtask s ON s.taskid = t.taskid
        INNER JOIN service_subtask_detail d ON d.sub_id = s.id
        WHERE 1=1
        <if test="taskIds != null and taskIds.size() > 0">
            AND t.taskid IN
            <foreach collection="taskIds" item="taskId" open="(" separator="," close=")">
                #{taskId}
            </foreach>
        </if>
        <!-- ä¼ äº† subtaskIds åªç»Ÿè®¡è¿™æ‰¹å­ä»»åŠ¡ï¼ˆæœ¬æ¬¡å¯¼å‡ºçš„æ‚£è€…ï¼‰ -->
        <if test="subtaskIds != null and subtaskIds.size() > 0">
            AND s.id IN
            <foreach collection="subtaskIds" item="subtaskId" open="(" separator="," close=")">
                #{subtaskId}
            </foreach>
        </if>
        <if test="valueType != null">AND d.value_type = #{valueType}</if>
        <if test="scoreStart != null">AND d.score &gt;= #{scoreStart}</if>
        <if test="scoreEnd != null">AND d.score &lt;= #{scoreEnd}</if>
        <if test="questiontext != null  and questiontext != ''">and d.questiontext like concat('%', #{questiontext},
            '%')
        </if>
        and s.sendstate=6
        and d.del_flag=0
        <!-- åˆ†æ¯å¿…须和明细用同一套时间条件,否则选了时间范围后比例会按全量历史被稀释 -->
        <if test="startOutHospTime != null">
            AND date_format(s.endtime,'%y%m%d') &gt;= date_format(#{startOutHospTime},'%y%m%d')
        </if>
        <if test="endOutHospTime != null">
            AND date_format(s.endtime,'%y%m%d') &lt;= date_format(#{endOutHospTime},'%y%m%d')
        </if>
        <if test="startFinishTime != null">
            AND date_format(s.finishtime,'%y%m%d') &gt;= date_format(#{startFinishTime},'%y%m%d')
        </if>
        <if test="endFinishTime != null">
            AND date_format(s.finishtime,'%y%m%d') &lt;= date_format(#{endFinishTime},'%y%m%d')
        </if>
        GROUP BY
        t.taskid,
        d.questiontext
        ) t2 ON t2.taskid = t.taskid AND t2.questiontext = d.questiontext
        WHERE 1=1
        <if test="taskIds != null and taskIds.size() > 0">
            AND t.taskid IN
            <foreach collection="taskIds" item="taskId" open="(" separator="," close=")">
                #{taskId}
            </foreach>
        </if>
        <if test="subtaskIds != null and subtaskIds.size() > 0">
            AND s.id IN
            <foreach collection="subtaskIds" item="subtaskId" open="(" separator="," close=")">
                #{subtaskId}
            </foreach>
        </if>
        <if test="valueType != null">AND d.value_type = #{valueType}</if>
        <if test="scoreStart != null">AND d.score &gt;= #{scoreStart}</if>
        <if test="scoreEnd != null">AND d.score &lt;= #{scoreEnd}</if>
        <if test="questiontext != null  and questiontext != ''">and d.questiontext like concat('%', #{questiontext},
            '%')
        </if>
        AND s.sendstate = 6
        and d.del_flag=0
        <if test="startOutHospTime != null">
            AND date_format(s.endtime,'%y%m%d') &gt;= date_format(#{startOutHospTime},'%y%m%d')
        </if>
        <if test="endOutHospTime != null">
            AND date_format(s.endtime,'%y%m%d') &lt;= date_format(#{endOutHospTime},'%y%m%d')
        </if>
        <if test="startFinishTime != null">
            AND date_format(s.finishtime,'%y%m%d') &gt;= date_format(#{startFinishTime},'%y%m%d')
        </if>
        <if test="endFinishTime != null">
            AND date_format(s.finishtime,'%y%m%d') &lt;= date_format(#{endFinishTime},'%y%m%d')
        </if>
        GROUP BY
        t.taskid,
        d.questiontext,
        d.score,
        d.targetvalue,
        d.asrtext,
        d.value_type
        ORDER BY
        d.questiontext,
        t.taskid
    </select>
    <select id="statQuestionOptionBySubtaskIds" resultType="com.ruoyi.satisfaction.domain.ServiceSubtaskDetailRatioExport">
        SELECT
        d.questiontext AS questiontext,
        <!-- æ˜Žç»†å·²æŒ‰ã€Œé¢˜ç›® + é€‰é¡¹ã€æ±‡æ€»ï¼Œä»»åŠ¡/科室/病区只是附带信息,必须用聚合函数取值(MySQL ONLY_FULL_GROUP_BY) -->
        MAX(t.taskid) AS taskid,
        MAX(t.task_name) AS taskName,
        d.asrtext AS optionresult,
        d.score AS score,
        MAX(s.deptcode) AS deptcode,
        MAX(s.deptname) AS deptname,
        MAX(s.leavehospitaldistrictcode) AS leavehospitaldistrictcode,
        MAX(s.leavehospitaldistrictname) AS leavehospitaldistrictname,
        d.targetvalue AS targetvalue,
        d.value_type AS valueType,
        COUNT(*) AS count,
        CONCAT(ROUND(COUNT(*) * 100.0 / t2.total_count, 2), '%') AS ratio
        FROM
        service_task t
        INNER JOIN service_subtask s ON s.taskid = t.taskid
        INNER JOIN service_subtask_detail d ON d.sub_id = s.id
        <!-- åŽ»é‡ï¼šåŒä¸€å­ä»»åŠ¡åŒä¸€é¢˜ç›®åªä¿ç•™æœ€æ–°ä¸€æ¡ï¼Œé¿å…é‡ç­”/重复回调把同一道题重复计入项数(与 mzMydScoreRank å£å¾„一致) -->
        AND d.id = (
        SELECT MAX(d2.id)
        FROM service_subtask_detail d2
        WHERE d2.sub_id = d.sub_id
        AND d2.questiontext &lt;=&gt; d.questiontext
        AND d2.del_flag = '0'
        )
        INNER JOIN (
        <!-- åˆ†æ¯ = è¯¥é¢˜å„选项项数之和(未作答的空 asrtext ä¸è¿›åˆ†æ¯ï¼‰ï¼Œè¿™æ ·ä¸€é“题各选项比例合计才是 100% -->
        SELECT
        x.questiontext AS questiontext,
        SUM(x.option_count) AS total_count
        FROM (
        SELECT
        d.questiontext,
        d.asrtext,
        COUNT(*) AS option_count
        FROM
        service_task t
        INNER JOIN service_subtask s ON s.taskid = t.taskid
        INNER JOIN service_subtask_detail d ON d.sub_id = s.id
        <!-- åŽ»é‡ï¼šåˆ†æ¯ï¼ˆè¯¥é¢˜æœ‰æ•ˆç­”å·æ€»æ•°ï¼‰ä¸Žæ˜Žç»†å£å¾„ä¿æŒä¸€è‡´ï¼Œä¿è¯å„é€‰é¡¹æ¯”ä¾‹åˆè®¡ 100% -->
        AND d.id = (
        SELECT MAX(d4.id)
        FROM service_subtask_detail d4
        WHERE d4.sub_id = d.sub_id
        AND d4.questiontext &lt;=&gt; d.questiontext
        AND d4.del_flag = '0'
        )
        WHERE 1=1
        <if test="subtaskIds != null and subtaskIds.size() > 0">
            AND s.id IN
            <foreach collection="subtaskIds" item="subtaskId" open="(" separator="," close=")">
                #{subtaskId}
            </foreach>
        </if>
        <if test="valueType != null">AND d.value_type &gt;= #{valueType}</if>
        <if test="scoreStart != null">AND d.score &gt;= #{scoreStart}</if>
        <if test="scoreEnd != null">AND d.score &lt;= #{scoreEnd}</if>
        <if test="questiontext != null  and questiontext != ''">and d.questiontext like concat('%', #{questiontext},
            '%')
        </if>
        AND s.sendstate = 6
        AND d.del_flag=0
        <!-- åˆ†æ¯ä¸Žæ˜Žç»†ç”¨åŒä¸€å¥—时间条件,否则选了日期区间后各选项比例合计不到 100% -->
        <if test="startOutHospTime != null">
            AND date_format(s.endtime,'%y%m%d') &gt;= date_format(#{startOutHospTime},'%y%m%d')
        </if>
        <if test="endOutHospTime != null">
            AND date_format(s.endtime,'%y%m%d') &lt;= date_format(#{endOutHospTime},'%y%m%d')
        </if>
        <if test="startFinishTime != null">
            AND date_format(s.finishtime,'%y%m%d') &gt;= date_format(#{startFinishTime},'%y%m%d')
        </if>
        <if test="endFinishTime != null">
            AND date_format(s.finishtime,'%y%m%d') &lt;= date_format(#{endFinishTime},'%y%m%d')
        </if>
        <!-- æœªä½œç­”(asrtext ä¸ºç©ºï¼‰çš„æ˜Žç»†ä¸è®¡å…¥åˆ†æ¯ -->
        AND d.asrtext IS NOT NULL AND TRIM(d.asrtext) &lt;&gt; ''
        GROUP BY d.questiontext, d.asrtext
        ) x
        GROUP BY x.questiontext
        ) t2 ON t2.questiontext = d.questiontext
        WHERE 1=1
        <!-- åŽ»é‡ï¼šæ˜Žç»†è¡ŒåŒä¸€å­ä»»åŠ¡åŒä¸€é¢˜ç›®åªä¿ç•™æœ€æ–°ä¸€æ¡ï¼Œé‡ç­”ä¸é‡å¤è®¡å…¥é¡¹æ•° -->
        AND d.id = (
        SELECT MAX(d3.id)
        FROM service_subtask_detail d3
        WHERE d3.sub_id = d.sub_id
        AND d3.questiontext &lt;=&gt; d.questiontext
        AND d3.del_flag = '0'
        )
        <if test="subtaskIds != null and subtaskIds.size() > 0">
            AND s.id IN
            <foreach collection="subtaskIds" item="subtaskId" open="(" separator="," close=")">
                #{subtaskId}
            </foreach>
        </if>
        AND s.sendstate = 6
        AND d.del_flag=0
        <!-- æœªä½œç­”(asrtext ä¸ºç©ºï¼‰ä¸ç®—任何选项,明细里直接排除,避免出现空选项行 -->
        AND d.asrtext IS NOT NULL AND TRIM(d.asrtext) &lt;&gt; ''
        <if test="valueType != null">AND d.value_type &gt;= #{valueType}</if>
        <if test="scoreStart != null">AND d.score &gt;= #{scoreStart}</if>
        <if test="scoreEnd != null">AND d.score &lt;= #{scoreEnd}</if>
        <if test="questiontext != null  and questiontext != ''">and d.questiontext like concat('%', #{questiontext},
            '%')
        </if>
        <if test="startOutHospTime != null">
            AND date_format(s.endtime,'%y%m%d') &gt;= date_format(#{startOutHospTime},'%y%m%d')
        </if>
        <if test="endOutHospTime != null">
            AND date_format(s.endtime,'%y%m%d') &lt;= date_format(#{endOutHospTime},'%y%m%d')
        </if>
        <if test="startFinishTime != null">
            AND date_format(s.finishtime,'%y%m%d') &gt;= date_format(#{startFinishTime},'%y%m%d')
        </if>
        <if test="endFinishTime != null">
            AND date_format(s.finishtime,'%y%m%d') &lt;= date_format(#{endFinishTime},'%y%m%d')
        </if>
        <!-- åªæŒ‰ã€Œé¢˜ç›® + é€‰é¡¹ã€æ±‡æ€»ï¼šåŽŸæ¥ GROUP BY å¸¦ä¸Š taskid/科室/病区,同一个选项被拆成多行、每行数量都是 1,
             å‰ç«¯/导出拿到的「数量」就永远做不了累加 -->
        GROUP BY
        d.questiontext,
        d.asrtext,
        d.score,
        d.targetvalue,
        d.value_type
        ORDER BY
        d.questiontext,
        MIN(t.taskid)
    </select>
    <select id="statQuestionOptionByKsOrBq" resultType="com.ruoyi.satisfaction.domain.dto.QuestionCountKsOrBqDTO"
            parameterType="com.ruoyi.satisfaction.domain.vo.QuestionCountKsOrBqVO">
        SELECT
        s.deptname AS deptname,
        s.leavehospitaldistrictname AS leavehospitaldistrictname,
        d.questiontext AS questionText,
        d.targetvalue AS targetvalue, -- æ‰€æœ‰é€‰é¡¹æ‹¼æŽ¥
        d.asrtext AS asrtext, -- ç”¨æˆ·å®žé™…选中的选项
        d.score AS singleScore -- å•条记录的分数
        FROM service_subtask_detail d
        JOIN service_subtask s ON d.sub_id = s.id
        WHERE s.del_flag = 0
        AND d.del_flag = 0
        AND s.service_type IN ('6', '14')
        <if test="taskTemplateid != null">
            AND s.templateid = #{taskTemplateid}
        </if>
        <if test="endFinishTime != null">
            AND d.create_time &lt; #{endFinishTime}
        </if>
        <if test="startFinishTime != null">
            AND d.create_time &gt;= #{startFinishTime}
        </if>
        <if test="deptOrDistrict ==1">
            <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0">
                AND leavehospitaldistrictcode IN
                <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="("
                         separator=","
                         close=")">
                    #{leavehospitaldistrictcode}
                </foreach>
            </if>
            <if test=" leaveldeptcodes != null and leaveldeptcodes.size()>0">
                AND deptcode IN
                <foreach collection="leaveldeptcodes" item="leaveldeptcode" open="(" separator=","
                         close=")">
                    #{leaveldeptcode}
                </foreach>
            </if>
        </if>
        <if test="deptOrDistrict ==2">
            <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0">
                AND leavehospitaldistrictcode IN
                <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="("
                         separator=","
                         close=")">
                    #{leavehospitaldistrictcode}
                </foreach>
            </if>
            <if test=" leaveldeptcodes != null and leaveldeptcodes.size()>0">
                OR deptcode IN
                <foreach collection="leaveldeptcodes" item="leaveldeptcode" open="(" separator=","
                         close=")">
                    #{leaveldeptcode}
                </foreach>
            </if>
        </if>
    </select>
    <select id="selectMzMydScoreRank" parameterType="com.ruoyi.satisfaction.domain.vo.MzMydScoreRankVO"
            resultType="com.ruoyi.satisfaction.domain.dto.MzMydScoreRankDTO">
        SELECT
        t.code                   AS code,
        t.name                   AS name,
        ROUND(AVG(t.w) * 100, 2) AS score,
        COUNT(*)                 AS itemCount
        FROM (
        SELECT
        <choose>
            <when test='dimensionType == "2"'>
                s.leavehospitaldistrictcode AS code,
                s.leavehospitaldistrictname AS name,
            </when>
            <otherwise>
                s.deptcode AS code,
                s.deptname AS name,
            </otherwise>
        </choose>
        CAST(d.score AS DECIMAL(10,4)) AS w
        FROM service_subtask s
        INNER JOIN service_subtask_detail d ON d.sub_id = s.id
        WHERE s.del_flag = '0'
        AND d.del_flag = '0'
        AND s.sendstate = 6
        <if test="serviceTypes != null and serviceTypes.size() > 0">
            AND service_type IN
            <foreach collection="serviceTypes" item="serviceType" open="(" separator=","
                     close=")">
                #{serviceType}
            </foreach>
        </if>
<!--        &lt;!&ndash; åªå–公式里的五档权重,空值/0/未答等既不进分子也不进分母 &ndash;&gt;-->
<!--        AND CAST(d.score AS DECIMAL(10,4)) IN (1, 0.8, 0.6, 0.4, 0.2)-->
        <!-- åŒä¸€å­ä»»åŠ¡åŒä¸€é¢˜ç›®åªä¿ç•™æœ€æ–°ä¸€æ¡ï¼Œé¿å…é‡ç­”è¢«é‡å¤è®¡å…¥é¡¹æ•° -->
        AND d.id = (
        SELECT MAX(d2.id)
        FROM service_subtask_detail d2
        WHERE d2.sub_id = d.sub_id
        AND d2.questiontext &lt;=&gt; d.questiontext
        AND d2.del_flag = '0'
        )
        <if test="orgid != null and orgid != ''">
            AND s.orgid = #{orgid}
        </if>
        <if test="campusid != null and campusid != ''">
            AND s.campusid = #{campusid}
        </if>
        <if test="startFinishTime != null">
            AND s.finishtime &gt;= #{startFinishTime}
        </if>
        <if test="endFinishTime != null">
            AND s.finishtime &lt; DATE_ADD(#{endFinishTime}, INTERVAL 1 DAY)
        </if>
        <if test="questionText != null  and questionText != ''">and d.questiontext like concat('%', #{questionText},
            '%')
        </if>
        <choose>
            <when test='dimensionType == "2"'>
                AND s.leavehospitaldistrictcode IS NOT NULL AND s.leavehospitaldistrictcode &lt;&gt; ''
                AND s.leavehospitaldistrictname IS NOT NULL AND s.leavehospitaldistrictname &lt;&gt; ''
                <if test="districtCodes != null and districtCodes.size() > 0">
                    AND s.leavehospitaldistrictcode IN
                    <foreach collection="districtCodes" item="districtCode" open="(" separator="," close=")">
                        #{districtCode}
                    </foreach>
                </if>
            </when>
            <otherwise>
                AND s.deptcode IS NOT NULL AND s.deptcode &lt;&gt; ''
                AND s.deptname IS NOT NULL AND s.deptname &lt;&gt; ''
                <if test="deptCodes != null and deptCodes.size() > 0">
                    AND s.deptcode IN
                    <foreach collection="deptCodes" item="deptCode" open="(" separator="," close=")">
                        #{deptCode}
                    </foreach>
                </if>
            </otherwise>
        </choose>
        ) t
        GROUP BY t.code, t.name
        ORDER BY AVG(t.w) DESC, COUNT(*) DESC
        <if test="limit != null and limit > 0">
            LIMIT #{limit}
        </if>
    </select>
    <select id="selectSatisfactionFillStat" parameterType="com.ruoyi.satisfaction.domain.entity.SatisfactionStatQuery"
            resultType="java.util.Map">
        select ss.libtemplateid as libtemplateid,
               ssd.questiontext as questiontext,
               ssd.score as score,
               count(*) as cnt
        from service_subtask_detail ssd
        join service_subtask ss on ss.id = ssd.sub_id
        where ssd.del_flag = 0
        and ss.del_flag = 0
        <if test="orgid != null and orgid != ''">
            and ss.orgid = #{orgid}
        </if>
        <if test="campusid != null and campusid != ''">
            and ss.campusid = #{campusid}
        </if>
        <if test="type != null and type != ''">and ss.type = #{type}</if>
        <if test="startTime != null">
            and ss.visit_time &gt;= #{startTime}
        </if>
        <if test="endTime != null">
            and ss.visit_time &lt; date_add(#{endTime}, interval 1 day)
        </if>
        <if test="libTemplateIds != null and libTemplateIds.size() > 0">
            and ss.libtemplateid IN
            <foreach collection="libTemplateIds" item="libTemplateId" open="(" separator="," close=")">
                #{libTemplateId}
            </foreach>
        </if>
        <if test="(hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0) or (deptcodes != null and deptcodes.size() > 0)">
            AND (
            <if test="hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0">
                ss.leavehospitaldistrictcode IN
                <foreach collection="hospitaldistrictcodes" item="hospitaldistrictcode" open="("
                         separator=","
                         close=")">
                    #{hospitaldistrictcode}
                </foreach>
            </if>
            <if test="hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0 and deptcodes != null and deptcodes.size() > 0">
                OR
            </if>
            <if test="deptcodes != null and deptcodes.size() > 0">
                ss.deptcode IN
                <foreach collection="deptcodes" item="deptcode" open="(" separator="," close=")">
                    #{deptcode}
                </foreach>
            </if>
            )
        </if>
        group by ss.libtemplateid, ssd.questiontext, ssd.score
    </select>
    <select id="selectSatisfactionFillStatByMatchedtext" parameterType="com.ruoyi.satisfaction.domain.entity.SatisfactionStatQuery"
            resultType="java.util.Map">
        select ss.libtemplateid as libtemplateid,
               ssd.questiontext as questiontext,
               ssd.matchedtext as matchedtext,
               ssd.score as score,
               count(*) as cnt,
               min(ssd.targetvalue) as targetvalue
        from service_subtask_detail ssd
        join service_subtask ss on ss.id = ssd.sub_id
        where ssd.del_flag = 0
        and ss.del_flag = 0
        <if test="orgid != null and orgid != ''">
            and ss.orgid = #{orgid}
        </if>
        <if test="campusid != null and campusid != ''">
            and ss.campusid = #{campusid}
        </if>
        <if test="type != null and type != ''">and ss.type = #{type}</if>
        <if test="startTime != null">
            and ss.visit_time &gt;= #{startTime}
        </if>
        <if test="endTime != null">
            and ss.visit_time &lt; date_add(#{endTime}, interval 1 day)
        </if>
        <if test="libTemplateIds != null and libTemplateIds.size() > 0">
            and ss.libtemplateid IN
            <foreach collection="libTemplateIds" item="libTemplateId" open="(" separator="," close=")">
                #{libTemplateId}
            </foreach>
        </if>
        <if test="(hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0) or (deptcodes != null and deptcodes.size() > 0)">
            AND (
            <if test="hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0">
                ss.leavehospitaldistrictcode IN
                <foreach collection="hospitaldistrictcodes" item="hospitaldistrictcode" open="("
                         separator=","
                         close=")">
                    #{hospitaldistrictcode}
                </foreach>
            </if>
            <if test="hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0 and deptcodes != null and deptcodes.size() > 0">
                OR
            </if>
            <if test="deptcodes != null and deptcodes.size() > 0">
                ss.deptcode IN
                <foreach collection="deptcodes" item="deptcode" open="(" separator="," close=")">
                    #{deptcode}
                </foreach>
            </if>
            )
        </if>
        group by ss.libtemplateid, ssd.questiontext, ssd.matchedtext, ssd.score
    </select>
    <select id="selectSatisfactionFillPersonCount" parameterType="com.ruoyi.satisfaction.domain.entity.SatisfactionStatQuery"
            resultType="java.util.Map">
        select ss.libtemplateid as libtemplateid,
               count(distinct ssd.sub_id) as cnt
        from service_subtask_detail ssd
        join service_subtask ss on ss.id = ssd.sub_id
        where ssd.del_flag = 0
        and ss.del_flag = 0
        <if test="orgid != null and orgid != ''">
            and ss.orgid = #{orgid}
        </if>
        <if test="campusid != null and campusid != ''">
            and ss.campusid = #{campusid}
        </if>
        <if test="type != null and type != ''">and ss.type = #{type}</if>
        <if test="startTime != null">
            and ss.visit_time &gt;= #{startTime}
        </if>
        <if test="endTime != null">
            and ss.visit_time &lt; date_add(#{endTime}, interval 1 day)
        </if>
        <if test="libTemplateIds != null and libTemplateIds.size() > 0">
            and ss.libtemplateid IN
            <foreach collection="libTemplateIds" item="libTemplateId" open="(" separator="," close=")">
                #{libTemplateId}
            </foreach>
        </if>
        <if test="(hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0) or (deptcodes != null and deptcodes.size() > 0)">
            AND (
            <if test="hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0">
                ss.leavehospitaldistrictcode IN
                <foreach collection="hospitaldistrictcodes" item="hospitaldistrictcode" open="("
                         separator=","
                         close=")">
                    #{hospitaldistrictcode}
                </foreach>
            </if>
            <if test="hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0 and deptcodes != null and deptcodes.size() > 0">
                OR
            </if>
            <if test="deptcodes != null and deptcodes.size() > 0">
                ss.deptcode IN
                <foreach collection="deptcodes" item="deptcode" open="(" separator="," close=")">
                    #{deptcode}
                </foreach>
            </if>
            )
        </if>
        group by ss.libtemplateid
    </select>
    <!-- ================================================ é‡å¤æ˜Žç»†æ¸…理(满意度统计前置)
         åŽŸå±ž ServiceSubtaskDetailMapper.xml,只有满意度统计调用(已核实),故一并搬入。
         æ³¨æ„ deleteRepeatDetailByIds æ˜¯ã€å†™æ“ä½œã€‘:按 id é€»è¾‘删除重复明细(del_flag=1)。 -->
    <select id="selectRepeatDetailIds" resultType="Long">
        SELECT d1.id
        FROM service_subtask_detail d1
        INNER JOIN (
            SELECT sub_id, questiontext,
                   MAX(create_time) AS max_create_time,
                   MAX(id) AS keep_id
            FROM service_subtask_detail
            WHERE del_flag = 0
            GROUP BY sub_id, questiontext
            HAVING COUNT(*) &gt; 1
        ) d2 ON d1.sub_id = d2.sub_id
            AND d1.questiontext = d2.questiontext
            AND (
                d1.create_time &lt; d2.max_create_time
                OR (d1.create_time = d2.max_create_time AND d1.id &lt; d2.keep_id)
            )
        WHERE d1.del_flag = 0
    </select>
    <update id="deleteRepeatDetailByIds">
        UPDATE service_subtask_detail
        SET del_flag = 1
        WHERE del_flag = 0
        AND id IN
        <foreach collection="ids" item="id" open="(" separator="," close=")">
            #{id}
        </foreach>
    </update>
    <!-- ================================================ å­ä»»åŠ¡ï¼ˆæ»¡æ„åº¦ç»Ÿè®¡å–æ•°ï¼‰
         å¯¹åº”原 getServiceSubtasks â†’ selectServiceSubtaskList ä¸­æ»¡æ„åº¦å®žé™…用到的口径。
         åŽŸæ–‡æ— é»˜è®¤æŽ’åº/分页,这里同样不加。仅取 id、taskid ä¸¤åˆ—。 -->
    <select id="selectMydSubtaskRefs"
            resultType="com.ruoyi.satisfaction.domain.row.SatisfactionSubtaskRef">
        SELECT id, taskid
        FROM service_subtask
        WHERE del_flag = 0
        <if test="q.orgid != null and q.orgid != ''">
            AND orgid = #{q.orgid}
        </if>
        <if test="q.campusid != null and q.campusid != ''">
            AND campusid = #{q.campusid}
        </if>
        <if test="q.serviceType != null">
            AND service_type = #{q.serviceType}
        </if>
        <if test="q.startOutHospTime != null">
            AND date_format(endtime,'%y%m%d') &gt;= date_format(#{q.startOutHospTime},'%y%m%d')
        </if>
        <if test="q.endOutHospTime != null">
            AND date_format(endtime,'%y%m%d') &lt;= date_format(#{q.endOutHospTime},'%y%m%d')
        </if>
        <if test="q.deptOrDistrict==1">
            <if test="q.leavehospitaldistrictcodes != null and q.leavehospitaldistrictcodes.size() > 0">
                AND leavehospitaldistrictcode IN
                <foreach collection="q.leavehospitaldistrictcodes" item="districtcode" open="(" separator="," close=")">
                    #{districtcode}
                </foreach>
            </if>
        </if>
        <if test="q.leaveldeptcodes != null and q.leaveldeptcodes.size() > 0">
            AND deptcode IN
            <foreach collection="q.leaveldeptcodes" item="deptcode" open="(" separator="," close=")">
                #{deptcode}
            </foreach>
        </if>
    </select>
    <!-- ================================================================
         éšè®¿ã€Œæ»¡æ„åº¦æŒ‡æ•°ã€å–数(原 ServiceSubtaskServiceImpl#getSatisfactionIndex ç”¨åˆ°çš„ 5 æ¡æŸ¥è¯¢ï¼‰
         åŽŸå®žçŽ°ä¾æ¬¡è°ƒç”¨äº† 5 ä¸ªä¸åŒæ¨¡å—çš„ statement:
           ServiceTaskMapper.selectServiceTaskList
           IvrTaskTemplateScriptMapper.selectIvrTaskTemplateScriptList
           SvyTaskTemplateScriptMapper.selectSvyTaskTemplateScriptList
           ServiceSubtaskMapper.selectServiceSubtaskBySendstate
           ServiceSubtaskDetailMapper.selectServiceSubtaskDetailList
         è°ƒç”¨æ–¹åªç”¨åˆ°å®ƒä»¬çš„ size()/若干字段,因此这里各写一条【等价且精简】的查询:
           - åªä¿ç•™è°ƒç”¨æ–¹çœŸæ­£è®¾ç½®è¿‡çš„æ¡ä»¶ï¼ˆé€æ¡å¯¹ç…§åŽŸ statement çš„ <if>);
           - è®¡æ•°åœºæ™¯ç›´æŽ¥ç”¨ count(*)(与 Java ä¾§ .size() å®Œå…¨ç­‰ä»·ï¼‰ï¼›
           - ç”¨åˆ°çš„ service_task / ivr_task_template_script / svy_task_template_script
             æ˜¯ã€Œéšè®¿ä»»åŠ¡ä¸Žæ¨¡æ¿ã€è¡¨ï¼Œæœ¬æ¨¡å—å¯¹å®ƒä»¬åªåšã€åªè¯»ã€‘æŸ¥è¯¢ï¼Œä¸å†™ã€‚
         ================================================================ -->
    <!-- åŽŸï¼šServiceTaskMapper.selectServiceTaskList
         è°ƒç”¨æ–¹åªè®¾äº† (deptcode + orgid) æˆ– (leavehospitaldistrictcode + orgid),
         ä¸¤è€…互斥,所以一条 SQL è¦†ç›–原来的两个分支。
         æ³¨æ„ REGEXP çš„写法与原 statement é€å­—一致(匹配逗号分隔的编码串)。 -->
    <select id="selectSatisfactionServiceTasks"
            resultType="com.ruoyi.satisfaction.domain.row.SatisfactionTaskRow">
        select taskid,
               templateid,
               type,
               orgid
        from service_task
        where 1=1
        and del_flag = 0
        <if test="deptcode != null and deptcode != ''">
            and deptcode REGEXP CONCAT('(^|,)', #{deptcode}, '(,|$)')
        </if>
        <if test="leavehospitaldistrictcode != null and leavehospitaldistrictcode != ''">
            and leavehospitaldistrictcode REGEXP CONCAT('(^|,)', #{leavehospitaldistrictcode}, '(,|$)')
        </if>
        <if test="orgid != null and orgid != ''">
            and orgid = #{orgid}
        </if>
        order by update_time desc, taskid desc
    </select>
    <!-- åŽŸï¼šIvrTaskTemplateScriptMapper.selectIvrTaskTemplateScriptList(只用到 3 ä¸ªæ¡ä»¶ï¼‰ -->
    <select id="countIvrTaskTemplateScripts" resultType="int">
        select count(*)
        from ivr_task_template_script
        where 1=1
        and del_flag = 0
        <if test="templateID != null">
            and templateID = #{templateID}
        </if>
        <if test="orgid != null and orgid != ''">
            and orgid = #{orgid}
        </if>
        <if test="scriptAssortid != null">
            and script_assortid = #{scriptAssortid}
        </if>
    </select>
    <!-- åŽŸï¼šSvyTaskTemplateScriptMapper.selectSvyTaskTemplateScriptList(只用到 3 ä¸ªæ¡ä»¶ï¼‰ -->
    <select id="countSvyTaskTemplateScripts" resultType="int">
        select count(*)
        from svy_task_template_script
        where del_flag = 0
        <if test="templateID != null">
            and templateID = #{templateID}
        </if>
        <if test="orgid != null and orgid != ''">
            and orgid = #{orgid}
        </if>
        <if test="categoryid != null">
            and categoryid = #{categoryid}
        </if>
    </select>
    <!-- åŽŸï¼šServiceSubtaskMapper.selectServiceSubtaskBySendstate
         è°ƒç”¨æ–¹åªè®¾äº† taskid ä¸Ž sendstates(3,5,6);orgid æœªè®¾ç½®ï¼Œæ•…这里不含该条件。 -->
    <select id="countSubtaskBySendstates" resultType="int">
        select count(*)
        from service_subtask
        where 1=1
        and del_flag = 0
        <if test="taskid != null">
            and taskid = #{taskid}
        </if>
        <if test="sendstates != null">
            AND sendstate IN
            <foreach collection="sendstates" item="sendstate" open="(" separator="," close=")">
                #{sendstate}
            </foreach>
        </if>
    </select>
    <!-- åŽŸï¼šServiceSubtaskDetailMapper.selectServiceSubtaskDetailList
         âš ï¸ åŽŸæ–‡ã€æ²¡æœ‰ã€‘ del_flag æ¡ä»¶ï¼Œè¿™é‡ŒåŒæ ·ä¸åŠ ï¼Œé¿å…å£å¾„å˜åŒ–ã€‚
         categoryid ä»…在任务类型为语音/问卷时才被原实现设置,故用 <if> ä¿ä½"不设即不过滤"。 -->
    <select id="countSubtaskDetail" resultType="int">
        select count(*)
        from service_subtask_detail
        where 1=1
        <if test="taskid != null">
            and taskid = #{taskid}
        </if>
        <if test="categoryid != null">
            and categoryid = #{categoryid}
        </if>
        <if test="orgid != null and orgid != ''">
            and orgid = #{orgid}
        </if>
    </select>
</mapper>
ruoyi-satisfaction/src/main/resources/mapper/satisfaction/SatisfactionScriptMapper.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,200 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.satisfaction.mapper.SatisfactionScriptMapper">
    <!--
      æ»¡æ„åº¦ç»Ÿè®¡å–「满意度分类 / æ¨¡æ¿é—®é¢˜ / é€‰é¡¹åˆ†å€¼ã€çš„只读查询。
      ä¸Ž smartor åŽŸå®žçŽ°çš„å·®å¼‚ï¼ˆåˆ»æ„ä¸ºä¹‹ï¼Œä¸”ä¸æ”¹å£å¾„ï¼‰ï¼š
      1) åŽŸæ–‡ç”¨ resultMap + å…±äº« <sql> ç‰‡æ®µï¼ˆselectXxxVo),而片段被同文件里其它
         éžæ»¡æ„åº¦æŸ¥è¯¢å…±ç”¨ã€‚这里【不复制片段】,改为显式列出满意度真正用到的列,
         é¿å…åŒä¸€ä»½ SQL ä¸¤å¤„维护。
      2) å®Œå…¨ä¸å¼•用 smartor çš„类型;一律用全限定类名或 java.util.Map。
      3) å…¨éƒ¨æ˜¯åªè¯»æŸ¥è¯¢ï¼Œä¸å¯¹ ivr_liba_* / svy_lib_* åšä»»ä½•写操作。
      å£å¾„要点(必须与原文逐字一致):
      - queryIvrScriptByAssort / querySvyScriptByCategory:ids ä¸ºç©ºæ—¶ä¸æ‹¼ IN æ¡ä»¶ï¼›
      - selectIvrScriptByDimension:dimensionList ä¸ºç©ºæ—¶æ‹¼ `dimension IS NOT NULL`;
      - selectSvyScriptByDimension:dimensionList ä¸ºç©ºæ—¶ã€ä¸æ‹¼ã€‘该条件(两侧不一致是原文行为);
      - selectIvr/SvyTargetOptionScores:scriptIds ä¸º null æ—¶ä¸æ‹¼è¿‡æ»¤ï¼Œç­‰äºŽå–全部分值。
    -->
    <!-- ================================================ æ»¡æ„åº¦åˆ†ç±»ï¼ˆè¯­éŸ³ä¾§ï¼‰
         åŽŸï¼šIvrLibaScriptAssortMapper.selectIvrLibaScriptAssortList
         åŽŸæ–‡ where é‡Œ del_flag=0 å‡ºçŽ°ä¸¤æ¬¡ï¼ˆç‰‡æ®µåŽä¸€æ¬¡ã€æœ«å°¾åˆä¸€æ¬¡ï¼‰ï¼Œç»“æžœç­‰ä»·äºŽä¸€æ¬¡ï¼Œè¿™é‡Œä¿ç•™ä¸€æ¬¡ã€‚
         æ³¨æ„ï¼šè¿™é‡Œå–的是【原始行】,而原服务方法 selectIvrLibaScriptAssortList ä¼šæŠŠè¡Œæ‹¼æˆæ ‘、
         åªè¿”回顶层(pid is null or pid = -1)。需要顶层语义的调用方必须自己按 pid è¿‡æ»¤ã€‚ -->
    <select id="selectIvrCategoryList"
            resultType="com.ruoyi.satisfaction.domain.row.SatisfactionCategoryRow">
        select id,
               index_assort_name as name,
               type,
               pid
        from ivr_liba_script_assort
        where del_flag = 0
        <if test="q.type != null and q.type != ''">
            and type like concat('%', #{q.type}, '%')
        </if>
        <if test="q.orgid != null and q.orgid != ''">
            and orgid = #{q.orgid}
        </if>
        order by seqno asc
    </select>
    <!-- ================================================ æ»¡æ„åº¦åˆ†ç±»ï¼ˆé—®å·ä¾§ï¼‰
         åŽŸï¼šSvyLibScriptCategoryMapper.selectSvyLibScriptCategoryList
             ï¼ˆè¯¥ statement å®žé™…定义在 SvyLibTopicCategoryMapper.xml,namespace ä¸Žæ–‡ä»¶åä¸ä¸€è‡´ï¼‰
         æ³¨æ„ï¼šé—®å·ä¾§åŽŸæ–‡å°±æ˜¯ç›´æŽ¥å–ã€å…¨éƒ¨è¡Œã€‘ï¼ˆä¸æ‹¼æ ‘ã€ä¸ç­›é¡¶å±‚ï¼‰ï¼Œä¸Žè¯­éŸ³ä¾§ä¸åŒã€‚ -->
    <select id="selectSvyCategoryList"
            resultType="com.ruoyi.satisfaction.domain.row.SatisfactionCategoryRow">
        select id,
               name,
               type,
               pid
        from svy_lib_script_category
        where del_flag = 0
        <if test="q.type != null and q.type != ''">
            and type like concat('%', #{q.type}, '%')
        </if>
        <if test="q.orgid != null and q.orgid != ''">
            and orgid = #{q.orgid}
        </if>
        order by seqno asc
    </select>
    <!-- ================================================ æŒ‰è¯­éŸ³åˆ†ç±»å–问题
         åŽŸï¼šIvrLibaTemplateScriptMapper.queryLibTemplateIds
         å¦å¤–,「满意度异常配置题目」接口走的是 IvrLibaTemplateScriptMapper.selectIvrLibaTemplateScriptList
         ï¼ˆåª set scriptAssortid),其有效条件同样是 del_flag=0 + script_assortid è¿‡æ»¤ï¼Œ
         ä¸¤æ¡ statement éƒ½ã€æ²¡æœ‰ order by】,因此本查询可一并服务两处;
         ä¸ºäº†è®©è¯¥æŽ¥å£æ‹¿åˆ° questionId / questionTopic / questionType / categoryId,
         è¿™é‡Œé¢å¤–多取 id、script_topic、script_type、script_assortid å››åˆ—(统计口径不使用它们)。 -->
    <select id="queryIvrScriptByAssort"
            resultType="com.ruoyi.satisfaction.domain.row.SatisfactionIvrScriptRow">
        select id,
               templateid,
               scriptid,
               script_content,
               script_assortname,
               script_assortid,
               script_topic,
               script_type,
               dimension
        from ivr_liba_template_script
        where del_flag = 0
        <if test="q.scriptAssortids != null and q.scriptAssortids.size() > 0">
            and script_assortid in
            <foreach item="scriptAssortid" collection="q.scriptAssortids" open="(" separator="," close=")">
                #{scriptAssortid}
            </foreach>
        </if>
    </select>
    <!-- ================================================ æŒ‰é—®å·åˆ†ç±»å–问题
         åŽŸï¼šSvyLibTemplateScriptMapper.querySvyLibTemplateIds
         æ³¨æ„ï¼šåŒæ—¶å– svyid ä¸Ž id â€”— èšåˆç»Ÿè®¡ç”¨ svyid,选项分值关联用 id(与原文一致)
         å¦å¤–,「满意度异常配置题目」接口走的是 SvyLibTemplateScriptMapper.selectSvyLibTemplateScriptList
         ï¼ˆåª set categoryid),其有效条件是 1=1 + del_flag=0 + categoryid è¿‡æ»¤ï¼Œ
         ä¸¤æ¡ statement éƒ½ã€æ²¡æœ‰ order by】,因此本查询可一并服务两处;
         è¿™é‡Œé¢å¤–多取 script_topic、script_type、categoryid ä¸‰åˆ—(统计口径不使用它们)。 -->
    <select id="querySvyScriptByCategory"
            resultType="com.ruoyi.satisfaction.domain.row.SatisfactionSvyScriptRow">
        select id,
               svyid,
               script_content,
               categoryName,
               categoryid,
               script_topic,
               script_type,
               dimension
        from svy_lib_template_script
        where del_flag = 0
        <if test="q.scriptCategoryids != null and q.scriptCategoryids.size() > 0">
            and categoryid in
            <foreach item="scriptCategoryid" collection="q.scriptCategoryids" open="(" separator="," close=")">
                #{scriptCategoryid}
            </foreach>
        </if>
    </select>
    <!-- ================================================ æŒ‰ç»´åº¦å–问题(语音侧)
         åŽŸï¼šIvrLibaTemplateScriptMapper.selectILTSBydimension
         å£å¾„要点:dimensionList ä¸ºç©ºæ—¶æ‹¼ `dimension IS NOT NULL`(问卷侧不拼,两侧不同是原文行为) -->
    <select id="selectIvrScriptByDimension"
            resultType="com.ruoyi.satisfaction.domain.row.SatisfactionIvrScriptRow">
        select templateid,
               scriptid,
               script_content,
               script_assortname,
               dimension
        from ivr_liba_template_script
        <where>
            del_flag = 0
            <if test="q.dimensionList != null and q.dimensionList.size() > 0">
                and dimension in
                <foreach item="dimension" collection="q.dimensionList" open="(" separator="," close=")">
                    #{dimension}
                </foreach>
            </if>
            <if test="q.dimensionList == null or q.dimensionList.size() == 0">
                and dimension IS NOT NULL
            </if>
        </where>
    </select>
    <!-- ================================================ æŒ‰ç»´åº¦å–问题(问卷侧)
         åŽŸï¼šSvyLibTemplateScriptMapper.selectSLTScriptListBydimension
         å£å¾„要点:dimensionList ä¸ºç©ºæ—¶ã€ä¸æ‹¼ã€‘dimension IS NOT NULL(等于全表 del_flag=0);
                   èšåˆç”¨çš„æ¨¡æ¿ id ä¸Žé€‰é¡¹å…³è” id éƒ½æ˜¯ svyid -->
    <select id="selectSvyScriptByDimension"
            resultType="com.ruoyi.satisfaction.domain.row.SatisfactionSvyScriptRow">
        select svyid,
               svyid as id,
               script_content,
               categoryName,
               dimension
        from svy_lib_template_script
        <where>
            del_flag = 0
            <if test="q.dimensionList != null and q.dimensionList.size() > 0">
                and dimension in
                <foreach item="dimension" collection="q.dimensionList" open="(" separator="," close=")">
                    #{dimension}
                </foreach>
            </if>
        </where>
    </select>
    <!-- ================================================ é€‰é¡¹åˆ†å€¼ï¼ˆè¯­éŸ³ä¾§ï¼‰
         åŽŸï¼šIvrLibaTemplateTargetoptionMapper.selectIvrLibaTargetoptionScores
         å£å¾„要点:scriptIds ä¸º null æ—¶ä¸æ‹¼è¿‡æ»¤ï¼ˆç­‰äºŽå–该表全部分值) -->
    <select id="selectIvrTargetOptionScores"
            resultType="com.ruoyi.satisfaction.domain.row.SatisfactionOptionScoreRow">
        select scriptid as scriptid, score as score
        from ivr_liba_template_targetoption
        where del_flag = 0
        <if test="scriptIds != null and scriptIds.size() > 0">
            and scriptid IN
            <foreach collection="scriptIds" item="scriptId" open="(" separator="," close=")">
                #{scriptId}
            </foreach>
        </if>
    </select>
    <!-- ================================================ é€‰é¡¹åˆ†å€¼ï¼ˆé—®å·ä¾§ï¼‰
         åŽŸï¼šSvyLibTemplateTargetoptionMapper.selectSvyLibTargetoptionScores -->
    <select id="selectSvyTargetOptionScores"
            resultType="com.ruoyi.satisfaction.domain.row.SatisfactionOptionScoreRow">
        select scriptid as scriptid, score as score
        from svy_lib_template_targetoption
        where del_flag = 0
        <if test="scriptIds != null and scriptIds.size() > 0">
            and scriptid IN
            <foreach collection="scriptIds" item="scriptId" open="(" separator="," close=")">
                #{scriptId}
            </foreach>
        </if>
    </select>
</mapper>
ruoyi-satisfaction/src/test/java/com/ruoyi/satisfaction/api/SatisfactionContextCheck.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,269 @@
package com.ruoyi.satisfaction.api;
import com.ruoyi.common.core.service.IConfigService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.satisfaction.config.SatisfactionAvailabilityInterceptor;
import com.ruoyi.satisfaction.config.SatisfactionProperties;
import com.ruoyi.satisfaction.config.SatisfactionSwitch;
import com.ruoyi.satisfaction.config.SatisfactionWebConfig;
import com.ruoyi.satisfaction.service.strategy.CommonSatisfactionScoreStrategy;
import com.ruoyi.satisfaction.service.strategy.SatisfactionScoreStrategyResolver;
import com.ruoyi.satisfaction.service.strategy.SatisfactionSites;
import com.ruoyi.satisfaction.service.strategy.SltdSatisfactionScoreStrategy;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
 * æ»¡æ„åº¦æ¨¡å—çš„ <b>真实 Spring å®¹å™¨</b>装配校验(不依赖 JUnit,也不依赖数据库)。
 * <p>
 * ä¸ºä»€ä¹ˆéœ€è¦è¿™ä¸ªæ£€æŸ¥ï¼šæœ¬æ¨¡å—的几个"静态校验"(Mapper ä¸€è‡´æ€§ã€é€è¡Œç­‰ä»·ã€ç­–略等价)都只能证明
 * <b>代码内容</b>正确,证明不了 <b>Spring èƒ½æŠŠ Bean è£…起来</b>。实际踩过两个坑:
 * <ol>
 *   <li>{@link SatisfactionFacade} æœ‰ä¸¤ä¸ªå…¬å¼€æž„造器,Spring å› "构造器不唯一"退回找无参构造器
 *       â†’ å¯åŠ¨æœŸ {@code No default constructor found};</li>
 *   <li>{@code SatisfactionApi} çš„æŒ‰ç±»åž‹æŸ¥æ‰¾ä¼šæŠŠé—¨é¢<b>自己</b>当兜底候选
 *       â†’ é—¨é¢æŠŠè¯·æ±‚转发给自己 â†’ {@code StackOverflowError}。</li>
 * </ol>
 * æ‰€ä»¥è¿™é‡Œç›´æŽ¥èµ·ä¸€ä¸ªçœŸå®žçš„ {@link AnnotationConfigApplicationContext},只注册本模块
 * <b>不依赖 Mapper / æ•°æ®åº“</b>的那些 Bean,验证装配与开关行为。
 * <p>
 * å¼€å…³æœ¬èº«å­˜åœ¨ {@code sys_config} å‚数表里(键 {@code satisfaction.enabled},运行时热读),
 * å› æ­¤è¿™é‡Œç”¨ä¸€ä¸ªå‡çš„ {@link IConfigService} æ¨¡æ‹Ÿå‚数表,验证「参数表优先、yml å…œåº•」。
 */
public final class SatisfactionContextCheck {
    private static int failed = 0;
    private SatisfactionContextCheck() {
    }
    /**
     * å‡çš„内部实现(真实场景里是 DefaultSatisfactionApi,它需要 Mapper/Redis,起不了轻量容器)
     */
    public static class StubApi implements SatisfactionApi {
        /**
         * åˆ†ç±»æŸ¥è¯¢è¢«è°ƒç”¨çš„æ¬¡æ•°ï¼šç”¨æ¥è¯æ˜Ž"参数表关闭时门面确实没有转发给实现"
         */
        static final java.util.concurrent.atomic.AtomicInteger CALLS = new java.util.concurrent.atomic.AtomicInteger();
        @Override
        public boolean isEnabled() {
            return true;
        }
        @Override
        public Map<String, Object> getSatisfactionCategories(String orgid) {
            CALLS.incrementAndGet();
            return Collections.singletonMap("fromStub", orgid);
        }
        @Override
        public SatisfactionExportBundle getQuestionOptionRatioExport(List<Long> subtaskIds, String questiontext) {
            return SatisfactionExportBundle.empty();
        }
        @Override
        public boolean hasSatisfactionCategory(String orgid) {
            return true;
        }
        @Override
        public Map<String, Object> getSatisfactionIndex(String type, String code, String orgid,
                                                        Long ivrCategoryid, Long svyCategoryid) {
            return SatisfactionFacade.emptySatisfactionIndex();
        }
    }
    /**
     * æŽ¢é’ˆï¼šä»¥"旁观者"身份注入 {@code ObjectProvider<SatisfactionApi>},看容器按类型能找到谁。
     * <p>
     * å®ƒä¸æ˜¯ {@code SatisfactionApi} çš„实现,所以不存在"自引用被排除"的情况,
     * å› æ­¤å®ƒçœ‹åˆ°çš„就是完整的候选集合 â€”— ç”¨æ¥è¯æ˜Ž"模块关闭时容器会把门面自己当候选"。
     */
    public static class ProviderProbe {
        @org.springframework.beans.factory.annotation.Autowired
        private org.springframework.beans.factory.ObjectProvider<SatisfactionApi> provider;
        SatisfactionApi rawCandidate() {
            return provider.getIfAvailable();
        }
    }
    public static void main(String[] args) {
        System.out.println("=== åœºæ™¯ 1:参数表 satisfaction.enabled=true,且存在内部实现(DefaultSatisfactionApi çš„角色)===");
        try (AnnotationConfigApplicationContext ctx = context(true, true, ContextCheckConfig.SYS_ON)) {
            SatisfactionFacade facade = ctx.getBean(SatisfactionFacade.class);
            check("门面能被 Spring å®žä¾‹åŒ–(构造器不再歧义)", "true", String.valueOf(facade != null));
            check("启用时 isEnabled()", "true", String.valueOf(facade.isEnabled()));
            check("取到的是【内部实现】而不是门面自己(stub æŠŠ orgid åŽŸæ ·å›žå¡«ï¼‰", "ORG1",
                    String.valueOf(facade.getSatisfactionCategories("ORG1").get("fromStub")));
            check("门面同时是 ISatisfactionCategoryService",
                    "true", String.valueOf(ctx.getBean(com.ruoyi.common.core.service.ISatisfactionCategoryService.class) == facade));
            // å®¹å™¨é‡Œåº”有 2 ä¸ª SatisfactionApi:门面自己 + å†…部实现(门面也实现该接口,这是设计使然)
            check("容器内 SatisfactionApi å®žçŽ°æ•° = é—¨é¢ 1 + å†…部实现 1", "2",
                    String.valueOf(ctx.getBeansOfType(SatisfactionApi.class).size()));
            check("开关读取的键名是 sys_config çš„ satisfaction.enabled", SatisfactionSwitch.CONFIG_KEY,
                    String.valueOf(SatisfactionSwitch.CONFIG_KEY));
            SatisfactionScoreStrategyResolver resolver = ctx.getBean(SatisfactionScoreStrategyResolver.class);
            check("站点策略解析器装配成功,支持站点 = [common, sltd]", "[common, sltd]",
                    String.valueOf(resolver.supportedSites()));
            check("未知站点回落到通用口径", SatisfactionSites.COMMON,
                    resolver.resolve("不存在的站点").site());
        }
        System.out.println();
        System.out.println("=== åœºæ™¯ 2:参数表 satisfaction.enabled=true,但【没有】内部实现(模块没被打进应用)===");
        try (AnnotationConfigApplicationContext ctx = context(true, false, ContextCheckConfig.SYS_ON)) {
            SatisfactionFacade facade = ctx.getBean(SatisfactionFacade.class);
            check("无实现时 isEnabled() ä¸º false", "false", String.valueOf(facade.isEnabled()));
            // å…³é”®ï¼šä¸èƒ½æŠŠé—¨é¢è‡ªå·±å½“实现,否则这里会 StackOverflowError
            String result;
            try {
                result = String.valueOf(facade.getSatisfactionCategories("ORG1").isEmpty());
            } catch (StackOverflowError e) {
                result = "StackOverflowError";
            }
            check("无实现时分类查询返回空 Map(不会自引用递归)", "true", result);
            check("无实现时占比导出包为空", "true",
                    String.valueOf(facade.getQuestionOptionRatioExport(Collections.singletonList(1L), null).isEmpty()));
            check("无实现时满意度指数为全零 Map", "0",
                    String.valueOf(facade.getSatisfactionIndex("1", "D001", "ORG1", null, null).get("mydtmzl")));
            // åè¯ï¼šç›´æŽ¥é—®å®¹å™¨"按类型有哪些 SatisfactionApi å€™é€‰",看它是否把门面自己算进去。
            // è‹¥è¿™é‡Œè¿”回的是门面自己,就说明"自引用兜底"确实存在,门面里的 self è¿‡æ»¤æ˜¯å¿…要的。
            ProviderProbe probe = ctx.getBean(ProviderProbe.class);
            check("无实现时,容器按类型只找到门面自己(证明自引用兜底存在)", "true",
                    String.valueOf(probe.rawCandidate() == facade));
            check("但门面不会把'自己'当成实现(self è¿‡æ»¤ç”Ÿæ•ˆï¼‰", "false",
                    String.valueOf(facade.isEnabled()));
        }
        System.out.println();
        System.out.println("=== åœºæ™¯ 3:参数表 satisfaction.enabled=false(实现 Bean ä¾ç„¶å­˜åœ¨ï¼Œä½†é—¨é¢å¿…须不转发)===");
        StubApi.CALLS.set(0);
        try (AnnotationConfigApplicationContext ctx = context(true, true, ContextCheckConfig.SYS_OFF)) {
            SatisfactionFacade facade = ctx.getBean(SatisfactionFacade.class);
            check("关闭时 isEnabled() ä¸º false", "false", String.valueOf(facade.isEnabled()));
            String result;
            try {
                result = String.valueOf(facade.getSatisfactionCategories("ORG1").isEmpty());
            } catch (StackOverflowError e) {
                result = "StackOverflowError";
            }
            check("关闭时分类查询返回空 Map(不会自递归)", "true", result);
            check("关闭时不再调用内部实现(stub è°ƒç”¨æ¬¡æ•° = 0)", "0",
                    String.valueOf(StubApi.CALLS.get()));
            SatisfactionAvailabilityInterceptor interceptor = ctx.getBean(SatisfactionAvailabilityInterceptor.class);
            String thrown = "(未抛异常)";
            try {
                interceptor.preHandle(null, null, null);
            } catch (Exception e) {
                thrown = e.getClass().getSimpleName() + "|" + e.getMessage();
            }
            check("关闭时拦截器抛 ServiceException", "true", String.valueOf(thrown.startsWith("ServiceException|")));
            check("关闭时错误提示明确", "true",
                    String.valueOf(thrown.contains("satisfaction.enabled=false") && thrown.contains("接口不可用")));
            check("关闭时受保护路径规则 7 æ¡", "7",
                    String.valueOf(SatisfactionWebConfig.guardedPathPatterns().size()));
        }
        System.out.println();
        System.out.println("=== åœºæ™¯ 4:参数表【没有】satisfaction.enabled è¿™ä¸€è¡Œæ—¶ï¼Œå›žè½ application.yml(默认 true)===");
        try (AnnotationConfigApplicationContext ctx = context(true, true, ContextCheckConfig.SYS_MISSING)) {
            SatisfactionFacade facade = ctx.getBean(SatisfactionFacade.class);
            check("参数行缺失、yml é»˜è®¤ true æ—¶ isEnabled() ä¸º true", "true", String.valueOf(facade.isEnabled()));
        }
        try (AnnotationConfigApplicationContext ctx = context(false, true, ContextCheckConfig.SYS_MISSING)) {
            SatisfactionFacade facade = ctx.getBean(SatisfactionFacade.class);
            check("参数行缺失、yml=false æ—¶ isEnabled() ä¸º false", "false", String.valueOf(facade.isEnabled()));
        }
        System.out.println();
        System.out.println("=== åœºæ™¯ 5:容器里【没有】IConfigService æ—¶ï¼ˆç‹¬ç«‹/测试环境)必须能启动且按 yml å…œåº• ===");
        try (AnnotationConfigApplicationContext ctx = context(true, true, null, false)) {
            SatisfactionFacade facade = ctx.getBean(SatisfactionFacade.class);
            check("无配置服务时门面仍能装配并启动", "true", String.valueOf(facade != null));
            check("无配置服务时按 yml å…œåº•(true)", "true", String.valueOf(facade.isEnabled()));
        }
        System.out.println();
        if (failed == 0) {
            System.out.println("===== æ»¡æ„åº¦æ¨¡å— Spring è£…配校验全部通过 =====");
        } else {
            System.out.println("===== æ»¡æ„åº¦æ¨¡å— Spring è£…配校验失败 " + failed + " é¡¹ =====");
            System.exit(1);
        }
    }
    /**
     * å‚数表的模拟取值
     */
    static final class ContextCheckConfig {
        /** å‚数行存在且为 true */
        static final String SYS_ON = "true";
        /** å‚数行存在且为 false */
        static final String SYS_OFF = "false";
        /** å‚数行不存在 */
        static final String SYS_MISSING = null;
        private ContextCheckConfig() {
        }
    }
    /**
     * èµ·ä¸€ä¸ªåªå«æœ¬æ¨¡å—"无 Mapper ä¾èµ–"Bean çš„真实容器
     *
     * @param ymlEnabled        application.yml é‡Œ satisfaction.enabled çš„取值(兜底用)
     * @param withDelegate      æ˜¯å¦æ³¨å†Œå†…部实现(DefaultSatisfactionApi çš„角色)
     * @param sysConfigValue    æ¨¡æ‹Ÿ sys_config é‡Œ satisfaction.enabled çš„键值,null=没有这一行
     */
    private static AnnotationConfigApplicationContext context(boolean ymlEnabled, boolean withDelegate,
                                                              final String sysConfigValue) {
        return context(ymlEnabled, withDelegate, sysConfigValue, true);
    }
    /**
     * @param withConfigService å®¹å™¨é‡Œæ˜¯å¦æ³¨å†Œ {@link IConfigService}(false æ¨¡æ‹Ÿç‹¬ç«‹/测试环境)
     */
    private static AnnotationConfigApplicationContext context(boolean ymlEnabled, boolean withDelegate,
                                                              final String sysConfigValue, boolean withConfigService) {
        AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext();
        SatisfactionProperties properties = new SatisfactionProperties();
        properties.setEnabled(ymlEnabled);
        ctx.registerBean(SatisfactionProperties.class, () -> properties);
        ctx.register(SatisfactionFacade.class,
                SatisfactionSwitch.class,
                SatisfactionAvailabilityInterceptor.class,
                SatisfactionWebConfig.class,
                SatisfactionScoreStrategyResolver.class,
                CommonSatisfactionScoreStrategy.class,
                SltdSatisfactionScoreStrategy.class,
                ProviderProbe.class);
        if (withConfigService) {
            // å‡çš„参数配置服务:只认 satisfaction.enabled è¿™ä¸€ä¸ªé”®ï¼Œå€¼ç”± sysConfigValue å†³å®š
            ctx.registerBean("stubConfigService", IConfigService.class, () -> (IConfigService) configKey ->
                    SatisfactionSwitch.CONFIG_KEY.equals(configKey) ? sysConfigValue : "");
        }
        if (withDelegate) {
            ctx.registerBean("stubSatisfactionApi", SatisfactionApi.class, StubApi::new);
        }
        ctx.refresh();
        return ctx;
    }
    private static void check(String key, String expected, String actual) {
        boolean ok = expected.equals(actual);
        if (!ok) {
            failed++;
        }
        System.out.println("[" + (ok ? "OK" : "FAIL") + "]   " + key + "  æœŸæœ›=" + expected + "  å®žé™…=" + actual);
    }
}
ruoyi-satisfaction/src/test/java/com/ruoyi/satisfaction/api/SatisfactionSwitchCheck.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,236 @@
package com.ruoyi.satisfaction.api;
import com.ruoyi.common.core.service.ISatisfactionCategoryService;
import com.ruoyi.satisfaction.config.SatisfactionAvailabilityInterceptor;
import com.ruoyi.satisfaction.config.SatisfactionProperties;
import com.ruoyi.satisfaction.config.SatisfactionSwitch;
import com.ruoyi.satisfaction.config.SatisfactionWebConfig;
import com.ruoyi.satisfaction.domain.ServiceSubtaskDetailRatioExport;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
 * æ»¡æ„åº¦å¼€å…³ä¸Žé—¨é¢è¡Œä¸ºæ ¡éªŒï¼ˆä¸ä¾èµ– JUnit,可直接用模块 classpath è¿è¡Œï¼‰ã€‚
 * <p>
 * å¼€å…³å·²ä»Ž {@code application.yml} æŒªåˆ° {@code sys_config} å‚数表的
 * {@code satisfaction.enabled}(运行时热读),因此这里验证三件事:
 * <ol>
 *   <li><b>参数表优先</b>:{@code sys_config} æœ‰å€¼æ—¶ä»¥å®ƒä¸ºå‡†ï¼ŒåŒä¸€ä»½ yml é…ç½®ä¸‹å¯å¼€å¯å…³ï¼›</li>
 *   <li><b>yml å…œåº•</b>:{@code sys_config} æ²¡æœ‰è¿™ä¸€è¡Œæ—¶å›žè½ {@code satisfaction.enabled}(默认 true);</li>
 *   <li><b>关闭时不抛异常</b>:门面返回安全空值,且满意度端点拿到的是「明确错误」而不是 404。</li>
 * </ol>
 */
public final class SatisfactionSwitchCheck {
    private static int failed = 0;
    /** sys_config é‡Œçš„「已开启」模拟值 */
    private static final String SYS_ON = "true";
    /** sys_config é‡Œçš„「已关闭」模拟值 */
    private static final String SYS_OFF = "false";
    /** sys_config é‡Œã€Œæ²¡æœ‰è¿™ä¸€è¡Œã€ */
    private static final String SYS_MISSING = null;
    private SatisfactionSwitchCheck() {
    }
    /** å‡å®žçŽ°ï¼šç”¨äºŽéªŒè¯"启用"路径 */
    static class StubApi implements SatisfactionApi {
        @Override
        public boolean isEnabled() {
            return true;
        }
        @Override
        public Map<String, Object> getSatisfactionCategories(String orgid) {
            return Collections.singletonMap("voiceCategories", Collections.singletonList(orgid));
        }
        @Override
        public SatisfactionExportBundle getQuestionOptionRatioExport(List<Long> subtaskIds, String questiontext) {
            return new SatisfactionExportBundle(
                    Collections.singletonList(new ServiceSubtaskDetailRatioExport()),
                    ServiceSubtaskDetailRatioExport.class, workbook -> { });
        }
        @Override
        public boolean hasSatisfactionCategory(String orgid) {
            return true;
        }
        @Override
        public Map<String, Object> getSatisfactionIndex(String type, String code, String orgid,
                                                        Long ivrCategoryid, Long svyCategoryid) {
            return SatisfactionFacade.emptySatisfactionIndex();
        }
    }
    public static void main(String[] args) throws Exception {
        // ---------------- å¯ç”¨ï¼šyml å…œåº• ----------------
        SatisfactionProperties on = new SatisfactionProperties();   // é»˜è®¤ enabled=true
        SatisfactionFacade onFacade = new SatisfactionFacade(on, new StubApi(), SYS_MISSING);
        check("启用时 isEnabled()", "true", String.valueOf(onFacade.isEnabled()));
        check("启用时分类查询有结果", "true",
                String.valueOf(!onFacade.getSatisfactionCategories("ORG1").isEmpty()));
        SatisfactionExportBundle onBundle = onFacade.getQuestionOptionRatioExport(Collections.singletonList(1L), null);
        check("启用时占比导出包有行", "true", String.valueOf(!onBundle.isEmpty()));
        check("启用时占比导出包带行类型与图表构建器", "true",
                String.valueOf(onBundle.getRowClass() != null && onBundle.getChartBuilder() != null));
        check("启用时 hasSatisfactionCategory", "true", String.valueOf(onFacade.hasSatisfactionCategory("ORG1")));
        // ---------------- å¯ç”¨ï¼šyml ä¸º false,但参数表说 true(参数表优先) ----------------
        SatisfactionProperties ymlOff = new SatisfactionProperties();
        ymlOff.setEnabled(false);
        SatisfactionFacade sysOnFacade = new SatisfactionFacade(ymlOff, new StubApi(), SYS_ON);
        check("参数表=true è¦†ç›– yml=false æ—¶ isEnabled()", "true", String.valueOf(sysOnFacade.isEnabled()));
        check("参数表=true è¦†ç›– yml=false æ—¶åˆ†ç±»æŸ¥è¯¢æœ‰ç»“æžœ", "true",
                String.valueOf(!sysOnFacade.getSatisfactionCategories("ORG1").isEmpty()));
        // ---------------- å…³é—­ï¼šå‚数表 false,实现 Bean ä¾ç„¶å­˜åœ¨ ----------------
        SatisfactionFacade offFacade = new SatisfactionFacade(on, new StubApi(), SYS_OFF);
        check("关闭时 isEnabled()", "false", String.valueOf(offFacade.isEnabled()));
        check("关闭时分类查询返回空 Map(即使实现还在也不转发)", "true",
                String.valueOf(offFacade.getSatisfactionCategories("ORG1").isEmpty()));
        SatisfactionExportBundle offBundle = offFacade.getQuestionOptionRatioExport(Collections.singletonList(1L), null);
        check("关闭时占比导出包为空", "true", String.valueOf(offBundle.isEmpty()));
        check("关闭时占比导出包不带图表构建器(导出照常完成)", "true",
                String.valueOf(offBundle.getChartBuilder() == null));
        check("关闭时 hasSatisfactionCategory", "false", String.valueOf(offFacade.hasSatisfactionCategory("ORG1")));
        // ---------------- å…³é—­ï¼šå‚数表缺失 + yml false(兜底生效) ----------------
        SatisfactionFacade ymlOnlyOff = new SatisfactionFacade(ymlOff, new StubApi(), SYS_MISSING);
        check("参数表缺失、yml=false æ—¶ isEnabled()", "false", String.valueOf(ymlOnlyOff.isEnabled()));
        // ---------------- å…³é—­ï¼šå‚数表缺失 + æ— å®žçް Bean(模块根本不在依赖里) ----------------
        SatisfactionFacade noImplFacade = new SatisfactionFacade(on, (SatisfactionApi) null, SYS_MISSING);
        check("参数表缺失、无实现 Bean æ—¶ isEnabled()", "false", String.valueOf(noImplFacade.isEnabled()));
        // å…³é—­æ—¶åå¤è°ƒç”¨ä¸åº”抛异常
        boolean noException = true;
        try {
            for (int i = 0; i < 3; i++) {
                offFacade.getSatisfactionCategories(null);
                offFacade.getQuestionOptionRatioExport(null, null);
                offFacade.hasSatisfactionCategory(null);
            }
        } catch (Exception e) {
            noException = false;
            System.out.println("     å¼‚常: " + e);
        }
        check("关闭时反复调用不抛异常", "true", String.valueOf(noException));
        // ---------------- ç™»å½•链路的接口暴露(第 9 æ­¥ï¼‰ ----------------
        // ruoyi-system é€šè¿‡ ruoyi-common çš„ ISatisfactionCategoryService æŒ‰ç±»åž‹æŸ¥æ‰¾å®žçŽ°ï¼Œ
        // å› æ­¤é—¨é¢å¿…须实现该接口,才能"关闭时返回空 Map è€Œä¸å½±å“ç™»å½•"。
        check("门面实现 ISatisfactionCategoryService(登录链路按接口查找)", "true",
                String.valueOf(onFacade instanceof ISatisfactionCategoryService));
        check("按接口调用(启用)返回非空", "true",
                String.valueOf(!((ISatisfactionCategoryService) onFacade).getSatisfactionCategories("ORG1").isEmpty()));
        check("按接口调用(关闭)返回空 Map ä¸”不抛异常", "true",
                String.valueOf(((ISatisfactionCategoryService) offFacade).getSatisfactionCategories("ORG1").isEmpty()));
        // ---------------- æ»¡æ„åº¦æŒ‡æ•°ï¼ˆå…³é—­æ—¶å¿…须是可用的全零 Map,不能让调用方 NPE) ----------------
        Map<String, Object> offIndex = offFacade.getSatisfactionIndex("1", "D001", "ORG1", null, null);
        check("关闭时满意度指数返回全零 mydtmzl", "0", String.valueOf(offIndex.get("mydtmzl")));
        check("关闭时满意度指数返回全零 mydtbl", "0", String.valueOf(offIndex.get("mydtbl")));
        check("关闭时满意度指数返回全零 wcbl", "0.0", String.valueOf(offIndex.get("wcbl")));
        check("关闭时满意度指数键齐全(不会让调用方 NPE)", "true",
                String.valueOf(offIndex.containsKey("mydtmzl") && offIndex.containsKey("mydtbl")
                        && offIndex.containsKey("wcbl")));
        // ---------------- å¼€å…³çƒ­æ›´æ–°ï¼šæ”¹ sys_config åŽè‡ªåŠ¨ç”Ÿæ•ˆï¼ˆ10 ç§’缓存过期) ----------------
        SatisfactionProperties hot = new SatisfactionProperties();
        final String[] configValue = { SYS_OFF };
        SatisfactionSwitch hotSwitch = hotSwitch(hot, configValue);
        check("热更新前(参数表=false)", "false", String.valueOf(hotSwitch.isEnabled()));
        configValue[0] = SYS_ON;
        check("缓存未过期时仍返回旧值(进程内 10 ç§’缓存)", "false", String.valueOf(hotSwitch.isEnabled()));
        hotSwitch.invalidateCache();
        check("缓存失效后读到新值(改成 true ç«‹å³ç”Ÿæ•ˆï¼‰", "true", String.valueOf(hotSwitch.isEnabled()));
        // ---------------- å…³é—­æ—¶è°ƒç”¨æ»¡æ„åº¦ç«¯ç‚¹å¿…须拿到「明确错误」而不是 404 ----------------
        SatisfactionAvailabilityInterceptor offInterceptor =
                new SatisfactionAvailabilityInterceptor(offFacade.getSatisfactionSwitch());
        SatisfactionAvailabilityInterceptor onInterceptor =
                new SatisfactionAvailabilityInterceptor(onFacade.getSatisfactionSwitch());
        check("启用时拦截器放行", "true", String.valueOf(onInterceptor.preHandle(null, null, null)));
        String thrown = "(未抛异常)";
        try {
            offInterceptor.preHandle(null, null, null);
        } catch (Exception e) {
            thrown = e.getClass().getName() + "|" + e.getMessage();
        }
        check("关闭时拦截器抛 ServiceException", "true",
                String.valueOf(thrown.startsWith("com.ruoyi.common.exception.ServiceException|")));
        check("关闭时错误提示明确(含 satisfaction.enabled=false ä¸ŽæŽ¥å£ä¸å¯ç”¨ï¼‰", "true",
                String.valueOf(thrown.contains("satisfaction.enabled=false") && thrown.contains("接口不可用")));
        // è¢«æ‹¦æˆªçš„端点必须是本模块的 17 ä¸ªï¼Œä¸”不能碰到随访/其它业务的路径
        List<String> guarded = SatisfactionWebConfig.guardedPathPatterns();
        check("受开关保护的路径数量 = 1 ä¸ªé€šé… + 5 ä¸ªåŒå¾· + 1 ä¸ªå¼‚常配置题 = 7 æ¡è§„则", "7",
                String.valueOf(guarded.size()));
        check("受保护路径不含随访导出等其它业务端点", "false",
                String.valueOf(guarded.contains("/smartor/serviceSubtask/patItemExport")
                        || guarded.contains("/smartor/serviceSubtask/getSfStatisticsJoy")
                        || guarded.contains("/smartor/subtaskAnswer/saveMYDQuestionAnswer")));
        System.out.println();
        if (failed == 0) {
            System.out.println("===== æ»¡æ„åº¦å¼€å…³ä¸Žé—¨é¢æ ¡éªŒå…¨éƒ¨é€šè¿‡ =====");
        } else {
            System.out.println("===== æ»¡æ„åº¦å¼€å…³ä¸Žé—¨é¢æ ¡éªŒå¤±è´¥ " + failed + " é¡¹ =====");
            System.exit(1);
        }
    }
    /**
     * é€ ä¸€ä¸ªã€Œ{@code sys_config} çš„值可以被中途改掉」的开关,用于验证缓存与热更新。
     */
    private static SatisfactionSwitch hotSwitch(SatisfactionProperties properties, final String[] configValue) {
        return new SatisfactionSwitch(properties, new org.springframework.beans.factory.ObjectProvider<com.ruoyi.common.core.service.IConfigService>() {
            private final com.ruoyi.common.core.service.IConfigService service =
                    configKey -> configValue[0];
            @Override
            public com.ruoyi.common.core.service.IConfigService getObject(Object... args) {
                return service;
            }
            @Override
            public com.ruoyi.common.core.service.IConfigService getIfAvailable() {
                return service;
            }
            @Override
            public com.ruoyi.common.core.service.IConfigService getIfUnique() {
                return service;
            }
            @Override
            public com.ruoyi.common.core.service.IConfigService getObject() {
                return service;
            }
            @Override
            public java.util.stream.Stream<com.ruoyi.common.core.service.IConfigService> stream() {
                return java.util.stream.Stream.of(service);
            }
        });
    }
    private static void check(String key, String expected, String actual) {
        boolean ok = expected.equals(actual);
        if (!ok) {
            failed++;
        }
        System.out.println((ok ? "[OK]   " : "[FAIL] ") + key + "  æœŸæœ›=" + expected + "  å®žé™…=" + actual);
    }
}
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysUserServiceImpl.java
@@ -16,6 +16,7 @@
import com.ruoyi.system.service.ISysConfigService;
import com.ruoyi.system.service.ISysUserService;
import com.ruoyi.common.core.service.IUserExtInfoService;
import com.ruoyi.common.core.service.ISatisfactionCategoryService;
import com.ruoyi.system.mapper.SysUserDeptMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -314,12 +315,43 @@
            }
            sysUser.setBelongDepts(list);
            // è®¾ç½®æ»¡æ„åº¦åˆ†ç±»ï¼ˆèµ°ç¼“存)
            sysUser.setSatisfactionCategories(userExtInfoService.getSatisfactionCategories(sysUser.getOrgid()));
            // æ»¡æ„åº¦æ¨¡å—化后:不再经由 smartor çš„ IUserExtInfoService,
            // æ”¹ä¸ºæŒ‰ ruoyi-common çš„ ISatisfactionCategoryService æŽ¥å£æŸ¥æ‰¾å®žçް
            // ï¼ˆå®žçŽ°åœ¨ ruoyi-satisfaction,由常驻门面保证"模块关闭/缺失时返回空 Map")
            sysUser.setSatisfactionCategories(loadSatisfactionCategories(sysUser.getOrgid()));
        }
        return sysUser;
    }
    /**
     * å®‰å…¨åœ°èŽ·å–æ»¡æ„åº¦åˆ†ç±»ã€‚
     * <p>
     * æ»¡æ„åº¦æ¨¡å—可以被配置关闭({@code sys_config} å‚æ•° {@code satisfaction.enabled=false}),也可以整体不在依赖里;
     * è¿™ä¸¤ç§æƒ…况下都必须<b>不影响登录主流程</b>,因此这里:
     * <ul>
     *   <li>容器里没有 {@code ISatisfactionCategoryService} å®žçŽ°æ—¶è¿”å›žç©º Map;</li>
     *   <li>调用过程中抛任何异常也只记日志并返回空 Map。</li>
     * </ul>
     *
     * @param orgid æœºæž„ID
     * @return æ»¡æ„åº¦åˆ†ç±»ï¼›ä¸å¯ç”¨æ—¶è¿”回空 Map(不会返回 null)
     */
    private Map<String, Object> loadSatisfactionCategories(String orgid) {
        try {
            ISatisfactionCategoryService satisfactionCategoryService =
                    SpringUtils.getBean(ISatisfactionCategoryService.class);
            if (satisfactionCategoryService == null) {
                return Collections.emptyMap();
            }
            Map<String, Object> categories = satisfactionCategoryService.getSatisfactionCategories(orgid);
            return categories == null ? Collections.emptyMap() : categories;
        } catch (Exception e) {
            log.warn("获取满意度分类失败,已降级为空结果(不影响登录)。orgid={}", orgid, e);
            return Collections.emptyMap();
        }
    }
    /**
     * æŒ‰deptCode去重
     */
    private List<SysUserDept> distinctByDeptCode(List<SysUserDept> list) {
smartor/pom.xml
@@ -160,6 +160,15 @@
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-message</artifactId>
        </dependency>
        <!--
            æ»¡æ„åº¦æ¨¡å—:smartor åªé€šè¿‡å®ƒçš„对外门面 SatisfactionFacade å–满意度数据
            ï¼ˆéšè®¿å¯¼å‡º patItemExport、随访统计 getSfStatisticsJoy),不直接依赖其内部 Service/Mapper。
            æ³¨æ„ï¼šruoyi-satisfaction åªä¾èµ– ruoyi-common,因此不会与本模块形成循环依赖。
        -->
        <dependency>
            <groupId>com.ruoyi</groupId>
            <artifactId>ruoyi-satisfaction</artifactId>
        </dependency>
    </dependencies>
smartor/src/main/java/com/smartor/domain/PatMedOuthospError.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,83 @@
package com.smartor.domain;
import com.ruoyi.common.core.domain.BaseEntity;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.Date;
/**
 * çœç«‹åŒå¾·é—¨æ€¥è¯Šé‡‡é›†å¤±è´¥è®°å½•对象 pat_med_outhosp_error
 * <p>
 * é—¨æ€¥è¯Šæ•°æ®é‡‡é›†æ—¶ï¼Œå•条记录转换失败、患者档案处理失败或入库失败,不再中断整轮采集,
 * è€Œæ˜¯æŠŠåŽŸå§‹æ•°æ®å’Œå¼‚å¸¸ä¿¡æ¯å†™è¿›è¿™å¼ è¡¨äº¤ç»™äººå·¥å¤„ç†ï¼Œä¿è¯é‡‡é›†è¿›åº¦ä¸è¢«å•æ¡è„æ•°æ®é˜»æ–­ã€‚
 * åŒä¸€æ¡è®°å½•同一类错误只保留一行(唯一索引 uk_serialnum_type),重复失败累加 fail_count。
 *
 * @author smartor
 */
@Data
@ApiModel("门急诊采集失败记录")
public class PatMedOuthospError extends BaseEntity {
    private static final long serialVersionUID = 1L;
    /** ä¸»é”®ID */
    @ApiModelProperty("主键ID")
    private Long id;
    /** è¯Šç–—记录号(healthcareRecordNo),人工重推时按这个定位 */
    @ApiModelProperty("诊疗记录号")
    private String serialnum;
    /** é—¨è¯Šå· */
    @ApiModelProperty("门诊号")
    private String outhospno;
    /** æ‚£è€…姓名 */
    @ApiModelProperty("患者姓名")
    private String patname;
    /** ç—…历号 */
    @ApiModelProperty("病历号")
    private String patno;
    /** é™¢åŒºID */
    @ApiModelProperty("院区ID")
    private String campusid;
    /** æœºæž„ID */
    @ApiModelProperty("机构ID")
    private String orgid;
    /** å°±è¯Šæ—¶é—´ */
    @ApiModelProperty("就诊时间")
    private Date admitdate;
    /** é‡‡é›†æ—¥æœŸ yyyy-MM-dd */
    @ApiModelProperty("采集日期")
    private String gatherDate;
    /** é”™è¯¯ç±»åž‹ï¼šCONVERT-数据转换失败 ARCHIVE-患者档案处理失败 INSERT-入库失败 */
    @ApiModelProperty("错误类型")
    private String errorType;
    /** å¼‚常信息 */
    @ApiModelProperty("异常信息")
    private String errorMsg;
    /** æŽ¥å£è¿”回的原始记录JSON,人工修复后可据此重推 */
    @ApiModelProperty("原始记录JSON")
    private String rawData;
    /** ç´¯è®¡å¤±è´¥æ¬¡æ•° */
    @ApiModelProperty("累计失败次数")
    private Integer failCount;
    /** å¤„理状态:0待处理 1已处理 2已忽略 */
    @ApiModelProperty("处理状态")
    private String handleStatus;
    /** å¤„理备注 */
    @ApiModelProperty("处理备注")
    private String handleRemark;
}
smartor/src/main/java/com/smartor/mapper/IvrLibaTemplateScriptMapper.java
@@ -1,7 +1,6 @@
package com.smartor.mapper;
import com.smartor.domain.IvrLibaTemplateScript;
import com.smartor.domain.VO.PatSatisfactionReqVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
smartor/src/main/java/com/smartor/mapper/PatMedOuthospErrorMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,39 @@
package com.smartor.mapper;
import com.smartor.domain.PatMedOuthospError;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
 * é—¨æ€¥è¯Šé‡‡é›†å¤±è´¥è®°å½•Mapper接口
 *
 * @author smartor
 */
@Mapper
public interface PatMedOuthospErrorMapper {
    /**
     * å†™å…¥é‡‡é›†å¤±è´¥è®°å½•(同一条记录同一类错误只保留一行,重复失败累加 fail_count)
     *
     * @param error å¤±è´¥è®°å½•
     * @return ç»“æžœ
     */
    int insertPatMedOuthospError(PatMedOuthospError error);
    /**
     * æŸ¥è¯¢é‡‡é›†å¤±è´¥è®°å½•列表(人工处理用)
     *
     * @param error æŸ¥è¯¢æ¡ä»¶ï¼ˆhandleStatus / errorType / serialnum / gatherDate)
     * @return å¤±è´¥è®°å½•集合
     */
    List<PatMedOuthospError> selectPatMedOuthospErrorList(PatMedOuthospError error);
    /**
     * äººå·¥å¤„理:更新处理状态和备注
     *
     * @param error å¤±è´¥è®°å½•
     * @return ç»“æžœ
     */
    int updateHandleStatus(PatMedOuthospError error);
}
smartor/src/main/java/com/smartor/mapper/PatSatisfactionMapper.java
ÎļþÒÑɾ³ý
smartor/src/main/java/com/smartor/mapper/ServiceSubtaskDetailMapper.java
@@ -4,7 +4,6 @@
import com.smartor.domain.ServiceSubtaskDetail;
import com.smartor.domain.ServiceSubtaskDetailTarget;
import com.smartor.domain.ServiceSubtaskDetailVO;
import com.smartor.domain.entity.SatisfactionStatQuery;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@@ -28,40 +27,6 @@
    public ServiceSubtaskDetail selectServiceSubtaskDetailByCalldetailid(String calldetailid);
    public List<ServiceSubtaskDetailDTO> queryFillCount(@Param("subids") List<Long> subids, @Param("questiontext") String questiontext);
    /**
     * æ»¡æ„åº¦æŸ±çŠ¶å›¾ï¼šä¸€æ¬¡æ€§ç»Ÿè®¡æœ¬æ¬¡è¯·æ±‚å…¨éƒ¨æ¨¡æ¿çš„å¡«æŠ¥é‡
     * ï¼ˆæŒ‰ libtemplateid + questiontext + score åˆ†ç»„,只回传分值与条数,不回传明细行)
     * <p>
     * æ›¿ä»£ queryFillCount çš„ N+1 å†™æ³•,见 PatSatisfactionServiceImpl#satisfactionGraph
     *
     * @param query èšåˆæŸ¥è¯¢æ¡ä»¶
     * @return æ¯è¡Œå« libtemplateid、questiontext、score、cnt
     */
    public List<Map<String, Object>> selectSatisfactionFillStat(SatisfactionStatQuery query);
    /**
     * æ»¡æ„åº¦ç»Ÿè®¡ï¼šä¸€æ¬¡æ€§ç»Ÿè®¡æœ¬æ¬¡è¯·æ±‚全部问题的填报量(按 æ¨¡æ¿+问题+matchedtext+score åˆ†ç»„)
     * <p>
     * ç”¨äºŽ statistics / statisticsByDimension çš„「各选项数量及占比」,替代逐个问题执行的
     * queryFillCount(那一步会先查出几万个 sub_id å†æ‹¼è¿› IN(...),并把约十万行明细传回 Java)。
     *
     * @param query èšåˆæŸ¥è¯¢æ¡ä»¶
     * @return æ¯è¡Œå« libtemplateid、questiontext、matchedtext、score、cnt、targetvalue
     */
    public List<Map<String, Object>> selectSatisfactionFillStatByMatchedtext(SatisfactionStatQuery query);
    /**
     * æ»¡æ„åº¦æŸ±çŠ¶å›¾ï¼šä¸€æ¬¡æ€§ç»Ÿè®¡æœ¬æ¬¡è¯·æ±‚å…¨éƒ¨æ¨¡æ¿çš„ã€Œå›žæ”¶äººæ•°ã€
     * ï¼ˆæŒ‰ libtemplateid åˆ†ç»„,count(distinct sub_id):同一个人答了多道题只算一人)
     * <p>
     * satisfactionGraph / satisfactionGraphDimension çš„回收人数必须是人数口径,
     * ä¸èƒ½åƒ selectSatisfactionFillStat é‚£æ ·æŒ‰é¢˜ç›®æ±‡æ€»ç­”案行数(那是题量口径)
     *
     * @param query èšåˆæŸ¥è¯¢æ¡ä»¶
     * @return æ¯è¡Œå« libtemplateid、cnt(该模板已回收的份数/人数)
     */
    public List<Map<String, Object>> selectSatisfactionFillPersonCount(SatisfactionStatQuery query);
    /**
     * æŒ‰ scriptid æ‰¹é‡ç»Ÿè®¡å¡«å†™è®°å½•数(一次 SQL æ›¿ä»£é€ä¸ª scriptid æŸ¥æ˜Žç»†ï¼‰
@@ -123,7 +88,6 @@
     */
    public int deleteServiceSubtaskDetailByCalldetailids(String[] calldetailids);
    /**
     * æŒ‡æ ‡ç»Ÿè®¡
     *
@@ -131,19 +95,4 @@
     * @return æŒ‡æ ‡ç»Ÿè®¡
     */
    public List<ServiceSubtaskDetailTarget> countPatByTarget(Long targetid);
    /**
     * æŸ¥è¯¢åŒä¸ªsubid重复的detail数据ID列表
     *
     * @return é‡å¤è®°å½•ID列表
     */
    public List<Long> selectRepeatDetailIds();
    /**
     * æ ¹æ®ID列表批量标记重复detail数据为删除
     *
     * @param ids é‡å¤è®°å½•ID列表
     * @return æ›´æ–°è¡Œæ•°
     */
    public Integer deleteRepeatDetailByIds(@Param("ids") List<Long> ids);
}
smartor/src/main/java/com/smartor/mapper/ServiceSubtaskMapper.java
@@ -1,18 +1,9 @@
package com.smartor.mapper;
import com.smartor.domain.*;
import com.smartor.domain.DTO.MydTrendReqDTO;
import com.smartor.domain.DTO.MzMydScoreRankDTO;
import com.smartor.domain.DTO.QuestionCountKsOrBqDTO;
import com.smartor.domain.DTO.ServiceSubtaskOptionStatDTO;
import com.smartor.domain.VO.PatSatisfactionReqVO;
import com.smartor.domain.VO.HeLibraryCountVO;
import com.smartor.domain.VO.MzMydScoreRankVO;
import com.smartor.domain.VO.QuestionCountKsOrBqVO;
import com.smartor.domain.VO.ServiceSubtaskCotinueCountVO;
import com.smartor.domain.entity.ServiceSubtaskEntity;
import com.smartor.domain.entity.ServiceSubtaskSatisfactionEntity;
import com.smartor.domain.entity.SatisfactionStatQuery;
import org.apache.ibatis.annotations.MapKey;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@@ -45,7 +36,7 @@
     */
    public List<ServiceSubtask> selectServiceSubtaskList(ServiceSubtaskEntity serviceSubtaskVO);
    public List<ServiceSubtask> selectSatisfactionSubtaskList(PatSatisfactionReqVO serviceSubtaskVO);
    /**
     * ç”¨äºŽè¡¥å¿æŸ¥è¯¢
@@ -87,11 +78,11 @@
     */
    public int countActiveByScheduleIdAndLoop(@Param("scheduleId") Long scheduleId, @Param("loopCount") Long loopCount);
    public  List<MydTrendReqDTO> mydTrend(@Param("startFinishTime") Date startFinishTime, @Param("endFinishTime") Date endFinishTime, @Param("type") String type);
    public  List<MydTrendReqDTO> sltdMydTotalByDimension(@Param("startFinishTime") Date startFinishTime, @Param("endFinishTime") Date endFinishTime, @Param("type") String type);
    public  List<MydTrendReqDTO> sltdMydTotalByYQ(@Param("startFinishTime") Date startFinishTime, @Param("endFinishTime") Date endFinishTime, @Param("type") String type);
    /**
     * ä»»åŠ¡ç»„ï¼ˆappltype=5):拉取某一轮次的全部 subtask(用于克隆到下一轮)。
@@ -238,29 +229,11 @@
     */
    PatMedRes selectVisitCount(PatMedReq patMedReq);
    List<Long> querySendCount(ServiceSubtaskSatisfactionEntity serviceSubtaskSatisfactionEntity);
    /**
     * æ»¡æ„åº¦æŸ±çŠ¶å›¾ï¼šä¸€æ¬¡æ€§ç»Ÿè®¡æœ¬æ¬¡è¯·æ±‚å…¨éƒ¨æ¨¡æ¿çš„å‘é€é‡ï¼ˆæŒ‰ libtemplateid åˆ†ç»„)
     * <p>
     * æ›¿ä»£é€ä¸ªæ¨¡æ¿è°ƒç”¨ querySendCount çš„ N+1 å†™æ³•,见 PatSatisfactionServiceImpl#satisfactionGraph
     *
     * @param query èšåˆæŸ¥è¯¢æ¡ä»¶
     * @return æ¯è¡Œå« libtemplateid、cnt
     */
    List<Map<String, Object>> selectSatisfactionSendCount(SatisfactionStatQuery query);
    /**
     * æ»¡æ„åº¦ç»Ÿè®¡ï¼šåªæŸ¥æœ¬æ¬¡æ¡ä»¶ä¸‹å®žé™…存在哪些 type(1 è¯­éŸ³ / 2 é—®å·ï¼‰
     * <p>
     * æ›¿ä»£ selectSatisfactionSubtaskList â€”— åŽŸæ–¹æ³•ä¼šæŠŠæŽ¥è¿‘å…¨è¡¨çš„ä¸Šç™¾ä¸ªå­—æ®µæŸ¥å›žæ¥ï¼Œ
     * è°ƒç”¨æ–¹å´åªç”¨å®ƒåˆ¤æ–­ã€Œè¯­éŸ³åˆ†æ”¯/问卷分支是否为空」,见
     * PatSatisfactionServiceImpl#statistics / #statisticsByDimension
     *
     * @param patSatisfactionReqVO æŸ¥è¯¢æ¡ä»¶ï¼ˆä¸ŽåŽŸ selectSatisfactionSubtaskList å®Œå…¨ä¸€è‡´ï¼‰
     * @return åŽ»é‡åŽçš„ type åˆ—表(可能含 null)
     */
    List<String> selectSatisfactionSubtaskTypes(PatSatisfactionReqVO patSatisfactionReqVO);
    /**
     * æŒ‰ç—…区分组统计延续护理数量
@@ -291,54 +264,14 @@
     */
    List<ServiceSubtask> gethelibraryCountHyperlink(HeLibraryCountVO heLibraryCountVO);
    /**
     * ç»Ÿè®¡é—®é¢˜çš„选项的百分比
     *
     * @param taskIds          ä»»åŠ¡ ID åˆ—表 (用于服务类型 2)
     * @param subtaskIds       å­ä»»åŠ¡ ID åˆ—表(可选)。传了就只统计这批子任务(即本次导出的患者),
     *                         ä¸ä¼ åˆ™ç»Ÿè®¡è¿™äº›ä»»åŠ¡ä¸‹çš„å…¨éƒ¨ç­”å·ï¼Œä¿æŒåŽŸè¡Œä¸º
     * @param startOutHospTime
     * @param endOutHospTime
     * @return
     */
    List<ServiceSubtaskDetailRatioExport> statQuestionOption(@Param("taskIds") List<Long> taskIds, @Param("startOutHospTime") Date startOutHospTime, @Param("endOutHospTime") Date endOutHospTime, @Param("startFinishTime") Date startFinishTime, @Param("endFinishTime") Date endFinishTime, @Param("scoreStart") Double scoreStart, @Param("scoreEnd") Double scoreEnd, @Param("questiontext") String questiontext, @Param("valueType") String valueType, @Param("subtaskIds") List<Long> subtaskIds);
    /**
     * å¯¼å‡ºã€Œé—®é¢˜è¯¦æƒ…占比」专用统计(按本次导出的子任务统计)
     * <p>
     * æ•°é‡ = é€‰æ‹©è¯¥é€‰é¡¹çš„æ‚£è€…数(同一患者同一题只取最新一条明细),分数 = è¯¥é€‰é¡¹æ˜Žç»†çš„ score ä¹‹å’Œã€‚
     *
     * @param subtaskIds   æœ¬æ¬¡å¯¼å‡ºçš„子任务 ID(必传,为空则查不到数据)
     * @param questiontext é—®é¢˜å†…容(可选,模糊匹配)
     */
    List<ServiceSubtaskOptionStatDTO> statMydQuestionOptionForExport(@Param("subtaskIds") List<Long> subtaskIds, @Param("questiontext") String questiontext);
    /**
     * æ ¹æ®å­ä»»åŠ¡ ID ç»Ÿè®¡é—®é¢˜çš„选项的百分比(用于满意度统计 serviceType=6)
     *
     * @param subtaskIds       å­ä»»åŠ¡ ID åˆ—表 (只能单个 taskid ä¸‹çš„æ‰€æœ‰å­ä»»åŠ¡ id)
     * @param startOutHospTime
     * @param endOutHospTime
     * @return
     */
    List<ServiceSubtaskDetailRatioExport> statQuestionOptionBySubtaskIds(@Param("subtaskIds") List<Long> subtaskIds, @Param("startOutHospTime") Date startOutHospTime, @Param("endOutHospTime") Date endOutHospTime, @Param("startFinishTime") Date startFinishTime, @Param("endFinishTime") Date endFinishTime, @Param("scoreStart") Double scoreStart, @Param("scoreEnd") Double scoreEnd, @Param("questiontext") String questiontext, @Param("valueType") String valueType);
    /**
     * æ ¹æ®ç§‘室、病区、题目类型统计的科室、病区评分
     * @param questionCountKsOrBqVO
     * @return
     */
    List<QuestionCountKsOrBqDTO> statQuestionOptionByKsOrBq(QuestionCountKsOrBqVO questionCountKsOrBqVO);
    /**
     * é—¨è¯Šæ»¡æ„åº¦ç»¼åˆå¾—分排名(按科室或病区)
     * <p>
     * ç»¼åˆæ»¡æ„åº¦ = Î£(评分明细权重) / è¯„分明细条数 Ã— 100%,
     * æƒé‡åªå– 1、0.8、0.6、0.4、0.2 äº”档。
     *
     * @param mzMydScoreRankVO æŸ¥è¯¢æ¡ä»¶ï¼ˆdimensionType、serviceType、时间区间、科室/病区编号、orgid/campusid)
     * @return æ¯ä¸ªç§‘室/病区的综合得分与项数,已按综合得分降序
     */
    List<MzMydScoreRankDTO> selectMzMydScoreRank(MzMydScoreRankVO mzMydScoreRankVO);
}
smartor/src/main/java/com/smartor/message/LishuiMessageConfigProviderImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,87 @@
package com.smartor.message;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.message.sms.provider.ls.LishuiMessageConfig;
import com.ruoyi.message.sms.provider.ls.LishuiMessageConfigProvider;
import com.ruoyi.system.domain.SysConfig;
import com.ruoyi.system.service.ISysConfigService;
import com.smartor.common.LSHospTokenUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
 * ä¸½æ°´çŸ­ä¿¡æœºæž„级配置实现。
 * <p>
 * ç»Ÿä¸€äº†åŽŸå…ˆä¸‰å¥—å¹¶å­˜çš„æŸ¥è¯¢æ–¹å¼ï¼š
 * <ul>
 *   <li>{@code RyTask.ls åˆ†æ”¯}:按机构查 {@code ls.msg.info};</li>
 *   <li>{@code ServiceSubtaskServiceImpl.smsSubTask}:用 {@code MsgLSEnum}(已废弃);</li>
 *   <li>{@code SmsRecordsServiceImpl.sendSMS}:又是第三套(本轮已改为委托发送方)。</li>
 * </ul>
 * çŽ°åœ¨å”¯ä¸€å…¥å£å°±æ˜¯æœ¬ç±»ï¼šè¯» {@code sys_config} è¡¨çš„ {@code ls.msg.info}(按机构),
 * token èµ° {@code LSHospTokenUtil}。
 */
@Slf4j
@Component
public class LishuiMessageConfigProviderImpl implements LishuiMessageConfigProvider {
    /**
     * ä¸½æ°´æœºæž„级配置的配置项 key(与原实现一致)
     */
    private static final String CONFIG_KEY = "ls.msg.info";
    private final ISysConfigService configService;
    public LishuiMessageConfigProviderImpl(ISysConfigService configService) {
        this.configService = configService;
    }
    @Override
    public LishuiMessageConfig getConfig(String orgid) {
        if (StringUtils.isEmpty(orgid)) {
            return null;
        }
        SysConfig query = new SysConfig();
        query.setConfigKey(CONFIG_KEY);
        query.setOrgid(orgid);
        List<SysConfig> sysConfigs = configService.selectConfigList(query);
        if (CollectionUtils.isEmpty(sysConfigs)) {
            log.warn("【LishuiMessageConfigProviderImpl】机构 {} æœªé…ç½® {}", orgid, CONFIG_KEY);
            return null;
        }
        String configValue = sysConfigs.get(0).getConfigValue();
        Map<String, String> msgInfo = new HashMap<>();
        if (StringUtils.isNotEmpty(configValue)) {
            try {
                msgInfo = new ObjectMapper().readValue(configValue, Map.class);
            } catch (Exception e) {
                log.error("【LishuiMessageConfigProviderImpl】解析 {} å¤±è´¥ï¼Œorgid:{},value:{},err:{}",
                        CONFIG_KEY, orgid, configValue, e.getMessage(), e);
                return null;
            }
        }
        log.info("-----------丽水短信配置 map çš„值为:{}", msgInfo);
        LishuiMessageConfig config = new LishuiMessageConfig();
        config.setOrgid(orgid);
        config.setAddress(msgInfo.get("address"));
        config.setZuHuId(msgInfo.get("ZuHuID"));
        config.setZuHuMc(msgInfo.get("ZuHuMC"));
        config.setSendPersonId(msgInfo.get("sendPersonId"));
        config.setSendPersonName(msgInfo.get("sendPersonName"));
        config.setTokenUrl(msgInfo.get("tokenUrl"));
        return config;
    }
    @Override
    public String getToken(String orgid, String tokenUrl) {
        return LSHospTokenUtil.getToken(orgid, tokenUrl);
    }
}
smartor/src/main/java/com/smartor/message/WechatMpConfigProviderImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,89 @@
package com.smartor.message;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.common.enums.WxGZHEnum;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.message.wechat.WechatMpConfig;
import com.ruoyi.message.wechat.WechatMpConfigProvider;
import com.ruoyi.system.service.ISysConfigService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
 * å¾®ä¿¡å…¬ä¼—号机构级配置实现:收口原先散落在三个类里的 {@code WxGZHEnum.getDescByCode(orgid)}
 * è°ƒç”¨ï¼ˆ{@code RyTask} / {@code RedisMqReceiver} / {@code ServiceSubtaskServiceImpl})。
 * <p>
 * é…ç½®æºä¼˜å…ˆçº§ï¼š
 * <ol>
 *   <li>{@code sys_config} è¡¨ key = {@code wx.mp.config.<orgid>},值形如
 *       {@code {"appid":"xx","appSecret":"yy","address":"zz"}} â€”— ä¾¿äºŽæ–°æœºæž„不改代码接入;</li>
 *   <li>代码内 {@link WxGZHEnum} æžšä¸¾ï¼ˆä¿æŒåŽŸæœ‰é…ç½®æºä¸å˜ï¼Œè¡Œä¸ºä¸Žçº¿ä¸Šä¸€è‡´ï¼‰ã€‚</li>
 * </ol>
 * åŒæ—¶æŠŠã€ŒæŒ‰ List ä¸‹æ ‡å– appid/appSecret/address」这种脆弱写法改成了显式字段取值。
 */
@Slf4j
@Component
public class WechatMpConfigProviderImpl implements WechatMpConfigProvider {
    /**
     * æœºæž„级公众号配置的配置项 key å‰ç¼€
     */
    private static final String CONFIG_KEY_PREFIX = "wx.mp.config.";
    private final ISysConfigService configService;
    public WechatMpConfigProviderImpl(ISysConfigService configService) {
        this.configService = configService;
    }
    @Override
    public WechatMpConfig getConfig(String orgid) {
        if (StringUtils.isEmpty(orgid)) {
            return null;
        }
        //优先取数据库配置(支持新机构)
        WechatMpConfig fromDb = readFromDb(orgid);
        if (fromDb != null && fromDb.isComplete()) {
            return fromDb;
        }
        WechatMpConfig config = new WechatMpConfig();
        config.setOrgid(orgid);
        for (WxGZHEnum item : WxGZHEnum.values()) {
            if (orgid.equals(item.getOrgid())) {
                config.setAppid(item.getAppid());
                config.setAppSecret(item.getAppSecret());
                config.setAddress(item.getAddress());
                break;
            }
        }
        if (!config.isComplete()) {
            log.warn("【WechatMpConfigProviderImpl】机构 {} çš„公众号配置不完整:{}", orgid, config);
        }
        return config;
    }
    private WechatMpConfig readFromDb(String orgid) {
        try {
            String value = configService.selectConfigByKey(CONFIG_KEY_PREFIX + orgid, orgid);
            if (StringUtils.isEmpty(value)) {
                return null;
            }
            Map<String, String> map = new ObjectMapper().readValue(value, Map.class);
            WechatMpConfig config = new WechatMpConfig();
            config.setOrgid(orgid);
            config.setAppid(map.get("appid"));
            config.setAppSecret(map.get("appSecret"));
            config.setAddress(map.get("address"));
            return config;
        } catch (Exception e) {
            log.warn("【WechatMpConfigProviderImpl】读取 {} é…ç½®å¤±è´¥ï¼Œorgid:{},err:{}",
                    CONFIG_KEY_PREFIX + orgid, orgid, e.getMessage());
            return null;
        }
    }
}
smartor/src/main/java/com/smartor/service/IPatSatisfactionService.java
ÎļþÒÑɾ³ý
smartor/src/main/java/com/smartor/service/IServiceSubtaskDetailTraceService.java
@@ -6,7 +6,6 @@
import com.smartor.domain.DTO.DetailTraceDealDTO;
import com.smartor.domain.ServiceSubtaskDetailTrace;
import com.smartor.domain.VO.DetailTraceDealVO;
import com.smartor.domain.VO.SatisfactionExceptConfigQuestionReqVO;
import com.smartor.domain.VO.ServiceSubtaskDetailTraceVO;
import org.springframework.web.bind.annotation.RequestBody;
@@ -73,10 +72,5 @@
     */
    public int deleteServiceSubtaskDetailTtraceById(Long id);
    /**
     * èŽ·å–æ»¡æ„åº¦å¼‚å¸¸é…ç½®é¢˜ç›®
     * @param questionReqVO
     * @return
     */
    public Map<String, Object> getSatisfactionExceptConfigQuestion(SatisfactionExceptConfigQuestionReqVO questionReqVO);
}
smartor/src/main/java/com/smartor/service/IServiceSubtaskService.java
@@ -2,10 +2,6 @@
import com.ruoyi.common.core.domain.AjaxResult;
import com.smartor.domain.*;
import com.smartor.domain.DTO.MydTrendReqDTO;
import com.smartor.domain.DTO.MzMydScoreRankDTO;
import com.smartor.domain.DTO.QuestionCountKsOrBqDTO;
import com.smartor.domain.DTO.QuestionResultDTO;
import com.smartor.domain.ServiceSubtask;
import com.smartor.domain.VO.*;
import com.smartor.domain.entity.ServiceSubtaskEntity;
@@ -140,14 +136,7 @@
    public PhoneCallBackSHVO processResust(PhoneCallResultYQVO phoneCallReqYQVO);
    /**
     * é—®é¢˜ç»“果占比
     *
     * @param QuestionResultVO
     * @param serviceSubtaskRes
     * @return
     */
    public List<ServiceSubtaskDetailRatioExport> getDetailRatio(SltdMydTotalVO sltdMydTotalVO, List<ServiceSubtaskRes> serviceSubtaskRes);
    /**
@@ -222,57 +211,21 @@
    Map<String, Object> smsSubTask(Long subid);
    /**
     * ç»Ÿè®¡é—®é¢˜çš„选项的百分比 (用于服务类型 2)
     *
     * @param taskIds    ä»»åŠ¡ ID åˆ—表
     * @param subtaskIds å­ä»»åŠ¡ ID åˆ—表(可选):传了就只统计这批子任务(本次导出的患者),分母也用同一批数据
     * @return
     */
    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);
    /**
     * å¯¼å‡ºã€Œé—®é¢˜è¯¦æƒ…占比」专用统计(只统计本次导出的这批患者)
     * <p>
     * æ•°é‡ = é€‰æ‹©è¯¥é€‰é¡¹çš„æ‚£è€…数;比例 = æ•°é‡ / è¯¥é¢˜ä½œç­”患者数;分数 = è¯¥é€‰é¡¹æ˜Žç»† score ä¹‹å’Œï¼›
     * ä»»åŠ¡åç§°/任务编号 = service_task.task_name / taskid。
     *
     * @param subtaskIds   æœ¬æ¬¡å¯¼å‡ºçš„子任务 ID
     * @param questiontext é—®é¢˜å†…容(可选)
     */
    public List<ServiceSubtaskDetailRatioExport> statMydQuestionOptionForExport(List<Long> subtaskIds, String questiontext);
    /**
     * æ ¹æ®å­ä»»åŠ¡ ID ç»Ÿè®¡é—®é¢˜çš„选项的百分比(用于满意度统计 serviceType=6)
     *
     * @param subtaskIds å­ä»»åŠ¡ ID åˆ—表
     * @return
     */
    public List<ServiceSubtaskDetailRatioExport> statQuestionOptionBySubtaskIds(List<Long> subtaskIds, Date startOutHospTime, Date endOutHospTime, Date startFinishTime, Date endFinishTime, String scriptContent, Double scoreStart, Double scoreEnd, String valueType);
    public  Map<String, Map<String, List<QuestionCountKsOrBqDTO>>> statQuestionOptionByKsOrBq(QuestionCountKsOrBqVO questionCountKsOrBqVO);
    public String scanGenerateSubtask(ServiceTask serviceTaskVo);
    public List<MydTrendReqDTO> mydTrend(MydTrendReqVO mydTrendReqVO);
    /**
     * æ ¹æ®æ—¶é—´ç»™åº¦è¿›è¡Œå¯¹æ¯”满意度分数
     * @param mydTrendReqVO
     * @return
     */
    public List<MydTrendReqDTO> sltdMydTotalByDimension(MydTrendReqVO mydTrendReqVO);
    public Integer matchOptionIndex(String url, String questionText, String voiceText, List<IvrTaskTemplateTargetoption> options, String uuid);
    /**
     * é—¨è¯Šæ»¡æ„åº¦ç»¼åˆå¾—分排名(按科室或病区)
     * <p>
     * ç»¼åˆæ»¡æ„åº¦ = Î£(评分明细权重) / è¯„分明细条数 Ã— 100%,
     * æƒé‡åªå– 1、0.8、0.6、0.4、0.2 äº”档。
     *
     * @param mzMydScoreRankVO æŸ¥è¯¢æ¡ä»¶
     * @return æŽ’名结果,已按综合得分降序并填充名次(从 1 å¼€å§‹ï¼‰
     */
    public List<MzMydScoreRankDTO> mzMydScoreRank(MzMydScoreRankVO mzMydScoreRankVO);
}
smartor/src/main/java/com/smartor/service/impl/ServiceSubtaskDetailTraceServiceImpl.java
@@ -10,7 +10,6 @@
import com.smartor.domain.*;
import com.smartor.domain.DTO.DetailTraceDealDTO;
import com.smartor.domain.VO.DetailTraceDealVO;
import com.smartor.domain.VO.SatisfactionExceptConfigQuestionReqVO;
import com.smartor.domain.VO.ServiceSubtaskDetailTraceVO;
import com.smartor.mapper.ServiceSubtaskDetailMapper;
import com.smartor.mapper.ServiceSubtaskDetailTraceMapper;
@@ -273,79 +272,4 @@
    public int deleteServiceSubtaskDetailTtraceById(Long id) {
        return serviceSubtaskDetailTraceMapper.deleteServiceSubtaskDetailTtraceById(id);
    }
    @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<>();
            //获取满意度的分类
            IvrLibaScriptAssort ivrLibaScriptAssort = new IvrLibaScriptAssort();
            ivrLibaScriptAssort.setType("myd");
            if (!StringUtils.isEmpty(questionReqVO.getType())) ivrLibaScriptAssort.setType(questionReqVO.getType());
            List<IvrLibaScriptAssortVO> ivrLibaScriptAssortVOS = iIvrLibaScriptAssortService.selectIvrLibaScriptAssortList(ivrLibaScriptAssort);
            List<Long> assortIdList = Optional.ofNullable(ivrLibaScriptAssortVOS).filter(list -> !list.isEmpty()).map(list -> list.stream().map(IvrLibaScriptAssortVO::getId).collect(Collectors.toList())).orElse(Collections.emptyList());
            //用来记录满意度的所有问题
            List<IvrLibaTemplateScript> ivrLibaTemplateScriptList = new ArrayList<>();
            if (CollectionUtils.isNotEmpty(assortIdList)) {
                for (Long assortId : assortIdList) {
                    IvrLibaTemplateScript ivrLibaTemplateScript = new IvrLibaTemplateScript();
                    ivrLibaTemplateScript.setScriptAssortid(assortId);
                    List<IvrLibaTemplateScript> ivrLibaTemplateScripts = iIvrLibaTemplateScriptService.selectIvrLibaTemplateScriptList(ivrLibaTemplateScript);
                    ivrLibaTemplateScriptList.addAll(ivrLibaTemplateScripts);
                }
                for (IvrLibaTemplateScript ivrLibaTemplateScript : ivrLibaTemplateScriptList) {
                    Map<String, Object> voicetData = new HashMap<>();
                    voicetData.put("questionId", ivrLibaTemplateScript.getId());
                    voicetData.put("libtemplateid", ivrLibaTemplateScript.getTemplateid());
                    voicetData.put("questionContent", ivrLibaTemplateScript.getScriptContent());
                    voicetData.put("questionTopic", ivrLibaTemplateScript.getScriptTopic());
                    voicetData.put("questionType", ivrLibaTemplateScript.getScriptType());
                    voicetData.put("categoryName", ivrLibaTemplateScript.getScriptAssortname());
                    voicetData.put("categoryId", ivrLibaTemplateScript.getScriptAssortid());
                    voicetDataList.add(voicetData);
                }
            }
            //语音数据返回集合
            result.put("voiceDataList", voicetDataList);
        }
        if (StringUtils.isEmpty(questionReqVO.getScriptType()) || questionReqVO.getScriptType().equals("2")) {
            List<Map<String, Object>> questionnaireDataList = new ArrayList<>();
            //获取满意度的分类
            SvyLibScriptCategory svyLibScriptCategory = new SvyLibScriptCategory();
            svyLibScriptCategory.setType("myd");
            if (!StringUtils.isEmpty(questionReqVO.getType())) svyLibScriptCategory.setType(questionReqVO.getType());
            List<SvyLibScriptCategory> svyLibScriptCategoryVOS = svyLibScriptCategoryMapper.selectSvyLibScriptCategoryList(svyLibScriptCategory);
            List<Long> assortIdList = Optional.ofNullable(svyLibScriptCategoryVOS).filter(list -> !list.isEmpty()).map(list -> list.stream().map(SvyLibScriptCategory::getId).collect(Collectors.toList())).orElse(Collections.emptyList());
            //用来记录满意度的所有问题
            List<SvyLibTemplateScript> svyLibTemplateScriptList = new ArrayList<>();
            if (CollectionUtils.isNotEmpty(assortIdList)) {
                for (Long categoryid : assortIdList) {
                    SvyLibTemplateScript svyLibTemplateScript = new SvyLibTemplateScript();
                    svyLibTemplateScript.setCategoryid(categoryid);
                    List<SvyLibTemplateScript> svyLibTemplateScripts = isvyLibTemplateScriptService.selectSvyLibTemplateScriptList(svyLibTemplateScript);
                    svyLibTemplateScriptList.addAll(svyLibTemplateScripts);
                }
                for (SvyLibTemplateScript svyLibTemplateScript : svyLibTemplateScriptList) {
                    Map<String, Object> voicetData = new HashMap<>();
                    voicetData.put("questionId", svyLibTemplateScript.getId());
                    voicetData.put("libtemplateid", svyLibTemplateScript.getSvyid());
                    voicetData.put("questionContent", svyLibTemplateScript.getScriptContent());
                    voicetData.put("questionTopic", svyLibTemplateScript.getScriptTopic());
                    voicetData.put("questionType", svyLibTemplateScript.getScriptType());
                    voicetData.put("categoryName", svyLibTemplateScript.getCategoryName());
                    voicetData.put("categoryId", svyLibTemplateScript.getCategoryid());
                    questionnaireDataList.add(voicetData);
                }
            }
            //问卷数据返回集合
            result.put("questionnaireDataList", questionnaireDataList);
        }
        return result;
    }
}
}
smartor/src/main/java/com/smartor/service/impl/ServiceSubtaskServiceImpl.java
@@ -9,6 +9,7 @@
import com.ruoyi.common.core.domain.entity.SysDept;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.core.service.IConfigService;
import com.ruoyi.satisfaction.api.SatisfactionFacade;
import com.ruoyi.message.sms.provider.sltd.ws.MessageSend;
import com.ruoyi.common.exception.base.BaseException;
import com.ruoyi.common.utils.*;
@@ -72,6 +73,16 @@
@Slf4j
@Service
public class ServiceSubtaskServiceImpl implements IServiceSubtaskService {
    /**
     * æ»¡æ„åº¦æ¨¡å—对外门面(常驻 Bean)。
     * <p>
     * éšè®¿ç»Ÿè®¡é‡Œçš„「满意度指数」({@code getSfStatisticsJoy})只能通过它取数;
     * æ»¡æ„åº¦æ¨¡å—关闭时门面返回全零 Map,不影响随访统计的其它字段与启动。
     */
    @Autowired
    private SatisfactionFacade satisfactionFacade;
    @Autowired
    private ServiceSubtaskMapper serviceSubtaskMapper;
@@ -604,7 +615,6 @@
//        return map;
//    }
    /**
     * ç»Ÿè®¡ä»»åŠ¡å„ç§çŠ¶æ€çš„æ•°é‡ï¼ˆå·²ä¼˜åŒ–ï¼‰
     * ä¼˜åŒ–说明:
@@ -851,7 +861,6 @@
            svyTaskTemplateService.deleteSvyTaskTemplateBySvyid(serviceSubtask.getTemplateid());
        } else if (serviceSubtask.getType().equals("1"))
            ivrTaskTemplateService.deleteIvrTaskTemplateByID(serviceSubtask.getTemplateid());
        String tempName = svyTaskTemplateService.selectSvyTaskTemplateBySvyid(Long.valueOf(taskTempid)).getSvyname();
        serviceSubtask.setTemplateid(taskTempid.longValue());
@@ -2471,7 +2480,6 @@
        return phoneCallBackYQVO;
    }
    private String getObject(ServiceSubtask serviceSubtask, String scriptContent) {
        Map<String, Map<String, String>> param = getParam(serviceSubtask.getTaskid());
        for (Map<String, String> map : param.values()) {
@@ -2488,7 +2496,6 @@
        return scriptContent;
    }
    /**
     * èŽ·å–ä»»åŠ¡é‡Œçš„é€šé…ç¬¦
@@ -2538,7 +2545,6 @@
        }
        return map;
    }
    /**
     * é›¨ç»®ä»»åŠ¡æ‹‰å–
@@ -2608,7 +2614,6 @@
        log.info("-----------实际拉取的电话数据量:{}", CollectionUtils.isNotEmpty(pullTaskVOList) ? pullTaskVOList.size() : null);
        return pullTaskVOList;
    }
    private List<PullTaskVO> getPullTaskList(List<String> subIds, String cacheName) {
        log.error("任务拉取subIds的值是多少:{}", subIds);
@@ -2681,7 +2686,6 @@
        return pullTaskVOList;
    }
    @Override
    public Integer saveQuestionAnswerPhone(ServiceSubTaskDetailReq serviceSubTaskDetailReq) {
@@ -3080,7 +3084,6 @@
        return jsRates;
    }
    public List<ServiceSubtaskStatistic> getSfStatisticsExport(ServiceSubtaskCountReq serviceSubtaskCountReq) {
        List<ServiceSubtaskStatistic> sfStatistics = getSfStatistics(serviceSubtaskCountReq);
        // æ·»åŠ æ€»è®¡è¡Œ
@@ -3167,13 +3170,15 @@
        if (CollectionUtils.isNotEmpty(serviceSubtaskStatistics)) {
            for (ServiceSubtaskStatistic serviceSubtaskStatistic : serviceSubtaskStatistics) {
                //所有满意度数量
                //满意度模块化后:满意度指数据此改为调用满意度模块门面;
                //模块关闭时门面返回全零 Map(与被迁移实现的实际输出一致),不影响随访统计其它字段。
                if (CollectionUtils.isNotEmpty(serviceSubtaskCountReq.getDeptcodes())) {
                    Map<String, Object> satisfactionIndex = getSatisfactionIndex("1", serviceSubtaskStatistic.getDeptcode(), orgid, serviceSubtaskCountReq.getIvrCategoryid(), serviceSubtaskCountReq.getSvyCategoryid());
                    Map<String, Object> satisfactionIndex = satisfactionFacade.getSatisfactionIndex("1", serviceSubtaskStatistic.getDeptcode(), orgid, serviceSubtaskCountReq.getIvrCategoryid(), serviceSubtaskCountReq.getSvyCategoryid());
                    serviceSubtaskStatistic.setJoyAllCount(satisfactionIndex.get("mydtmzl") != null ? (Integer) satisfactionIndex.get("mydtmzl") : 0);
                    serviceSubtaskStatistic.setJoyCount(satisfactionIndex.get("mydtbl") != null ? (Integer) satisfactionIndex.get("mydtbl") : 0);
                    serviceSubtaskStatistic.setJoyTotal((Double) satisfactionIndex.get("wcbl"));
                } else if (CollectionUtils.isNotEmpty(serviceSubtaskCountReq.getLeavehospitaldistrictcodes())) {
                    Map<String, Object> satisfactionIndex = getSatisfactionIndex("2", serviceSubtaskStatistic.getLeavehospitaldistrictcode(), orgid, serviceSubtaskCountReq.getIvrCategoryid(), serviceSubtaskCountReq.getSvyCategoryid());
                    Map<String, Object> satisfactionIndex = satisfactionFacade.getSatisfactionIndex("2", serviceSubtaskStatistic.getLeavehospitaldistrictcode(), orgid, serviceSubtaskCountReq.getIvrCategoryid(), serviceSubtaskCountReq.getSvyCategoryid());
                    serviceSubtaskStatistic.setJoyAllCount(satisfactionIndex.get("mydtmzl") != null ? (Integer) satisfactionIndex.get("mydtmzl") : 0);
                    serviceSubtaskStatistic.setJoyCount(satisfactionIndex.get("mydtbl") != null ? (Integer) satisfactionIndex.get("mydtbl") : 0);
                    serviceSubtaskStatistic.setJoyTotal((Double) satisfactionIndex.get("wcbl"));
@@ -3183,103 +3188,6 @@
        return serviceSubtaskStatistics;
    }
    /**
     * èŽ·å–æ»¡æ„åº¦é¢˜ç›®æ€»é‡
     * type : 1-科室   2病区
     * code: ç§‘室或病区code
     *
     * @return
     */
    private Map<String, Object> getSatisfactionIndex(String type, String code, String orgid, Long ivrCategoryid, Long svyCategoryid) {
        Map<String, Object> map = new HashMap<>();
        List<ServiceTask> serviceTasks = null;
        if (type.equals("1")) {
            ServiceTask serviceTask = new ServiceTask();
            serviceTask.setDeptcode(code);
            serviceTask.setOrgid(orgid);
            serviceTasks = serviceTaskService.selectServiceTaskList(serviceTask);
        } else if (type.equals("2")) {
            ServiceTask serviceTask = new ServiceTask();
            serviceTask.setLeavehospitaldistrictcode(code);
            serviceTask.setOrgid(orgid);
            serviceTasks = serviceTaskService.selectServiceTaskList(serviceTask);
        }
        //获取满意度题目总量
        BigDecimal mydtmzlsum = new BigDecimal(0);
        //获取满意度题目填报量
        BigDecimal mydtblsum = new BigDecimal(0);
        if (CollectionUtils.isNotEmpty(serviceTasks)) {
            //获取满意度题目总量
            for (ServiceTask serviceTask : serviceTasks) {
                if (serviceTask.getType().equals("1")) {
                    //语音
                    Long templateid = serviceTask.getTemplateid();
                    IvrTaskTemplateScript ivrTaskTemplateScript = new IvrTaskTemplateScript();
                    ivrTaskTemplateScript.setTemplateID(templateid);
                    ivrTaskTemplateScript.setOrgid(serviceTask.getOrgid());
                    //先默认为1吧,assortid=1  ä»£è¡¨æ˜¯æ»¡æ„åº¦
                    ivrTaskTemplateScript.setScriptAssortid(ivrCategoryid);
                    List<IvrTaskTemplateScript> ivrTaskTemplateScripts = iIvrTaskTemplateScriptService.selectIvrTaskTemplateScriptList(ivrTaskTemplateScript);
                    if (CollectionUtils.isNotEmpty(ivrTaskTemplateScripts)) {
                        //需要先计算出发送量,再乘以单个问券满意度题目数量,得到满意度题目总量(包含了未做的)
                        ServiceSubtaskEntity serviceSubtaskVO = new ServiceSubtaskEntity();
                        serviceSubtaskVO.setTaskid(serviceTask.getTaskid());
                        List<Long> sendstates = new ArrayList<>(Arrays.asList(3L, 5L, 6L));
                        serviceSubtaskVO.setSendstates(sendstates);
                        List<ServiceSubtask> serviceSubtaskList = serviceSubtaskMapper.selectServiceSubtaskBySendstate(serviceSubtaskVO);
                        if (CollectionUtils.isNotEmpty(serviceSubtaskList)) {
                            int mydtmzlCount = ivrTaskTemplateScripts.size() * serviceSubtaskList.size();
                            mydtmzlsum.add(new BigDecimal(mydtmzlCount));
                        }
                    }
                } else if (serviceTask.getType().equals("2")) {
                    //问卷
                    SvyTaskTemplateScript svyTaskTemplateScript = new SvyTaskTemplateScript();
                    svyTaskTemplateScript.setTemplateID(serviceTask.getTemplateid());
                    svyTaskTemplateScript.setOrgid(serviceTask.getOrgid());
                    svyTaskTemplateScript.setCategoryid(svyCategoryid);
                    List<SvyTaskTemplateScript> svyTaskTemplateScriptList = svyTaskTemplateScriptService.selectSvyTaskTemplateScriptList(svyTaskTemplateScript);
                    if (CollectionUtils.isNotEmpty(svyTaskTemplateScriptList)) {
                        ServiceSubtaskEntity serviceSubtaskEntity = new ServiceSubtaskEntity();
                        serviceSubtaskEntity.setTaskid(serviceTask.getTaskid());
                        List<Long> sendstates = new ArrayList<>(Arrays.asList(3L, 5L, 6L));
                        serviceSubtaskEntity.setSendstates(sendstates);
                        List<ServiceSubtask> serviceSubtaskList = serviceSubtaskMapper.selectServiceSubtaskBySendstate(serviceSubtaskEntity);
                        if (CollectionUtils.isNotEmpty(serviceSubtaskList)) {
                            int mydtmzlCount = svyTaskTemplateScriptList.size() * serviceSubtaskList.size();
                            mydtmzlsum.add(new BigDecimal(mydtmzlCount));
                        }
                    }
                }
                //获取满意度题目填报量
                ServiceSubtaskDetail serviceSubtaskDetail = new ServiceSubtaskDetail();
                serviceSubtaskDetail.setTaskid(serviceTask.getTaskid());
                if (serviceTask.getType().equals("1")) serviceSubtaskDetail.setCategoryid(ivrCategoryid);
                if (serviceTask.getType().equals("2")) serviceSubtaskDetail.setCategoryid(svyCategoryid);
                serviceSubtaskDetail.setOrgid(serviceTask.getOrgid());
                List<ServiceSubtaskDetail> serviceSubtaskDetails = serviceSubtaskDetailMapper.selectServiceSubtaskDetailList(serviceSubtaskDetail);
                if (CollectionUtils.isNotEmpty(serviceSubtaskDetails))
                    mydtblsum.add(new BigDecimal(serviceSubtaskDetails.size()));
            }
        }
        //满意度题目总量
        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;
    }
    private ServiceSubtaskStatistic getStatistic(List<ServiceSubtask> serviceSubtaskList, String followUpCountStyle, Integer visitCount) {
        ServiceSubtaskStatistic serviceSubtaskStatistic = new ServiceSubtaskStatistic();
@@ -3911,7 +3819,6 @@
        return serviceSubtaskStatistic;
    }
    private ServiceSubtaskStatistic getStatisticJoy(List<ServiceSubtask> serviceSubtaskList, List<ServiceSubtaskCount> joySubCount) {
        ServiceSubtaskStatistic serviceSubtaskStatistic = new ServiceSubtaskStatistic();
        //格式化为两位小数的百分比
@@ -4034,7 +3941,6 @@
        if (subCounts == null) {
            subCounts = new ArrayList<>();
        }
        for (ServiceSubtask serviceSubtask : serviceSubtaskList) {
            List<ServiceSubtaskCount> subCountTemp = subCounts.stream().filter(r -> r.getSubTaskId().equals(serviceSubtask.getId())).collect(Collectors.toList());
@@ -4512,7 +4418,6 @@
        }
    }
    /**
     * èŽ·å–éšè®¿ç»Ÿè®¡æ¯”ä¾‹
     *
@@ -4734,7 +4639,6 @@
        return result;
    }
    /**
     * @param serviceSubtask
     * @param preachform
@@ -4765,7 +4669,6 @@
            serviceSubtaskPreachform1.setSendstate(failSendstate);
            serviceSubtaskPreachform1.setRemark(remark);
            serviceSubtaskPreachformMapper.updateServiceSubtaskPreachform(serviceSubtaskPreachform1);
            //如果当前的preachform已经是最后一个了,那直接将serviceSubtask的sendstate状态改成5就行了(全失败了)
            if (serviceSubtaskPreachform1.getSort() == spSize.size() && failSendstate.equals("4")) {
@@ -4812,7 +4715,6 @@
        }
        return visitTime;
    }
    /**
     * è®¡ç®—总计行
@@ -5229,7 +5131,6 @@
            serviceOutPath.setUrl(url);
            iServiceOutPathService.updateServiceOutPath(serviceOutPath);
            //his患者id校验(与原实现一致:先校验这一项)
            if (StringUtils.isEmpty(serviceSubtask.getPatidHis())) {
                resultMap.put("code", -1);
@@ -5269,450 +5170,6 @@
            return resultMap;
        }
        return resultMap;
    }
    @Override
    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 = serviceSubtaskDetailMapper.selectRepeatDetailIds();
        if (!CollectionUtils.isEmpty(repeatIds)) {
            serviceSubtaskDetailMapper.deleteRepeatDetailByIds(repeatIds);
        }
        List<ServiceSubtaskDetailRatioExport> serviceSubtaskDetailRatioExports = serviceSubtaskMapper.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;
    }
    @Override
    public List<ServiceSubtaskDetailRatioExport> statMydQuestionOptionForExport(List<Long> subtaskIds, String questiontext) {
        List<ServiceSubtaskDetailRatioExport> result = new ArrayList<>();
        if (CollectionUtils.isEmpty(subtaskIds)) {
            return result;
        }
        List<ServiceSubtaskOptionStatDTO> statList = serviceSubtaskMapper.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();
    }
    /*
     * æ ¹æ®ç§‘室或病区统计问卷选项(科室、题目查询的科室评分表)
     */
    @Override
    public Map<String, Map<String, List<QuestionCountKsOrBqDTO>>> statQuestionOptionByKsOrBq(QuestionCountKsOrBqVO vo) {
        // 1. æŸ¥æ˜Žç»†
        List<QuestionCountKsOrBqDTO> sourceList = serviceSubtaskMapper.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;
    }
    @Override
    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 = serviceSubtaskDetailMapper.selectRepeatDetailIds();
        if (!CollectionUtils.isEmpty(repeatIds)) {
            serviceSubtaskDetailMapper.deleteRepeatDetailByIds(repeatIds);
        }
        List<ServiceSubtaskDetailRatioExport> serviceSubtaskDetailRatioExports = serviceSubtaskMapper.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;
    }
    private Boolean saveServiceSubtaskDetail(ServiceSubtask serviceSubtask) {
@@ -5918,34 +5375,6 @@
        return content;
    }
    /**
     * æ»¡æ„åº¦è¶‹åŠ¿æŸ¥è¯¢
     *
     * @param mydTrendReqVO
     * @return
     */
    @Override
    public List<MydTrendReqDTO> mydTrend(MydTrendReqVO mydTrendReqVO) {
        List<MydTrendReqDTO> list = serviceSubtaskMapper.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;
    }
    @Override
    public List<MydTrendReqDTO> sltdMydTotalByDimension(MydTrendReqVO mydTrendReqVO) {
        List<MydTrendReqDTO> list = null;
        if (mydTrendReqVO.getDimensionType().equals("1"))
            list = serviceSubtaskMapper.sltdMydTotalByDimension(mydTrendReqVO.getStartFinishTime(), mydTrendReqVO.getEndFinishTime(), mydTrendReqVO.getType());
        else if (mydTrendReqVO.getDimensionType().equals("2"))
            list = serviceSubtaskMapper.sltdMydTotalByYQ(mydTrendReqVO.getStartFinishTime(), mydTrendReqVO.getEndFinishTime(), mydTrendReqVO.getType());
        return list;
    }
    public ServiceSubtask boxedServiceSubtask(ServiceTask serviceTask, ServiceTaskdept serviceTaskdept, PatArchive patArchive) {
        ServiceSubtask serviceSubtask = DtoConversionUtils.sourceToTarget(serviceTask, ServiceSubtask.class);
        serviceSubtask.setTaskid(serviceTask.getTaskid());
@@ -6059,7 +5488,6 @@
        }
    }
    public PhoneCallBackSHVO getFirstQues(PhoneCallReqYQVO phoneCallReqYQVO) {
        //用于返回
        PhoneCallBackSHVO phoneCallBackSHVO = new PhoneCallBackSHVO();
@@ -6097,7 +5525,6 @@
        }
        return phoneCallBackSHVO;
    }
    /**
     * ä¸‰æ±‡ASR结果处理
@@ -6171,7 +5598,6 @@
            Object cacheObject = redisCache.getCacheObject(phoneCallResultYQVO.getSubId() + "-" + phoneCallResultYQVO.getScriptId());
            if (cacheObject == null || (Integer) cacheObject < ivrTaskTemplate.getMateNum()) {
                // èŽ·å–é€‰é¡¹é›†åˆä¸­çš„ optiondesc ç”¨äºŽå¼•导患者
                List<IvrTaskTemplateTargetoption> optionList = ivrTaskTemplateScriptVO.getIvrTaskScriptTargetoptionList();
                String optionDescStr = "";
@@ -6214,7 +5640,6 @@
        }
        return phoneCallBackSHVO;
    }
    public PhoneCallBackYQVO AiMatch(String url, PhoneCallReqYQVO phoneCallReqYQVO, PhoneCallBackYQVO phoneCallBackYQVO, IvrTaskTemplateScriptVO ivrTaskTemplateScriptVO, ServiceSubtask serviceSubtask, IvrTaskTemplate ivrTaskTemplate, List<IvrTaskTemplateScript> ivrTaskTemplateScripts, String scriptId, Integer flag, String uuid) {
        PhoneCallBackYQVO back = null;
@@ -6517,45 +5942,6 @@
        return back;
    }
    /**
     * é—®é¢˜ç»“果占比
     *
     * @param sltdMydTotalVO
     * @param serviceSubtaskRes
     * @return
     */
    public List<ServiceSubtaskDetailRatioExport> getDetailRatio(SltdMydTotalVO sltdMydTotalVO, List<ServiceSubtaskRes> serviceSubtaskRes) {
        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 = serviceSubtaskRes.stream().map(ServiceSubtaskRes::getTaskid).filter(ObjectUtils::isNotEmpty).distinct().collect(Collectors.toList());
            //同时带上本次导出的子任务 id:让「问题详情占比」只统计这批患者,与「患者随访信息表单」口径一致
            List<Long> subtaskIds = serviceSubtaskRes.stream().map(ServiceSubtaskRes::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 = serviceSubtaskRes.stream().map(ServiceSubtaskRes::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 void findServiceTaskScheduleVO(ServiceTaskVO serviceTaskVO) {
        if (StringUtils.isNotEmpty(serviceTaskVO.getAppltype()) && serviceTaskVO.getAppltype().equals("5")) {
@@ -6594,307 +5980,6 @@
            }
        }
        log.info("ai匹配成功,匹配结果是:" + matchedText);
    }
    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 static Boolean isBlankChar(char c) {
        return c == '\u3000'          // å…¨è§’空格
                || c == ' '            // åŠè§’空格
                || c == '\t' || c == '\n' || c == '\r' || c == '\f' || Character.isWhitespace(c);
    }
    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;
    }
    /**
     * é—¨è¯Šæ»¡æ„åº¦ç»¼åˆå¾—分排名(按科室或病区)
     * <p>
     * ç»¼åˆæ»¡æ„åº¦ = Î£(评分明细权重) / è¯„分明细条数 Ã— 100%,
     * æƒé‡åªå– 1、0.8、0.6、0.4、0.2 äº”档(很满意、满意、一般、不满意、很不满意)。
     *
     * @param mzMydScoreRankVO æŸ¥è¯¢æ¡ä»¶
     * @return æŽ’名结果,名次从 1 å¼€å§‹é¡ºåºç¼–号
     */
    @Override
    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 = serviceSubtaskMapper.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;
    }
    // ---------------------------------------------------------------------
smartor/src/main/java/com/smartor/service/impl/UserExtInfoServiceImpl.java
@@ -1,45 +1,32 @@
package com.smartor.service.impl;
import com.ruoyi.common.core.service.IUserExtInfoService;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.utils.StringUtils;
import com.smartor.domain.IvrLibaScriptAssort;
import com.smartor.domain.SvyLibScriptCategory;
import com.smartor.domain.SysUserOrg;
import com.smartor.mapper.IvrLibaScriptAssortMapper;
import com.smartor.mapper.SvyLibScriptCategoryMapper;
import com.smartor.mapper.SysUserOrgMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
 * ç”¨æˆ·æ‰©å±•信息服务实现(smartor æ¨¡å—)
 * ä¾› ruoyi-system é€šè¿‡ SpringUtils è°ƒç”¨
 * <p>
 * è¿ç§»è¯´æ˜Žï¼šåŽŸ {@code getSatisfactionCategories}(含 Redis ç¼“存、语音/问卷分类查询)
 * å±žäºŽæ»¡æ„åº¦é€»è¾‘,已整段搬入 ruoyi-satisfaction æ¨¡å—,由
 * {@code com.ruoyi.satisfaction.api.SatisfactionFacade} å®žçް
 * {@code com.ruoyi.common.core.service.ISatisfactionCategoryService}。
 * æœ¬ç±»å› æ­¤ä¸å†ä¾èµ– {@code IvrLibaScriptAssortMapper} / {@code SvyLibScriptCategoryMapper} / {@code RedisCache}。
 */
@Service
public class UserExtInfoServiceImpl implements IUserExtInfoService {
    private static final String SATISFACTION_CACHE_KEY = "satisfaction:categories:";
    private static final int SATISFACTION_CACHE_TTL = 30;
    @Autowired
    private SysUserOrgMapper sysUserOrgMapper;
    @Autowired
    private IvrLibaScriptAssortMapper ivrLibaScriptAssortMapper;
    @Autowired
    private SvyLibScriptCategoryMapper svyLibScriptCategoryMapper;
    @Autowired
    private RedisCache redisCache;
    /**
     * èŽ·å–ç”¨æˆ·æœºæž„/院区信息
@@ -57,58 +44,5 @@
            result.put("campusid", sysUserOrgs.get(0).getCampusid());
        }
        return result;
    }
    /**
     * èŽ·å–æ»¡æ„åº¦é—®é¢˜åˆ†ç±»ï¼ˆè¯­éŸ³ + é—®å·ï¼‰ï¼Œå¸¦Redis缓存
     */
    @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<>();
        // èŽ·å–è¯­éŸ³é—®é¢˜åˆ†ç±»
        IvrLibaScriptAssort ivrLibaScriptAssort = new IvrLibaScriptAssort();
        ivrLibaScriptAssort.setOrgid(orgid);
        ivrLibaScriptAssort.setType("myd");
        List<IvrLibaScriptAssort> ivrLibaScriptAssorts = ivrLibaScriptAssortMapper.selectIvrLibaScriptAssortList(ivrLibaScriptAssort);
        List<Map<String, Object>> categoryList = new ArrayList<>();
        if (!CollectionUtils.isEmpty(ivrLibaScriptAssorts)) {
            for (IvrLibaScriptAssort assort : ivrLibaScriptAssorts) {
                Map<String, Object> map = new HashMap<>();
                map.put("categoryid", assort.getId());
                map.put("categoryName", assort.getIndexAssortName());
                categoryList.add(map);
            }
        }
        satisfactionCategories.put("voiceCategories", categoryList);
        // èŽ·å–é—®å·é—®é¢˜åˆ†ç±»
        SvyLibScriptCategory svyLibScriptCategory = new SvyLibScriptCategory();
        svyLibScriptCategory.setOrgid(orgid);
        svyLibScriptCategory.setType("myd");
        List<SvyLibScriptCategory> svyLibScriptCategories = svyLibScriptCategoryMapper.selectSvyLibScriptCategoryList(svyLibScriptCategory);
        List<Map<String, Object>> questionnaireCategorys = new ArrayList<>();
        if (!CollectionUtils.isEmpty(svyLibScriptCategories)) {
            for (SvyLibScriptCategory scriptCategory : svyLibScriptCategories) {
                Map<String, Object> map = new HashMap<>();
                map.put("categoryid", scriptCategory.getId());
                map.put("categoryName", scriptCategory.getName());
                questionnaireCategorys.add(map);
            }
        }
        satisfactionCategories.put("questionnaireCategorys", questionnaireCategorys);
        // å†™å…¥ç¼“å­˜
        redisCache.setCacheObject(cacheKey, satisfactionCategories, SATISFACTION_CACHE_TTL, TimeUnit.MINUTES);
        return satisfactionCategories;
    }
}
smartor/src/main/java/com/smartor/statemachine/README.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,258 @@
/**
 * ============================================================================
 * ä½é™¢çŠ¶æ€æœºé›†æˆæŒ‡å—
 * ============================================================================
 *
 * ä¸€ã€çŠ¶æ€æœºæ–‡ä»¶æ¸…å•
 * ============================================================================
 *
 * 1. æ ¸å¿ƒæžšä¸¾
 *    â””─ com.smartor.common.enums.InhospStateEnum
 *       å®šä¹‰çŠ¶æ€åŠè½¬æ¢è§„åˆ™
 *
 * 2. å¼‚常类
 *    â””─ com.smartor.common.exception.StateTransitionException
 *       å¤„理非法状态转换
 *
 * 3. å¤„理器接口
 *    â””─ com.smartor.statemachine.handler.InhospStateHandler
 *       çŠ¶æ€å¤„ç†å™¨æŽ¥å£
 *
 * 4. å¤„理器实现
 *    â”œâ”€ com.smartor.statemachine.handler.impl.PreAdmissionHandler
 *    â”‚  å¤„理预入院(state=3)
 *    â”œâ”€ com.smartor.statemachine.handler.impl.InHospitalHandler
 *    â”‚  å¤„理入院(state=0)
 *    â””─ com.smartor.statemachine.handler.impl.DischargeHandler
 *       å¤„理出院(state=1)
 *
 * 5. çŠ¶æ€æœºç®¡ç†å™¨
 *    â””─ com.smartor.statemachine.InhospStateMachine
 *       åè°ƒçŠ¶æ€è½¬æ¢ã€ç®¡ç†åˆ†å¸ƒå¼é”
 *
 * 6. é—¨é¢æœåŠ¡
 *    â””─ com.smartor.statemachine.InhospStateMachineService
 *       å¯¹å¤–API接口
 *
 * 7. ä½¿ç”¨ç¤ºä¾‹
 *    â””─ com.smartor.statemachine.InhospStateMachineExample
 *       é›†æˆç¤ºä¾‹ä»£ç 
 *
 *
 * äºŒã€å¦‚何在现有代码中集成
 * ============================================================================
 *
 * åŽŸä»£ç ä½ç½®ï¼š
 * ServiceSLTDHealthcareRecordServiceImpl.processPatientInhospInfo (L561-677)
 *
 * ã€æ–¹æ¡ˆ1】完全替换(推荐用于新功能)
 * -----------------------------------------
 *
 * @Autowired
 * private InhospStateMachineService stateMachineService;
 *
 * private void processPatientInhospInfo(ServiceSLTDInhospResDTO dto, PatArchive patArchive, String cry) {
 *     PatMedInhosp patMedInhosp = buildPatientInhospInfo(dto, patArchive, cry);
 *     stateMachineService.processStateChange(patMedInhosp, cry);
 * }
 *
 *
 * ã€æ–¹æ¡ˆ2】双写模式(推荐用于生产环境验证)
 * -----------------------------------------
 *
 * @Autowired
 * private InhospStateMachineService stateMachineService;
 *
 * private void processPatientInhospInfo(ServiceSLTDInhospResDTO dto, PatArchive patArchive, String cry) {
 *     PatMedInhosp patMedInhosp = buildPatientInhospInfo(dto, patArchive, cry);
 *
 *     // æ–¹å¼A:保留原有逻辑
 *     // processPatientInhospInfoOld(dto, patArchive, cry);
 *
 *     // æ–¹å¼B:使用状态机
 *     try {
 *         boolean success = stateMachineService.processStateChange(patMedInhosp, cry);
 *         log.info("【状态机】处理结果:{}, serialnum={}, cry={}", success, patMedInhosp.getSerialnum(), cry);
 *     } catch (Exception e) {
 *         log.error("【状态机】处理异常:serialnum={}, cry={}, é”™è¯¯ï¼š{}", patMedInhosp.getSerialnum(), cry, e.getMessage());
 *     }
 * }
 *
 *
 * ã€æ–¹æ¡ˆ3】开关控制(推荐用于灰度发布)
 * -----------------------------------------
 *
 * @Value("${inhosp.statemachine.enabled:false}")
 * private boolean stateMachineEnabled;
 *
 * @Autowired
 * private InhospStateMachineService stateMachineService;
 *
 * private void processPatientInhospInfo(ServiceSLTDInhospResDTO dto, PatArchive patArchive, String cry) {
 *     PatMedInhosp patMedInhosp = buildPatientInhospInfo(dto, patArchive, cry);
 *
 *     if (stateMachineEnabled) {
 *         // ä½¿ç”¨çŠ¶æ€æœº
 *         stateMachineService.processStateChange(patMedInhosp, cry);
 *     } else {
 *         // ä½¿ç”¨åŽŸæœ‰é€»è¾‘
 *         processPatientInhospInfoOld(dto, patArchive, cry);
 *     }
 * }
 *
 * é…ç½®æ–‡ä»¶ application.yml:
 * inhosp:
 *   statemachine:
 *     enabled: true  # å¼€å¯çŠ¶æ€æœº
 *
 *
 * ä¸‰ã€æ•°æ®åº“索引建议
 * ============================================================================
 *
 * ã€é€‰é¡¹1】允许多状态记录(推荐,与现有逻辑兼容)
 * -----------------------------------------
 * CREATE UNIQUE INDEX uk_serialnum_orgid_state
 * ON pat_med_inhosp(serialnum, orgid, inhospstate);
 *
 * ä¼˜ç‚¹ï¼š
 * - ä¸ŽçŽ°æœ‰æ•°æ®å…¼å®¹
 * - åªæ‹¦æˆªç›¸åŒçŠ¶æ€çš„é‡å¤æ’å…¥
 * - çŠ¶æ€æœºå¤„ç†å™¨å¯ä»¥å†³å®šæ˜¯æ–°å¢žè¿˜æ˜¯æ›´æ–°
 *
 *
 * ã€é€‰é¡¹2】强制单记录(需数据迁移)
 * -----------------------------------------
 * CREATE UNIQUE INDEX uk_serialnum_orgid
 * ON pat_med_inhosp(serialnum, orgid);
 *
 * ä¼˜ç‚¹ï¼š
 * - æ•°æ®ä¸€è‡´æ€§æ›´å¼º
 * - é¿å…ä¸€æ‚£å¤šè®°å½•问题
 *
 * ç¼ºç‚¹ï¼š
 * - éœ€è¦æ¸…理历史数据
 * - æ‰€æœ‰çŠ¶æ€å˜æ›´å¿…é¡»èµ°æ›´æ–°
 *
 * æ•°æ®æ¸…理脚本:
 * -- ä¿ç•™æ¯ä¸ªæ‚£è€…最新状态的记录,删除其他记录
 * DELETE t1 FROM pat_med_inhosp t1
 * INNER JOIN (
 *     SELECT serialnum, orgid, MAX(inhospid) as max_id
 *     FROM pat_med_inhosp
 *     GROUP BY serialnum, orgid
 *     HAVING COUNT(*) > 1
 * ) t2 ON t1.serialnum = t2.serialnum
 *     AND t1.orgid = t2.orgid
 *     AND t1.inhospid < t2.max_id;
 *
 *
 * å››ã€çŠ¶æ€æœºæ ¸å¿ƒä¼˜åŠ¿
 * ============================================================================
 *
 * 1. å¹¶å‘安全
 *    âœ… è‡ªåŠ¨åŠ åˆ†å¸ƒå¼é”ï¼ˆRedis)
 *    âœ… é”ç²’度:serialnum + orgid
 *    âœ… è¶…时时间:10秒(可配置)
 *    âœ… Redis不可用时自动降级
 *
 * 2. çŠ¶æ€æ ¡éªŒ
 *    âœ… å¼ºåˆ¶å•向流转:3(预入院) â†’ 0(入院) â†’ 1(出院)
 *    âœ… é˜»æ­¢éžæ³•转换(如:预入院→出院)
 *    âœ… é˜»æ­¢é‡å¤æ“ä½œï¼ˆå¦‚:重复入院)
 *
 * 3. ä»£ç ç®€åŒ–
 *    âœ… 100+行if-else â†’ 10行状态机调用
 *    âœ… ç­–略模式,易扩展
 *    âœ… é›†ä¸­å¼çŠ¶æ€ç®¡ç†
 *
 * 4. æ—¥å¿—完善
 *    âœ… æ¯ä¸ªçŠ¶æ€è½¬æ¢éƒ½æœ‰æ—¥å¿—
 *    âœ… å¼‚常信息详细
 *    âœ… ä¾¿äºŽé—®é¢˜æŽ’查
 *
 *
 * äº”、测试验证建议
 * ============================================================================
 *
 * 1. å•元测试
 *    - æµ‹è¯•状态枚举的转换规则
 *    - æµ‹è¯•每个Handler的逻辑
 *    - æµ‹è¯•状态机的锁机制
 *
 * 2. é›†æˆæµ‹è¯•
 *    - æ­£å¸¸æµç¨‹ï¼šé¢„入院→入院→出院
 *    - è·³è¿‡é¢„入院:直接入院→出院
 *    - å¼‚常流程:重复操作、非法转换
 *
 * 3. å¹¶å‘测试
 *    - ä½¿ç”¨JMeter模拟并发请求
 *    - éªŒè¯åŒä¸€æ‚£è€…100并发不会重复插入
 *    - éªŒè¯Redis锁的有效性
 *
 * 4. ç”Ÿäº§éªŒè¯
 *    - å…ˆå¼€å¯åŒå†™æ¨¡å¼
 *    - å¯¹æ¯”原逻辑与状态机结果
 *    - éªŒè¯1-2周后完全切换
 *
 *
 * å…­ã€é…ç½®å‚数(可选)
 * ============================================================================
 *
 * application.yml å¯æ·»åŠ ä»¥ä¸‹é…ç½®ï¼š
 *
 * inhosp:
 *   statemachine:
 *     enabled: true                  # æ˜¯å¦å¯ç”¨çŠ¶æ€æœº
 *     lock-timeout: 10               # åˆ†å¸ƒå¼é”è¶…时时间(秒)
 *     allow-direct-discharge: false  # æ˜¯å¦å…è®¸æ— å…¥é™¢è®°å½•直接出院
 *
 *
 * ä¸ƒã€å¸¸è§é—®é¢˜
 * ============================================================================
 *
 * Q1: çŠ¶æ€æœºæ˜¯å¦ä¼šå½±å“æ€§èƒ½ï¼Ÿ
 * A: ä¸ä¼šã€‚状态机内部逻辑与原代码相似,只是结构更清晰。Redis锁的获取耗时在毫秒级。
 *
 * Q2: Redis不可用时会怎样?
 * A: çŠ¶æ€æœºä¼šè‡ªåŠ¨é™çº§ä¸ºæ— é”æ¨¡å¼ï¼Œä¾ç„¶å¯ä»¥æ‰§è¡Œï¼Œä½†å¹¶å‘å®‰å…¨ä¾èµ–æ•°æ®åº“å”¯ä¸€ç´¢å¼•ã€‚
 *
 * Q3: å¦‚何扩展新状态?
 * A:
 *    1. åœ¨InhospStateEnum中添加新状态
 *    2. åˆ›å»ºå¯¹åº”çš„Handler实现
 *    3. çŠ¶æ€æœºä¼šè‡ªåŠ¨æ³¨å†Œå¹¶è¯†åˆ«
 *
 * Q4: çŠ¶æ€æœºæ˜¯å¦æ”¯æŒäº‹åŠ¡ï¼Ÿ
 * A: æ”¯æŒã€‚状态机调用本身可以在@Transactional方法中使用。
 *
 * Q5: å‡ºé™¢åŽèƒ½å¦å†å…¥é™¢ï¼Ÿ
 * A: ç›®å‰é€»è¾‘不允许。如果需要支持,建议在数据模型中增加"就诊批次"字段。
 *
 *
 * å…«ã€åŽç»­ä¼˜åŒ–方向
 * ============================================================================
 *
 * 1. çŠ¶æ€å˜æ›´åŽ†å²è¡¨
 *    - è®°å½•每次状态转换
 *    - æ”¯æŒçŠ¶æ€å›žæº¯
 *    - ä¾¿äºŽå®¡è®¡
 *
 * 2. çŠ¶æ€æœºå¯è§†åŒ–
 *    - æä¾›çŠ¶æ€æµè½¬å›¾
 *    - å®žæ—¶ç›‘控状态分布
 *
 * 3. è¡¥å¿æœºåˆ¶
 *    - æ”¯æŒçŠ¶æ€å›žæ»š
 *    - å¼‚常状态修复
 *
 * 4. é…ç½®åŒ–状态规则
 *    - ä»Žæ•°æ®åº“读取状态转换规则
 *    - åŠ¨æ€è°ƒæ•´ä¸éœ€è¦é‡å¯
 *
 *
 * ============================================================================
 * è”系人:smartor开发团队
 * åˆ›å»ºæ—¥æœŸï¼š2026-01-29
 * ============================================================================
 */
package com.smartor.statemachine;
smartor/src/main/resources/mapper/smartor/PatMedOuthospErrorMapper.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.smartor.mapper.PatMedOuthospErrorMapper">
    <resultMap type="com.smartor.domain.PatMedOuthospError" id="PatMedOuthospErrorResult">
        <result property="id" column="id"/>
        <result property="serialnum" column="serialnum"/>
        <result property="outhospno" column="outhospno"/>
        <result property="patname" column="patname"/>
        <result property="patno" column="patno"/>
        <result property="campusid" column="campusid"/>
        <result property="orgid" column="orgid"/>
        <result property="admitdate" column="admitdate"/>
        <result property="gatherDate" column="gather_date"/>
        <result property="errorType" column="error_type"/>
        <result property="errorMsg" column="error_msg"/>
        <result property="rawData" column="raw_data"/>
        <result property="failCount" column="fail_count"/>
        <result property="handleStatus" column="handle_status"/>
        <result property="handleRemark" column="handle_remark"/>
        <result property="createTime" column="create_time"/>
        <result property="updateTime" column="update_time"/>
    </resultMap>
    <sql id="selectPatMedOuthospErrorVo">
        select id, serialnum, outhospno, patname, patno, campusid, orgid, admitdate, gather_date,
               error_type, error_msg, raw_data, fail_count, handle_status, handle_remark, create_time, update_time
        from pat_med_outhosp_error
    </sql>
    <!--
      é‡‡é›†å¤±è´¥è½è¡¨ï¼šserialnum ä¸ä¸ºç©ºæ—¶é å”¯ä¸€ç´¢å¼• uk_serialnum_type(serialnum, error_type) åš upsert,
      é¿å…æ¯åŠå°æ—¶ä¸€è½®æŠŠåŒä¸€æ¡è„æ•°æ®é‡å¤å†™è¿›æ¥ï¼›fail_count ç´¯åŠ å¯ä»¥çœ‹åˆ°å®ƒåˆ°åº•å¤±è´¥äº†å¤šå°‘è½®ã€‚
      å·²è¢«äººå·¥æ ‡è®°"已处理"的记录如果再次失败,状态重新回到"待处理";"已忽略"的保持忽略不再打扰。
    -->
    <insert id="insertPatMedOuthospError" parameterType="com.smartor.domain.PatMedOuthospError"
            useGeneratedKeys="true" keyProperty="id">
        insert into pat_med_outhosp_error
        (serialnum, outhospno, patname, patno, campusid, orgid, admitdate, gather_date,
         error_type, error_msg, raw_data, fail_count, handle_status, handle_remark, create_time, update_time)
        values
        (#{serialnum}, #{outhospno}, #{patname}, #{patno}, #{campusid}, #{orgid}, #{admitdate}, #{gatherDate},
         #{errorType}, #{errorMsg}, #{rawData}, 1, #{handleStatus}, #{handleRemark}, now(), now())
        <if test="serialnum != null and serialnum != ''">
            on duplicate key update
            error_msg = values(error_msg),
            raw_data = values(raw_data),
            admitdate = values(admitdate),
            fail_count = fail_count + 1,
            handle_status = case when handle_status = '1' then '0' else handle_status end,
            update_time = now()
        </if>
    </insert>
    <select id="selectPatMedOuthospErrorList" parameterType="com.smartor.domain.PatMedOuthospError"
            resultMap="PatMedOuthospErrorResult">
        <include refid="selectPatMedOuthospErrorVo"/>
        <where>
            <if test="serialnum != null and serialnum != ''">and serialnum = #{serialnum}</if>
            <if test="errorType != null and errorType != ''">and error_type = #{errorType}</if>
            <if test="gatherDate != null and gatherDate != ''">and gather_date = #{gatherDate}</if>
            <if test="handleStatus != null and handleStatus != ''">and handle_status = #{handleStatus}</if>
        </where>
        order by id desc
    </select>
    <update id="updateHandleStatus" parameterType="com.smartor.domain.PatMedOuthospError">
        update pat_med_outhosp_error
        <trim prefix="SET" suffixOverrides=",">
            <if test="handleStatus != null and handleStatus != ''">handle_status = #{handleStatus},</if>
            <if test="handleRemark != null">handle_remark = #{handleRemark},</if>
            update_time = now(),
        </trim>
        where id = #{id}
    </update>
</mapper>
smartor/src/main/resources/mapper/smartor/ServiceSubtaskDetailMapper.xml
@@ -283,185 +283,7 @@
        <if test="questiontext != null">and questiontext = #{questiontext}</if>
    </select>
    <!--
        æ»¡æ„åº¦æŸ±çŠ¶å›¾ï¼šä¸€æ¬¡æ€§ç»Ÿè®¡æœ¬æ¬¡è¯·æ±‚å…¨éƒ¨æ¨¡æ¿çš„å¡«æŠ¥é‡ã€‚
        ç­‰ä»·äºŽå¯¹æ¯ä¸ªæ¨¡æ¿æ‰§è¡Œ queryFillCount(subids, questiontext),区别是:
          1) ç”¨ join service_subtask ä»£æ›¿å…ˆæŠŠå…¨éƒ¨ sub_id æŸ¥å›ž Java å†æ‹¼ IN(...);
          2) æŒ‰ libtemplateid + questiontext + score åˆ†ç»„,只回传分值与条数;
          3) visit_time æ”¹æˆå¯èµ°ç´¢å¼•的范围条件。
        æ³¨æ„ï¼šè¿™é‡Œåˆ»æ„ä¸åœ¨ SQL é‡Œè¿‡æ»¤ questiontext,由 Java æŒ‰åŽŸ queryFillCount çš„
        ã€Œquestiontext ä¸º null æ—¶ä¸æ‹¼è¯¥æ¡ä»¶ã€è¯­ä¹‰åšåŒ¹é…ï¼Œä¿è¯ç»“果口径完全一致。
    -->
    <select id="selectSatisfactionFillStat" parameterType="com.smartor.domain.entity.SatisfactionStatQuery"
            resultType="java.util.Map">
        select ss.libtemplateid as libtemplateid,
               ssd.questiontext as questiontext,
               ssd.score as score,
               count(*) as cnt
        from service_subtask_detail ssd
        join service_subtask ss on ss.id = ssd.sub_id
        where ssd.del_flag = 0
        and ss.del_flag = 0
        <if test="orgid != null and orgid != ''">
            and ss.orgid = #{orgid}
        </if>
        <if test="campusid != null and campusid != ''">
            and ss.campusid = #{campusid}
        </if>
        <if test="type != null and type != ''">and ss.type = #{type}</if>
        <if test="startTime != null">
            and ss.visit_time &gt;= #{startTime}
        </if>
        <if test="endTime != null">
            and ss.visit_time &lt; date_add(#{endTime}, interval 1 day)
        </if>
        <if test="libTemplateIds != null and libTemplateIds.size() > 0">
            and ss.libtemplateid IN
            <foreach collection="libTemplateIds" item="libTemplateId" open="(" separator="," close=")">
                #{libTemplateId}
            </foreach>
        </if>
        <if test="(hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0) or (deptcodes != null and deptcodes.size() > 0)">
            AND (
            <if test="hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0">
                ss.leavehospitaldistrictcode IN
                <foreach collection="hospitaldistrictcodes" item="hospitaldistrictcode" open="("
                         separator=","
                         close=")">
                    #{hospitaldistrictcode}
                </foreach>
            </if>
            <if test="hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0 and deptcodes != null and deptcodes.size() > 0">
                OR
            </if>
            <if test="deptcodes != null and deptcodes.size() > 0">
                ss.deptcode IN
                <foreach collection="deptcodes" item="deptcode" open="(" separator="," close=")">
                    #{deptcode}
                </foreach>
            </if>
            )
        </if>
        group by ss.libtemplateid, ssd.questiontext, ssd.score
    </select>
    <select id="selectSatisfactionFillPersonCount" parameterType="com.smartor.domain.entity.SatisfactionStatQuery"
            resultType="java.util.Map">
        select ss.libtemplateid as libtemplateid,
               count(distinct ssd.sub_id) as cnt
        from service_subtask_detail ssd
        join service_subtask ss on ss.id = ssd.sub_id
        where ssd.del_flag = 0
        and ss.del_flag = 0
        <if test="orgid != null and orgid != ''">
            and ss.orgid = #{orgid}
        </if>
        <if test="campusid != null and campusid != ''">
            and ss.campusid = #{campusid}
        </if>
        <if test="type != null and type != ''">and ss.type = #{type}</if>
        <if test="startTime != null">
            and ss.visit_time &gt;= #{startTime}
        </if>
        <if test="endTime != null">
            and ss.visit_time &lt; date_add(#{endTime}, interval 1 day)
        </if>
        <if test="libTemplateIds != null and libTemplateIds.size() > 0">
            and ss.libtemplateid IN
            <foreach collection="libTemplateIds" item="libTemplateId" open="(" separator="," close=")">
                #{libTemplateId}
            </foreach>
        </if>
        <if test="(hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0) or (deptcodes != null and deptcodes.size() > 0)">
            AND (
            <if test="hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0">
                ss.leavehospitaldistrictcode IN
                <foreach collection="hospitaldistrictcodes" item="hospitaldistrictcode" open="("
                         separator=","
                         close=")">
                    #{hospitaldistrictcode}
                </foreach>
            </if>
            <if test="hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0 and deptcodes != null and deptcodes.size() > 0">
                OR
            </if>
            <if test="deptcodes != null and deptcodes.size() > 0">
                ss.deptcode IN
                <foreach collection="deptcodes" item="deptcode" open="(" separator="," close=")">
                    #{deptcode}
                </foreach>
            </if>
            )
        </if>
        group by ss.libtemplateid
    </select>
    <!--
        æ»¡æ„åº¦ç»Ÿè®¡ï¼ˆstatistics / statisticsByDimension):一次性统计本次请求全部问题的填报量。
        ç­‰ä»·äºŽå¯¹æ¯ä¸ªé—®é¢˜æ‰§è¡Œ queryFillCount(subids, questiontext),区别是:
          1) ç”¨ join service_subtask ä»£æ›¿å…ˆæŠŠå…¨éƒ¨ sub_id æŸ¥å›ž Java å†æ‹¼ IN(...);
          2) æŒ‰ æ¨¡æ¿+问题+matchedtext+score åˆ†ç»„,只回传选项/分值与条数;
          3) targetvalue ç”¨ min() å–该问题的一个非空选项串(同一个问题该值一致);
          4) visit_time æ”¹æˆå¯èµ°ç´¢å¼•的范围条件。
        æ³¨æ„ï¼šè¿™é‡Œåˆ»æ„ä¸åœ¨ SQL é‡Œè¿‡æ»¤ questiontext,由 Java æŒ‰åŽŸ queryFillCount çš„
        ã€Œquestiontext ä¸º null æ—¶ä¸æ‹¼è¯¥æ¡ä»¶ã€è¯­ä¹‰åšåŒ¹é…ï¼Œä¿è¯ç»“果口径完全一致。
    -->
    <select id="selectSatisfactionFillStatByMatchedtext" parameterType="com.smartor.domain.entity.SatisfactionStatQuery"
            resultType="java.util.Map">
        select ss.libtemplateid as libtemplateid,
               ssd.questiontext as questiontext,
               ssd.matchedtext as matchedtext,
               ssd.score as score,
               count(*) as cnt,
               min(ssd.targetvalue) as targetvalue
        from service_subtask_detail ssd
        join service_subtask ss on ss.id = ssd.sub_id
        where ssd.del_flag = 0
        and ss.del_flag = 0
        <if test="orgid != null and orgid != ''">
            and ss.orgid = #{orgid}
        </if>
        <if test="campusid != null and campusid != ''">
            and ss.campusid = #{campusid}
        </if>
        <if test="type != null and type != ''">and ss.type = #{type}</if>
        <if test="startTime != null">
            and ss.visit_time &gt;= #{startTime}
        </if>
        <if test="endTime != null">
            and ss.visit_time &lt; date_add(#{endTime}, interval 1 day)
        </if>
        <if test="libTemplateIds != null and libTemplateIds.size() > 0">
            and ss.libtemplateid IN
            <foreach collection="libTemplateIds" item="libTemplateId" open="(" separator="," close=")">
                #{libTemplateId}
            </foreach>
        </if>
        <if test="(hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0) or (deptcodes != null and deptcodes.size() > 0)">
            AND (
            <if test="hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0">
                ss.leavehospitaldistrictcode IN
                <foreach collection="hospitaldistrictcodes" item="hospitaldistrictcode" open="("
                         separator=","
                         close=")">
                    #{hospitaldistrictcode}
                </foreach>
            </if>
            <if test="hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0 and deptcodes != null and deptcodes.size() > 0">
                OR
            </if>
            <if test="deptcodes != null and deptcodes.size() > 0">
                ss.deptcode IN
                <foreach collection="deptcodes" item="deptcode" open="(" separator="," close=")">
                    #{deptcode}
                </foreach>
            </if>
            )
        </if>
        group by ss.libtemplateid, ssd.questiontext, ssd.matchedtext, ssd.score
    </select>
    <select id="isExistServiceSubtaskDetail" resultType="com.smartor.domain.DTO.ServiceSubtaskDetailDTO">
                <select id="isExistServiceSubtaskDetail" resultType="com.smartor.domain.DTO.ServiceSubtaskDetailDTO">
        select id, questiontext, matchedtext, score
        from service_subtask_detail
        where del_flag = 0
@@ -650,7 +472,6 @@
        where id = #{id}
    </update>
    <update id="updateSSDByCondition" parameterType="com.smartor.domain.ServiceSubtaskDetail">
        update service_subtask_detail
        <trim prefix="SET" suffixOverrides=",">
@@ -743,33 +564,4 @@
            service_subtask_detail.matchedtext
    </select>
    <select id="selectRepeatDetailIds" resultType="Long">
        SELECT d1.id
        FROM service_subtask_detail d1
        INNER JOIN (
            SELECT sub_id, questiontext,
                   MAX(create_time) AS max_create_time,
                   MAX(id) AS keep_id
            FROM service_subtask_detail
            WHERE del_flag = 0
            GROUP BY sub_id, questiontext
            HAVING COUNT(*) &gt; 1
        ) d2 ON d1.sub_id = d2.sub_id
            AND d1.questiontext = d2.questiontext
            AND (
                d1.create_time &lt; d2.max_create_time
                OR (d1.create_time = d2.max_create_time AND d1.id &lt; d2.keep_id)
            )
        WHERE d1.del_flag = 0
    </select>
    <update id="deleteRepeatDetailByIds">
        UPDATE service_subtask_detail
        SET del_flag = 1
        WHERE del_flag = 0
        AND id IN
        <foreach collection="ids" item="id" open="(" separator="," close=")">
            #{id}
        </foreach>
    </update>
</mapper>
        </mapper>
smartor/src/main/resources/mapper/smartor/ServiceSubtaskMapper.xml
@@ -291,7 +291,6 @@
        <if test="continueTimeNext != null ">and continue_time_next = #{continueTimeNext}</if>
    </select>
    <select id="selectServiceSubtaskList" parameterType="com.smartor.domain.entity.ServiceSubtaskEntity"
            resultMap="ServiceSubtaskResult">
        <include refid="selectServiceSubtaskVo"/>
@@ -537,78 +536,7 @@
        <if test="pageSize != null  and pageNum != null">limit ${pageSize} OFFSET ${pageNum}</if>
    </select>
    <select id="selectSatisfactionSubtaskList" parameterType="com.smartor.domain.VO.PatSatisfactionReqVO"
            resultMap="ServiceSubtaskResult">
        <include refid="selectServiceSubtaskVo"/>
        where 1=1
        and del_flag = 0
        <if test="questionType != null">and type = #{questionType}</if>
        <if test="hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0">
            AND leavehospitaldistrictcode IN
            <foreach collection="hospitaldistrictcodes" item="hospitaldistrictcode" open="(" separator=","
                     close=")">
                #{hospitaldistrictcode}
            </foreach>
        </if>
        <if test="deptcodes != null and deptcodes.size() > 0">
            AND deptcode IN
            <foreach collection="deptcodes" item="deptcode" open="(" separator=","
                     close=")">
                #{deptcode}
            </foreach>
        </if>
        <if test="serviceTypes != null and serviceTypes.size() > 0">
            AND service_type IN
            <foreach collection="serviceTypes" item="serviceType" open="(" separator=","
                     close=")">
                #{serviceType}
            </foreach>
        </if>
        <if test="startTime != null and endTime != null">
            AND date_format(visit_time,'%y%m%d') >= date_format(#{startTime},'%y%m%d')
            AND date_format(visit_time,'%y%m%d') &lt;= date_format(#{endTime},'%y%m%d')
        </if>
    </select>
    <!--
        æ»¡æ„åº¦ç»Ÿè®¡ï¼šåªæŸ¥æœ¬æ¬¡æ¡ä»¶ä¸‹å®žé™…存在哪些 type。
        è¿‡æ»¤æ¡ä»¶ä¸Ž selectSatisfactionSubtaskList å®Œå…¨ä¸€è‡´ï¼Œåªæ˜¯ï¼š
          1) åª select distinct type(原来是把上百个字段全查回来);
          2) visit_time æ”¹æˆå¯èµ°ç´¢å¼•的范围条件(原来对列套 date_format,索引用不上)。
    -->
    <select id="selectSatisfactionSubtaskTypes" parameterType="com.smartor.domain.VO.PatSatisfactionReqVO"
            resultType="java.lang.String">
        select distinct type
        from service_subtask
        where del_flag = 0
        <if test="questionType != null">and type = #{questionType}</if>
        <if test="hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0">
            AND leavehospitaldistrictcode IN
            <foreach collection="hospitaldistrictcodes" item="hospitaldistrictcode" open="(" separator=","
                     close=")">
                #{hospitaldistrictcode}
            </foreach>
        </if>
        <if test="deptcodes != null and deptcodes.size() > 0">
            AND deptcode IN
            <foreach collection="deptcodes" item="deptcode" open="(" separator="," close=")">
                #{deptcode}
            </foreach>
        </if>
        <if test="serviceTypes != null and serviceTypes.size() > 0">
            AND service_type IN
            <foreach collection="serviceTypes" item="serviceType" open="(" separator="," close=")">
                #{serviceType}
            </foreach>
        </if>
        <if test="startTime != null and endTime != null">
            AND visit_time &gt;= #{startTime}
            AND visit_time &lt; date_add(#{endTime}, interval 1 day)
        </if>
    </select>
    <select id="getCompensateServiceSubtaskList" parameterType="com.smartor.domain.entity.ServiceSubtaskEntity"
            <select id="getCompensateServiceSubtaskList" parameterType="com.smartor.domain.entity.ServiceSubtaskEntity"
            resultMap="ServiceSubtaskResult">
        <include refid="selectServiceSubtaskVo"/>
        where del_flag = 0
@@ -669,7 +597,6 @@
        <if test="continueTimeNext != null ">and continue_time_next = #{continueTimeNext}</if>
    </select>
    <select id="selectServiceSubtaskListAgain" parameterType="com.smartor.domain.entity.ServiceSubtaskEntity"
            resultMap="ServiceSubtaskResult">
@@ -862,7 +789,6 @@
            GROUP BY ss.${groupKey}
        </if>
    </select>
    <select id="selectTimelyRateDetail" parameterType="com.smartor.domain.ServiceSubtask"
            resultMap="ServiceSubtaskResult">
@@ -1547,7 +1473,6 @@
        </trim>
        where task_guid = #{taskGuid} and task_name = #{taskName}
    </update>
    <update id="deleteServiceSubtaskById" parameterType="Long">
        update service_subtask
@@ -2830,7 +2755,6 @@
        </if>
    </select>
    <select id="selectVisitCount" parameterType="com.smartor.domain.PatMedReq"
            resultType="com.smartor.domain.PatMedRes">
        select
@@ -2897,109 +2821,7 @@
        </if>
    </select>
    <select id="querySendCount" parameterType="com.smartor.domain.entity.ServiceSubtaskSatisfactionEntity"
            resultType="Long">
        select distinct id from service_subtask
        where del_flag = 0
        <if test="orgid != null and orgid != ''">
            and orgid = #{orgid}
        </if>
        <if test="campusid != null and campusid != ''">
            and campusid = #{campusid}
        </if>
        <if test="type != null ">and type = #{type}</if>
        <if test="startTime != null">
            AND date_format(visit_time,'%y%m%d') >= date_format(#{startTime},'%y%m%d')
        </if>
        <if test="endTime != null">
            AND date_format(visit_time,'%y%m%d') &lt;= date_format(#{endTime},'%y%m%d')
        </if>
        <if test=" libTemplateIds != null and libTemplateIds.size()>0">
            AND libtemplateid IN
            <foreach collection="libTemplateIds" item="libTemplateId" open="(" separator=","
                     close=")">
                #{libTemplateId}
            </foreach>
        </if>
        <if test="(hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0) or (deptcodes != null and deptcodes.size() > 0)">
            AND (
            <if test="hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0">
                leavehospitaldistrictcode IN
                <foreach collection="hospitaldistrictcodes" item="hospitaldistrictcode" open="("
                         separator=","
                         close=")">
                    #{hospitaldistrictcode}
                </foreach>
            </if>
            <if test="hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0 and deptcodes != null and deptcodes.size() > 0">
                OR
            </if>
            <if test="deptcodes != null and deptcodes.size() > 0">
                deptcode IN
                <foreach collection="deptcodes" item="deptcode" open="(" separator=","
                         close=")">
                    #{deptcode}
                </foreach>
            </if>
            )
        </if>
    </select>
    <!--
        æ»¡æ„åº¦æŸ±çŠ¶å›¾ï¼šä¸€æ¬¡æ€§ç»Ÿè®¡æœ¬æ¬¡è¯·æ±‚å…¨éƒ¨æ¨¡æ¿çš„å‘é€é‡ã€‚
        ä¸Ž querySendCount çš„过滤条件完全一致,区别是:
          1) æŒ‰ libtemplateid åˆ†ç»„,一次返回所有模板(替代逐个模板查询);
          2) visit_time æ”¹æˆå¯èµ°ç´¢å¼•的范围条件(原来对列套 date_format,索引用不上)。
    -->
    <select id="selectSatisfactionSendCount" parameterType="com.smartor.domain.entity.SatisfactionStatQuery"
            resultType="java.util.Map">
        select libtemplateid as libtemplateid, count(distinct id) as cnt
        from service_subtask
        where del_flag = 0
        <if test="orgid != null and orgid != ''">
            and orgid = #{orgid}
        </if>
        <if test="campusid != null and campusid != ''">
            and campusid = #{campusid}
        </if>
        <if test="type != null and type != ''">and type = #{type}</if>
        <if test="startTime != null">
            and visit_time &gt;= #{startTime}
        </if>
        <if test="endTime != null">
            and visit_time &lt; date_add(#{endTime}, interval 1 day)
        </if>
        <if test="libTemplateIds != null and libTemplateIds.size() > 0">
            and libtemplateid IN
            <foreach collection="libTemplateIds" item="libTemplateId" open="(" separator="," close=")">
                #{libTemplateId}
            </foreach>
        </if>
        <if test="(hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0) or (deptcodes != null and deptcodes.size() > 0)">
            AND (
            <if test="hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0">
                leavehospitaldistrictcode IN
                <foreach collection="hospitaldistrictcodes" item="hospitaldistrictcode" open="("
                         separator=","
                         close=")">
                    #{hospitaldistrictcode}
                </foreach>
            </if>
            <if test="hospitaldistrictcodes != null and hospitaldistrictcodes.size() > 0 and deptcodes != null and deptcodes.size() > 0">
                OR
            </if>
            <if test="deptcodes != null and deptcodes.size() > 0">
                deptcode IN
                <foreach collection="deptcodes" item="deptcode" open="(" separator="," close=")">
                    #{deptcode}
                </foreach>
            </if>
            )
        </if>
        group by libtemplateid
    </select>
    <!--
            <!--
        æŒ‰ç—…区或科室分组统计 continue_flag æ•°é‡
        ä¼ äº† leavehospitaldistrictcodes â†’ æŒ‰ç—…区维度
        ä¼ äº† deptcodes              â†’ æŒ‰ç§‘室维度
@@ -3131,118 +2953,7 @@
          and sendstate in (1, 2, 3)
    </select>
    <select id="mydTrend" resultType="com.smartor.domain.DTO.MydTrendReqDTO">
        SELECT CASE #{type}
                   WHEN 'year' THEN DATE_FORMAT(d.create_time, '%Y')
                   WHEN 'quarter' THEN CONCAT(YEAR(d.create_time), '-Q', QUARTER(d.create_time))
                   WHEN 'month' THEN DATE_FORMAT(d.create_time, '%Y-%m')
                   WHEN 'week' THEN FLOOR(DATEDIFF(d.create_time, #{startFinishTime}) / 7) + 1
                   ELSE DATE_FORMAT(d.create_time, '%Y-%m-%d')
                   END                AS period,
               ROUND(avg(d.score), 2) AS totalScore
        FROM service_subtask_detail d
                 JOIN service_task t ON d.taskid = t.taskid
        WHERE t.service_type IN (14)
          AND t.del_flag = 0
          AND d.del_flag = 0
          AND d.create_time >= #{startFinishTime}
          AND d.create_time &lt; DATE_ADD(#{endFinishTime}, INTERVAL 1 DAY)
        GROUP BY period
        ORDER BY period;
    </select>
    <select id="sltdMydTotalByDimension" resultType="com.smartor.domain.DTO.MydTrendReqDTO">
        SELECT
        p.period,
        dim.dimension,
        IFNULL(ROUND(SUM(d.score), 2), 0) AS totalScore
        FROM (
        <!-- ç”ŸæˆæŸ¥è¯¢èŒƒå›´å†…实际出现过的时间段 -->
        SELECT DISTINCT
        CASE #{type}
        WHEN 'year' THEN DATE_FORMAT(d.create_time, '%Y')
        WHEN 'quarter' THEN CONCAT(YEAR(d.create_time), '-Q', QUARTER(d.create_time))
        WHEN 'month' THEN DATE_FORMAT(d.create_time, '%Y-%m')
        WHEN 'week' THEN FLOOR(DATEDIFF(d.create_time, #{startFinishTime}) / 7) + 1
        ELSE DATE_FORMAT(d.create_time, '%Y-%m-%d')
        END AS period
        FROM service_subtask_detail d
        WHERE d.del_flag = 0
        AND d.create_time >= #{startFinishTime}
        AND d.create_time &lt; #{endFinishTime}
        ) p
        CROSS JOIN (
        <!-- æ‰€æœ‰éœ€è¦å±•示的 dimension -->
        SELECT DISTINCT dimension
        FROM service_subtask_detail
        WHERE del_flag = 0
        AND dimension IS NOT NULL
        ) dim
        LEFT JOIN service_task t
        ON t.service_type IN (6, 14)
        AND t.del_flag = 0
        LEFT JOIN service_subtask_detail d
        ON d.taskid = t.taskid
        AND d.del_flag = 0
        AND d.dimension = dim.dimension
        AND CASE #{type}
        WHEN 'year' THEN DATE_FORMAT(d.create_time, '%Y')
        WHEN 'quarter' THEN CONCAT(YEAR(d.create_time), '-Q', QUARTER(d.create_time))
        WHEN 'month' THEN DATE_FORMAT(d.create_time, '%Y-%m')
        WHEN 'week' THEN FLOOR(DATEDIFF(d.create_time, #{startFinishTime}) / 7) + 1
        ELSE DATE_FORMAT(d.create_time, '%Y-%m-%d')
        END = p.period
        AND d.create_time >= #{startFinishTime}
        AND d.create_time &lt; #{endFinishTime}
        GROUP BY p.period, dim.dimension
        ORDER BY p.period, dim.dimension
    </select>
    <select id="sltdMydTotalByYQ" resultType="com.smartor.domain.DTO.MydTrendReqDTO">
        SELECT p.period,
               c.campusid,
               IFNULL(ROUND(AVG(d.score), 2), 0) AS totalScore
        FROM (
                 -- ç”ŸæˆæŸ¥è¯¢èŒƒå›´å†…实际出现过的时间段
                 SELECT DISTINCT CASE #{type}
                                     WHEN 'year' THEN DATE_FORMAT(d.create_time, '%Y')
                                     WHEN 'quarter' THEN CONCAT(YEAR(d.create_time), '-Q', QUARTER(d.create_time))
                                     WHEN 'month' THEN DATE_FORMAT(d.create_time, '%Y-%m')
                                     WHEN 'week' THEN FLOOR(DATEDIFF(d.create_time, #{startFinishTime}) / 7) + 1
                                     ELSE DATE_FORMAT(d.create_time, '%Y-%m-%d')
                                     END AS period
                 FROM service_subtask_detail d
                 WHERE d.del_flag = 0
                   AND d.create_time >= #{startFinishTime}
                   AND d.create_time &lt; #{endFinishTime}) p
                 CROSS JOIN (
            -- æ‰€æœ‰éœ€è¦å±•示的院区
            SELECT DISTINCT campusid
            FROM service_task
            WHERE service_type IN (6, 14)
              AND del_flag = 0
              AND campusid IS NOT NULL) c
                 LEFT JOIN service_task t
                           ON t.campusid = c.campusid
                               AND t.service_type IN (6, 14)
                               AND t.del_flag = 0
                 LEFT JOIN service_subtask_detail d
                           ON d.taskid = t.taskid
                               AND d.del_flag = 0
                               AND CASE #{type}
                                       WHEN 'year' THEN DATE_FORMAT(d.create_time, '%Y')
                                       WHEN 'quarter' THEN CONCAT(YEAR(d.create_time), '-Q', QUARTER(d.create_time))
                                       WHEN 'month' THEN DATE_FORMAT(d.create_time, '%Y-%m')
                                       WHEN 'week' THEN FLOOR(DATEDIFF(d.create_time, #{startFinishTime}) / 7) + 1
                                       ELSE DATE_FORMAT(d.create_time, '%Y-%m-%d')
                                       END = p.period
                               AND d.create_time >= #{startFinishTime}
                               AND d.create_time &lt; #{endFinishTime}
        GROUP BY p.period, c.campusid
        ORDER BY p.period, c.campusid;
    </select>
    <!-- ä»»åŠ¡ç»„ï¼ˆappltype=5)滚动调度用:拉本轮全量 subtask,作为下一轮的克隆模板 -->
                <!-- ä»»åŠ¡ç»„ï¼ˆappltype=5)滚动调度用:拉本轮全量 subtask,作为下一轮的克隆模板 -->
    <select id="selectByScheduleIdAndLoop" resultMap="ServiceSubtaskResult">
        <include refid="selectServiceSubtaskVo"/>
        where schedule_id = #{scheduleId}
@@ -3333,367 +3044,7 @@
        </if>
    </select>
    <select id="statQuestionOption" resultType="com.smartor.domain.ServiceSubtaskDetailRatioExport">
        SELECT
        d.questiontext AS questiontext,
        t.taskid AS taskid,
        t.task_name AS taskName,
        d.asrtext AS optionresult,
        d.score AS score,
        d.targetvalue AS targetvalue,
        d.value_type AS valueType,
        COUNT(*) AS count,
        CONCAT(ROUND(COUNT(*) * 100.0 / t2.total_count, 2), '%') AS ratio
        FROM
        service_task t
        INNER JOIN service_subtask s ON s.taskid = t.taskid
        INNER JOIN service_subtask_detail d ON d.sub_id = s.id
        INNER JOIN (
        SELECT
        t.taskid,
        d.questiontext,
        COUNT(*) AS total_count
        FROM
        service_task t
        INNER JOIN service_subtask s ON s.taskid = t.taskid
        INNER JOIN service_subtask_detail d ON d.sub_id = s.id
        WHERE 1=1
        <if test="taskIds != null and taskIds.size() > 0">
            AND t.taskid IN
            <foreach collection="taskIds" item="taskId" open="(" separator="," close=")">
                #{taskId}
            </foreach>
        </if>
        <!-- ä¼ äº† subtaskIds åªç»Ÿè®¡è¿™æ‰¹å­ä»»åŠ¡ï¼ˆæœ¬æ¬¡å¯¼å‡ºçš„æ‚£è€…ï¼‰ -->
        <if test="subtaskIds != null and subtaskIds.size() > 0">
            AND s.id IN
            <foreach collection="subtaskIds" item="subtaskId" open="(" separator="," close=")">
                #{subtaskId}
            </foreach>
        </if>
        <if test="valueType != null">AND d.value_type = #{valueType}</if>
        <if test="scoreStart != null">AND d.score &gt;= #{scoreStart}</if>
        <if test="scoreEnd != null">AND d.score &lt;= #{scoreEnd}</if>
        <if test="questiontext != null  and questiontext != ''">and d.questiontext like concat('%', #{questiontext},
            '%')
        </if>
        and s.sendstate=6
        and d.del_flag=0
        <!-- åˆ†æ¯å¿…须和明细用同一套时间条件,否则选了时间范围后比例会按全量历史被稀释 -->
        <if test="startOutHospTime != null">
            AND date_format(s.endtime,'%y%m%d') &gt;= date_format(#{startOutHospTime},'%y%m%d')
        </if>
        <if test="endOutHospTime != null">
            AND date_format(s.endtime,'%y%m%d') &lt;= date_format(#{endOutHospTime},'%y%m%d')
        </if>
        <if test="startFinishTime != null">
            AND date_format(s.finishtime,'%y%m%d') &gt;= date_format(#{startFinishTime},'%y%m%d')
        </if>
        <if test="endFinishTime != null">
            AND date_format(s.finishtime,'%y%m%d') &lt;= date_format(#{endFinishTime},'%y%m%d')
        </if>
        GROUP BY
        t.taskid,
        d.questiontext
        ) t2 ON t2.taskid = t.taskid AND t2.questiontext = d.questiontext
        WHERE 1=1
        <if test="taskIds != null and taskIds.size() > 0">
            AND t.taskid IN
            <foreach collection="taskIds" item="taskId" open="(" separator="," close=")">
                #{taskId}
            </foreach>
        </if>
        <if test="subtaskIds != null and subtaskIds.size() > 0">
            AND s.id IN
            <foreach collection="subtaskIds" item="subtaskId" open="(" separator="," close=")">
                #{subtaskId}
            </foreach>
        </if>
        <if test="valueType != null">AND d.value_type = #{valueType}</if>
        <if test="scoreStart != null">AND d.score &gt;= #{scoreStart}</if>
        <if test="scoreEnd != null">AND d.score &lt;= #{scoreEnd}</if>
        <if test="questiontext != null  and questiontext != ''">and d.questiontext like concat('%', #{questiontext},
            '%')
        </if>
        AND s.sendstate = 6
        and d.del_flag=0
        <if test="startOutHospTime != null">
            AND date_format(s.endtime,'%y%m%d') &gt;= date_format(#{startOutHospTime},'%y%m%d')
        </if>
        <if test="endOutHospTime != null">
            AND date_format(s.endtime,'%y%m%d') &lt;= date_format(#{endOutHospTime},'%y%m%d')
        </if>
        <if test="startFinishTime != null">
            AND date_format(s.finishtime,'%y%m%d') &gt;= date_format(#{startFinishTime},'%y%m%d')
        </if>
        <if test="endFinishTime != null">
            AND date_format(s.finishtime,'%y%m%d') &lt;= date_format(#{endFinishTime},'%y%m%d')
        </if>
        GROUP BY
        t.taskid,
        d.questiontext,
        d.score,
        d.targetvalue,
        d.asrtext,
        d.value_type
        ORDER BY
        d.questiontext,
        t.taskid
    </select>
    <!--
      å¯¼å‡ºã€Œé—®é¢˜è¯¦æƒ…占比」专用统计(只统计本次导出的这批患者)
      æ•°é‡ = é€‰æ‹©è¯¥é€‰é¡¹çš„æ‚£è€…数(COUNT(DISTINCT s.id),并用 MAX(id) ä¿è¯åŒä¸€æ‚£è€…同一题只算最新一条)
      åˆ†æ•° = è¯¥é€‰é¡¹æ˜Žç»†çš„ score ä¹‹å’Œ
      æ¯”例 = æ•°é‡ / è¯¥é¢˜ä½œç­”患者数,在 Java ä¾§ç»Ÿä¸€è®¡ç®—,因此「单题各选项数量之和 = è¯¥é¢˜ä½œç­”患者数」
    -->
    <select id="statMydQuestionOptionForExport" resultType="com.smartor.domain.DTO.ServiceSubtaskOptionStatDTO">
        SELECT
        t.taskid AS taskid,
        t.task_name AS taskName,
        d.questiontext AS questiontext,
        d.asrtext AS optionresult,
        d.targetvalue AS targetvalue,
        d.value_type AS valueType,
        COUNT(DISTINCT s.id) AS patientCount,
        SUM(COALESCE(CAST(d.score AS DECIMAL(18,2)), 0)) AS scoreSum
        FROM
        service_task t
        INNER JOIN service_subtask s ON s.taskid = t.taskid
        INNER JOIN service_subtask_detail d ON d.sub_id = s.id
        WHERE 1=1
        AND s.del_flag = '0'
        AND d.del_flag = 0
        <if test="subtaskIds != null and subtaskIds.size() > 0">
            AND s.id IN
            <foreach collection="subtaskIds" item="subtaskId" open="(" separator="," close=")">
                #{subtaskId}
            </foreach>
        </if>
        <!-- ä¸ä¼ å­ä»»åŠ¡æ—¶ç›´æŽ¥æŸ¥ä¸åˆ°æ•°æ®ï¼Œé¿å…è¯¯ç»Ÿè®¡å…¨é‡ -->
        <if test="subtaskIds == null or subtaskIds.size() == 0">
            AND 1 = 0
        </if>
        <if test="questiontext != null and questiontext != ''">
            AND d.questiontext LIKE CONCAT('%', #{questiontext}, '%')
        </if>
        <!-- åŒä¸€æ‚£è€…同一题目只保留最新一条,重答不重复计入数量 -->
        AND d.id = (
        SELECT MAX(d2.id)
        FROM service_subtask_detail d2
        WHERE d2.sub_id = d.sub_id
        AND d2.questiontext &lt;=&gt; d.questiontext
        AND d2.del_flag = '0'
        )
        GROUP BY
        t.taskid,
        t.task_name,
        d.questiontext,
        d.asrtext,
        d.targetvalue,
        d.value_type
        <!-- æŒ‰ä»»åŠ¡åç§°åˆ†ç»„å±•ç¤ºï¼šå…ˆä»»åŠ¡åç§°ã€å†ä»»åŠ¡ç¼–å·ï¼ŒåŒä¸€ä»»åŠ¡çš„é¢˜ç›®/选项连续排列 -->
        ORDER BY
        t.task_name,
        t.taskid,
        d.questiontext
    </select>
    <!-- æ–°æ–¹æ³•:根据子任务 ID ç»Ÿè®¡é—®é¢˜é€‰é¡¹ç™¾åˆ†æ¯” (用于满意度统计 serviceType=6) -->
    <select id="statQuestionOptionBySubtaskIds" resultType="com.smartor.domain.ServiceSubtaskDetailRatioExport">
        SELECT
        d.questiontext AS questiontext,
        <!-- æ˜Žç»†å·²æŒ‰ã€Œé¢˜ç›® + é€‰é¡¹ã€æ±‡æ€»ï¼Œä»»åŠ¡/科室/病区只是附带信息,必须用聚合函数取值(MySQL ONLY_FULL_GROUP_BY) -->
        MAX(t.taskid) AS taskid,
        MAX(t.task_name) AS taskName,
        d.asrtext AS optionresult,
        d.score AS score,
        MAX(s.deptcode) AS deptcode,
        MAX(s.deptname) AS deptname,
        MAX(s.leavehospitaldistrictcode) AS leavehospitaldistrictcode,
        MAX(s.leavehospitaldistrictname) AS leavehospitaldistrictname,
        d.targetvalue AS targetvalue,
        d.value_type AS valueType,
        COUNT(*) AS count,
        CONCAT(ROUND(COUNT(*) * 100.0 / t2.total_count, 2), '%') AS ratio
        FROM
        service_task t
        INNER JOIN service_subtask s ON s.taskid = t.taskid
        INNER JOIN service_subtask_detail d ON d.sub_id = s.id
        <!-- åŽ»é‡ï¼šåŒä¸€å­ä»»åŠ¡åŒä¸€é¢˜ç›®åªä¿ç•™æœ€æ–°ä¸€æ¡ï¼Œé¿å…é‡ç­”/重复回调把同一道题重复计入项数(与 mzMydScoreRank å£å¾„一致) -->
        AND d.id = (
        SELECT MAX(d2.id)
        FROM service_subtask_detail d2
        WHERE d2.sub_id = d.sub_id
        AND d2.questiontext &lt;=&gt; d.questiontext
        AND d2.del_flag = '0'
        )
        INNER JOIN (
        <!-- åˆ†æ¯ = è¯¥é¢˜å„选项项数之和(未作答的空 asrtext ä¸è¿›åˆ†æ¯ï¼‰ï¼Œè¿™æ ·ä¸€é“题各选项比例合计才是 100% -->
        SELECT
        x.questiontext AS questiontext,
        SUM(x.option_count) AS total_count
        FROM (
        SELECT
        d.questiontext,
        d.asrtext,
        COUNT(*) AS option_count
        FROM
        service_task t
        INNER JOIN service_subtask s ON s.taskid = t.taskid
        INNER JOIN service_subtask_detail d ON d.sub_id = s.id
        <!-- åŽ»é‡ï¼šåˆ†æ¯ï¼ˆè¯¥é¢˜æœ‰æ•ˆç­”å·æ€»æ•°ï¼‰ä¸Žæ˜Žç»†å£å¾„ä¿æŒä¸€è‡´ï¼Œä¿è¯å„é€‰é¡¹æ¯”ä¾‹åˆè®¡ 100% -->
        AND d.id = (
        SELECT MAX(d4.id)
        FROM service_subtask_detail d4
        WHERE d4.sub_id = d.sub_id
        AND d4.questiontext &lt;=&gt; d.questiontext
        AND d4.del_flag = '0'
        )
        WHERE 1=1
        <if test="subtaskIds != null and subtaskIds.size() > 0">
            AND s.id IN
            <foreach collection="subtaskIds" item="subtaskId" open="(" separator="," close=")">
                #{subtaskId}
            </foreach>
        </if>
        <if test="valueType != null">AND d.value_type &gt;= #{valueType}</if>
        <if test="scoreStart != null">AND d.score &gt;= #{scoreStart}</if>
        <if test="scoreEnd != null">AND d.score &lt;= #{scoreEnd}</if>
        <if test="questiontext != null  and questiontext != ''">and d.questiontext like concat('%', #{questiontext},
            '%')
        </if>
        AND s.sendstate = 6
        AND d.del_flag=0
        <!-- åˆ†æ¯ä¸Žæ˜Žç»†ç”¨åŒä¸€å¥—时间条件,否则选了日期区间后各选项比例合计不到 100% -->
        <if test="startOutHospTime != null">
            AND date_format(s.endtime,'%y%m%d') &gt;= date_format(#{startOutHospTime},'%y%m%d')
        </if>
        <if test="endOutHospTime != null">
            AND date_format(s.endtime,'%y%m%d') &lt;= date_format(#{endOutHospTime},'%y%m%d')
        </if>
        <if test="startFinishTime != null">
            AND date_format(s.finishtime,'%y%m%d') &gt;= date_format(#{startFinishTime},'%y%m%d')
        </if>
        <if test="endFinishTime != null">
            AND date_format(s.finishtime,'%y%m%d') &lt;= date_format(#{endFinishTime},'%y%m%d')
        </if>
        <!-- æœªä½œç­”(asrtext ä¸ºç©ºï¼‰çš„æ˜Žç»†ä¸è®¡å…¥åˆ†æ¯ -->
        AND d.asrtext IS NOT NULL AND TRIM(d.asrtext) &lt;&gt; ''
        GROUP BY d.questiontext, d.asrtext
        ) x
        GROUP BY x.questiontext
        ) t2 ON t2.questiontext = d.questiontext
        WHERE 1=1
        <!-- åŽ»é‡ï¼šæ˜Žç»†è¡ŒåŒä¸€å­ä»»åŠ¡åŒä¸€é¢˜ç›®åªä¿ç•™æœ€æ–°ä¸€æ¡ï¼Œé‡ç­”ä¸é‡å¤è®¡å…¥é¡¹æ•° -->
        AND d.id = (
        SELECT MAX(d3.id)
        FROM service_subtask_detail d3
        WHERE d3.sub_id = d.sub_id
        AND d3.questiontext &lt;=&gt; d.questiontext
        AND d3.del_flag = '0'
        )
        <if test="subtaskIds != null and subtaskIds.size() > 0">
            AND s.id IN
            <foreach collection="subtaskIds" item="subtaskId" open="(" separator="," close=")">
                #{subtaskId}
            </foreach>
        </if>
        AND s.sendstate = 6
        AND d.del_flag=0
        <!-- æœªä½œç­”(asrtext ä¸ºç©ºï¼‰ä¸ç®—任何选项,明细里直接排除,避免出现空选项行 -->
        AND d.asrtext IS NOT NULL AND TRIM(d.asrtext) &lt;&gt; ''
        <if test="valueType != null">AND d.value_type &gt;= #{valueType}</if>
        <if test="scoreStart != null">AND d.score &gt;= #{scoreStart}</if>
        <if test="scoreEnd != null">AND d.score &lt;= #{scoreEnd}</if>
        <if test="questiontext != null  and questiontext != ''">and d.questiontext like concat('%', #{questiontext},
            '%')
        </if>
        <if test="startOutHospTime != null">
            AND date_format(s.endtime,'%y%m%d') &gt;= date_format(#{startOutHospTime},'%y%m%d')
        </if>
        <if test="endOutHospTime != null">
            AND date_format(s.endtime,'%y%m%d') &lt;= date_format(#{endOutHospTime},'%y%m%d')
        </if>
        <if test="startFinishTime != null">
            AND date_format(s.finishtime,'%y%m%d') &gt;= date_format(#{startFinishTime},'%y%m%d')
        </if>
        <if test="endFinishTime != null">
            AND date_format(s.finishtime,'%y%m%d') &lt;= date_format(#{endFinishTime},'%y%m%d')
        </if>
        <!-- åªæŒ‰ã€Œé¢˜ç›® + é€‰é¡¹ã€æ±‡æ€»ï¼šåŽŸæ¥ GROUP BY å¸¦ä¸Š taskid/科室/病区,同一个选项被拆成多行、每行数量都是 1,
             å‰ç«¯/导出拿到的「数量」就永远做不了累加 -->
        GROUP BY
        d.questiontext,
        d.asrtext,
        d.score,
        d.targetvalue,
        d.value_type
        ORDER BY
        d.questiontext,
        MIN(t.taskid)
    </select>
    <select id="statQuestionOptionByKsOrBq" resultType="com.smartor.domain.DTO.QuestionCountKsOrBqDTO"
            parameterType="com.smartor.domain.VO.QuestionCountKsOrBqVO">
        SELECT
        s.deptname AS deptname,
        s.leavehospitaldistrictname AS leavehospitaldistrictname,
        d.questiontext AS questionText,
        d.targetvalue AS targetvalue, -- æ‰€æœ‰é€‰é¡¹æ‹¼æŽ¥
        d.asrtext AS asrtext, -- ç”¨æˆ·å®žé™…选中的选项
        d.score AS singleScore -- å•条记录的分数
        FROM service_subtask_detail d
        JOIN service_subtask s ON d.sub_id = s.id
        WHERE s.del_flag = 0
        AND d.del_flag = 0
        AND s.service_type IN ('6', '14')
        <if test="taskTemplateid != null">
            AND s.templateid = #{taskTemplateid}
        </if>
        <if test="endFinishTime != null">
            AND d.create_time &lt; #{endFinishTime}
        </if>
        <if test="startFinishTime != null">
            AND d.create_time &gt;= #{startFinishTime}
        </if>
        <if test="deptOrDistrict ==1">
            <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0">
                AND leavehospitaldistrictcode IN
                <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="("
                         separator=","
                         close=")">
                    #{leavehospitaldistrictcode}
                </foreach>
            </if>
            <if test=" leaveldeptcodes != null and leaveldeptcodes.size()>0">
                AND deptcode IN
                <foreach collection="leaveldeptcodes" item="leaveldeptcode" open="(" separator=","
                         close=")">
                    #{leaveldeptcode}
                </foreach>
            </if>
        </if>
        <if test="deptOrDistrict ==2">
            <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0">
                AND leavehospitaldistrictcode IN
                <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="("
                         separator=","
                         close=")">
                    #{leavehospitaldistrictcode}
                </foreach>
            </if>
            <if test=" leaveldeptcodes != null and leaveldeptcodes.size()>0">
                OR deptcode IN
                <foreach collection="leaveldeptcodes" item="leaveldeptcode" open="(" separator=","
                         close=")">
                    #{leaveldeptcode}
                </foreach>
            </if>
        </if>
    </select>
    <select id="selectServiceSubtaskListDeadLine" parameterType="com.smartor.domain.entity.ServiceSubtaskEntity"
                    <select id="selectServiceSubtaskListDeadLine" parameterType="com.smartor.domain.entity.ServiceSubtaskEntity"
            resultMap="ServiceSubtaskResult">
        <include refid="selectServiceSubtaskVo"/>
        where 1=1
@@ -3703,95 +3054,5 @@
    </select>
    <!-- é—¨è¯Šæ»¡æ„åº¦ç»¼åˆå¾—分排名(按科室或病区) -->
    <!-- ç»¼åˆæ»¡æ„åº¦ = Î£(评分明细权重) / è¯„分明细条数 Ã— 100%,权重只取 1、0.8、0.6、0.4、0.2 äº”æ¡£ -->
    <select id="selectMzMydScoreRank" parameterType="com.smartor.domain.VO.MzMydScoreRankVO"
            resultType="com.smartor.domain.DTO.MzMydScoreRankDTO">
        SELECT
        t.code                   AS code,
        t.name                   AS name,
        ROUND(AVG(t.w) * 100, 2) AS score,
        COUNT(*)                 AS itemCount
        FROM (
        SELECT
        <choose>
            <when test='dimensionType == "2"'>
                s.leavehospitaldistrictcode AS code,
                s.leavehospitaldistrictname AS name,
            </when>
            <otherwise>
                s.deptcode AS code,
                s.deptname AS name,
            </otherwise>
        </choose>
        CAST(d.score AS DECIMAL(10,4)) AS w
        FROM service_subtask s
        INNER JOIN service_subtask_detail d ON d.sub_id = s.id
        WHERE s.del_flag = '0'
        AND d.del_flag = '0'
        AND s.sendstate = 6
        <if test="serviceTypes != null and serviceTypes.size() > 0">
            AND service_type IN
            <foreach collection="serviceTypes" item="serviceType" open="(" separator=","
                     close=")">
                #{serviceType}
            </foreach>
        </if>
<!--        &lt;!&ndash; åªå–公式里的五档权重,空值/0/未答等既不进分子也不进分母 &ndash;&gt;-->
<!--        AND CAST(d.score AS DECIMAL(10,4)) IN (1, 0.8, 0.6, 0.4, 0.2)-->
        <!-- åŒä¸€å­ä»»åŠ¡åŒä¸€é¢˜ç›®åªä¿ç•™æœ€æ–°ä¸€æ¡ï¼Œé¿å…é‡ç­”è¢«é‡å¤è®¡å…¥é¡¹æ•° -->
        AND d.id = (
        SELECT MAX(d2.id)
        FROM service_subtask_detail d2
        WHERE d2.sub_id = d.sub_id
        AND d2.questiontext &lt;=&gt; d.questiontext
        AND d2.del_flag = '0'
        )
        <if test="orgid != null and orgid != ''">
            AND s.orgid = #{orgid}
        </if>
        <if test="campusid != null and campusid != ''">
            AND s.campusid = #{campusid}
        </if>
        <if test="startFinishTime != null">
            AND s.finishtime &gt;= #{startFinishTime}
        </if>
        <if test="endFinishTime != null">
            AND s.finishtime &lt; DATE_ADD(#{endFinishTime}, INTERVAL 1 DAY)
        </if>
        <if test="questionText != null  and questionText != ''">and d.questiontext like concat('%', #{questionText},
            '%')
        </if>
        <choose>
            <when test='dimensionType == "2"'>
                AND s.leavehospitaldistrictcode IS NOT NULL AND s.leavehospitaldistrictcode &lt;&gt; ''
                AND s.leavehospitaldistrictname IS NOT NULL AND s.leavehospitaldistrictname &lt;&gt; ''
                <if test="districtCodes != null and districtCodes.size() > 0">
                    AND s.leavehospitaldistrictcode IN
                    <foreach collection="districtCodes" item="districtCode" open="(" separator="," close=")">
                        #{districtCode}
                    </foreach>
                </if>
            </when>
            <otherwise>
                AND s.deptcode IS NOT NULL AND s.deptcode &lt;&gt; ''
                AND s.deptname IS NOT NULL AND s.deptname &lt;&gt; ''
                <if test="deptCodes != null and deptCodes.size() > 0">
                    AND s.deptcode IN
                    <foreach collection="deptCodes" item="deptCode" open="(" separator="," close=")">
                        #{deptCode}
                    </foreach>
                </if>
            </otherwise>
        </choose>
        ) t
        GROUP BY t.code, t.name
        ORDER BY AVG(t.w) DESC, COUNT(*) DESC
        <if test="limit != null and limit > 0">
            LIMIT #{limit}
        </if>
    </select>
</mapper>
    </mapper>