Browse Source

新增
1、新增导出办理入住流程实例接口
2、增加管理后台 - 运营分析 - 概况模块
修改
1、合同定时任务修改,把已退住的长者合同状态更新为4
2、调整获取企微AccessToken方法,调整称符合只有一个线程请求获取,其他线程从缓存获取
3、日常费用导入时计量单位根据字典适配
BUGFIX
1、解决变更时带出的日常费用机构id为null的问题
2、解决应收报表实际应收护理费合计有误问题

liangwenxuan 3 weeks ago
parent
commit
062d7d30d4
26 changed files with 1423 additions and 99 deletions
  1. 15 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/controller/admin/task/BpmProcessInstanceController.java
  2. 69 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/controller/admin/task/vo/excel/CheckInRecordExcelVO.java
  3. 60 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/convert/excel/CheckInStatusConvert.java
  4. 57 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/convert/excel/FileStatusConvert.java
  5. 97 28
      yudao-module-report/yudao-module-report-biz/src/main/java/cn/iocoder/yudao/module/report/controller/admin/goview/GoViewDataController.java
  6. 22 2
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/order/OrderApi.java
  7. 9 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/bpm/BpmElderlyApiImpl.java
  8. 2 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/bpm/BpmElderlyChangeApiImpl.java
  9. 38 28
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/order/OrderApiImpl.java
  10. 114 21
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/wechat/CorpWechatApiImpl.java
  11. 46 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/OperationAnalysisController.java
  12. 13 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/ElderlyWeeklyOperationSummaryRespVO.java
  13. 17 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/operationanalysis/OperationAnalysisOverviewDynamicsReqVO.java
  14. 81 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/operationanalysis/OperationAnalysisOverviewDynamicsRespVO.java
  15. 10 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/ElderlyContractMapper.java
  16. 106 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/OperationAnalysisMapper.java
  17. 33 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/bo/OperationNurseLevelCountBO.java
  18. 23 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/bo/OperationTenantCountBO.java
  19. 28 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/bo/OperationTenantMonthCountBO.java
  20. 6 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/job/ElderContractUpdateJob.java
  21. 2 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/ElderlyInfoServiceImpl.java
  22. 23 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/OperationAnalysisService.java
  23. 250 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/OperationAnalysisServiceImpl.java
  24. 126 19
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/SysOverheadChargeServiceImpl.java
  25. 17 0
      yudao-module-system/yudao-module-system-biz/src/main/resources/mapper/ElderlyContractMapper.xml
  26. 159 0
      yudao-module-system/yudao-module-system-biz/src/main/resources/mapper/OperationAnalysisMapper.xml

+ 15 - 0
yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/controller/admin/task/BpmProcessInstanceController.java

@@ -445,6 +445,21 @@ public class BpmProcessInstanceController {
         return success(pageResult);
     }
 
+    @GetMapping("/check-in-export-excel")
+    @Operation(summary = "导出办理入住流程实例 Excel", description = "内容为办理入住分页的全量数据")
+    @ApiAccessLog(operateType = EXPORT)
+    @TenantIgnore
+    public void exportProcessInstanceCheckInExcel(@Valid CheckInPageReqVO pageReqVO,
+                                                  HttpServletResponse response) throws IOException {
+        pageReqVO.setTenantIds(pageReqVO.getTenantIds() == null ? new Long[]{TenantContextHolder.getTenantId()} : pageReqVO.getTenantIds());
+        pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
+        List<CheckInRecordRespVO> list = processInstanceService.getProcessInstanceCheckInPage(
+                getLoginUserId(), pageReqVO).getList();
+        // 导出 Excel
+        ExcelUtils.write(response, "办理入住.xls", "数据", CheckInRecordExcelVO.class,
+                BeanUtils.toBean(list, CheckInRecordExcelVO.class));
+    }
+
     @GetMapping("/check-in-wait-page")
     @Operation(summary = "获取轮候入住流程实例", description = "在【我的流程】菜单中,进行调用")
     @TenantIgnore

+ 69 - 0
yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/controller/admin/task/vo/excel/CheckInRecordExcelVO.java

@@ -0,0 +1,69 @@
+package cn.iocoder.yudao.module.bpm.controller.admin.task.vo.excel;
+
+import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat;
+import cn.iocoder.yudao.framework.excel.core.convert.DictConvert;
+import cn.iocoder.yudao.module.bpm.convert.excel.BpmProcessInstanceStatusConvert;
+import cn.iocoder.yudao.module.bpm.convert.excel.CheckInStatusConvert;
+import cn.iocoder.yudao.module.bpm.convert.excel.FileStatusConvert;
+import cn.iocoder.yudao.module.bpm.convert.excel.LocalDateTimeConvert;
+import cn.iocoder.yudao.module.system.enums.DictTypeConstants;
+import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
+import com.alibaba.excel.annotation.ExcelProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 管理后台 - 办理入住导出 Excel VO
+ *
+ * @author 养老项目
+ */
+@Schema(description = "管理后台 - 办理入住导出 Excel VO")
+@Data
+@ExcelIgnoreUnannotated
+public class CheckInRecordExcelVO {
+
+    @Schema(description = "长者姓名")
+    @ExcelProperty("长者姓名")
+    private String elderName;
+
+    @Schema(description = "长者性别")
+    @ExcelProperty(value = "长者性别", converter = DictConvert.class)
+    @DictFormat(DictTypeConstants.USER_SEX)
+    private Integer elderSex;
+
+    @Schema(description = "入住类别")
+    @ExcelProperty(value = "入住类别", converter = DictConvert.class)
+    @DictFormat(DictTypeConstants.IN_STATUS_TYPE)
+    private Integer inStatusType;
+
+    @Schema(description = "护理等级")
+    @ExcelProperty("护理等级")
+    private String nurseLevelName;
+
+    @Schema(description = "入住时间")
+    @ExcelProperty(value = "入住时间", converter = LocalDateTimeConvert.class)
+    private LocalDateTime checkInTime;
+
+    @Schema(description = "文件状态")
+    @ExcelProperty(value = "文件状态", converter = FileStatusConvert.class)
+    private Integer fileStatus;
+
+    @Schema(description = "办理状态")
+    @ExcelProperty(value = "办理状态", converter = CheckInStatusConvert.class)
+    private Integer status;
+
+    @Schema(description = "审批状态")
+    @ExcelProperty(value = "审批状态", converter = BpmProcessInstanceStatusConvert.class)
+    private Integer bpmStatus;
+
+    @Schema(description = "申请时间")
+    @ExcelProperty(value = "申请时间", converter = LocalDateTimeConvert.class)
+    private LocalDateTime createdTime;
+
+    @Schema(description = "所属机构")
+    @ExcelProperty("所属机构")
+    private String tenantName;
+
+}

+ 60 - 0
yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/convert/excel/CheckInStatusConvert.java

@@ -0,0 +1,60 @@
+package cn.iocoder.yudao.module.bpm.convert.excel;
+
+import com.alibaba.excel.converters.Converter;
+import com.alibaba.excel.enums.CellDataTypeEnum;
+import com.alibaba.excel.metadata.GlobalConfiguration;
+import com.alibaba.excel.metadata.data.ReadCellData;
+import com.alibaba.excel.metadata.data.WriteCellData;
+import com.alibaba.excel.metadata.property.ExcelContentProperty;
+
+/**
+ * Excel 办理入住状态转换器:1-待入住办理,2-已入住,3-退住
+ *
+ * @author 养老项目
+ */
+public class CheckInStatusConvert implements Converter<Integer> {
+
+    @Override
+    public Class<?> supportJavaTypeKey() {
+        return Integer.class;
+    }
+
+    @Override
+    public CellDataTypeEnum supportExcelTypeKey() {
+        return CellDataTypeEnum.STRING;
+    }
+
+    @Override
+    public Integer convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty,
+                                     GlobalConfiguration globalConfiguration) {
+        // 导出使用,暂不支持导入
+        return null;
+    }
+
+    @Override
+    public WriteCellData<?> convertToExcelData(Integer value, ExcelContentProperty contentProperty,
+                                               GlobalConfiguration globalConfiguration) {
+        WriteCellData<String> cellData = new WriteCellData<>();
+        String cellValue = "";
+        if (value != null) {
+            switch (value) {
+                case 1:
+                    cellValue = "待入住办理";
+                    break;
+                case 2:
+                    cellValue = "已入住";
+                    break;
+                case 3:
+                    cellValue = "退住";
+                    break;
+                default:
+                    cellValue = String.valueOf(value);
+            }
+        }
+        cellData.setType(CellDataTypeEnum.STRING);
+        cellData.setStringValue(cellValue);
+        cellData.setData(cellValue);
+        return cellData;
+    }
+
+}

+ 57 - 0
yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/convert/excel/FileStatusConvert.java

@@ -0,0 +1,57 @@
+package cn.iocoder.yudao.module.bpm.convert.excel;
+
+import com.alibaba.excel.converters.Converter;
+import com.alibaba.excel.enums.CellDataTypeEnum;
+import com.alibaba.excel.metadata.GlobalConfiguration;
+import com.alibaba.excel.metadata.data.ReadCellData;
+import com.alibaba.excel.metadata.data.WriteCellData;
+import com.alibaba.excel.metadata.property.ExcelContentProperty;
+
+/**
+ * Excel 文件状态转换器:0-未上传,1-已上传
+ *
+ * @author 养老项目
+ */
+public class FileStatusConvert implements Converter<Integer> {
+
+    @Override
+    public Class<?> supportJavaTypeKey() {
+        return Integer.class;
+    }
+
+    @Override
+    public CellDataTypeEnum supportExcelTypeKey() {
+        return CellDataTypeEnum.STRING;
+    }
+
+    @Override
+    public Integer convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty,
+                                     GlobalConfiguration globalConfiguration) {
+        // 导出使用,暂不支持导入
+        return null;
+    }
+
+    @Override
+    public WriteCellData<?> convertToExcelData(Integer value, ExcelContentProperty contentProperty,
+                                               GlobalConfiguration globalConfiguration) {
+        WriteCellData<String> cellData = new WriteCellData<>();
+        String cellValue = "";
+        if (value != null) {
+            switch (value) {
+                case 0:
+                    cellValue = "未上传";
+                    break;
+                case 1:
+                    cellValue = "已上传";
+                    break;
+                default:
+                    cellValue = String.valueOf(value);
+            }
+        }
+        cellData.setType(CellDataTypeEnum.STRING);
+        cellData.setStringValue(cellValue);
+        cellData.setData(cellValue);
+        return cellData;
+    }
+
+}

+ 97 - 28
yudao-module-report/yudao-module-report-biz/src/main/java/cn/iocoder/yudao/module/report/controller/admin/goview/GoViewDataController.java

@@ -31,6 +31,7 @@ import javax.annotation.Resource;
 import javax.validation.Valid;
 
 import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
 
 import java.math.BigDecimal;
@@ -50,6 +51,8 @@ public class GoViewDataController {
 
     private static final int SCALE_TWO = 2;
 
+    private static final int SCALE_ZERO = 0;
+
     private static BigDecimal scale2(BigDecimal v) {
         if (v == null) {
             return null;
@@ -188,15 +191,55 @@ public class GoViewDataController {
     }
 
     /**
-     * 获取当前机构的「账单金额单项取整设置」配置值
+     * 「账单金额单项取整设置」配置值缓存:配置为低频变更数据,避免每个报表接口都查库
+     */
+    private static final long AMOUNT_ROUND_CONFIG_CACHE_TTL_MS = 5 * 60 * 1000L;
+    private static final Map<Long, AmountRoundConfigCacheEntry> AMOUNT_ROUND_CONFIG_CACHE = new ConcurrentHashMap<>();
+
+    private static class AmountRoundConfigCacheEntry {
+        private final String value;
+        private final long expireAt;
+
+        AmountRoundConfigCacheEntry(String value, long expireAt) {
+            this.value = value;
+            this.expireAt = expireAt;
+        }
+    }
+
+    /**
+     * 获取当前机构的「账单金额单项取整设置」配置值(本地短期缓存)
      */
     private String getAmountRoundConfigValue(Long tenantId) {
+        if (tenantId == null) {
+            return null;
+        }
+        long now = System.currentTimeMillis();
+        AmountRoundConfigCacheEntry entry = AMOUNT_ROUND_CONFIG_CACHE.get(tenantId);
+        if (entry != null && entry.expireAt > now) {
+            return entry.value;
+        }
+        String value;
         try {
-            return businessParamsConfigApi.getConfigValue(tenantId, AMOUNT_ROUND_CONFIG_TITLE, AMOUNT_ROUND_CONFIG_NAME);
+            value = businessParamsConfigApi.getConfigValue(tenantId, AMOUNT_ROUND_CONFIG_TITLE, AMOUNT_ROUND_CONFIG_NAME);
         } catch (Exception e) {
-            // 配置获取失败时,保持现状,不影响报表查询
+            // 配置获取失败时,保持现状,不影响报表查询;不缓存,下次继续尝试
             return null;
         }
+        AMOUNT_ROUND_CONFIG_CACHE.put(tenantId, new AmountRoundConfigCacheEntry(value, now + AMOUNT_ROUND_CONFIG_CACHE_TTL_MS));
+        return value;
+    }
+
+    /**
+     * 配置值转换为小数保留位数:4-保留两位小数;2-四舍五入取整;其它-不做处理
+     */
+    private static Integer toRoundScale(String configValue) {
+        if (AMOUNT_ROUND_VALUE_TWO_SCALE.equals(configValue)) {
+            return SCALE_TWO;
+        }
+        if (AMOUNT_ROUND_VALUE_INTEGER.equals(configValue)) {
+            return SCALE_ZERO;
+        }
+        return null;
     }
 
     private OrderSubListGroupedRespVO loadOrComputeGrouped(Long tenantId, String billingMonth, Integer payStatus) {
@@ -210,7 +253,7 @@ public class GoViewDataController {
             }
         }
 
-        List<OrderItemRespVO> listByMonth = orderApi.getOrderSubListForReport(null, tenantId, billingMonth, payStatus);
+        List<OrderItemRespVO> listByMonth = orderApi.getOrderSubListForReport(1, tenantId, billingMonth, payStatus);
         OrderSubListGroupedRespVO grouped = new OrderSubListGroupedRespVO();
         if (!CollectionUtils.isEmpty(listByMonth)) {
             // 按当前机构的「账单金额单项取整设置」配置,处理金额小数位
@@ -343,9 +386,14 @@ public class GoViewDataController {
             @RequestParam(required = false) Map<String, String> params,
             @RequestBody(required = false) String body) { // params、body 按照需要去接收,这里仅仅是示例
         String billingMonth = params.get("billingMonth");
+        Long tenantId = getTenantId(params);
         Integer payStatus = params.get("payStatus") == null ? null : Integer.valueOf(params.get("payStatus"));
-        OrderItemTotalRespVO orderItemTotalRespVO = orderApi.getTotalByMonth(null, getTenantId(params), billingMonth,payStatus);
-        scale2OrderTotal(orderItemTotalRespVO);
+        // 配置了小数保留位数时:由取数侧先对每项金额处理小数,再进行汇总(一次取数完成,不再二次取数)
+        Integer roundScale = toRoundScale(getAmountRoundConfigValue(tenantId));
+        OrderItemTotalRespVO orderItemTotalRespVO = orderApi.getTotalByMonth(1, tenantId, billingMonth, payStatus, roundScale);
+        if (roundScale == null) {
+            scale2OrderTotal(orderItemTotalRespVO);
+        }
         List<OrderItemTotalRespVO> list = new ArrayList<>();
         list.add(orderItemTotalRespVO);
         return success(list);
@@ -375,9 +423,14 @@ public class GoViewDataController {
         String billingMonth = params.get("billingMonth");
         List<OrderItemTotalRespVO> list = new ArrayList<>();
         if (null == billingMonth) return success(list);
+        Long tenantId = getTenantId(params);
         Integer payStatus = params.get("payStatus") == null ? null : Integer.valueOf(params.get("payStatus"));
-        OrderItemTotalRespVO orderItemTotalRespVO = orderApi.getOrderReceivableTotal(null, getTenantId(params), billingMonth,payStatus);
-        scale2OrderTotal(orderItemTotalRespVO);
+        // 配置了小数保留位数时:由取数侧先对每项金额处理小数,再进行汇总(复用原有取数,不重复遍历月份)
+        Integer roundScale = toRoundScale(getAmountRoundConfigValue(tenantId));
+        OrderItemTotalRespVO orderItemTotalRespVO = orderApi.getOrderReceivableTotal(null, tenantId, billingMonth, payStatus, roundScale);
+        if (roundScale == null) {
+            scale2OrderTotal(orderItemTotalRespVO);
+        }
         list.add(orderItemTotalRespVO);
         return success(list);
     }
@@ -386,6 +439,7 @@ public class GoViewDataController {
     // 新增:仅汇总在住(本地缓存聚合)
     @RequestMapping("/getOrderSubTotalIn")
     @Operation(summary = "查询在住汇总", description = "仅汇总在住记录 (本地缓存聚合)")
+    @TenantIgnore
     public CommonResult<List<OrderItemTotalRespVO>> getOrderSubTotalIn(
             @RequestParam(required = false) Map<String, String> params,
             @RequestBody(required = false) String body) {
@@ -393,14 +447,17 @@ public class GoViewDataController {
         Integer payStatus = params.get("payStatus") == null ? null : Integer.valueOf(params.get("payStatus"));
         Long tenantId = getTenantId(params);
         OrderSubListGroupedRespVO grouped = loadOrComputeGrouped(tenantId, billingMonth, payStatus);
-        OrderItemTotalRespVO total = aggregate(grouped.getInList(), billingMonth);
-        roundOrderTotalByConfig(total, getAmountRoundConfigValue(tenantId));
+        // 先对每项金额处理小数,再进行汇总
+        String roundConfigValue = getAmountRoundConfigValue(tenantId);
+        OrderItemTotalRespVO total = aggregate(grouped.getInList(), billingMonth, roundConfigValue);
+        roundOrderTotalByConfig(total, roundConfigValue);
         return success(java.util.Collections.singletonList(total));
     }
 
     // 新增:仅汇总离住
     @RequestMapping("/getOrderSubTotalLeave")
     @Operation(summary = "查询离住汇总", description = "仅汇总离住记录 (本地缓存聚合)")
+    @TenantIgnore
     public CommonResult<List<OrderItemTotalRespVO>> getOrderSubTotalLeave(
             @RequestParam(required = false) Map<String, String> params,
             @RequestBody(required = false) String body) {
@@ -408,14 +465,17 @@ public class GoViewDataController {
         Integer payStatus = params.get("payStatus") == null ? null : Integer.valueOf(params.get("payStatus"));
         Long tenantId = getTenantId(params);
         OrderSubListGroupedRespVO grouped = loadOrComputeGrouped(tenantId, billingMonth, payStatus);
-        OrderItemTotalRespVO total = aggregate(grouped.getLeaveList(), billingMonth);
-        roundOrderTotalByConfig(total, getAmountRoundConfigValue(tenantId));
+        // 先对每项金额处理小数,再进行汇总
+        String roundConfigValue = getAmountRoundConfigValue(tenantId);
+        OrderItemTotalRespVO total = aggregate(grouped.getLeaveList(), billingMonth, roundConfigValue);
+        roundOrderTotalByConfig(total, roundConfigValue);
         return success(java.util.Collections.singletonList(total));
     }
 
     // 新增:仅汇总退住
     @RequestMapping("/getOrderSubTotalRetreat")
     @Operation(summary = "查询退住汇总", description = "仅汇总退住记录 (本地缓存聚合)")
+    @TenantIgnore
     public CommonResult<List<OrderItemTotalRespVO>> getOrderSubTotalRetreat(
             @RequestParam(required = false) Map<String, String> params,
             @RequestBody(required = false) String body) {
@@ -423,31 +483,40 @@ public class GoViewDataController {
         Integer payStatus = params.get("payStatus") == null ? null : Integer.valueOf(params.get("payStatus"));
         Long tenantId = getTenantId(params);
         OrderSubListGroupedRespVO grouped = loadOrComputeGrouped(tenantId, billingMonth, payStatus);
-        OrderItemTotalRespVO total = aggregate(grouped.getRetreatList(), billingMonth);
-        roundOrderTotalByConfig(total, getAmountRoundConfigValue(tenantId));
+        // 先对每项金额处理小数,再进行汇总
+        String roundConfigValue = getAmountRoundConfigValue(tenantId);
+        OrderItemTotalRespVO total = aggregate(grouped.getRetreatList(), billingMonth, roundConfigValue);
+        roundOrderTotalByConfig(total, roundConfigValue);
         return success(java.util.Collections.singletonList(total));
     }
 
-    // Helper:本地聚合
-    private OrderItemTotalRespVO aggregate(List<OrderItemRespVO> src, String billingMonth) {
+    /**
+     * Helper:本地聚合
+     * 如果配置了小数保留位数(roundConfigValue),则先对每项金额做小数处理,再进行汇总,
+     * 保证「汇总金额 = 各项金额之和」(而不是先汇总再对合计做小数处理)
+     */
+    private OrderItemTotalRespVO aggregate(List<OrderItemRespVO> src, String billingMonth, String roundConfigValue) {
         OrderItemTotalRespVO t = new OrderItemTotalRespVO();
         t.setBillingMonth(billingMonth);
         if (src == null || src.isEmpty()) {
             return t;
         }
         for (OrderItemRespVO vo : src) {
-            t.setBedTotalAmount(t.getBedTotalAmount().add(nz(vo.getBedAmount())));
-            t.setMealTotalAmount(t.getMealTotalAmount().add(nz(vo.getMealAmount())));
-            t.setNurseTotalAmount(t.getNurseTotalAmount().add(nz(vo.getNurseAmount())));
-            t.setServiceTotalAmount(t.getServiceTotalAmount().add(nz(vo.getServiceAmount())));
-            t.setTotalAmount(t.getTotalAmount().add(nz(vo.getTotalAmount())));
-            t.setAdjustTotalAmount(t.getAdjustTotalAmount().add(nz(vo.getAdjustAmount())));
-            t.setBedActualTotalAmount(t.getBedActualTotalAmount().add(nz(vo.getBedActualAmount())));
-            t.setMealActualTotalAmount(t.getMealActualTotalAmount().add(nz(vo.getMealActualAmount())));
-            t.setNurseActualTotalAmount(t.getNurseActualTotalAmount().add(nz(vo.getNurseActualAmount())));
-            t.setServiceActualTotalAmount(t.getServiceActualTotalAmount().add(nz(vo.getServiceActualAmount())));
-            t.setTotalActualAmount(t.getTotalActualAmount().add(nz(vo.getTotalActualAmount())));
-            t.setExtraServiceAmount(t.getExtraServiceAmount().add(nz(vo.getExtraServiceAmount())));
+            if (vo == null) {
+                continue;
+            }
+            t.setBedTotalAmount(t.getBedTotalAmount().add(nz(roundByConfig(vo.getBedAmount(), roundConfigValue))));
+            t.setMealTotalAmount(t.getMealTotalAmount().add(nz(roundByConfig(vo.getMealAmount(), roundConfigValue))));
+            t.setNurseTotalAmount(t.getNurseTotalAmount().add(nz(roundByConfig(vo.getNurseAmount(), roundConfigValue))));
+            t.setServiceTotalAmount(t.getServiceTotalAmount().add(nz(roundByConfig(vo.getServiceAmount(), roundConfigValue))));
+            t.setTotalAmount(t.getTotalAmount().add(nz(roundByConfig(vo.getTotalAmount(), roundConfigValue))));
+            t.setAdjustTotalAmount(t.getAdjustTotalAmount().add(nz(roundByConfig(vo.getAdjustAmount(), roundConfigValue))));
+            t.setBedActualTotalAmount(t.getBedActualTotalAmount().add(nz(roundByConfig(vo.getBedActualAmount(), roundConfigValue))));
+            t.setMealActualTotalAmount(t.getMealActualTotalAmount().add(nz(roundByConfig(vo.getMealActualAmount(), roundConfigValue))));
+            t.setNurseActualTotalAmount(t.getNurseActualTotalAmount().add(nz(roundByConfig(vo.getNurseActualAmount(), roundConfigValue))));
+            t.setServiceActualTotalAmount(t.getServiceActualTotalAmount().add(nz(roundByConfig(vo.getServiceActualAmount(), roundConfigValue))));
+            t.setTotalActualAmount(t.getTotalActualAmount().add(nz(roundByConfig(vo.getTotalActualAmount(), roundConfigValue))));
+            t.setExtraServiceAmount(t.getExtraServiceAmount().add(nz(roundByConfig(vo.getExtraServiceAmount(), roundConfigValue))));
         }
         if (t.getTotalAmount().compareTo(java.math.BigDecimal.ZERO) > 0) {
             java.math.BigDecimal p = t.getTotalActualAmount().divide(t.getTotalAmount(),2,java.math.RoundingMode.HALF_UP).multiply(new java.math.BigDecimal("100"));

+ 22 - 2
yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/order/OrderApi.java

@@ -10,11 +10,31 @@ public interface OrderApi {
 
     List<OrderItemRespVO> getListByMonth(Integer orgType, Long tenantId, String billMonth, Integer inStatus,Integer payStatus);
 
-    OrderItemTotalRespVO getTotalByMonth(Integer orgType, Long tenantId, String billingMonth,Integer payStatus);
+    default OrderItemTotalRespVO getTotalByMonth(Integer orgType, Long tenantId, String billingMonth, Integer payStatus) {
+        return getTotalByMonth(orgType, tenantId, billingMonth, payStatus, null);
+    }
+
+    /**
+     * 账单金额汇总
+     *
+     * @param roundScale 金额小数保留位数:非空时,先对每项金额按该位数处理小数,再进行汇总;
+     *                   null 表示不对金额做小数处理(保持原样)
+     */
+    OrderItemTotalRespVO getTotalByMonth(Integer orgType, Long tenantId, String billingMonth, Integer payStatus, Integer roundScale);
 
     List<OrderItemRespVO> getReceivableListByMonth(Integer orgType, Long tenantId, String queryFLag, String billingMonth,Integer payStatus);
 
-    OrderItemTotalRespVO getOrderReceivableTotal(Integer orgType, Long tenantId, String billingMonth,Integer payStatus);
+    default OrderItemTotalRespVO getOrderReceivableTotal(Integer orgType, Long tenantId, String billingMonth, Integer payStatus) {
+        return getOrderReceivableTotal(orgType, tenantId, billingMonth, payStatus, null);
+    }
+
+    /**
+     * 应收账单金额汇总
+     *
+     * @param roundScale 金额小数保留位数:非空时,先对每项金额按该位数处理小数,再进行汇总;
+     *                   null 表示不对金额做小数处理(保持原样)
+     */
+    OrderItemTotalRespVO getOrderReceivableTotal(Integer orgType, Long tenantId, String billingMonth, Integer payStatus, Integer roundScale);
 
     /**
      * 报表用:获取当月账单明细并完成离住/退住/在住判定及备注处理

+ 9 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/bpm/BpmElderlyApiImpl.java

@@ -379,6 +379,15 @@ public class BpmElderlyApiImpl implements BpmElderlyApi {
         elderlyInfoDO.setInStatus(2);
         elderlyInfoMapper.updateById(elderlyInfoDO);
 
+        // 已退住长者:把进行中(1)和未开始(2)合同更新为已退住(4)
+        Long tenantId = elderlyRetreatRecordDO.getTenantId() != null
+                ? elderlyRetreatRecordDO.getTenantId()
+                : (originalElderlyInfoDO != null ? originalElderlyInfoDO.getTenantId() : TenantContextHolder.getTenantId());
+        int updatedContractCount = contractMapper.batchUpdateStatusToRetreatedByElderId(elderId, tenantId);
+        if (updatedContractCount > 0) {
+            log.info("[releaseBed] 退住长者 elderId={} 合同置为已退住(4) {} 条", elderId, updatedContractCount);
+        }
+
         BuildBedDO bedDO = buildBedMapper.selectById(originalElderlyInfoDO.getBedId());
         if(bedDO != null){
             bedDO.setStatus(0);

+ 2 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/bpm/BpmElderlyChangeApiImpl.java

@@ -409,6 +409,7 @@ public class BpmElderlyChangeApiImpl implements BpmElderlyChangeApi {
                     currentDailyExpensesDO.setItemName(chargeCategory.getItemName());
                     currentDailyExpensesDO.setType(2);
                     currentDailyExpensesDO.setPrice(changeExpense.setScale(2));
+                    currentDailyExpensesDO.setTenantId(e.getTenantId());
                     if(changeBillingMonth.equals(e.getBillingMonth())){
                         currentDailyExpensesDO.setAmount(dailyExpensesDOAmount);
                         currentDailyExpensesDO.setStartDate(bedChangeRecordDO.getChangeDate());
@@ -748,6 +749,7 @@ public class BpmElderlyChangeApiImpl implements BpmElderlyChangeApi {
                             currentDailyExpensesDO.setItemCategory(itemCategory);
                             currentDailyExpensesDO.setItemName(category.getItemName());
                             currentDailyExpensesDO.setType(2);
+                            currentDailyExpensesDO.setTenantId(e.getTenantId());
                             currentDailyExpensesDO.setPrice(changeExpense.setScale(2,RoundingMode.HALF_UP));
                             if(changeRecordDO.getChangeDate().format(DateTimeFormatter.ofPattern("yyyy-MM")).equals(e.getBillingMonth())){
                                 currentDailyExpensesDO.setAmount(dailyExpensesDOAmount);

+ 38 - 28
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/order/OrderApiImpl.java

@@ -577,8 +577,8 @@ public class OrderApiImpl implements OrderApi {
         BigDecimal actualTotal = dto.getBedActualAmount()
                 .add(dto.getMealActualAmount().setScale(2, RoundingMode.HALF_UP))
                 .add(dto.getServiceActualAmount().setScale(2, RoundingMode.HALF_UP))
-                .add(dto.getNurseActualAmount().setScale(2, RoundingMode.HALF_UP))
-                .add(dto.getExtraServiceAmount() == null ? BigDecimal.ZERO : dto.getExtraServiceAmount().setScale(2, RoundingMode.HALF_UP));
+                .add(dto.getNurseActualAmount().setScale(2, RoundingMode.HALF_UP));
+//                .add(dto.getExtraServiceAmount() == null ? BigDecimal.ZERO : dto.getExtraServiceAmount().setScale(2, RoundingMode.HALF_UP))
         dto.setTotalActualAmount(actualTotal);
 
         String actualProportion = "0%";
@@ -893,7 +893,7 @@ public class OrderApiImpl implements OrderApi {
     }
 
     @Override
-    public OrderItemTotalRespVO getTotalByMonth(Integer orgType, Long tenantId, String billingMonth,Integer payStatus) {
+    public OrderItemTotalRespVO getTotalByMonth(Integer orgType, Long tenantId, String billingMonth, Integer payStatus, Integer roundScale) {
 
         // 使用与三明细接口一致的取数口:不按 in_status 过滤,避免分类差异导致的口径不一致
         List<OrderItemRespVO> all = getListByMonth(orgType, tenantId, billingMonth, null, payStatus);
@@ -903,18 +903,19 @@ public class OrderApiImpl implements OrderApi {
         }
         if (CollectionUtils.isNotEmpty(all)) {
             for (OrderItemRespVO vo : all) {
-                BigDecimal bedAmount = vo.getBedAmount() == null ? BigDecimal.ZERO : vo.getBedAmount();
-                BigDecimal mealAmount = vo.getMealAmount() == null ? BigDecimal.ZERO : vo.getMealAmount();
-                BigDecimal nurseAmount = vo.getNurseAmount() == null ? BigDecimal.ZERO : vo.getNurseAmount();
-                BigDecimal serviceAmount = vo.getServiceAmount() == null ? BigDecimal.ZERO : vo.getServiceAmount();
-                BigDecimal totalAmount = vo.getTotalAmount() == null ? BigDecimal.ZERO : vo.getTotalAmount();
-                BigDecimal adjustTotalAmount = vo.getAdjustAmount() == null ? BigDecimal.ZERO : vo.getAdjustAmount();
-                BigDecimal bedActualTotalAmount = vo.getBedActualAmount() == null ? BigDecimal.ZERO : vo.getBedActualAmount();
-                BigDecimal mealActualTotalAmount = vo.getMealActualAmount() == null ? BigDecimal.ZERO : vo.getMealActualAmount();
-                BigDecimal nurseActualTotalAmount = vo.getNurseActualAmount() == null ? BigDecimal.ZERO : vo.getNurseActualAmount();
-                BigDecimal serviceActualTotalAmount = vo.getServiceActualAmount() == null ? BigDecimal.ZERO : vo.getServiceActualAmount();
-                BigDecimal totalActualAmount = vo.getTotalActualAmount() == null ? BigDecimal.ZERO : vo.getTotalActualAmount();
-                BigDecimal extraServiceAmount = vo.getExtraServiceAmount() == null ? BigDecimal.ZERO : vo.getExtraServiceAmount();
+                // 配置了小数保留位数时,先对每项金额处理小数,再进行汇总
+                BigDecimal bedAmount = roundByScale(vo.getBedAmount(), roundScale);
+                BigDecimal mealAmount = roundByScale(vo.getMealAmount(), roundScale);
+                BigDecimal nurseAmount = roundByScale(vo.getNurseAmount(), roundScale);
+                BigDecimal serviceAmount = roundByScale(vo.getServiceAmount(), roundScale);
+                BigDecimal totalAmount = roundByScale(vo.getTotalAmount(), roundScale);
+                BigDecimal adjustTotalAmount = roundByScale(vo.getAdjustAmount(), roundScale);
+                BigDecimal bedActualTotalAmount = roundByScale(vo.getBedActualAmount(), roundScale);
+                BigDecimal mealActualTotalAmount = roundByScale(vo.getMealActualAmount(), roundScale);
+                BigDecimal nurseActualTotalAmount = roundByScale(vo.getNurseActualAmount(), roundScale);
+                BigDecimal serviceActualTotalAmount = roundByScale(vo.getServiceActualAmount(), roundScale);
+                BigDecimal totalActualAmount = roundByScale(vo.getTotalActualAmount(), roundScale);
+                BigDecimal extraServiceAmount = roundByScale(vo.getExtraServiceAmount(), roundScale);
 
                 orderItemTotalRespVO.setBedTotalAmount(orderItemTotalRespVO.getBedTotalAmount().add(bedAmount));
                 orderItemTotalRespVO.setMealTotalAmount(orderItemTotalRespVO.getMealTotalAmount().add(mealAmount));
@@ -1019,7 +1020,7 @@ public class OrderApiImpl implements OrderApi {
     }
 
     @Override
-    public OrderItemTotalRespVO getOrderReceivableTotal(Integer orgType, Long tenantId, String billingMonth,Integer payStatus) {
+    public OrderItemTotalRespVO getOrderReceivableTotal(Integer orgType, Long tenantId, String billingMonth, Integer payStatus, Integer roundScale) {
         OrderItemTotalRespVO total = new OrderItemTotalRespVO();
         TenantDO tenantDO = tenantMapper.selectById(tenantId);
         total.setTenantName(tenantDO.getName());
@@ -1034,18 +1035,19 @@ public class OrderApiImpl implements OrderApi {
         }
 
         for (OrderItemRespVO vo : list) {
-            BigDecimal bedAmount = nz(vo.getBedAmount());
-            BigDecimal mealAmount = nz(vo.getMealAmount());
-            BigDecimal nurseAmount = nz(vo.getNurseAmount());
-            BigDecimal serviceAmount = nz(vo.getServiceAmount());
-            BigDecimal totalAmount = nz(vo.getTotalAmount());
-            BigDecimal adjustAmount = nz(vo.getAdjustAmount());
-            BigDecimal bedActual = nz(vo.getBedActualAmount());
-            BigDecimal mealActual = nz(vo.getMealActualAmount());
-            BigDecimal nurseActual = nz(vo.getNurseActualAmount());
-            BigDecimal serviceActual = nz(vo.getServiceActualAmount());
-            BigDecimal totalActual = nz(vo.getTotalActualAmount());
-            BigDecimal extraService = nz(vo.getExtraServiceAmount());
+            // 配置了小数保留位数时,先对每项金额处理小数,再进行汇总
+            BigDecimal bedAmount = roundByScale(vo.getBedAmount(), roundScale);
+            BigDecimal mealAmount = roundByScale(vo.getMealAmount(), roundScale);
+            BigDecimal nurseAmount = roundByScale(vo.getNurseAmount(), roundScale);
+            BigDecimal serviceAmount = roundByScale(vo.getServiceAmount(), roundScale);
+            BigDecimal totalAmount = roundByScale(vo.getTotalAmount(), roundScale);
+            BigDecimal adjustAmount = roundByScale(vo.getAdjustAmount(), roundScale);
+            BigDecimal bedActual = roundByScale(vo.getBedActualAmount(), roundScale);
+            BigDecimal mealActual = roundByScale(vo.getMealActualAmount(), roundScale);
+            BigDecimal nurseActual = roundByScale(vo.getNurseActualAmount(), roundScale);
+            BigDecimal serviceActual = roundByScale(vo.getServiceActualAmount(), roundScale);
+            BigDecimal totalActual = roundByScale(vo.getTotalActualAmount(), roundScale);
+            BigDecimal extraService = roundByScale(vo.getExtraServiceAmount(), roundScale);
 
             total.setBedTotalAmount(total.getBedTotalAmount().add(bedAmount));
             total.setMealTotalAmount(total.getMealTotalAmount().add(mealAmount));
@@ -1084,6 +1086,14 @@ public class OrderApiImpl implements OrderApi {
         return val == null ? BigDecimal.ZERO : val;
     }
 
+    /**
+     * 按小数保留位数处理金额(null 安全):scale 为 null 时不做小数处理
+     */
+    private BigDecimal roundByScale(BigDecimal val, Integer scale) {
+        BigDecimal v = nz(val);
+        return scale == null ? v : v.setScale(scale, BigDecimal.ROUND_HALF_UP);
+    }
+
     private String getYearStr(Integer contractTerm) {
         if (null == contractTerm || 0 == contractTerm) return "";
         if (contractTerm < 12) {

+ 114 - 21
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/wechat/CorpWechatApiImpl.java

@@ -34,6 +34,7 @@ import javax.annotation.Resource;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.UUID;
 import java.util.concurrent.TimeUnit;
 
 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@@ -53,6 +54,26 @@ import static cn.iocoder.yudao.module.system.enums.oauth2.EnterpriseWeChatConsta
 @Slf4j
 public class CorpWechatApiImpl implements CorpWechatApi{
 
+    /**
+     * 获取 AccessToken 的锁 key 后缀
+     */
+    private static final String ACCESS_TOKEN_LOCK_KEY_SUFFIX = ":lock";
+
+    /**
+     * 获取 AccessToken 的锁过期时间(秒),防止持锁线程异常退出导致死锁
+     */
+    private static final long ACCESS_TOKEN_LOCK_EXPIRE_SECONDS = 10L;
+
+    /**
+     * 未获取到锁时,等待其它线程写入 Redis 的最大重试次数
+     */
+    private static final int ACCESS_TOKEN_WAIT_RETRY_TIMES = 30;
+
+    /**
+     * 未获取到锁时,等待其它线程写入 Redis 的重试间隔(毫秒)
+     */
+    private static final long ACCESS_TOKEN_WAIT_INTERVAL_MILLIS = 100L;
+
     @Resource
     private StringRedisTemplate stringRedisTemplate;
 
@@ -77,37 +98,106 @@ public class CorpWechatApiImpl implements CorpWechatApi{
         }
         SocialClientDO client = socialClientMapper.selectOne(SocialTypeEnum.WECHAT_ENTERPRISE.getType(), UserTypeEnum.ADMIN.getValue(), tenantId);
         Assert.notNull(client, String.format("第三方(%d) 不存在", SocialTypeEnum.WECHAT_ENTERPRISE.getType()));
-        String accessToken = "";
         String rKey = String.format(redisKey, client.getClientId(), client.getAgentId());
         log.info("getAccessToken key:" + rKey);
-        Object reddisValue = stringRedisTemplate.opsForValue().get(rKey);
-        if (reddisValue != null){
-            accessToken = reddisValue.toString();
-            log.info("getAccessToken accessToken:" + accessToken);
+        String accessToken = getAccessTokenFromRedis(rKey);
+        if (StringUtil.isNotEmptyORNull(accessToken)) {
+            return accessToken;
         }
-        if (StringUtil.isEmptyORNull(accessToken)) {
-            String url = CORP_WECHAT_URL + GET_TOKEN_URL;
-            Map<String, String> map = new HashMap<>();
-            map.put("corpid", client.getClientId());
-            map.put("corpsecret", client.getClientSecret());
-            try{
-                HttpClientResult httpClientResult = HttpClientUtil.doGet(url, map, null);
-                String content = httpClientResult.getContent();
-                log.info("getAccessToken content:" + content);
-                CorpWeChatAccessToken corpWeChatAccessToken = JSON.parseObject(content, CorpWeChatAccessToken.class);
-                if (corpWeChatAccessToken.getErrcode() == 0) {
-                    log.info("getAccessToken corpWeChatAccessToken.getAccessToken():" + corpWeChatAccessToken.getAccessToken());
-                    stringRedisTemplate.opsForValue().set(rKey, corpWeChatAccessToken.getAccessToken(), corpWeChatAccessToken.getExpiresIn() - TimeUtil.ONE_MINUTE, TimeUnit.SECONDS);
-                    accessToken = corpWeChatAccessToken.getAccessToken();
+        // Redis 无值时,通过分布式锁保证同一时刻只有一个线程去请求企微接口
+        String lockKey = rKey + ACCESS_TOKEN_LOCK_KEY_SUFFIX;
+        String lockValue = UUID.randomUUID().toString();
+        Boolean locked = stringRedisTemplate.opsForValue()
+                .setIfAbsent(lockKey, lockValue, ACCESS_TOKEN_LOCK_EXPIRE_SECONDS, TimeUnit.SECONDS);
+        if (Boolean.TRUE.equals(locked)) {
+            try {
+                // 双重检查:持锁期间可能已被其它线程写入 Redis
+                accessToken = getAccessTokenFromRedis(rKey);
+                if (StringUtil.isNotEmptyORNull(accessToken)) {
+                    return accessToken;
                 }
-            } catch (Exception e) {
-                log.error("获取企微AccessToken发生错误: ", e);
+                return requestAccessToken(client, rKey);
+            } finally {
+                releaseAccessTokenLock(lockKey, lockValue);
             }
+        }
+        // 未获取到锁,说明已有线程在请求,等待其写入 Redis 后直接读取
+        for (int i = 0; i < ACCESS_TOKEN_WAIT_RETRY_TIMES; i++) {
+            try {
+                Thread.sleep(ACCESS_TOKEN_WAIT_INTERVAL_MILLIS);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                break;
+            }
+            accessToken = getAccessTokenFromRedis(rKey);
+            if (StringUtil.isNotEmptyORNull(accessToken)) {
+                return accessToken;
+            }
+        }
+        log.warn("等待其它线程获取企微AccessToken超时,key:{}", rKey);
+        return accessToken;
+    }
 
+    /**
+     * 从 Redis 中读取 AccessToken
+     *
+     * @param rKey Redis key
+     * @return AccessToken,不存在时返回 null
+     */
+    private String getAccessTokenFromRedis(String rKey) {
+        Object value = stringRedisTemplate.opsForValue().get(rKey);
+        if (value != null) {
+            log.info("getAccessToken accessToken:" + value);
+            return value.toString();
+        }
+        return null;
+    }
+
+    /**
+     * 请求企微接口获取 AccessToken,并写入 Redis
+     *
+     * @param client 企微第三方配置
+     * @param rKey   Redis key
+     * @return AccessToken,获取失败时返回空字符串
+     */
+    private String requestAccessToken(SocialClientDO client, String rKey) {
+        String accessToken = StringUtil.EMPY_STRING;
+        String url = CORP_WECHAT_URL + GET_TOKEN_URL;
+        Map<String, String> map = new HashMap<>();
+        map.put("corpid", client.getClientId());
+        map.put("corpsecret", client.getClientSecret());
+        try {
+            HttpClientResult httpClientResult = HttpClientUtil.doGet(url, map, null);
+            String content = httpClientResult.getContent();
+            log.info("getAccessToken content:" + content);
+            CorpWeChatAccessToken corpWeChatAccessToken = JSON.parseObject(content, CorpWeChatAccessToken.class);
+            if (corpWeChatAccessToken.getErrcode() == 0) {
+                log.info("getAccessToken corpWeChatAccessToken.getAccessToken():" + corpWeChatAccessToken.getAccessToken());
+                stringRedisTemplate.opsForValue().set(rKey, corpWeChatAccessToken.getAccessToken(), corpWeChatAccessToken.getExpiresIn() - TimeUtil.ONE_MINUTE, TimeUnit.SECONDS);
+                accessToken = corpWeChatAccessToken.getAccessToken();
+            }
+        } catch (Exception e) {
+            log.error("获取企微AccessToken发生错误: ", e);
         }
         return accessToken;
     }
 
+    /**
+     * 释放 AccessToken 分布式锁,只有加锁线程才能释放
+     *
+     * @param lockKey   锁 key
+     * @param lockValue 加锁时的唯一值
+     */
+    private void releaseAccessTokenLock(String lockKey, String lockValue) {
+        try {
+            if (lockValue.equals(stringRedisTemplate.opsForValue().get(lockKey))) {
+                stringRedisTemplate.delete(lockKey);
+            }
+        } catch (Exception e) {
+            log.error("释放企微AccessToken锁失败: ", e);
+        }
+    }
+
     @Override
     @TenantIgnore
     public CorpUserResponse getUserByUserTicket(String userTicket) {
@@ -262,6 +352,9 @@ public class CorpWechatApiImpl implements CorpWechatApi{
         List<AdminUserDO> list = userMapper.selectList(new LambdaQueryWrapperX<AdminUserDO>().eq(AdminUserDO::getStatus, 0)
                 .eq(AdminUserDO::getWorkStatus, 0)
                 .isNull(AdminUserDO::getCorpUserId)
+                .isNotNull(AdminUserDO::getMobile)
+                .ne(AdminUserDO::getMobile,"")
+                .ne(AdminUserDO::getMobile,"18888888888")
                 .eq(AdminUserDO::getDeleted, false).orderByDesc(AdminUserDO::getUsername).last("LIMIT 10"));
         list.forEach(user -> {
             String mobile = user.getMobile();

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

@@ -0,0 +1,46 @@
+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.OperationAnalysisOverviewDynamicsReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisOverviewDynamicsRespVO;
+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/overview")
+@Validated
+public class OperationAnalysisController {
+
+    @Resource
+    private OperationAnalysisService operationAnalysisService;
+
+    @GetMapping("/dynamics")
+    @Operation(summary = "获得院内动态",
+            description = "统计今日新收、今日退院、请假人数、当天请假人数、轮候待住、预订在途," +
+                    "以及近 12 个月已完成退住趋势、护理等级分布;按机构分组返回,每个机构各一份。")
+    @TenantIgnore
+    @PermitAll
+    public CommonResult<List<OperationAnalysisOverviewDynamicsRespVO>> getOverviewDynamics(
+            OperationAnalysisOverviewDynamicsReqVO reqVO) {
+        return success(operationAnalysisService.getOverviewDynamics(reqVO));
+    }
+
+}

+ 13 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/ElderlyWeeklyOperationSummaryRespVO.java

@@ -1,8 +1,13 @@
 package cn.iocoder.yudao.module.system.controller.admin.biz.vo;
 
+import com.fasterxml.jackson.annotation.JsonFormat;
 import io.swagger.v3.oas.annotations.media.Schema;
 import lombok.Data;
 
+import java.time.LocalDate;
+
+import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY;
+
 @Schema(description = "管理后台 - 机构长者周运营统计 Response VO")
 @Data
 public class ElderlyWeeklyOperationSummaryRespVO {
@@ -36,5 +41,13 @@ public class ElderlyWeeklyOperationSummaryRespVO {
 
     @Schema(description = "本周期末院内空余床位数", example = "193")
     private Long periodEndEmptyBedCount;
+
+    @Schema(description = "开始日期", example = "2026-01-01")
+    @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY)
+    private LocalDate periodBegin;
+
+    @Schema(description = "结束日期", example = "2026-01-01")
+    @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY)
+    private LocalDate periodEnd;
 }
 

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

@@ -0,0 +1,17 @@
+package cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 运营分析 - 概况 - 院内动态 ReqVO
+ *
+ * @author kyj
+ */
+@Data
+public class OperationAnalysisOverviewDynamicsReqVO {
+
+    @Schema(description = "机构ID数组(即租户ID)", example = "[1, 2, 3]")
+    private Long[] tenantIds;
+
+}

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

@@ -0,0 +1,81 @@
+package cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 运营分析 - 概况 - 院内动态 RespVO
+ *
+ * <p>每个机构一条记录,趋势与护理等级分布按机构各自返回一份。</p>
+ *
+ * @author kyj
+ */
+@Data
+public class OperationAnalysisOverviewDynamicsRespVO {
+
+    @Schema(description = "机构ID(租户ID)", example = "1")
+    private Long tenantId;
+
+    @Schema(description = "机构名称", example = "康养中心")
+    private String tenantName;
+
+    @Schema(description = "今日新收", example = "3")
+    private Long newCheckInCount;
+
+    @Schema(description = "今日退院", example = "1")
+    private Long checkOutCount;
+
+    @Schema(description = "请假人数(截至当前仍在请假中,含往日请假未归)", example = "5")
+    private Long askLeaveCount;
+
+    @Schema(description = "当天请假人数(当天离院的人数)", example = "2")
+    private Long todayAskLeaveCount;
+
+    @Schema(description = "轮候待住", example = "4")
+    private Long waitingCount;
+
+    @Schema(description = "预订在途", example = "6")
+    private Long reservationCount;
+
+    @Schema(description = "近 12 个月已完成退住趋势(含当月,无数据补 0)")
+    private List<MonthlyRetreatTrendVO> retreatTrend;
+
+    @Schema(description = "护理等级分布(统计在住长者)")
+    private List<NurseLevelDistributionVO> nurseLevelDistribution;
+
+    /**
+     * 已完成退住月度趋势
+     */
+    @Data
+    @Schema(description = "已完成退住月度趋势")
+    public static class MonthlyRetreatTrendVO {
+
+        @Schema(description = "月份,格式 yyyy-MM", example = "2026-09")
+        private String month;
+
+        @Schema(description = "退住人数", example = "2")
+        private Long count;
+
+    }
+
+    /**
+     * 护理等级分布
+     */
+    @Data
+    @Schema(description = "护理等级分布")
+    public static class NurseLevelDistributionVO {
+
+        @Schema(description = "护理等级ID", example = "1")
+        private Long nurseLevelId;
+
+        @Schema(description = "护理等级名称", example = "一级护理")
+        private String nurseLevelName;
+
+        @Schema(description = "人数", example = "10")
+        private Long count;
+
+    }
+
+}

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

@@ -34,6 +34,16 @@ public interface ElderlyContractMapper extends BaseMapperX<ElderlyContractDO> {
 
     int batchUpdateStatusToInProgress(@Param("tenantId") Long tenantId, @Param("nowDate") Date nowDate);
 
+    /**
+     * 已退住长者(in_status=2)的进行中(1)和未开始(2)合同置为已退住(4)
+     */
+    int batchUpdateStatusToRetreated(@Param("tenantId") Long tenantId);
+
+    /**
+     * 指定长者(已退住)对应的进行中(1)和未开始(2)合同置为已退住(4)
+     */
+    int batchUpdateStatusToRetreatedByElderId(@Param("elderId") Long elderId, @Param("tenantId") Long tenantId);
+
     List<ElderlyContractDO> selectExpiringWithinOneMonth(@Param("tenantId") Long tenantId,
                                                          @Param("startDate") Date startDate,
                                                          @Param("endDate") Date endDate);

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

@@ -0,0 +1,106 @@
+package cn.iocoder.yudao.module.system.dal.mysql.biz;
+
+import cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationNurseLevelCountBO;
+import cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationTenantCountBO;
+import cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationTenantMonthCountBO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.time.LocalDate;
+import java.util.Collection;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 运营分析 - 概况 Mapper
+ *
+ * <p>所有统计都按机构(租户)分组一次性返回,避免机构数量增多时产生 N 倍查询。</p>
+ *
+ * @author kyj
+ */
+@Mapper
+public interface OperationAnalysisMapper {
+
+    /**
+     * 今日新收:以长者档案的入住时间为准
+     *
+     * @param tenantIds 机构ID
+     * @param begin     当天 00:00:00(含)
+     * @param end       次日 00:00:00(不含)
+     * @return 按机构分组的今日新收人数
+     */
+    List<OperationTenantCountBO> selectTodayNewCheckInCount(@Param("tenantIds") Collection<Long> tenantIds,
+                                                            @Param("begin") Date begin,
+                                                            @Param("end") Date end);
+
+    /**
+     * 今日退院:以退住登记表的退住时间为准,且必须是「已完成」的退住
+     *
+     * @param tenantIds 机构ID
+     * @param today     今天
+     * @return 按机构分组的今日退院人数
+     */
+    List<OperationTenantCountBO> selectTodayCheckOutCount(@Param("tenantIds") Collection<Long> tenantIds,
+                                                          @Param("today") LocalDate today);
+
+    /**
+     * 近 N 个月已完成退住趋势
+     *
+     * @param tenantIds 机构ID
+     * @param begin     起始月份 1 号(含)
+     * @param end       结束月份次月 1 号(不含)
+     * @return 按机构 + 月份分组的退住人数
+     */
+    List<OperationTenantMonthCountBO> selectRetreatTrend(@Param("tenantIds") Collection<Long> tenantIds,
+                                                         @Param("begin") LocalDate begin,
+                                                         @Param("end") LocalDate end);
+
+    /**
+     * 请假人数:截至当前仍在请假中(含往日请假未归),按长者去重
+     *
+     * @param tenantIds 机构ID
+     * @param end       当天 23:59:59
+     * @param now       当前时刻
+     * @return 按机构分组的请假人数
+     */
+    List<OperationTenantCountBO> selectOnLeaveCount(@Param("tenantIds") Collection<Long> tenantIds,
+                                                    @Param("end") Date end,
+                                                    @Param("now") Date now);
+
+    /**
+     * 当天请假人数:当天离院的人数,按长者去重
+     *
+     * @param tenantIds 机构ID
+     * @param begin     当天 00:00:00(含)
+     * @param end       次日 00:00:00(不含)
+     * @return 按机构分组的当天请假人数
+     */
+    List<OperationTenantCountBO> selectTodayLeaveCount(@Param("tenantIds") Collection<Long> tenantIds,
+                                                       @Param("begin") Date begin,
+                                                       @Param("end") Date end);
+
+    /**
+     * 轮候待住:轮候入住审批完成、等待入住
+     *
+     * @param tenantIds 机构ID
+     * @return 按机构分组的轮候待住人数
+     */
+    List<OperationTenantCountBO> selectWaitingCount(@Param("tenantIds") Collection<Long> tenantIds);
+
+    /**
+     * 预订在途:已缴费(已预订)的床位预约
+     *
+     * @param tenantIds 机构ID
+     * @return 按机构分组的预订在途人数
+     */
+    List<OperationTenantCountBO> selectReservationCount(@Param("tenantIds") Collection<Long> tenantIds);
+
+    /**
+     * 护理等级分布:统计在住长者
+     *
+     * @param tenantIds 机构ID
+     * @return 按机构 + 护理等级分组的人数
+     */
+    List<OperationNurseLevelCountBO> selectNurseLevelDistribution(@Param("tenantIds") Collection<Long> tenantIds);
+
+}

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

@@ -0,0 +1,33 @@
+package cn.iocoder.yudao.module.system.dal.mysql.biz.bo;
+
+import lombok.Data;
+
+/**
+ * 运营分析 —— 按机构(租户)+ 护理等级分组的计数 BO
+ *
+ * @author kyj
+ */
+@Data
+public class OperationNurseLevelCountBO {
+
+    /**
+     * 机构ID(租户ID)
+     */
+    private Long tenantId;
+
+    /**
+     * 护理等级ID,为空表示长者未定级
+     */
+    private Long nurseLevelId;
+
+    /**
+     * 护理等级名称
+     */
+    private String nurseLevelName;
+
+    /**
+     * 数量
+     */
+    private Long count;
+
+}

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

@@ -0,0 +1,23 @@
+package cn.iocoder.yudao.module.system.dal.mysql.biz.bo;
+
+import lombok.Data;
+
+/**
+ * 运营分析 —— 按机构(租户)分组的计数 BO
+ *
+ * @author kyj
+ */
+@Data
+public class OperationTenantCountBO {
+
+    /**
+     * 机构ID(租户ID)
+     */
+    private Long tenantId;
+
+    /**
+     * 数量
+     */
+    private Long count;
+
+}

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

@@ -0,0 +1,28 @@
+package cn.iocoder.yudao.module.system.dal.mysql.biz.bo;
+
+import lombok.Data;
+
+/**
+ * 运营分析 —— 按机构(租户)+ 月份分组的计数 BO
+ *
+ * @author kyj
+ */
+@Data
+public class OperationTenantMonthCountBO {
+
+    /**
+     * 机构ID(租户ID)
+     */
+    private Long tenantId;
+
+    /**
+     * 月份,格式 yyyy-MM
+     */
+    private String month;
+
+    /**
+     * 数量
+     */
+    private Long count;
+
+}

+ 6 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/job/ElderContractUpdateJob.java

@@ -59,6 +59,12 @@ public class ElderContractUpdateJob implements JobHandler {
             logger.info("租户{}合同状态更新:未开始->进行中 {} 条", tenantId, startToInProgressCount);
         }
 
+        // 1.6) 已退住长者(in_status=2)的进行中(1)和未开始(2)合同置为已退住(4)
+        int retreatedCount = elderlyContractMapper.batchUpdateStatusToRetreated(tenantId);
+        if (retreatedCount > 0) {
+            logger.info("租户{}合同状态更新:进行中/未开始->已退住 {} 条", tenantId, retreatedCount);
+        }
+
         // 2) 原有逻辑:到期的旧合同置为过期(0),并将符合条件的未开始合同置为进行中(1)
         //    这里先重新查询一次进行中合同(避免上一步修复后状态已变化造成遗漏/误判)
         List<ElderlyContractDO> elderlyContractDOList = elderlyContractMapper.selectList(new LambdaQueryWrapperX<ElderlyContractDO>()

+ 2 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/ElderlyInfoServiceImpl.java

@@ -1647,7 +1647,8 @@ public class ElderlyInfoServiceImpl implements ElderlyInfoService {
         LocalDateTime weekEndDateTime = weekEnd.plusDays(1).atStartOfDay();
 
         ElderlyWeeklyOperationSummaryRespVO respVO = new ElderlyWeeklyOperationSummaryRespVO();
-
+        respVO.setPeriodBegin(weekStart);
+        respVO.setPeriodEnd(weekEnd);
         TenantDO tenantDO = tenantMapper.selectById(targetTenantId);
         respVO.setInstitutionName(tenantDO != null ? tenantDO.getName() : "");
 

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

@@ -0,0 +1,23 @@
+package cn.iocoder.yudao.module.system.service.biz;
+
+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 java.util.List;
+
+/**
+ * 运营分析 Service 接口
+ *
+ * @author kyj
+ */
+public interface OperationAnalysisService {
+
+    /**
+     * 获得运营分析 - 概况 - 院内动态
+     *
+     * @param reqVO 查询条件(机构ID数组,不传时取当前登录机构)
+     * @return 按机构分组的院内动态,每个机构一条
+     */
+    List<OperationAnalysisOverviewDynamicsRespVO> getOverviewDynamics(OperationAnalysisOverviewDynamicsReqVO reqVO);
+
+}

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

@@ -0,0 +1,250 @@
+package cn.iocoder.yudao.module.system.service.biz;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.ArrayUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
+import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
+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.OperationAnalysisOverviewDynamicsRespVO.MonthlyRetreatTrendVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisOverviewDynamicsRespVO.NurseLevelDistributionVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.tenant.GroupTenantDO;
+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.OperationNurseLevelCountBO;
+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.tenant.GroupTenantMapper;
+import cn.iocoder.yudao.module.system.service.tenant.TenantService;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+import javax.annotation.Resource;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.YearMonth;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/**
+ * 运营分析 Service 实现
+ *
+ * <p>统计口径:</p>
+ * <ol>
+ *     <li>今日新收 —— 长者档案 {@code elderly_info.check_in_time} 为当天;</li>
+ *     <li>今日退院 —— 退住登记表 {@code elderly_retreat_record.retreat_date} 为当天,
+ *     且退住类型为退住、是最新记录、已完成(系统审批 或 BPM 审批通过);</li>
+ *     <li>请假人数 —— 已离院({@code out_date})且截至当前未返院({@code update_date} 为空)的【存量】人数,按长者去重;</li>
+ *     <li>当天请假人数 —— 当天离院的人数,按长者去重;</li>
+ *     <li>轮候待住 —— 轮候入住 {@code status = 10}(审批完成待入住);</li>
+ *     <li>预订在途 —— 床位预约 {@code status = 2}(已缴费);</li>
+ *     <li>近 12 个月已完成退住趋势 —— 按月统计「已完成」的退住,含当月,无数据补 0;</li>
+ *     <li>护理等级分布 —— 在住长者({@code in_status = 1})按护理等级分组。</li>
+ * </ol>
+ *
+ * @author kyj
+ */
+@Service
+@Validated
+public class OperationAnalysisServiceImpl implements OperationAnalysisService {
+
+    /**
+     * 趋势月份数量
+     */
+    private static final int TREND_MONTHS = 12;
+
+    /**
+     * 长者未定护理等级时展示的名称
+     */
+    private static final String UNRATED_NURSE_LEVEL_NAME = "未定级";
+
+    /**
+     * 集团账号的租户ID,集团账号需要展开为旗下所有机构
+     */
+    private static final Long GROUP_TENANT_ID = 1L;
+
+    private static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM");
+
+    @Resource
+    private OperationAnalysisMapper operationAnalysisMapper;
+
+    @Resource
+    private TenantService tenantService;
+
+    @Resource
+    private GroupTenantMapper groupTenantMapper;
+
+    @Override
+    @TenantIgnore
+    public List<OperationAnalysisOverviewDynamicsRespVO> getOverviewDynamics(OperationAnalysisOverviewDynamicsReqVO reqVO) {
+        List<Long> tenantIds = resolveTenantIds(reqVO.getTenantIds());
+        if (CollUtil.isEmpty(tenantIds)) {
+            return Collections.emptyList();
+        }
+
+        LocalDate today = LocalDate.now();
+        Date dayBegin = toDate(today.atStartOfDay());
+        Date dayEnd = toDate(today.plusDays(1).atStartOfDay().minusNanos(1));
+        Date now = new Date();
+        // 近 12 个月(含当月):起始月 1 号(含)~ 次月 1 号(不含)
+        YearMonth currentMonth = YearMonth.now();
+        LocalDate trendBegin = currentMonth.minusMonths(TREND_MONTHS - 1).atDay(1);
+        LocalDate trendEnd = currentMonth.plusMonths(1).atDay(1);
+
+        // 1、六个指标:按机构分组,各一次查询
+        Map<Long, Long> newCheckInMap = toTenantCountMap(
+                operationAnalysisMapper.selectTodayNewCheckInCount(tenantIds, dayBegin, dayEnd));
+        Map<Long, Long> checkOutMap = toTenantCountMap(
+                operationAnalysisMapper.selectTodayCheckOutCount(tenantIds, today));
+        Map<Long, Long> askLeaveMap = toTenantCountMap(
+                operationAnalysisMapper.selectOnLeaveCount(tenantIds, dayEnd, now));
+        Map<Long, Long> todayAskLeaveMap = toTenantCountMap(
+                operationAnalysisMapper.selectTodayLeaveCount(tenantIds, dayBegin, dayEnd));
+        Map<Long, Long> waitingMap = toTenantCountMap(
+                operationAnalysisMapper.selectWaitingCount(tenantIds));
+        Map<Long, Long> reservationMap = toTenantCountMap(
+                operationAnalysisMapper.selectReservationCount(tenantIds));
+
+        // 2、趋势与分布:先按机构分组
+        Map<Long, List<OperationTenantMonthCountBO>> trendGroup = operationAnalysisMapper
+                .selectRetreatTrend(tenantIds, trendBegin, trendEnd).stream()
+                .filter(bo -> bo.getTenantId() != null)
+                .collect(Collectors.groupingBy(OperationTenantMonthCountBO::getTenantId));
+        Map<Long, List<OperationNurseLevelCountBO>> nurseLevelGroup = operationAnalysisMapper
+                .selectNurseLevelDistribution(tenantIds).stream()
+                .filter(bo -> bo.getTenantId() != null)
+                .collect(Collectors.groupingBy(OperationNurseLevelCountBO::getTenantId));
+
+        // 3、组装每个机构的结果
+        List<OperationAnalysisOverviewDynamicsRespVO> result = new ArrayList<>(tenantIds.size());
+        for (Long tenantId : tenantIds) {
+            OperationAnalysisOverviewDynamicsRespVO vo = new OperationAnalysisOverviewDynamicsRespVO();
+            vo.setTenantId(tenantId);
+            vo.setTenantName(getTenantName(tenantId));
+            vo.setNewCheckInCount(newCheckInMap.getOrDefault(tenantId, 0L));
+            vo.setCheckOutCount(checkOutMap.getOrDefault(tenantId, 0L));
+            vo.setAskLeaveCount(askLeaveMap.getOrDefault(tenantId, 0L));
+            vo.setTodayAskLeaveCount(todayAskLeaveMap.getOrDefault(tenantId, 0L));
+            vo.setWaitingCount(waitingMap.getOrDefault(tenantId, 0L));
+            vo.setReservationCount(reservationMap.getOrDefault(tenantId, 0L));
+            vo.setRetreatTrend(buildRetreatTrend(trendGroup.get(tenantId)));
+            vo.setNurseLevelDistribution(buildNurseLevelDistribution(nurseLevelGroup.get(tenantId)));
+            result.add(vo);
+        }
+        return result;
+    }
+
+    /**
+     * 解析要统计的机构ID:优先使用入参,否则取当前登录机构;集团账号展开为旗下所有机构
+     *
+     * @param tenantIds 入参机构ID数组
+     * @return 机构ID列表
+     */
+    private List<Long> resolveTenantIds(Long[] tenantIds) {
+        if (ArrayUtil.isNotEmpty(tenantIds)) {
+            return Arrays.stream(tenantIds)
+                    .filter(Objects::nonNull)
+                    .distinct()
+                    .collect(Collectors.toList());
+        }
+        Long currentTenantId = TenantContextHolder.getTenantId();
+        if (currentTenantId == null) {
+            return Collections.emptyList();
+        }
+        if (GROUP_TENANT_ID.equals(currentTenantId)) {
+            return groupTenantMapper.selectList(new LambdaQueryWrapperX<GroupTenantDO>()
+                            .eq(GroupTenantDO::getDeleted, 0))
+                    .stream()
+                    .map(GroupTenantDO::getOrgTenantId)
+                    .filter(Objects::nonNull)
+                    .distinct()
+                    .collect(Collectors.toList());
+        }
+        return Collections.singletonList(currentTenantId);
+    }
+
+    /**
+     * 构建近 12 个月已完成退住趋势,无数据的月份补 0
+     *
+     * @param list 该机构的月度退住统计,可能为空
+     * @return 固定 12 个点的趋势
+     */
+    private List<MonthlyRetreatTrendVO> buildRetreatTrend(List<OperationTenantMonthCountBO> list) {
+        Map<String, Long> monthCountMap = new HashMap<>();
+        if (CollUtil.isNotEmpty(list)) {
+            for (OperationTenantMonthCountBO bo : list) {
+                if (StrUtil.isBlank(bo.getMonth())) {
+                    continue;
+                }
+                monthCountMap.merge(bo.getMonth(), nullToZero(bo.getCount()), Long::sum);
+            }
+        }
+        YearMonth currentMonth = YearMonth.now();
+        List<MonthlyRetreatTrendVO> result = new ArrayList<>(TREND_MONTHS);
+        for (int i = TREND_MONTHS - 1; i >= 0; i--) {
+            String month = currentMonth.minusMonths(i).format(MONTH_FORMATTER);
+            MonthlyRetreatTrendVO vo = new MonthlyRetreatTrendVO();
+            vo.setMonth(month);
+            vo.setCount(monthCountMap.getOrDefault(month, 0L));
+            result.add(vo);
+        }
+        return result;
+    }
+
+    /**
+     * 构建护理等级分布,未定级的长者归入「未定级」
+     *
+     * @param list 该机构的护理等级统计,可能为空
+     * @return 护理等级分布
+     */
+    private List<NurseLevelDistributionVO> buildNurseLevelDistribution(List<OperationNurseLevelCountBO> list) {
+        if (CollUtil.isEmpty(list)) {
+            return Collections.emptyList();
+        }
+        List<NurseLevelDistributionVO> result = new ArrayList<>(list.size());
+        for (OperationNurseLevelCountBO bo : list) {
+            NurseLevelDistributionVO vo = new NurseLevelDistributionVO();
+            vo.setNurseLevelId(bo.getNurseLevelId());
+            vo.setNurseLevelName(StrUtil.isNotBlank(bo.getNurseLevelName())
+                    ? bo.getNurseLevelName() : UNRATED_NURSE_LEVEL_NAME);
+            vo.setCount(nullToZero(bo.getCount()));
+            result.add(vo);
+        }
+        return result;
+    }
+
+    private Map<Long, Long> toTenantCountMap(List<OperationTenantCountBO> list) {
+        if (CollUtil.isEmpty(list)) {
+            return Collections.emptyMap();
+        }
+        return list.stream()
+                .filter(bo -> bo.getTenantId() != null)
+                .collect(Collectors.toMap(OperationTenantCountBO::getTenantId,
+                        bo -> nullToZero(bo.getCount()), Long::sum));
+    }
+
+    private String getTenantName(Long tenantId) {
+        TenantDO tenant = tenantService.getTenant(tenantId);
+        return tenant == null ? null : tenant.getName();
+    }
+
+    private static Date toDate(LocalDateTime dateTime) {
+        return Date.from(dateTime.atZone(ZoneId.systemDefault()).toInstant());
+    }
+
+    private static Long nullToZero(Long value) {
+        return value == null ? 0L : value;
+    }
+
+}

+ 126 - 19
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/SysOverheadChargeServiceImpl.java

@@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.system.service.biz;
 
 
 import cn.hutool.core.collection.CollectionUtil;
+import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
 import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
@@ -19,9 +20,11 @@ import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
 import cn.iocoder.yudao.module.system.controller.admin.sys.vo.OverheadChargeSheetImportExcelVO;
 import cn.iocoder.yudao.module.system.dal.dataobject.biz.OverheadChargeDO;
 import cn.iocoder.yudao.module.system.dal.dataobject.biz.SysChargeCategoryDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.dict.DictDataDO;
 import cn.iocoder.yudao.module.system.dal.mysql.biz.SysChargeCategoryMapper;
 import cn.iocoder.yudao.module.system.dal.mysql.biz.SysOverheadChargeMapper;
 import cn.iocoder.yudao.module.system.dal.mysql.tenant.TenantMapper;
+import cn.iocoder.yudao.module.system.service.dict.DictDataService;
 import com.alibaba.excel.EasyExcel;
 import com.alibaba.excel.context.AnalysisContext;
 import com.alibaba.excel.read.listener.ReadListener;
@@ -52,6 +55,11 @@ import java.util.stream.Collectors;
 @Slf4j
 public class SysOverheadChargeServiceImpl implements SysOverheadChargeService {
 
+    /**
+     * 收费项计量单位的字典类型
+     */
+    private static final String FEE_CHARGE_TYPE = "fee_charge_type";
+
     private String in_sequence = "inSequence";
     private String daily = "daily";
     private String month = "month";
@@ -63,6 +71,8 @@ public class SysOverheadChargeServiceImpl implements SysOverheadChargeService {
     private SysChargeCategoryMapper sysChargeCategoryMapper;
     @Resource
     private TenantMapper tenantMapper;
+    @Resource
+    private DictDataService dictDataService;
 
     @Override
     public void add(OverheadChargeDO overheadChargeDO) {
@@ -223,8 +233,9 @@ public class SysOverheadChargeServiceImpl implements SysOverheadChargeService {
     public Map<String,Object> importOverheadCharge(List<OverheadChargeImportExcelVO> dataList) {
         Map<String,Object> map = new HashMap<>();
         List<OverheadChargeImportExcelVO> errorList = new ArrayList<>();
-        int hour = 24;
         int i = 0;
+        // 计量单位字典(标签/字典值 -> 字典数据),整个导入只需查询一次
+        Map<String, DictDataDO> chargeTypeDictMap = getChargeTypeDictMap();
         for (OverheadChargeImportExcelVO overheadChargeImportExcelVO : dataList) {
             OverheadChargeDO overheadCharge = new OverheadChargeDO();
             overheadCharge.setChargeName(overheadChargeImportExcelVO.getChargeName());
@@ -243,24 +254,19 @@ public class SysOverheadChargeServiceImpl implements SysOverheadChargeService {
             SysChargeCategoryDO sysChargeCategory = optional.get();
             overheadCharge.setSuperiorsId(String.valueOf(sysChargeCategory.getId()));
             overheadCharge.setCreateBy(SecurityFrameworkUtils.getLoginUserNickname());
-            double price = overheadChargeImportExcelVO.getPrice();
-            if (overheadChargeImportExcelVO.getChargeType().equals("/月")){
-                int num = 30 * hour;
-                double hourlyPrice = BigDecimal.valueOf(price / num).setScale(8, BigDecimal.ROUND_DOWN).doubleValue();
-                double dayPrice = BigDecimal.valueOf(hourlyPrice * hour).setScale(8, BigDecimal.ROUND_DOWN).doubleValue();
-
-                overheadCharge.setHourlyPrice(hourlyPrice);
-                overheadCharge.setDayExpense(dayPrice);
-                overheadCharge.setChargeType(month);
-
-            }else if (overheadChargeImportExcelVO.getChargeType().equals("/日")){
-                double hourlyPrice = BigDecimal.valueOf(price / hour).setScale(8, BigDecimal.ROUND_DOWN).doubleValue();
-                overheadCharge.setHourlyPrice(hourlyPrice);
-                overheadCharge.setDayExpense(price);
-                overheadCharge.setChargeType(daily);
-            }else {
-                overheadCharge.setDayExpense(price);
-                overheadCharge.setChargeType(in_sequence);
+
+            if (overheadChargeImportExcelVO.getPrice() == null) {
+                overheadChargeImportExcelVO.setErrorMsg("价格不能为空");
+                errorList.add(overheadChargeImportExcelVO);
+                continue;
+            }
+            // 计量单位按照 fee_charge_type 字典处理
+            if (!fillChargeType(overheadCharge, chargeTypeDictMap,
+                    overheadChargeImportExcelVO.getChargeType(), overheadChargeImportExcelVO.getPrice())) {
+                overheadChargeImportExcelVO.setErrorMsg(
+                        "计量单位【" + overheadChargeImportExcelVO.getChargeType() + "】不在字典【" + FEE_CHARGE_TYPE + "】中");
+                errorList.add(overheadChargeImportExcelVO);
+                continue;
             }
             sysOverheadChargeMapper.insert(overheadCharge);
             i++;
@@ -391,6 +397,107 @@ public class SysOverheadChargeServiceImpl implements SysOverheadChargeService {
         return map;
     }
 
+    /**
+     * 获取计量单位字典数据,key 同时支持字典标签与字典值
+     *
+     * @return 计量单位字典数据,字典未配置时返回空 Map
+     */
+    private Map<String, DictDataDO> getChargeTypeDictMap() {
+        List<DictDataDO> dictDataList = dictDataService.getDictDataList(
+                CommonStatusEnum.ENABLE.getStatus(), FEE_CHARGE_TYPE);
+        Map<String, DictDataDO> dictMap = new HashMap<>();
+        for (DictDataDO dictData : dictDataList) {
+            String label = normalizeUnit(dictData.getLabel());
+            String value = normalizeUnit(dictData.getValue());
+            if (StrUtil.isNotEmpty(label)) {
+                dictMap.putIfAbsent(label, dictData);
+            }
+            if (StrUtil.isNotEmpty(value)) {
+                dictMap.putIfAbsent(value, dictData);
+            }
+        }
+        return dictMap;
+    }
+
+    /**
+     * 归一化计量单位:去除斜杠、每字等修饰,便于与字典标签/字典值匹配,例如 /月 -> 月
+     */
+    private String normalizeUnit(String unit) {
+        if (unit == null) {
+            return StrUtil.EMPTY;
+        }
+        return unit.trim().replace("/", "").replace("每", "").trim();
+    }
+
+    /**
+     * 根据 fee_charge_type 字典设置计量单位,并计算小时单价/日费用。
+     * 月、日保持原有业务处理(折算小时单价与日费用),其他计量单位直接取字典对应的值。
+     *
+     * @param chargesDO  收费项
+     * @param dictMap    计量单位字典
+     * @param rawUnit    Excel 中填写的计量单位
+     * @param price      价格
+     * @return 是否解析成功
+     */
+    private boolean fillChargeType(OverheadChargeDO chargesDO, Map<String, DictDataDO> dictMap,
+                                   String rawUnit, double price) {
+        int hour = 24;
+        String unit = normalizeUnit(rawUnit);
+        String chargeType;
+        if (CollUtil.isNotEmpty(dictMap)) {
+            // 字典已配置:优先按字典标签匹配,其次按字典值匹配
+            DictDataDO dictData = dictMap.get(unit);
+            if (dictData == null) {
+                dictData = dictMap.get(StrUtil.trimToEmpty(rawUnit));
+            }
+            if (dictData == null) {
+                return false;
+            }
+            chargeType = dictData.getValue();
+            // 判断是否为月/日:字典值、字典标签或填写值任一命中即可
+            boolean monthUnit = matchUnit(dictData, chargeType, unit, month, "月");
+            boolean dailyUnit = matchUnit(dictData, chargeType, unit, daily, "日");
+            if (monthUnit) {
+                chargeType = month;
+            } else if (dailyUnit) {
+                chargeType = daily;
+            }
+        } else {
+            // 字典未配置,兜底保持原有逻辑:月、日,其余按次
+            if ("月".equals(unit)) {
+                chargeType = month;
+            } else if ("日".equals(unit)) {
+                chargeType = daily;
+            } else {
+                chargeType = in_sequence;
+            }
+        }
+        chargesDO.setChargeType(chargeType);
+
+        if (month.equals(chargeType)) {
+            int num = 30 * hour;
+            double hourlyPrice = BigDecimal.valueOf(price / num).setScale(8, RoundingMode.DOWN).doubleValue();
+            double dayPrice = BigDecimal.valueOf(hourlyPrice * hour).setScale(8, RoundingMode.DOWN).doubleValue();
+            chargesDO.setHourlyPrice(hourlyPrice);
+            chargesDO.setDayExpense(dayPrice);
+        } else if (daily.equals(chargeType)) {
+            double hourlyPrice = BigDecimal.valueOf(price / hour).setScale(8, RoundingMode.DOWN).doubleValue();
+            chargesDO.setHourlyPrice(hourlyPrice);
+            chargesDO.setDayExpense(price);
+        } else {
+            // 其他计量单位(次、件、张、小时等):按次计费
+            chargesDO.setDayExpense(price);
+            chargesDO.setHourlyPrice(0);
+        }
+        return true;
+    }
+
+    private boolean matchUnit(DictDataDO dictData, String chargeType, String unit,
+                              String dictValue, String unitName) {
+        return dictValue.equals(chargeType) || unitName.equals(unit)
+                || unitName.equals(normalizeUnit(dictData.getLabel()));
+    }
+
     private void setChargeTypeAndPrice(OverheadChargeDO chargeDO, String unit, double price) {
         int hour = 24;
         if ("/月".equals(unit)) {

+ 17 - 0
yudao-module-system/yudao-module-system-biz/src/main/resources/mapper/ElderlyContractMapper.xml

@@ -94,6 +94,23 @@
           AND DATE(expire_time) &gt;= DATE(#{nowDate})
     </update>
 
+    <update id="batchUpdateStatusToRetreated">
+        UPDATE elderly_contract ec
+            INNER JOIN elderly_info ei ON ec.elder_id = ei.id
+        SET ec.status = 4
+        WHERE ei.in_status = 2
+          AND ec.tenant_id = #{tenantId}
+          AND ec.status IN (1, 2)
+    </update>
+
+    <update id="batchUpdateStatusToRetreatedByElderId">
+        UPDATE elderly_contract
+        SET status = 4
+        WHERE elder_id = #{elderId}
+          AND tenant_id = #{tenantId}
+          AND status IN (1, 2)
+    </update>
+
     <select id="selectExpiringWithinOneMonth" resultType="cn.iocoder.yudao.module.system.dal.dataobject.biz.ElderlyContractDO">
         SELECT ec.*, ei.in_status_type
         FROM elderly_contract ec

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

@@ -0,0 +1,159 @@
+<?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="cn.iocoder.yudao.module.system.dal.mysql.biz.OperationAnalysisMapper">
+
+    <!-- 退住「已完成」判定:
+         1)系统审批(elderly_retreat_record.status = 1),不产生 BPM 流程;
+         2)BPM 审批通过(bpm_business_form.type = 2 申请退住 且 complete_status = 1)。 -->
+    <sql id="retreatFinishedCondition">
+        (t.status = 1
+        OR EXISTS (
+            SELECT 1
+            FROM bpm_business_form bf
+            WHERE bf.business_id = t.id
+              AND bf.type = 2
+              AND bf.complete_status = 1
+              AND bf.deleted = 0
+        ))
+    </sql>
+
+    <!-- 今日新收:长者档案的入住时间在当天 -->
+    <select id="selectTodayNewCheckInCount"
+            resultType="cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationTenantCountBO">
+        SELECT t.tenant_id AS tenantId,
+               COUNT(*)    AS count
+        FROM elderly_info t
+        WHERE t.deleted = 0
+          AND t.check_in_time &gt;= #{begin}
+          AND t.check_in_time &lt;= #{end}
+          AND t.tenant_id IN
+        <foreach collection="tenantIds" item="tenantId" open="(" separator="," close=")">
+            #{tenantId}
+        </foreach>
+        GROUP BY t.tenant_id
+    </select>
+
+    <!-- 今日退院:退住登记表的退住时间为当天,且已完成 -->
+    <select id="selectTodayCheckOutCount"
+            resultType="cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationTenantCountBO">
+        SELECT t.tenant_id AS tenantId,
+               COUNT(*)    AS count
+        FROM elderly_retreat_record t
+        WHERE t.deleted = 0
+          AND t.apply_type = 1
+          AND t.current_flag = 0
+          AND t.retreat_date = #{today}
+          AND
+        <include refid="retreatFinishedCondition"/>
+          AND t.tenant_id IN
+        <foreach collection="tenantIds" item="tenantId" open="(" separator="," close=")">
+            #{tenantId}
+        </foreach>
+        GROUP BY t.tenant_id
+    </select>
+
+    <!-- 近 N 个月已完成退住趋势:按月 + 机构分组 -->
+    <select id="selectRetreatTrend"
+            resultType="cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationTenantMonthCountBO">
+        SELECT t.tenant_id                              AS tenantId,
+               DATE_FORMAT(t.retreat_date, '%Y-%m')     AS month,
+               COUNT(*)                                 AS count
+        FROM elderly_retreat_record t
+        WHERE t.deleted = 0
+          AND t.apply_type = 1
+          AND t.current_flag = 0
+          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>
+        GROUP BY t.tenant_id, DATE_FORMAT(t.retreat_date, '%Y-%m')
+    </select>
+
+    <!-- 请假人数:截至当前仍在请假中(已离院且未返院),按长者去重 -->
+    <select id="selectOnLeaveCount"
+            resultType="cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationTenantCountBO">
+        SELECT t.tenant_id                AS tenantId,
+               COUNT(DISTINCT t.elder_id) AS count
+        FROM elderly_ask_leave t
+        WHERE (t.leave_status = 1 OR t.leave_status IS NULL)
+          AND (t.status = 0 OR t.status IS NULL)
+          AND t.out_date &lt;= #{end}
+          AND (t.update_date &gt; #{now} OR t.update_date IS NULL)
+          AND t.tenant_id IN
+        <foreach collection="tenantIds" item="tenantId" open="(" separator="," close=")">
+            #{tenantId}
+        </foreach>
+        GROUP BY t.tenant_id
+    </select>
+
+    <!-- 当天请假人数:当天离院的人数,按长者去重 -->
+    <select id="selectTodayLeaveCount"
+            resultType="cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationTenantCountBO">
+        SELECT t.tenant_id                AS tenantId,
+               COUNT(DISTINCT t.elder_id) AS count
+        FROM elderly_ask_leave t
+        WHERE (t.leave_status = 1 OR t.leave_status IS NULL)
+          AND (t.status = 0 OR t.status IS NULL)
+          AND t.out_date &gt;= #{begin}
+          AND t.out_date &lt;= #{end}
+          AND t.tenant_id IN
+        <foreach collection="tenantIds" item="tenantId" open="(" separator="," close=")">
+            #{tenantId}
+        </foreach>
+        GROUP BY t.tenant_id
+    </select>
+
+    <!-- 轮候待住:轮候入住审批完成(status = 10),等待入住。该表无 deleted 字段 -->
+    <select id="selectWaitingCount"
+            resultType="cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationTenantCountBO">
+        SELECT t.tenant_id AS tenantId,
+               COUNT(*)    AS count
+        FROM elderly_check_in_wait t
+        WHERE t.status = 10
+          AND t.tenant_id IN
+        <foreach collection="tenantIds" item="tenantId" open="(" separator="," close=")">
+            #{tenantId}
+        </foreach>
+        GROUP BY t.tenant_id
+    </select>
+
+    <!-- 预订在途:已缴费(status = 2)的床位预约 -->
+    <select id="selectReservationCount"
+            resultType="cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationTenantCountBO">
+        SELECT t.tenant_id AS tenantId,
+               COUNT(*)    AS count
+        FROM elderly_reservation_bed t
+        WHERE t.deleted = 0
+          AND t.status = 2
+          AND t.tenant_id IN
+        <foreach collection="tenantIds" item="tenantId" open="(" separator="," close=")">
+            #{tenantId}
+        </foreach>
+        GROUP BY t.tenant_id
+    </select>
+
+    <!-- 护理等级分布:在住长者(in_status = 1)按护理等级分组 -->
+    <select id="selectNurseLevelDistribution"
+            resultType="cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationNurseLevelCountBO">
+        SELECT e.tenant_id           AS tenantId,
+               e.nurse_level_id      AS nurseLevelId,
+               nl.nurse_level_name   AS nurseLevelName,
+               COUNT(*)              AS count
+        FROM elderly_info e
+        LEFT JOIN nurse_level nl ON nl.id = e.nurse_level_id
+        WHERE e.deleted = 0
+          AND e.org_type = 1
+          AND e.in_status = 1
+          AND e.tenant_id IN
+        <foreach collection="tenantIds" item="tenantId" open="(" separator="," close=")">
+            #{tenantId}
+        </foreach>
+        GROUP BY e.tenant_id, e.nurse_level_id, nl.nurse_level_name
+        ORDER BY e.tenant_id, nl.sort IS NULL, nl.sort, nl.id
+    </select>
+
+</mapper>