ruoyi-admin/src/main/resources/application.yml
@@ -78,7 +78,7 @@ # 国际化资源文件路径 basename: i18n/messages profiles: active: sltd active: nhfy # 文件上传 servlet: ruoyi-inhospvisit/src/main/java/com/ruoyi/inhospvisit/support/VisitSubtaskFactory.java
@@ -49,12 +49,25 @@ * 实际实现时把它收窄为"纯组装 + 纯工具",因为 {@code writeInSubTask}(232 行)里有大量 * 写库与去重编排,混在一起会让本类的 A 档逐行等价探针失去意义。 * * <h3>⚠️ 唯一的"非搬运"行:{@code hospno}</h3> * {@link #boxedServiceSubtask} 里多了一句 {@code d.setHospno(inhosp.getInhospno())}。 * 原实现确实<b>没有</b>这一句,所以出院链路的 {@code service_subtask.hospno} 一直落空; * 而 {@code ServiceSubtask.hospno} 的语义就是"就诊编号(对应的 outhospno、inhospno)"。 * 这是<b>有意补齐</b>,不是抄漏 —— 反向探针({@code verify_inhospvisit_boxed_fields.py}) * 只校验"原 setter 是否都有对应调用",不会因此报错。 * <h3>⚠️ 两处有意的"非搬运"行</h3> * <ol> * <li><b>{@code hospno}</b>:{@link #boxedServiceSubtask} 里多了一句 * {@code d.setHospno(inhosp.getInhospno())}。原实现确实<b>没有</b>这一句, * 所以出院链路的 {@code service_subtask.hospno} 一直落空; * 而 {@code ServiceSubtask.hospno} 的语义就是"就诊编号(对应的 outhospno、inhospno)"。 * 这是<b>有意补齐</b>,不是抄漏 —— 反向探针({@code verify_inhospvisit_boxed_fields.py}) * 只校验"原 setter 是否都有对应调用",不会因此报错。</li> * <li><b>campusid 不再被空值覆盖</b>(2026-10-29 修):原实现无条件 * {@code d.setCampusid(inhosp.getCampusid())},会把从长期任务搬来的院区抹成 {@code null}。 * 后果是任务落库 {@code campusid=NULL},而随访列表接口 * ({@code ServiceSubtaskController#patItem})强制用登录用户的院区过滤 * ({@code and campusid = #{campusid}}),NULL 不匹配任何值 → * <b>任务存在、页面永远查不到</b>。实测南华附一 2026-07 有 894 条这样"生成了但不显示"的 * 出院随访任务(其中骨科中心一病区 25 条)。现改为"出院记录有院区才覆盖"。 * ⚠️ 这是<b>行为变更</b>(不是等价搬迁),触发条件只有"出院记录院区为空"这一种, * 其余情况仍与原实现完全一致。历史数据回填脚本: * {@code sql/fix_nhfy_出院随访任务院区病区回填_20261029.sql}。</li> * </ol> */ @Slf4j @Component @@ -157,7 +170,17 @@ d.setAge(archive.getAge()); // ---- 原 L1147-1163 ---- d.setSendstate(2L); d.setCampusid(inhosp.getCampusid()); // ⚠️ 第二处【有意偏离原实现】(2026-10-29,见类注释"campusid 不再被空值覆盖"): // 原实现无条件 d.setCampusid(inhosp.getCampusid()),会把上面从长期任务搬来的院区抹成 null。 // 线上后果:任务落库 campusid=NULL → // ServiceSubtaskController#patItem 强制 setCampusid(登录用户院区), // SQL 条件 `and campusid = #{campusid}` → 该任务对任何人都不显示("生成了但查不到")。 // 实测南华附一 2026-07:894 条出院随访任务因此在页面上不可见 // (其中骨科中心一病区 25 条,正是"出院 475 条 / 随访 444 条"的来源)。 // 这里改为"出院记录有院区才覆盖",出院记录取不到时保留任务自带的院区。 if (StringUtils.isNotEmpty(inhosp.getCampusid())) { d.setCampusid(inhosp.getCampusid()); } d.setManagementDoctor(inhosp.getManagementDoctor()); d.setManagementDoctorCode(inhosp.getManagementDoctorCode()); d.setStarttime(inhosp.getStarttime()); ruoyi-satisfaction/src/main/java/com/ruoyi/satisfaction/api/DefaultSatisfactionApi.java
@@ -3,6 +3,7 @@ import com.ruoyi.satisfaction.domain.ServiceSubtaskDetailRatioExport; import com.ruoyi.satisfaction.service.SatisfactionSubtaskService; import com.ruoyi.satisfaction.support.MydOptionRatioChartBuilder; import com.ruoyi.satisfaction.support.MydOptionRatioPieChartBuilder; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -53,8 +54,12 @@ // 没有行时不给图表构建器:ExcelUtil 会跳过该 Sheet(与原实现"空数据不画图"一致) return new SatisfactionExportBundle(rows, ServiceSubtaskDetailRatioExport.class, null); } // 一个 Sheet 柱状图(原有),一个 Sheet 饼图(按题目一张,带百分比和图例) return new SatisfactionExportBundle(rows, ServiceSubtaskDetailRatioExport.class, new MydOptionRatioChartBuilder(rows)); workbook -> { new MydOptionRatioChartBuilder(rows).build(workbook); new MydOptionRatioPieChartBuilder(rows, null).build(workbook); }); } @Override ruoyi-task/src/main/java/com/ruoyi/task/controller/ServiceSubtaskController.java
@@ -887,7 +887,7 @@ * <p> * 口径:service_subtask 中 * service_type = '2' 出院随访 / '3' 门诊随访 * + 待随访状态(sendstate in 1,2,3,5,7) * + 待随访状态(sendstate in 1,2,3,7,发送失败(5) 不计入) * + 出院满 7 天(含「7 天前那一天」出院的患者) * + 只看最近 4 个出院月份(含当月); * 科室/病区权限取 OR(与随访列表 deptOrDistrict=2 一致)。 @@ -921,9 +921,10 @@ List<SysUserDept> sysUserWards = sysUserDeptService.selectSysUserDeptList(wardQuery); boolean isAdmin = "admin".equals(sysUser.getUserName()); // 没有科室/病区配置的账号(如部分管理员账号),列表本身是按 机构+院区 查的, // 这里也按同样口径统计,否则列表有数据、卡片却全是 0。 if (!isAdmin && CollectionUtils.isEmpty(sysUserDepts) && CollectionUtils.isEmpty(sysUserWards)) { // 既没有科室也没有病区权限时不统计,避免越权看到全机构数据 return result; log.info("[pendingTaskMonthStats] 用户 {} 未配置科室/病区,按机构+院区统计", sysUser.getUserName()); } // sys_user_dept 里同一科室/病区会重复很多行,去重后再拼 IN,避免 SQL 参数过多 @@ -940,6 +941,8 @@ ServiceSubtaskEntity entity = new ServiceSubtaskEntity(); entity.setOrgid(sysUser.getOrgid()); // 与列表一致:按登录会话里的院区过滤(登录页选择的院区) entity.setCampusid(sysUser.getCampusid()); entity.setLeaveldeptcodes(deptCodes); entity.setLeavehospitaldistrictcodes(wardCodes); @@ -1060,9 +1063,9 @@ userId = loginUser.getUser().getUserId(); } if (serviceSubtaskEntity.getSendstateView() != null) { // 1:待随访(1 被领取、2 待发送、3 已发送、5 发送失败、7、超时);2:已完成( 6 已完成)、3:无需随访(4 不执行) // 1:待随访(1 被领取、2 待发送、3 已发送、7 超时;发送失败(5) 单独统计);2:已完成( 6 已完成)、3:无需随访(4 不执行) if (serviceSubtaskEntity.getSendstateView() == 1) serviceSubtaskEntity.setSendstates(new ArrayList<>(Arrays.asList(1L, 2L, 3L, 5L, 7L))); serviceSubtaskEntity.setSendstates(new ArrayList<>(Arrays.asList(1L, 2L, 3L, 7L))); if (serviceSubtaskEntity.getSendstateView() == 2) serviceSubtaskEntity.setSendstates(new ArrayList<>(Arrays.asList(6L))); if (serviceSubtaskEntity.getSendstateView() == 3) ruoyi-task/src/main/java/com/ruoyi/task/domain/ServiceSubtaskCountReq.java
@@ -183,6 +183,37 @@ private String groupKey; /** * 病区统计(离院维度)实际使用的“有效病区”列表达式(仅服务端内部使用)。 * <p> * 满意度子任务(service_type 6/14/15/16)的 {@code leavehospitaldistrictcode} 约三成为空, * 旧写法把空值当成“不限”放行({@code xxx IS NULL OR xxx IN (...)}), * 结果选中 A 病区时会把空病区的记录全带进来,再按 * {@code IFNULL(leavehospitaldistrictcode,'Unknown')} 归成一个组, * 这一组的病区名又是 {@code MAX(hospitaldistrictname)} 随便挑的。 * <p> * 这里给出“出院病区为空则回退到在院病区”的表达式,过滤与分组都改用它, * 空病区的记录因此回到它真正的在院病区,而不是污染用户选中的病区。 * 为 null 时保持历史行为(兼容其它调用方)。 */ @JsonIgnore private String districtColumn; /** * 分页偏移量(仅服务端内部使用)。 * <p> * pageNum 是 1 起始的,而历史写法直接把它当 SQL 的 OFFSET 用 * ({@code limit 20 OFFSET 1}),第一页会凭空跳掉第一条分组数据。 */ @JsonIgnore public Integer getPageOffset() { if (pageSize == null) { return null; } int current = (pageNum == null || pageNum < 1) ? 1 : pageNum; return (current - 1) * pageSize; } /** * 随访统计方式 1-默认统计方式 2-统计时算上不发送的 3-只统计已完成的 */ @ApiModelProperty(value = "统计方式") ruoyi-task/src/main/java/com/ruoyi/task/domain/ServiceSubtaskStatistic.java
@@ -377,6 +377,16 @@ private String groupCode; /** * 分组键对应的显示名(病区统计时即该组的病区名)。 * <p> * 仅服务端内部使用:原来病区列取的是 {@code MAX(hospitaldistrictname)}, * 一个组里混了多个病区时就会随机显示其中一个(选血液内科病区却显示“骨科中心三病区”)。 * 改成按分组键取名字,再回填到病区 code/name 字段。 */ @JsonIgnore private String groupName; /** * 首次随访记录数(visit_count = 1 的行数)。 * <p> * 仅服务端内部使用:原内存统计里“首次随访率”只在该分组存在首次随访记录时才会被赋值 ruoyi-task/src/main/java/com/ruoyi/task/service/impl/ServiceSubtaskServiceImpl.java
@@ -538,9 +538,9 @@ public List<ServiceSubtask> getServiceSubtasks(ServiceSubtaskEntity serviceSubtaskEntity) { if (serviceSubtaskEntity.getSendstateView() != null) { // 1:待随访(1 被领取、2 待发送、3 已发送、5 发送失败、7、超时);2:已完成( 6 已完成)、3:无需随访(4 不执行) // 1:待随访(1 被领取、2 待发送、3 已发送、7 超时;发送失败(5) 单独统计);2:已完成( 6 已完成)、3:无需随访(4 不执行) if (serviceSubtaskEntity.getSendstateView() == 1) serviceSubtaskEntity.setSendstates(new ArrayList<>(Arrays.asList(1L, 2L, 3L, 5L, 7L))); serviceSubtaskEntity.setSendstates(new ArrayList<>(Arrays.asList(1L, 2L, 3L, 7L))); if (serviceSubtaskEntity.getSendstateView() == 2) serviceSubtaskEntity.setSendstates(new ArrayList<>(Arrays.asList(6L))); if (serviceSubtaskEntity.getSendstateView() == 3) @@ -809,6 +809,50 @@ } /** * 统一「随访病区」口径:一律以【出院记录】{@code pat_med_inhosp} 为准(2026-10-29 业务确认)。 * * <h3>为什么需要它</h3> * 出院随访子任务的科室/病区本应来自出院记录,但「再次随访 / 手工新增」这两个入口是 * <b>直接按前端传来的字段落库</b>的:前端不带这些字段时 * {@code leaveldeptcode} / {@code leavehospitaldistrictcode} / {@code campusid} 就是空。 * 而随访列表是按科室/病区过滤的({@code and leavehospitaldistrictcode = #{...}}), * 空值不匹配任何值 → 这些任务"生成了但页面上查不到"(线上 2026-10 实测 314 条)。 * * <p>口径:<b>病区只认出院记录</b> —— 不按患者档案反推、也不用前端传的值。 * 只对出院随访({@code service_type='2'})且能定位到出院记录({@code inhospid} 非空)的行生效; * 其余情况原样放行,对满意度(6)/宣教/门诊等其它类型没有任何影响。 * * @param serviceSubtask 待落库的子任务(就地修改) */ private void alignDischargeWardFromInhosp(ServiceSubtask serviceSubtask) { if (serviceSubtask == null || serviceSubtask.getInhospid() == null) { return; } if (!"2".equals(serviceSubtask.getServiceType())) { return; } PatMedInhosp inhosp; try { inhosp = inhospVisitFacade.selectPatMedInhospByInhospid(serviceSubtask.getInhospid()); } catch (Exception e) { log.warn("【随访任务】按 inhospid={} 取出院记录失败,病区沿用入参:{}", serviceSubtask.getInhospid(), e.toString()); return; } if (inhosp == null) { log.warn("【随访任务】inhospid={} 查不到出院记录,病区沿用入参", serviceSubtask.getInhospid()); return; } serviceSubtask.setLeaveldeptcode(inhosp.getLeaveldeptcode()); serviceSubtask.setLeaveldeptname(inhosp.getLeaveldeptname()); serviceSubtask.setLeavehospitaldistrictcode(inhosp.getLeavehospitaldistrictcode()); serviceSubtask.setLeavehospitaldistrictname(inhosp.getLeavehospitaldistrictname()); if (StringUtils.isNotEmpty(inhosp.getCampusid())) { serviceSubtask.setCampusid(inhosp.getCampusid()); } } /** * 新增单一任务(随访) * * @param serviceSubtask 单一任务(随访) @@ -850,6 +894,8 @@ } serviceSubtask.setCreateTime(DateUtils.getNowDate()); // 口径(2026-10-29 业务确认):出院随访的科室/病区一律以出院记录为准 alignDischargeWardFromInhosp(serviceSubtask); serviceSubtaskMapper.insertServiceSubtask(serviceSubtask); //修改住院满意度状态(避免重复查询) if (serviceSubtask.getServiceType() != null && "6".equals(serviceSubtask.getServiceType())) { @@ -943,6 +989,13 @@ serviceSubtask2.setVisitType(serviceSubtask.getVisitType()); serviceSubtask2.setVisitDeptCode(serviceSubtask.getVisitDeptCode()); serviceSubtask2.setVisitDeptName(serviceSubtask.getVisitDeptName()); // 口径(2026-10-29 业务确认):随访病区一律取出院记录的 // ① 再次随访是从请求 DTO 复制出来的,出院记录ID 不在请求里 → 先用原任务的上; // ② 再按出院记录把科室/病区/院区统一过来(取不到就保持不变)。 if (serviceSubtask2.getInhospid() == null) { serviceSubtask2.setInhospid(serviceSubtask1.getInhospid()); } alignDischargeWardFromInhosp(serviceSubtask2); int i = serviceSubtaskMapper.insertServiceSubtask(serviceSubtask2); if (i == 1) { //新增成功后,需要将之前的任务设置为不要再随访(防止重复点,然后重复生成) @@ -3206,6 +3259,20 @@ serviceSubtaskCountReq.setGroupKey(groupKey); // 分组列与过滤列保持一致:离院科室 leaveldeptcode、在院科室 deptcode serviceSubtaskCountReq.setGroupColumn(groupKey); /* * 满意度「病区统计」(离院维度):出院病区为空则回退到在院病区。 * 满意度子任务(service_type 6/14/15/16)的 leavehospitaldistrictcode 约三成为空, * 之前为了“查得到数据”把病区条件写成 `xxx IS NULL OR xxx IN (...)`, * 于是选了 A 病区也会把空病区的全量数据带进来,再统一归到 'Unknown' 组, * 该组的病区名又是 MAX(hospitaldistrictname) 随便挑的 —— 表现就是 * “选了血液内科病区,却查出一行显示骨科中心三病区”。 * 用“有效病区”列过滤 + 分组后:筛选真正生效,空病区的记录也回到它真实的在院病区。 */ if ("leavehospitaldistrictcode".equals(groupKey)) { String districtColumn = "IFNULL(leavehospitaldistrictcode, hospitaldistrictcode)"; serviceSubtaskCountReq.setGroupColumn(districtColumn); serviceSubtaskCountReq.setDistrictColumn(districtColumn); } //分组键集合:这一步保持不变(分页参数照旧作用在该查询上),保证“参与统计的分组”与改造前一致 List<String> groupKeyList = serviceSubtaskMapper.getSfStatisticsGroupKey(serviceSubtaskCountReq); /* @@ -3280,6 +3347,24 @@ serviceSubtaskStatistics.add(statistic); } /* * 病区统计:这一行的病区就是分组键本身,展示名取该组自己的病区名。 * 聚合 SQL 里原来的 MAX(hospitaldistrictname) 在“一个组混了多个病区”时会挑出 * 一个毫不相干的病区名(选血液内科病区显示成骨科中心三病区就是这么来的)。 * 这里把分组键回填到在院/离院两套病区字段,保证前端不论读哪一列都是同一个病区, * 也让“查看详情”带上来的病区编码能查回同一批数据。 */ if (StringUtils.isNotEmpty(serviceSubtaskCountReq.getDistrictColumn())) { for (ServiceSubtaskStatistic statistic : serviceSubtaskStatistics) { String districtCode = statistic.getGroupCode() == null ? "Unknown" : statistic.getGroupCode(); String districtName = StringUtils.isNotEmpty(statistic.getGroupName()) ? statistic.getGroupName() : districtCode; statistic.setLeavehospitaldistrictcode(districtCode); statistic.setLeavehospitaldistrictname(districtName); statistic.setHospitaldistrictcode(districtCode); statistic.setHospitaldistrictname(districtName); } } //获取及时率统计合集(一次批量查询,不按分组循环查) Map<String, Map<String, Object>> jsRates = getSsRateForSfStatistics(serviceSubtaskCountReq, statisticsGroupKeys); for (ServiceSubtaskStatistic statistic : serviceSubtaskStatistics) { @@ -3309,7 +3394,9 @@ sum = new BigDecimal(serviceSubtaskStatistic.getJoyAllCount()); } if (sum.intValue() != 0) { finishedPercentage = finishedSum.divide(sum, 2, RoundingMode.HALF_UP).doubleValue(); // 原来只保留 2 位小数(23634/26106 会被截成 0.91),前端再 ×100 // 就显示成 91.00%,与实际的 90.53% 差了半个百分点。这里保留 4 位。 finishedPercentage = finishedSum.divide(sum, 4, RoundingMode.HALF_UP).doubleValue(); } serviceSubtaskStatistic.setJoyAllCount(sum.intValue()); serviceSubtaskStatistic.setJoyCount(finishedSum.intValue()); ruoyi-task/src/main/resources/mapper/task/ServiceSubtaskMapper.xml
@@ -1861,9 +1861,17 @@ <select id="getSfStatisticsGroupKey" parameterType="com.ruoyi.task.domain.ServiceSubtaskCountReq" resultType="String"> select <if test="groupKey != null and groupKey != ''"> ${groupKey} as groupKey </if> <choose> <when test="groupColumn != null and groupColumn != ''"> ${groupColumn} as groupKey </when> <when test="groupKey != null and groupKey != ''"> ${groupKey} as groupKey </when> <otherwise> NULL as groupKey </otherwise> </choose> from service_subtask where 1=1 and del_flag = 0 @@ -1877,14 +1885,39 @@ 南华满意度子任务的 leavehospitaldistrictcode 有 83%(3236/3811)为 NULL, 原来「科室 AND 病区」两个条件永远不可能同时成立 → 整张统计表空。 判空放行只影响病区为空的记录,有病区的数据行为不变。 --> <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0"> AND (leavehospitaldistrictcode IS NULL OR leavehospitaldistrictcode IN <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator="," close=")"> #{leavehospitaldistrictcode} </foreach> ) </if> <choose> <!-- 满意度统计(getSfStatisticsScript):改用“有效病区”列, 出院病区为空则回退到在院病区。筛选因此真正生效, 空病区的记录也会归到它真实的在院病区,而不是混进用户选中的病区。 --> <when test="districtColumn != null and districtColumn != ''"> <!-- 出院病区为空回退到在院病区。这里刻意写成 OR 展开而不是 IFNULL(a,b) IN (...): 列被函数包住就用不了索引,56 万行明细表上是全表扫(实测 5.1s → 0.7s)。 两个分支分别命中 idx_ss_district_pair 的 (leave..., hospital...) 前缀。 --> <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0"> AND (leavehospitaldistrictcode IN <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator="," close=")"> #{leavehospitaldistrictcode} </foreach> OR (leavehospitaldistrictcode IS NULL AND hospitaldistrictcode IN <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator="," close=")"> #{leavehospitaldistrictcode} </foreach> )) </if> </when> <otherwise> <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0"> AND (leavehospitaldistrictcode IS NULL OR leavehospitaldistrictcode IN <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator="," close=")"> #{leavehospitaldistrictcode} </foreach> ) </if> </otherwise> </choose> <if test="leaveldeptcodes != null and leaveldeptcodes.size() > 0"> AND leaveldeptcode IN <foreach collection="leaveldeptcodes" item="leaveldeptcode" open="(" separator="," @@ -1947,11 +1980,20 @@ <if test="visitCount != null and visitCount > 1"> AND visit_count > 1 </if> <if test="groupKey != null and groupKey != ''"> group by ${groupKey} </if> <choose> <when test="groupColumn != null and groupColumn != ''"> group by ${groupColumn} order by ${groupColumn} </when> <when test="groupKey != null and groupKey != ''"> group by ${groupKey} order by ${groupKey} </when> </choose> <!-- pageNum 是 1 起始的,OFFSET 必须用 (pageNum-1)*pageSize; 以前直接写 OFFSET ${pageNum},第一页会跳掉第一条分组。 --> <if test="pageSize != null and pageNum != null"> limit ${pageSize} OFFSET ${pageNum} limit ${pageSize} OFFSET ${pageOffset} </if> </select> @@ -1977,6 +2019,8 @@ --> <resultMap type="com.ruoyi.task.domain.ServiceSubtaskStatistic" id="ServiceSubtaskScriptStatisticResult"> <result property="groupCode" column="group_code"/> <result property="groupName" column="group_name"/> <result property="dischargeCount" column="discharge_count"/> <result property="firstVisitCount" column="first_visit_count"/> <result property="againVisitCount" column="again_visit_count"/> <result property="drcode" column="drcode"/> @@ -2025,6 +2069,10 @@ resultMap="ServiceSubtaskScriptStatisticResult"> select IFNULL(${groupColumn}, 'Unknown') as group_code, <!-- 分组键对应的显示名:病区统计时就是这一组的病区名。 以前这一列是 MAX(hospitaldistrictname),一个组混了多个病区时会随机显示其中一个, 于是出现“选血液内科病区却显示骨科中心三病区”。 --> MAX(IFNULL(leavehospitaldistrictname, hospitaldistrictname)) as group_name, MAX(drcode) as drcode, MAX(drname) as drname, MAX(leaveldeptcode) as deptcode, @@ -2056,7 +2104,10 @@ SUM(CASE WHEN visit_count > 1 and sendstate is not null and current_preachform = '3' THEN 1 ELSE 0 END) as voice_again, SUM(CASE WHEN visit_count > 1 and sendstate is not null and current_preachform = '4' THEN 1 ELSE 0 END) as sms_again, SUM(CASE WHEN visit_count > 1 and sendstate is not null and current_preachform = '5' THEN 1 ELSE 0 END) as we_chat_again, SUM(CASE WHEN visit_count > 1 and sendstate is not null and excep = '1' THEN 1 ELSE 0 END) as abnormal_again SUM(CASE WHEN visit_count > 1 and sendstate is not null and excep = '1' THEN 1 ELSE 0 END) as abnormal_again, <!-- 出院人次:原来的内存实现是 serviceSubtaskList.size(),即分组内命中的子任务条数。 改成本语句后一直没有聚合这一列,页面上“出院人次”恒为 0,与应随访人次自相矛盾。 --> COUNT(*) as discharge_count from service_subtask where 1=1 and del_flag = 0 @@ -2066,14 +2117,37 @@ <if test="campusid != null and campusid != ''"> and campusid = #{campusid} </if> <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0"> AND (leavehospitaldistrictcode IS NULL OR leavehospitaldistrictcode IN <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator="," close=")"> #{leavehospitaldistrictcode} </foreach> ) </if> <choose> <!-- 满意度统计:改用“有效病区”列(出院病区为空回退在院病区),筛选才真正生效 --> <when test="districtColumn != null and districtColumn != ''"> <!-- 出院病区为空回退到在院病区。这里刻意写成 OR 展开而不是 IFNULL(a,b) IN (...): 列被函数包住就用不了索引,56 万行明细表上是全表扫(实测 5.1s → 0.7s)。 两个分支分别命中 idx_ss_district_pair 的 (leave..., hospital...) 前缀。 --> <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0"> AND (leavehospitaldistrictcode IN <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator="," close=")"> #{leavehospitaldistrictcode} </foreach> OR (leavehospitaldistrictcode IS NULL AND hospitaldistrictcode IN <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator="," close=")"> #{leavehospitaldistrictcode} </foreach> )) </if> </when> <otherwise> <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0"> AND (leavehospitaldistrictcode IS NULL OR leavehospitaldistrictcode IN <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator="," close=")"> #{leavehospitaldistrictcode} </foreach> ) </if> </otherwise> </choose> <if test="deptcodes != null and deptcodes.size() > 0"> AND leaveldeptcode IN <foreach collection="deptcodes" item="deptcode" open="(" separator="," @@ -2134,8 +2208,10 @@ <if test="visitCount != null and visitCount > 1"> AND visit_count > 1 </if> <if test="groupKey != null and groupKey != '' and groupKeyList != null and groupKeyList.size>0"> AND IFNULL(${groupKey}, 'Unknown') IN <!-- 过滤列必须与 GROUP BY 的列一致:都用 groupColumn(满意度病区统计时它是 IFNULL(出院病区, 在院病区) 这样的表达式),否则聚合出来的分组键过滤不上,题目量会全变 0。 --> <if test="groupColumn != null and groupColumn != '' and groupKeyList != null and groupKeyList.size>0"> AND IFNULL(${groupColumn}, 'Unknown') IN <foreach collection="groupKeyList" item="key" open="(" separator="," close=")"> #{key} @@ -2153,7 +2229,14 @@ <select id="getSfStatisticsScriptJoySum" parameterType="com.ruoyi.task.domain.ServiceSubtaskCountReq" resultType="map"> select IFNULL(t.${groupColumn}, 'Unknown') as groupCode, <choose> <when test="districtColumn != null and districtColumn != ''"> IFNULL(${districtColumn}, 'Unknown') as groupCode, </when> <otherwise> IFNULL(t.${groupColumn}, 'Unknown') as groupCode, </otherwise> </choose> SUM(CASE WHEN a.categoryid IN(${configValue}) THEN 1 ELSE 0 END) as joyAllCount, SUM(CASE WHEN a.categoryid IN(${configValue}) @@ -2171,14 +2254,38 @@ <if test="campusid != null and campusid != ''"> and t.campusid = #{campusid} </if> <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0"> AND (t.leavehospitaldistrictcode IS NULL OR t.leavehospitaldistrictcode IN <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator="," close=")"> #{leavehospitaldistrictcode} </foreach> ) </if> <choose> <!-- 满意度统计:与聚合主查询一致,用“有效病区”列过滤, 否则题目量会和主查询的行对不上(分组键不一样,过滤不上就全变 0)。 --> <when test="districtColumn != null and districtColumn != ''"> <!-- 出院病区为空回退到在院病区。这里刻意写成 OR 展开而不是 IFNULL(a,b) IN (...): 列被函数包住就用不了索引,56 万行明细表上是全表扫(实测 5.1s → 0.7s)。 两个分支分别命中 idx_ss_district_pair 的 (leave..., hospital...) 前缀。 --> <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0"> AND (leavehospitaldistrictcode IN <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator="," close=")"> #{leavehospitaldistrictcode} </foreach> OR (leavehospitaldistrictcode IS NULL AND hospitaldistrictcode IN <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator="," close=")"> #{leavehospitaldistrictcode} </foreach> )) </if> </when> <otherwise> <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0"> AND (t.leavehospitaldistrictcode IS NULL OR t.leavehospitaldistrictcode IN <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator="," close=")"> #{leavehospitaldistrictcode} </foreach> ) </if> </otherwise> </choose> <if test="deptcodes != null and deptcodes.size() > 0"> AND t.leaveldeptcode IN <foreach collection="deptcodes" item="deptcode" open="(" separator="," @@ -2239,14 +2346,28 @@ <if test="visitCount != null and visitCount > 1"> AND t.visit_count > 1 </if> <if test="groupKey != null and groupKey != '' and groupKeyList != null and groupKeyList.size>0"> <if test="districtColumn != null and districtColumn != '' and groupKeyList != null and groupKeyList.size>0"> AND IFNULL(${districtColumn}, 'Unknown') IN <foreach collection="groupKeyList" item="key" open="(" separator="," close=")"> #{key} </foreach> </if> <if test="(districtColumn == null or districtColumn == '') and groupKey != null and groupKey != '' and groupKeyList != null and groupKeyList.size>0"> AND IFNULL(t.${groupKey}, 'Unknown') IN <foreach collection="groupKeyList" item="key" open="(" separator="," close=")"> #{key} </foreach> </if> group by IFNULL(t.${groupColumn}, 'Unknown') <choose> <when test="districtColumn != null and districtColumn != ''"> group by IFNULL(${districtColumn}, 'Unknown') </when> <otherwise> group by IFNULL(t.${groupColumn}, 'Unknown') </otherwise> </choose> </select> <select id="getSfStatisticsHyperlink" parameterType="com.ruoyi.task.domain.ServiceSubtaskCountReq" @@ -2300,13 +2421,20 @@ and campusid = #{campusid} </if> <!-- 2026-10-21:病区为空时视为"不限"(南华满意度 83% 子任务无病区),有病区的数据行为不变 --> <!-- 与 getSfStatisticsScript 的汇总行保持一致:出院病区为空则回退到在院病区, 否则点“查看详情”查出来的条数会和汇总行的数字对不上。 --> <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0"> AND (leavehospitaldistrictcode IS NULL OR leavehospitaldistrictcode IN AND (leavehospitaldistrictcode IN <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator="," close=")"> #{leavehospitaldistrictcode} </foreach> ) OR (leavehospitaldistrictcode IS NULL AND hospitaldistrictcode IN <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator="," close=")"> #{leavehospitaldistrictcode} </foreach> )) </if> <if test="deptcodes != null and deptcodes.size() > 0"> AND leaveldeptcode IN @@ -2637,11 +2765,17 @@ AND ss.campusid = #{campusid} </if> <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0"> AND ss.leavehospitaldistrictcode IN AND (ss.leavehospitaldistrictcode IN <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator="," close=")"> #{leavehospitaldistrictcode} </foreach> OR (ss.leavehospitaldistrictcode IS NULL AND ss.hospitaldistrictcode IN <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator="," close=")"> #{leavehospitaldistrictcode} </foreach> )) </if> <if test="deptcodes != null and deptcodes.size() > 0"> AND ss.leaveldeptcode IN @@ -2719,11 +2853,17 @@ and ss.campusid = #{campusid} </if> <if test="leavehospitaldistrictcodes != null and leavehospitaldistrictcodes.size() > 0"> AND ss.leavehospitaldistrictcode IN AND (ss.leavehospitaldistrictcode IN <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator="," close=")"> #{leavehospitaldistrictcode} </foreach> OR (ss.leavehospitaldistrictcode IS NULL AND ss.hospitaldistrictcode IN <foreach collection="leavehospitaldistrictcodes" item="leavehospitaldistrictcode" open="(" separator="," close=")"> #{leavehospitaldistrictcode} </foreach> )) </if> <if test="deptcodes != null and deptcodes.size() > 0"> AND ss.leaveldeptcode IN @@ -3085,7 +3225,7 @@ SUM(CASE WHEN sendstate = 1 THEN 1 ELSE 0 END) AS blq, SUM(CASE WHEN sendstate = 4 THEN 1 ELSE 0 END) AS wxsf, SUM(CASE WHEN sendstate != 4 THEN 1 ELSE 0 END) AS xsf, SUM(CASE WHEN (sendstate = 1 OR sendstate = 2 OR sendstate = 3 OR sendstate = 5 OR sendstate = 7 ) THEN 1 ELSE 0 SUM(CASE WHEN (sendstate = 1 OR sendstate = 2 OR sendstate = 3 OR sendstate = 7 ) THEN 1 ELSE 0 END) AS dsf, SUM(CASE WHEN sendstate != 2 THEN 1 ELSE 0 END) AS yfs, SUM(CASE WHEN sendstate = 2 THEN 1 ELSE 0 END) AS wfs, @@ -3282,7 +3422,8 @@ 当前登录人「待处理」随访任务,按出院年月(endtime)分组统计,出院随访 / 门诊随访分开计数。 口径(与随访列表「待随访」页签一致): 1) 出院随访 service_type = '2',门诊随访 service_type = '3' 2) 待随访(未完成)→ sendstate IN (1,2,3,5,7),排除 4 不执行 / 6 完成 2) 统计状态 → sendstate IN (1,2,3,7):1 被领取 / 2 待发送 / 3 已发送 / 7 超时 (不含 4 不执行、5 发送失败、6 已完成) 3) 出院满 7 天 → endtime < (今天 - 7 天) + 1 天, 即「7 天前那一天」出院的也计入(用 <= DATE_SUB(...) 只会截到当天 0 点,会漏掉 9 条) 4) 只看最近 4 个出院月份(含当月)→ endtime >= 当月往前推 3 个月的 1 号 @@ -3302,7 +3443,8 @@ FROM service_subtask WHERE del_flag = '0' AND service_type IN ('2', '3') AND sendstate IN (1, 2, 3, 5, 7) <!-- 浮窗「待处理随访任务」口径:1 被领取 / 2 待发送 / 3 已发送 / 7 超时 --> AND sendstate IN (1, 2, 3, 7) AND endtime IS NOT NULL AND endtime < DATE_ADD(DATE_SUB(CURDATE(), INTERVAL 7 DAY), INTERVAL 1 DAY) AND endtime >= DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 3 MONTH), '%Y-%m-01') smartor/src/main/java/com/smartor/service/impl/ServiceSubtaskAnswerServiceImpl.java
@@ -518,8 +518,29 @@ } } /** * 同一份问卷的保存锁:按「任务+患者+子任务」加锁, * 前端重复点击 / 并发请求进来时串行执行,避免同一份问卷被写两遍。 * 注意:只是串行化,不阻止再次保存——问卷仍然允许修改后再保存(走更新逻辑)。 */ private static final java.util.concurrent.ConcurrentHashMap<String, Object> SAVE_LOCKS = new java.util.concurrent.ConcurrentHashMap<>(); @Override public Integer saveQuestionDetail(ServiceSubTaskCacheReq serviceSubTaskCacheReq, Long flag) { String lockKey = serviceSubTaskCacheReq.getParam1() + "_" + serviceSubTaskCacheReq.getParam2() + "_" + serviceSubTaskCacheReq.getParam6(); Object lock = SAVE_LOCKS.computeIfAbsent(lockKey, k -> new Object()); if (SAVE_LOCKS.size() > 20000) { SAVE_LOCKS.clear(); } synchronized (lock) { return doSaveQuestionDetail(serviceSubTaskCacheReq, flag); } } private Integer doSaveQuestionDetail(ServiceSubTaskCacheReq serviceSubTaskCacheReq, Long flag) { Long tid = Long.valueOf(serviceSubTaskCacheReq.getParam1()); Long pid = Long.valueOf(serviceSubTaskCacheReq.getParam2()); Long subid = Long.valueOf(serviceSubTaskCacheReq.getParam6());