Browse Source

新增
1、新增退住原因与平均入住时长报表接口

liangwenxuan 11 hours ago
parent
commit
789b855c0e

+ 47 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/OperationAnalysisRetreatReasonController.java

@@ -0,0 +1,47 @@
+package cn.iocoder.yudao.module.system.controller.admin.biz;
+
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisRetreatReasonReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisRetreatReasonRespVO;
+import cn.iocoder.yudao.module.system.service.biz.OperationAnalysisService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import javax.annotation.security.PermitAll;
+import java.util.List;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+
+/**
+ * 管理后台 - 运营分析 - 退住原因与平均入住时长
+ *
+ * @author kyj
+ */
+@Tag(name = "管理后台 - 运营分析 - 退住原因与平均入住时长")
+@RestController
+@RequestMapping("/operation-analysis/retreat-reason")
+@Validated
+public class OperationAnalysisRetreatReasonController {
+
+    @Resource
+    private OperationAnalysisService operationAnalysisService;
+
+    @GetMapping("/stats")
+    @Operation(summary = "退住原因与平均入住时长",
+            description = "按机构分组返回:退住总人数、平均入住时长(天 / 月)、TOP1 退住原因及其占比、" +
+                    "退住原因分布(含各原因平均入住时长)、月度退住趋势(按原因拆分)。" +
+                    "日期只到天不含时分秒,不传时默认近 12 个自然月,月度趋势无数据的月份按原因补 0。")
+    @TenantIgnore
+    @PermitAll
+    public CommonResult<List<OperationAnalysisRetreatReasonRespVO>> getRetreatReasonStats(
+            OperationAnalysisRetreatReasonReqVO reqVO) {
+        return success(operationAnalysisService.getRetreatReasonStats(reqVO));
+    }
+
+}

+ 35 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/operationanalysis/OperationAnalysisRetreatReasonReqVO.java

@@ -0,0 +1,35 @@
+package cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDate;
+
+import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY;
+
+/**
+ * 运营分析 - 概况 - 退住原因与平均入住时长 ReqVO
+ *
+ * <p>日期只到天,不含时分秒;不传日期时默认统计近 12 个自然月。</p>
+ *
+ * @author kyj
+ */
+@Data
+public class OperationAnalysisRetreatReasonReqVO {
+
+    @Schema(description = "机构ID数组(即租户ID),不传时取当前登录机构范围")
+    private Long[] tenantIds;
+
+    @Schema(description = "开始日期 yyyy-MM-dd(含),不传时取结束日期往前 11 个月的 1 号")
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY)
+    private LocalDate startDate;
+
+    @Schema(description = "结束日期 yyyy-MM-dd(含),不传时取今天")
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY)
+    private LocalDate endDate;
+
+    @Schema(description = "系统类型", example = "1")
+    private Integer orgType = 1;
+
+}

+ 116 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/operationanalysis/OperationAnalysisRetreatReasonRespVO.java

@@ -0,0 +1,116 @@
+package cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 运营分析 - 概况 - 退住原因与平均入住时长 RespVO
+ *
+ * <p>每个机构一条记录:退住总人数、平均入住时长、TOP1 退住原因及其占比、
+ * 退住原因分布(含各原因平均入住时长)与月度退住趋势(按原因拆分)。</p>
+ *
+ * <p>口径:仅统计区间内「已完成」的退住(系统审批 或 BPM 审批通过),
+ * 入住时长 = 退住日期 - 长者档案入住日期,入住时间缺失的记录不计入平均值。</p>
+ *
+ * @author kyj
+ */
+@Data
+public class OperationAnalysisRetreatReasonRespVO {
+
+    @Schema(description = "机构ID(租户ID)", example = "196")
+    private Long tenantId;
+
+    @Schema(description = "机构名称", example = "颐年天河")
+    private String tenantName;
+
+    @Schema(description = "统计开始日期(含)", example = "2025-10-01")
+    private String startDate;
+
+    @Schema(description = "统计结束日期(含)", example = "2026-09-30")
+    private String endDate;
+
+    @Schema(description = "退住总人数", example = "128")
+    private Long retreatTotalCount;
+
+    @Schema(description = "平均入住天数(保留 1 位小数,无有效入住时间时为 null)", example = "486.5")
+    private BigDecimal avgStayDays;
+
+    @Schema(description = "平均入住月数(保留 1 位小数,无有效入住时间时为 null)", example = "16.0")
+    private BigDecimal avgStayMonths;
+
+    @Schema(description = "TOP1 退住原因", example = "不适应")
+    private String topReason;
+
+    @Schema(description = "TOP1 退住原因人数", example = "52")
+    private Long topReasonCount;
+
+    @Schema(description = "TOP1 退住原因占比(百分比,保留 1 位小数)", example = "40.6")
+    private BigDecimal topReasonRate;
+
+    @Schema(description = "退住原因分布(按人数降序,含各原因平均入住时长)")
+    private List<ReasonStatVO> reasonDistribution;
+
+    @Schema(description = "月度退住趋势(按退住原因统计,无数据月份按原因补 0)")
+    private List<MonthlyReasonTrendVO> monthlyTrend;
+
+    /**
+     * 退住原因统计
+     */
+    @Data
+    @Schema(description = "退住原因统计")
+    public static class ReasonStatVO {
+
+        @Schema(description = "退住原因,未填写时为「未填写」", example = "不适应")
+        private String reason;
+
+        @Schema(description = "退住人数", example = "52")
+        private Long count;
+
+        @Schema(description = "占比(百分比,保留 1 位小数)", example = "40.6")
+        private BigDecimal rate;
+
+        @Schema(description = "该原因平均入住天数(保留 1 位小数)", example = "512.3")
+        private BigDecimal avgStayDays;
+
+        @Schema(description = "该原因平均入住月数(保留 1 位小数)", example = "16.8")
+        private BigDecimal avgStayMonths;
+
+    }
+
+    /**
+     * 月度退住趋势
+     */
+    @Data
+    @Schema(description = "月度退住趋势")
+    public static class MonthlyReasonTrendVO {
+
+        @Schema(description = "月份,格式 yyyy-MM", example = "2026-09")
+        private String month;
+
+        @Schema(description = "当月退住总人数", example = "12")
+        private Long count;
+
+        @Schema(description = "当月各退住原因人数(顺序与 reasonDistribution 一致)")
+        private List<ReasonCountVO> reasons;
+
+    }
+
+    /**
+     * 某原因在某个期间的退住人数
+     */
+    @Data
+    @Schema(description = "退住原因人数")
+    public static class ReasonCountVO {
+
+        @Schema(description = "退住原因,未填写时为「未填写」", example = "不适应")
+        private String reason;
+
+        @Schema(description = "退住人数", example = "5")
+        private Long count;
+
+    }
+
+}

+ 15 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/OperationAnalysisMapper.java

@@ -5,6 +5,7 @@ import cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationNurseLevelCountB
 import cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationTenantBedCountBO;
 import cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationTenantCountBO;
 import cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationTenantMonthCountBO;
+import cn.iocoder.yudao.module.system.dal.mysql.biz.bo.RetreatReasonRowBO;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 
@@ -207,4 +208,18 @@ public interface OperationAnalysisMapper {
     List<ElderStructureRowBO> selectElderStructureRows(@Param("tenantIds") Collection<Long> tenantIds,
                                                        @Param("orgType") Integer orgType);
 
+    /**
+     * 退住原因与入住时长明细行:区间内「已完成」的退住,关联长者档案取入住时间
+     *
+     * @param tenantIds 机构ID
+     * @param orgType   系统类型
+     * @param begin     区间起始日(含)
+     * @param end       区间结束日(含)
+     * @return 退住明细行
+     */
+    List<RetreatReasonRowBO> selectRetreatReasonRows(@Param("tenantIds") Collection<Long> tenantIds,
+                                                     @Param("orgType") Integer orgType,
+                                                     @Param("begin") LocalDate begin,
+                                                     @Param("end") LocalDate end);
+
 }

+ 38 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/bo/RetreatReasonRowBO.java

@@ -0,0 +1,38 @@
+package cn.iocoder.yudao.module.system.dal.mysql.biz.bo;
+
+import lombok.Data;
+
+import java.time.LocalDate;
+import java.util.Date;
+
+/**
+ * 运营分析 —— 退住原因与入住时长明细行 BO
+ *
+ * <p>一条已完成的退住记录一行,入住时间来自长者档案,用于计算入住时长。</p>
+ *
+ * @author kyj
+ */
+@Data
+public class RetreatReasonRowBO {
+
+    /**
+     * 机构ID(租户ID)
+     */
+    private Long tenantId;
+
+    /**
+     * 退住原因(原样返回,未填写时由上层归为「未填写」)
+     */
+    private String reason;
+
+    /**
+     * 退住日期
+     */
+    private LocalDate retreatDate;
+
+    /**
+     * 入住时间,为空表示长者档案缺失或入住时间未录入
+     */
+    private Date checkInTime;
+
+}

+ 14 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/OperationAnalysisService.java

@@ -6,6 +6,8 @@ import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisElderStructureRespVO;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisOverviewDynamicsReqVO;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisOverviewDynamicsRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisRetreatReasonReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisRetreatReasonRespVO;
 
 import java.util.List;
 
@@ -44,4 +46,16 @@ public interface OperationAnalysisService {
      */
     OperationAnalysisElderStructureRespVO getElderStructure(OperationAnalysisElderStructureReqVO reqVO);
 
+    /**
+     * 运营分析 - 概况:退住原因与平均入住时长
+     *
+     * <p>仅统计区间内「已完成」的退住,入住时长 = 退住日期 - 长者档案入住日期;
+     * 按月补齐趋势,无数据的月份按原因补 0。</p>
+     *
+     * @param reqVO 请求参数(机构ID数组 + 日期范围)
+     * @return 按机构分组的退住原因统计,每个机构一条
+     */
+    List<OperationAnalysisRetreatReasonRespVO> getRetreatReasonStats(
+            OperationAnalysisRetreatReasonReqVO reqVO);
+
 }

+ 225 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/OperationAnalysisServiceImpl.java

@@ -16,6 +16,11 @@ import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisOverviewDynamicsRespVO;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisOverviewDynamicsRespVO.MonthlyRetreatTrendVO;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisOverviewDynamicsRespVO.NurseLevelDistributionVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisRetreatReasonReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisRetreatReasonRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisRetreatReasonRespVO.MonthlyReasonTrendVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisRetreatReasonRespVO.ReasonCountVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisRetreatReasonRespVO.ReasonStatVO;
 import cn.iocoder.yudao.module.system.dal.dataobject.tenant.TenantDO;
 import cn.iocoder.yudao.module.system.dal.mysql.biz.OperationAnalysisMapper;
 import cn.iocoder.yudao.module.system.dal.mysql.biz.bo.ElderStructureRowBO;
@@ -23,6 +28,7 @@ import cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationNurseLevelCountB
 import cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationTenantBedCountBO;
 import cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationTenantCountBO;
 import cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationTenantMonthCountBO;
+import cn.iocoder.yudao.module.system.dal.mysql.biz.bo.RetreatReasonRowBO;
 import cn.iocoder.yudao.module.system.service.tenant.TenantService;
 import org.springframework.stereotype.Service;
 import org.springframework.validation.annotation.Validated;
@@ -90,6 +96,11 @@ public class OperationAnalysisServiceImpl implements OperationAnalysisService {
      */
     private static final String UNSET_LABEL = "未设置";
 
+    /**
+     * 退住原因未填写时展示的名称
+     */
+    private static final String UNFILLED_REASON = "未填写";
+
     /**
      * 按日统计时最多返回的天数
      */
@@ -886,6 +897,193 @@ public class OperationAnalysisServiceImpl implements OperationAnalysisService {
                 .divide(BigDecimal.valueOf(count), 1, RoundingMode.HALF_UP);
     }
 
+    // ========== 退住原因与平均入住时长 ==========
+
+    @Override
+    @TenantIgnore
+    public List<OperationAnalysisRetreatReasonRespVO> getRetreatReasonStats(
+            OperationAnalysisRetreatReasonReqVO reqVO) {
+        List<Long> tenantIds = tenantScopeService.resolveTenantIds(reqVO.getTenantIds());
+        if (CollUtil.isEmpty(tenantIds)) {
+            return Collections.emptyList();
+        }
+        int orgType = reqVO.getOrgType() == null ? 1 : reqVO.getOrgType();
+        LocalDate[] range = resolveRetreatReasonRange(reqVO);
+        LocalDate begin = range[0];
+        LocalDate end = range[1];
+
+        List<RetreatReasonRowBO> rows =
+                operationAnalysisMapper.selectRetreatReasonRows(tenantIds, orgType, begin, end);
+
+        // 机构 -> 原因 -> 聚合;机构 -> 月份 -> 原因 -> 人数
+        Map<Long, Map<String, ReasonAgg>> tenantReasonMap = new LinkedHashMap<>();
+        Map<Long, Map<String, Map<String, Long>>> tenantMonthMap = new LinkedHashMap<>();
+        for (Long tenantId : tenantIds) {
+            tenantReasonMap.put(tenantId, new HashMap<>());
+            tenantMonthMap.put(tenantId, new LinkedHashMap<>());
+        }
+        for (RetreatReasonRowBO row : rows) {
+            if (row.getTenantId() == null) {
+                continue;
+            }
+            String reason = normalizeReason(row.getReason());
+            ReasonAgg agg = tenantReasonMap.computeIfAbsent(row.getTenantId(), k -> new HashMap<>())
+                    .computeIfAbsent(reason, k -> new ReasonAgg());
+            agg.count++;
+            accumulateStay(agg, row);
+
+            if (row.getRetreatDate() != null) {
+                tenantMonthMap.computeIfAbsent(row.getTenantId(), k -> new LinkedHashMap<>())
+                        .computeIfAbsent(row.getRetreatDate().format(MONTH_FORMATTER), k -> new HashMap<>())
+                        .merge(reason, 1L, Long::sum);
+            }
+        }
+
+        List<String> months = buildPeriods(begin, end, false);
+        List<OperationAnalysisRetreatReasonRespVO> result = new ArrayList<>(tenantIds.size());
+        for (Long tenantId : tenantIds) {
+            Map<String, ReasonAgg> reasonMap = tenantReasonMap.getOrDefault(tenantId, Collections.emptyMap());
+            long totalCount = 0L;
+            long stayDaySum = 0L;
+            long stayMonthSum = 0L;
+            long staySampleCount = 0L;
+            for (ReasonAgg agg : reasonMap.values()) {
+                totalCount += agg.count;
+                stayDaySum += agg.stayDaySum;
+                stayMonthSum += agg.stayMonthSum;
+                staySampleCount += agg.staySampleCount;
+            }
+            List<ReasonStatVO> reasonStats = buildReasonStats(reasonMap, totalCount);
+
+            OperationAnalysisRetreatReasonRespVO vo = new OperationAnalysisRetreatReasonRespVO();
+            vo.setTenantId(tenantId);
+            vo.setTenantName(getTenantName(tenantId));
+            vo.setStartDate(begin.format(DAY_FORMATTER));
+            vo.setEndDate(end.format(DAY_FORMATTER));
+            vo.setRetreatTotalCount(totalCount);
+            vo.setAvgStayDays(calcAvg(stayDaySum, staySampleCount));
+            vo.setAvgStayMonths(calcAvg(stayMonthSum, staySampleCount));
+            if (CollUtil.isNotEmpty(reasonStats)) {
+                ReasonStatVO top = reasonStats.get(0);
+                vo.setTopReason(top.getReason());
+                vo.setTopReasonCount(top.getCount());
+                vo.setTopReasonRate(top.getRate());
+            }
+            vo.setReasonDistribution(reasonStats);
+            vo.setMonthlyTrend(buildMonthlyReasonTrend(months, reasonStats,
+                    tenantMonthMap.getOrDefault(tenantId, Collections.emptyMap())));
+            result.add(vo);
+        }
+        return result;
+    }
+
+    /**
+     * 解析退住原因统计区间:未传日期时默认近 12 个自然月
+     *
+     * @param reqVO 请求参数
+     * @return [0] 起始日(含),[1] 结束日(含)
+     */
+    private LocalDate[] resolveRetreatReasonRange(OperationAnalysisRetreatReasonReqVO reqVO) {
+        LocalDate end = reqVO.getEndDate() != null ? reqVO.getEndDate() : LocalDate.now();
+        YearMonth endMonth = YearMonth.from(end);
+        LocalDate begin = reqVO.getStartDate() != null ? reqVO.getStartDate()
+                : endMonth.minusMonths(TREND_MONTHS - 1).atDay(1);
+        if (begin.isAfter(end)) {
+            begin = end;
+        }
+        if (ChronoUnit.MONTHS.between(YearMonth.from(begin), endMonth) >= MAX_TREND_MONTHS) {
+            begin = endMonth.minusMonths(MAX_TREND_MONTHS - 1).atDay(1);
+        }
+        return new LocalDate[]{begin, end};
+    }
+
+    /**
+     * 归一化退住原因:空值归入「未填写」
+     *
+     * @param reason 退住原因
+     * @return 展示用的退住原因
+     */
+    private static String normalizeReason(String reason) {
+        return StrUtil.isBlank(reason) ? UNFILLED_REASON : reason.trim();
+    }
+
+    /**
+     * 累加一条退住的入住时长:入住时间缺失或晚于退住日期时不计入平均值
+     *
+     * @param agg 原因聚合器
+     * @param row 退住明细行
+     */
+    private void accumulateStay(ReasonAgg agg, RetreatReasonRowBO row) {
+        if (row.getCheckInTime() == null || row.getRetreatDate() == null) {
+            return;
+        }
+        LocalDate checkInDate = row.getCheckInTime().toInstant()
+                .atZone(ZoneId.systemDefault())
+                .toLocalDate();
+        if (checkInDate.isAfter(row.getRetreatDate())) {
+            return;
+        }
+        agg.stayDaySum += ChronoUnit.DAYS.between(checkInDate, row.getRetreatDate());
+        agg.stayMonthSum += ChronoUnit.MONTHS.between(checkInDate, row.getRetreatDate());
+        agg.staySampleCount++;
+    }
+
+    /**
+     * 构建退住原因分布:按人数降序,人数相同按原因名称升序
+     *
+     * @param reasonMap 原因聚合
+     * @param total     该机构退住总人数
+     * @return 退住原因分布
+     */
+    private List<ReasonStatVO> buildReasonStats(Map<String, ReasonAgg> reasonMap, long total) {
+        return reasonMap.entrySet().stream()
+                .sorted((o1, o2) -> {
+                    int compare = Long.compare(o2.getValue().count, o1.getValue().count);
+                    return compare != 0 ? compare : o1.getKey().compareTo(o2.getKey());
+                })
+                .map(entry -> {
+                    ReasonAgg agg = entry.getValue();
+                    ReasonStatVO vo = new ReasonStatVO();
+                    vo.setReason(entry.getKey());
+                    vo.setCount(agg.count);
+                    vo.setRate(calcRate(agg.count, total));
+                    vo.setAvgStayDays(calcAvg(agg.stayDaySum, agg.staySampleCount));
+                    vo.setAvgStayMonths(calcAvg(agg.stayMonthSum, agg.staySampleCount));
+                    return vo;
+                })
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 构建月度退住趋势:月份骨架来自统计区间,各原因无数据补 0
+     *
+     * @param months    月份列表(升序)
+     * @param reasonStats 退住原因分布,决定趋势中原因的顺序
+     * @param monthMap  月份 -> 原因 -> 人数
+     * @return 月度退住趋势
+     */
+    private List<MonthlyReasonTrendVO> buildMonthlyReasonTrend(List<String> months,
+                                                               List<ReasonStatVO> reasonStats,
+                                                               Map<String, Map<String, Long>> monthMap) {
+        List<MonthlyReasonTrendVO> result = new ArrayList<>(months.size());
+        for (String month : months) {
+            Map<String, Long> reasonCountMap = monthMap.getOrDefault(month, Collections.emptyMap());
+            MonthlyReasonTrendVO vo = new MonthlyReasonTrendVO();
+            vo.setMonth(month);
+            vo.setCount(reasonCountMap.values().stream().mapToLong(Long::longValue).sum());
+            List<ReasonCountVO> reasons = new ArrayList<>(reasonStats.size());
+            for (ReasonStatVO stat : reasonStats) {
+                ReasonCountVO item = new ReasonCountVO();
+                item.setReason(stat.getReason());
+                item.setCount(reasonCountMap.getOrDefault(stat.getReason(), 0L));
+                reasons.add(item);
+            }
+            vo.setReasons(reasons);
+            result.add(vo);
+        }
+        return result;
+    }
+
     /**
      * 长者结构聚合器
      */
@@ -983,4 +1181,31 @@ public class OperationAnalysisServiceImpl implements OperationAnalysisService {
 
     }
 
+    /**
+     * 退住原因聚合器
+     */
+    private static class ReasonAgg {
+
+        /**
+         * 退住人数
+         */
+        private long count;
+
+        /**
+         * 入住天数合计
+         */
+        private long stayDaySum;
+
+        /**
+         * 入住月数合计
+         */
+        private long stayMonthSum;
+
+        /**
+         * 有效入住时长样本数(入住时间缺失或晚于退住日期的记为无效)
+         */
+        private long staySampleCount;
+
+    }
+
 }

+ 23 - 0
yudao-module-system/yudao-module-system-biz/src/main/resources/mapper/OperationAnalysisMapper.xml

@@ -320,4 +320,27 @@
         </foreach>
     </select>
 
+    <!-- 退住原因与入住时长明细行:区间内「已完成」的退住,入住时间取长者档案 -->
+    <select id="selectRetreatReasonRows"
+            resultType="cn.iocoder.yudao.module.system.dal.mysql.biz.bo.RetreatReasonRowBO">
+        SELECT t.tenant_id     AS tenantId,
+               t.reason        AS reason,
+               t.retreat_date  AS retreatDate,
+               e.check_in_time AS checkInTime
+        FROM elderly_retreat_record t
+                 LEFT JOIN elderly_info e ON e.id = t.elderly_id AND e.deleted = 0
+        WHERE t.deleted = 0
+          AND t.apply_type = 1
+          AND t.current_flag = 0
+          AND t.org_type = #{orgType}
+          AND t.retreat_date &gt;= #{begin}
+          AND t.retreat_date &lt;= #{end}
+          AND
+        <include refid="retreatFinishedCondition"/>
+          AND t.tenant_id IN
+        <foreach collection="tenantIds" item="tenantId" open="(" separator="," close=")">
+            #{tenantId}
+        </foreach>
+    </select>
+
 </mapper>