瀏覽代碼

新增
1、新增使用用户编号登录接口
2、新增审批流推送企业微信
3、新增统计楼栋 / 楼层床位数据接口用于统计报表
4、新增空床明细分页接口用于统计报表
5、新增空床汇总接口用于统计报表
6、新增导出空床明细接口用于统计报表
7、新增入住 / 退住趋势接口
8、新增长者结构聚合接口
修改
1、获得院内动态接口内容调整
2、长期医嘱模块停止遗嘱功能调整
3、执行医嘱模块停止遗嘱功能调整

liangwenxuan 2 周之前
父節點
當前提交
f75b167168
共有 58 個文件被更改,包括 2920 次插入 和 268 次删除
  1. 13 0
      sql/mysql/alter_elderly_doctor_order_execution_add_exec_type.sql
  2. 3 1
      sql/mysql/elderly_doctor_order.sql
  3. 8 83
      yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/corp/CorpMsgUtil.java
  4. 26 22
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/message/BpmMessageServiceImpl.java
  5. 2 2
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/wechat/dto/CorpSendMessage.java
  6. 27 0
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/wechat/dto/CorpWechatContentItem.java
  7. 41 0
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/wechat/dto/CorpWechatMiniProgramNotice.java
  8. 0 37
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/wechat/dto/CorpWechatTextcard.java
  9. 2 0
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java
  10. 29 2
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/oauth2/EnterpriseWeChatConstants.java
  11. 4 6
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/bpm/BpmCheckInApiImpl.java
  12. 4 7
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/bpm/BpmElderlyApiImpl.java
  13. 0 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/bpm/BpmElderlyChangeApiImpl.java
  14. 4 6
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/bpm/BpmElderlyExpenseApiImpl.java
  15. 65 18
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/wechat/CorpWechatApiImpl.java
  16. 8 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/AuthController.http
  17. 7 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/AuthController.java
  18. 22 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthUserIdLoginReqVO.java
  19. 42 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/BuildController.java
  20. 27 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/OperationAnalysisController.java
  21. 3 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/BuildFloorDataRespVO.java
  22. 74 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/BuildStatisticsRespVO.java
  23. 44 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/EmptyBedPageReqVO.java
  24. 68 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/EmptyBedRespVO.java
  25. 28 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/EmptyBedSummaryRespVO.java
  26. 47 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/excel/EmptyBedExcelVO.java
  27. 39 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/operationanalysis/OperationAnalysisCheckTrendReqVO.java
  28. 52 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/operationanalysis/OperationAnalysisCheckTrendRespVO.java
  29. 20 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/operationanalysis/OperationAnalysisElderStructureReqVO.java
  30. 149 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/operationanalysis/OperationAnalysisElderStructureRespVO.java
  31. 3 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/doctororder/vo/DoctorOrderExecutionPageReqVO.java
  32. 5 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/doctororder/vo/DoctorOrderExecutionRespVO.java
  33. 5 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/doctororder/vo/DoctorOrderExecutionSaveReqVO.java
  34. 4 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/biz/DoctorOrderExecutionDO.java
  35. 29 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/BuildBedMapper.java
  36. 32 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/BuildStatisticsMapper.java
  37. 51 2
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/DoctorOrderExecutionMapper.java
  38. 63 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/OperationAnalysisMapper.java
  39. 75 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/bo/BuildFloorBedStatisticsBO.java
  40. 62 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/bo/ElderStructureRowBO.java
  41. 34 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/enums/biz/DoctorOrderExecTypeEnum.java
  42. 10 16
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/job/BusinessConfigJob.java
  43. 8 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthService.java
  44. 31 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImpl.java
  45. 36 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/BuildService.java
  46. 299 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/BuildServiceImpl.java
  47. 3 2
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/ExpenseOrderServiceImpl.java
  48. 24 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/OperationAnalysisService.java
  49. 651 39
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/OperationAnalysisServiceImpl.java
  50. 23 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/TenantScopeService.java
  51. 78 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/TenantScopeServiceImpl.java
  52. 72 4
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/doctororder/DoctorOrderExecutionServiceImpl.java
  53. 86 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/doctororder/LongDoctorOrderServiceImpl.java
  54. 28 18
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/notify/NotifySendServiceImpl.java
  55. 201 0
      yudao-module-system/yudao-module-system-biz/src/main/resources/mapper/BuildBedMapper.xml
  56. 56 0
      yudao-module-system/yudao-module-system-biz/src/main/resources/mapper/BuildStatisticsMapper.xml
  57. 92 0
      yudao-module-system/yudao-module-system-biz/src/main/resources/mapper/OperationAnalysisMapper.xml
  58. 1 0
      yudao-server/src/main/resources/application.yaml

+ 13 - 0
sql/mysql/alter_elderly_doctor_order_execution_add_exec_type.sql

@@ -0,0 +1,13 @@
+-- 护士执行记录:新增 执行类型(exec_type)、医生签名(doctor_sign)
+-- 背景:长期医嘱“停止”需要护士做停止确认签名,复用执行记录表承载停止确认任务
+ALTER TABLE `elderly_doctor_order_execution`
+    ADD COLUMN `exec_type` tinyint NOT NULL DEFAULT 1 COMMENT '执行类型 1=常规执行 2=停止确认' AFTER `order_entry_id`,
+    ADD COLUMN `doctor_sign` varchar(64) DEFAULT '' COMMENT '医生签名(常规执行=开嘱医生;停止确认=停止医生)' AFTER `exec_nurse_sign`;
+
+-- 历史数据统一按常规执行处理
+UPDATE `elderly_doctor_order_execution` SET `exec_type` = 1 WHERE `exec_type` IS NULL OR `exec_type` = 0;
+
+-- 唯一键补充 exec_type,避免常规执行与停止确认任务相互冲突
+ALTER TABLE `elderly_doctor_order_execution` DROP INDEX `uk_exec`;
+ALTER TABLE `elderly_doctor_order_execution`
+    ADD UNIQUE KEY `uk_exec` (`order_type`, `order_entry_id`, `plan_time`, `exec_type`);

+ 3 - 1
sql/mysql/elderly_doctor_order.sql

@@ -83,6 +83,7 @@ CREATE TABLE elderly_doctor_order_execution (
   id              BIGINT       NOT NULL AUTO_INCREMENT,
   order_type      TINYINT      NOT NULL COMMENT '1=长期医嘱 2=临时医嘱',
   order_entry_id  BIGINT       NOT NULL COMMENT '关联 long/short_doctor_order_entry.id',
+  exec_type       TINYINT      NOT NULL DEFAULT 1 COMMENT '执行类型 1=常规执行 2=停止确认',
   elder_id        BIGINT       DEFAULT NULL,
   elder_name      VARCHAR(64)  DEFAULT '',
   bed_name        VARCHAR(64)  DEFAULT '',
@@ -93,6 +94,7 @@ CREATE TABLE elderly_doctor_order_execution (
   plan_time       DATETIME     DEFAULT NULL COMMENT '计划执行时间,前端生成 YYYY-MM-DD HH:mm',
   exec_time       DATETIME     DEFAULT NULL COMMENT '实际执行时间',
   exec_nurse_sign VARCHAR(64)  DEFAULT '' COMMENT '执行护士签名',
+  doctor_sign     VARCHAR(64)  DEFAULT '' COMMENT '医生签名(常规执行=开嘱医生;停止确认=停止医生)',
   status          TINYINT      NOT NULL DEFAULT 0 COMMENT '0待执行 1已执行 2已跳过',
   remark          VARCHAR(500) DEFAULT '' COMMENT '执行备注 / 跳过原因',
   tenant_id       BIGINT       DEFAULT NULL,
@@ -101,6 +103,6 @@ CREATE TABLE elderly_doctor_order_execution (
   updater          VARCHAR(64)  DEFAULT '' COMMENT '更新者',
   update_time      DATETIME     DEFAULT NULL COMMENT '更新时间',
   PRIMARY KEY (id),
-  UNIQUE KEY uk_exec (order_type, order_entry_id, plan_time),
+  UNIQUE KEY uk_exec (order_type, order_entry_id, plan_time, exec_type),
   KEY idx_plan (plan_time, tenant_id)
 ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '护士执行记录';

+ 8 - 83
yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/corp/CorpMsgUtil.java

@@ -1,39 +1,23 @@
 package cn.iocoder.yudao.framework.common.util.corp;
 
-import cn.hutool.core.date.DateUtil;
-import cn.iocoder.yudao.framework.common.util.date.DateUtils;
-import cn.iocoder.yudao.framework.common.util.string.StringUtil;
-
-import java.time.LocalDateTime;
-import java.time.format.DateTimeFormatter;
-
-import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
-
+/**
+ * 企业微信消息相关常量
+ *
+ * <p>说明:原 textcard 消息的内容拼装方法(builderXxxContent / getContent / getDescription)
+ * 已随消息类型切换为「小程序通知消息 miniprogram_notice」移除,
+ * 消息正文改由 content_item 键值对承载,不再需要拼 HTML。
+ */
 public class CorpMsgUtil {
 
-    private static String datePrefix = "<div class=\"gray\">";
-
-    private static String dateSuffix = "</div>";
-
-    private static String classPrefix = "<div class=\"";
-
-    private static String classSuffix = "\">";
-
-    private static String contentPrefix = "<div class=\"normal\">";
-
-    private static String contentSuffix = "</div>";
-
     // 灰色
     public static String GRAY = "gray";
     // 高亮
     public static String HIGHLIGHT = "highlight";
     // 默认黑色
     public static String NORMAL = "normal";
-    // 按钮名称
-    public static String BTNTXT_DETAIL_NOTICE = "详情";
-    public static String BTNTXT_DETAIL_HANDLED = "马上处理";
 
     public static String TITLE_COPY = "抄送通知";
+
     public static String TITLE_HANDLED = "待审批通知";
 
     public static String TITLE_REFUSE = "审批驳回通知";
@@ -44,63 +28,4 @@ public class CorpMsgUtil {
 
     public static String TITLE_TODO = "待办事项通知";
 
-    public static String getDescription(String date, String content) {
-        StringBuffer stringBuffer = new StringBuffer();
-        if (StringUtil.isNotEmpty(date)) {
-            stringBuffer.append(datePrefix).append(date).append(dateSuffix);
-        }
-        stringBuffer.append(content);
-        return stringBuffer.toString();
-    }
-
-    public static String getContent(String content, String color) {
-        if (StringUtil.isEmpty(color)) {
-            color = NORMAL;
-        }
-        StringBuffer stringBuffer = new StringBuffer();
-        stringBuffer.append(classPrefix).append(color).append(classSuffix).append(content).append(contentSuffix);
-        return stringBuffer.toString();
-    }
-
-    public static String builderHandledContent(String tenantName, String typeName, String content, String applyTime, String reason) {
-        String fianllContent = "所属组织:" + tenantName + "\n" +
-                "申请类型:" + typeName + "\n" +
-                "申请事由:" + content;
-        if (StringUtil.isNotEmptyORNull(applyTime)) {
-            fianllContent += "\n申请时间:" + applyTime;
-        }
-        return fianllContent;
-    }
-
-    public static String builderCopyContent(String tenantName, String content) {
-        String fianllContent = "所属组织:" + tenantName + "\n" +
-                "抄送内容:" + content;
-        return fianllContent;
-    }
-
-    public static String builderCancelContent(String tenantName, String typeName, String content) {
-        String fianllContent = "所属组织:" + tenantName + "\n" +
-                "申请类型:" + typeName + "\n" +
-                "申请事由:" + content;
-        return fianllContent;
-    }
-
-    public static String builderTodoContent(String content) {
-        String fianllContent = content;
-        return fianllContent;
-    }
-
-
-    public static void main(String[] args) {
-
-        // LocalDateTime.now()格式化时间
-
-        LocalDateTime now = LocalDateTime.now();
-        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
-        String format = now.format(formatter);
-
-        System.out.println("format = " + format);
-
-    }
-
 }

+ 26 - 22
yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/message/BpmMessageServiceImpl.java

@@ -40,7 +40,7 @@ import cn.iocoder.yudao.module.system.api.tenant.TenantRespDTO;
 import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
 import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
 import cn.iocoder.yudao.module.system.api.wechat.dto.CorpSendMessage;
-import cn.iocoder.yudao.module.system.api.wechat.dto.CorpWechatTextcard;
+import cn.iocoder.yudao.module.system.api.wechat.dto.CorpWechatMiniProgramNotice;
 import cn.iocoder.yudao.module.system.api.wechat.dto.WechatMessageSaveReq;
 import cn.iocoder.yudao.module.system.enums.notice.NotieBusinessTypeEnum;
 import cn.iocoder.yudao.module.system.enums.notice.NotifyPriorityEnum;
@@ -63,7 +63,7 @@ import java.util.*;
 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
 import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
 import static cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmnModelConstants.START_USER_NODE_ID;
-import static cn.iocoder.yudao.module.system.enums.oauth2.EnterpriseWeChatConstants.BPM_DETAIL_URL;
+import static cn.iocoder.yudao.module.system.enums.oauth2.EnterpriseWeChatConstants.MINI_PROGRAM_DETAIL_PAGE;
 
 /**
  * BPM 消息 Service 实现类
@@ -404,12 +404,13 @@ public class BpmMessageServiceImpl implements BpmMessageService {
             return;
         }
         String corpUserId = assigneeUser.getCorpUserId() == null ? "" : assigneeUser.getCorpUserId().toString();
-        CorpWechatTextcard corpTextCard = new CorpWechatTextcard();
-        corpTextCard.setTitle(CorpMsgUtil.TITLE_APPROVE);
-        corpTextCard.setBtntxt(CorpMsgUtil.BTNTXT_DETAIL_NOTICE);
-        corpTextCard.setUrl(String.format(BPM_DETAIL_URL, reqDTO.getProcessInstanceId(), 1, businessFormDO.getStatus()));
+        CorpWechatMiniProgramNotice corpNotice = new CorpWechatMiniProgramNotice();
+        corpNotice.setTitle(CorpMsgUtil.TITLE_APPROVE);
+        corpNotice.setPage(String.format(MINI_PROGRAM_DETAIL_PAGE, reqDTO.getProcessInstanceId(), 1,
+                businessFormDO.getStatus() == null ? "" : String.valueOf(businessFormDO.getStatus()),
+                assigneeUser.getId() == null ? "" : String.valueOf(assigneeUser.getId())));
         corpSendMessage.setTouser(corpUserId);
-        corpSendMessage.setTextcard(corpTextCard);
+        corpSendMessage.setMiniProgramNotice(corpNotice);
         wechatMessageSaveReq.setTitle(CorpMsgUtil.TITLE_APPROVE);
         wechatMessageSaveReq.setPkMessage(reqDTO.getProcessInstanceId());
         wechatMessageSaveReq.setPkMessage2("");
@@ -479,12 +480,13 @@ public class BpmMessageServiceImpl implements BpmMessageService {
             WechatMessageSaveReq wechatMessageSaveReq = new WechatMessageSaveReq();
             AdminUserRespDTO promoter = adminUserApi.getUser(taskCopyUserDO.getId());
             String corpUserId = promoter.getCorpUserId() == null ? "" : promoter.getCorpUserId().toString();
-            CorpWechatTextcard corpTextCard = new CorpWechatTextcard();
-            corpTextCard.setTitle(CorpMsgUtil.TITLE_COPY);
-            corpTextCard.setBtntxt(CorpMsgUtil.BTNTXT_DETAIL_NOTICE);
-            corpTextCard.setUrl(String.format(BPM_DETAIL_URL, reqDTO.getProcessInstanceId(), 4, businessFormDO.getStatus()));
+            CorpWechatMiniProgramNotice corpNotice = new CorpWechatMiniProgramNotice();
+            corpNotice.setTitle(CorpMsgUtil.TITLE_COPY);
+            corpNotice.setPage(String.format(MINI_PROGRAM_DETAIL_PAGE, reqDTO.getProcessInstanceId(), 4,
+                    businessFormDO.getStatus() == null ? "" : String.valueOf(businessFormDO.getStatus()),
+                    promoter.getId() == null ? "" : String.valueOf(promoter.getId())));
             corpSendMessage.setTouser(corpUserId);
-            corpSendMessage.setTextcard(corpTextCard);
+            corpSendMessage.setMiniProgramNotice(corpNotice);
             wechatMessageSaveReq.setTitle(CorpMsgUtil.TITLE_COPY);
             wechatMessageSaveReq.setTenantName(tenant.getAbbreviation());
             wechatMessageSaveReq.setPkMessage(reqDTO.getProcessInstanceId());
@@ -642,12 +644,13 @@ public class BpmMessageServiceImpl implements BpmMessageService {
             return;
         }
         String corpUserId = assigneeUser.getCorpUserId() == null ? "" : assigneeUser.getCorpUserId().toString();
-        CorpWechatTextcard corpTextCard = new CorpWechatTextcard();
-        corpTextCard.setTitle(CorpMsgUtil.TITLE_REFUSE);
-        corpTextCard.setBtntxt(CorpMsgUtil.BTNTXT_DETAIL_NOTICE);
-        corpTextCard.setUrl(String.format(BPM_DETAIL_URL, reqDTO.getProcessInstanceId(), 1, businessFormDO.getStatus()));
+        CorpWechatMiniProgramNotice corpNotice = new CorpWechatMiniProgramNotice();
+        corpNotice.setTitle(CorpMsgUtil.TITLE_REFUSE);
+        corpNotice.setPage(String.format(MINI_PROGRAM_DETAIL_PAGE, reqDTO.getProcessInstanceId(), 1,
+                businessFormDO.getStatus() == null ? "" : String.valueOf(businessFormDO.getStatus()),
+                assigneeUser.getId() == null ? "" : String.valueOf(assigneeUser.getId())));
         corpSendMessage.setTouser(corpUserId);
-        corpSendMessage.setTextcard(corpTextCard);
+        corpSendMessage.setMiniProgramNotice(corpNotice);
         wechatMessageSaveReq.setTitle(CorpMsgUtil.TITLE_REFUSE);
         wechatMessageSaveReq.setPkMessage(reqDTO.getProcessInstanceId());
         wechatMessageSaveReq.setPkMessage2("");
@@ -900,12 +903,13 @@ public class BpmMessageServiceImpl implements BpmMessageService {
             return;
         }
         String corpUserId = assigneeUser.getCorpUserId() == null ? "" : assigneeUser.getCorpUserId().toString();
-        CorpWechatTextcard corpTextCard = new CorpWechatTextcard();
-        corpTextCard.setTitle(CorpMsgUtil.TITLE_HANDLED);
-        corpTextCard.setBtntxt(CorpMsgUtil.BTNTXT_DETAIL_HANDLED);
-        corpTextCard.setUrl(String.format(BPM_DETAIL_URL, reqDTO.getProcessInstanceId(), 1, businessFormDO.getStatus()));
+        CorpWechatMiniProgramNotice corpNotice = new CorpWechatMiniProgramNotice();
+        corpNotice.setTitle(CorpMsgUtil.TITLE_HANDLED);
+        corpNotice.setPage(String.format(MINI_PROGRAM_DETAIL_PAGE, reqDTO.getProcessInstanceId(), 1,
+                businessFormDO.getStatus() == null ? "" : String.valueOf(businessFormDO.getStatus()),
+                assigneeUser.getId() == null ? "" : String.valueOf(assigneeUser.getId())));
         corpSendMessage.setTouser(corpUserId);
-        corpSendMessage.setTextcard(corpTextCard);
+        corpSendMessage.setMiniProgramNotice(corpNotice);
         wechatMessageSaveReq.setTitle(CorpMsgUtil.TITLE_HANDLED);
         wechatMessageSaveReq.setPkMessage(reqDTO.getProcessInstanceId());
         wechatMessageSaveReq.setPkMessage2(reqDTO.getTaskId());

+ 2 - 2
yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/wechat/dto/CorpSendMessage.java

@@ -40,9 +40,9 @@ public class CorpSendMessage {
     private String agentid;
 
     /**
-     * 卡片详情
+     * 小程序通知消息卡片内容
      */
-    private CorpWechatTextcard textcard;
+    private CorpWechatMiniProgramNotice miniProgramNotice;
 
     /**
      * 表示是否开启id转译,0表示否,1表示是,默认0

+ 27 - 0
yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/wechat/dto/CorpWechatContentItem.java

@@ -0,0 +1,27 @@
+package cn.iocoder.yudao.module.system.api.wechat.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 企业微信「小程序通知消息」的消息内容键值对
+ *
+ * @author jinyd
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class CorpWechatContentItem {
+
+    /**
+     * 字段名,长度 10 个汉字以内
+     */
+    private String key;
+
+    /**
+     * 字段值,长度 30 个汉字以内
+     */
+    private String value;
+
+}

+ 41 - 0
yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/wechat/dto/CorpWechatMiniProgramNotice.java

@@ -0,0 +1,41 @@
+package cn.iocoder.yudao.module.system.api.wechat.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 企业微信「小程序通知消息」卡片内容,点击后直接进入小程序
+ *
+ * @author jinyd
+ */
+@Data
+public class CorpWechatMiniProgramNotice {
+
+    /**
+     * 标题,最多 12 个汉字(支持 id 转译)
+     */
+    private String title;
+
+    /**
+     * 描述,4 到 12 个汉字(支持 id 转译)
+     */
+    private String description;
+
+    /**
+     * 点击消息卡片后进入的小程序页面路径,最长 1024 字节
+     * 注意:不带前导 /,只能是本小程序内的页面
+     */
+    private String page;
+
+    /**
+     * 消息内容键值对,展示在卡片正文下方
+     */
+    private List<CorpWechatContentItem> contentItem;
+
+    /**
+     * 是否放大展示第一个 content_item,0-否,1-是
+     */
+    private Integer emphasisFirstItem;
+
+}

+ 0 - 37
yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/wechat/dto/CorpWechatTextcard.java

@@ -1,37 +0,0 @@
-package cn.iocoder.yudao.module.system.api.wechat.dto;
-
-import lombok.Builder;
-import lombok.Data;
-
-/**
- * @Author: jinyd
- * @CreateTime: 2024/11/26  17:26
- * @Description:
- * @Version: 4.0
- */
-
-
-@Data
-public class CorpWechatTextcard {
-
-    /**
-     * 标题,不超过128个字符,超过会自动截断(支持id转译)
-     */
-    private String title;
-
-    /**
-     * 描述,不超过512个字符,超过会自动截断(支持id转译)
-     */
-    private String description;
-
-    /**
-     * 点击后跳转的链接。最长2048字节,请确保包含了协议头(http/https)
-     */
-    private String url;
-
-    /**
-     * 按钮文字。 默认为“详情”, 不超过4个文字,超过自动截断。
-     */
-    private String btntxt;
-
-}

+ 2 - 0
yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java

@@ -344,6 +344,8 @@ public interface ErrorCodeConstants {
     ErrorCode ELDERLY_DOCTOR_ORDER_ENTRY_NOT_EXISTS = new ErrorCode(1_008_024_001, "医嘱条目不存在");
     ErrorCode ELDERLY_DOCTOR_ORDER_EXEC_NOT_EXISTS = new ErrorCode(1_008_024_002, "护士执行记录不存在");
     ErrorCode ELDERLY_DOCTOR_ORDER_ELDER_NOT_EXISTS = new ErrorCode(1_008_024_003, "长者信息不存在");
+    ErrorCode ELDERLY_DOCTOR_ORDER_EXEC_STOP_CONFIRM_CANNOT_SKIP = new ErrorCode(1_008_024_004, "停止确认任务不可跳过");
+    ErrorCode ELDERLY_DOCTOR_ORDER_EXEC_STOP_CONFIRM_ONLY_LONG = new ErrorCode(1_008_024_005, "停止确认任务仅支持长期医嘱");
 
     // ========== 出院小结 1-008-026-000 ==========
     ErrorCode ELDERLY_DISCHARGE_ABSTRACT_NOT_EXISTS = new ErrorCode(1_008_026_000, "出院小结不存在");

+ 29 - 2
yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/oauth2/EnterpriseWeChatConstants.java

@@ -44,9 +44,36 @@ public class EnterpriseWeChatConstants {
 
     public static final String GET_USER_BY_MOBILE_URL = CORP_WECHAT_URL + "user/getuserid?access_token=%s";
 
-    public static final String SSO_URL = "http://www.kangyijian.com/authorize";
+    /**
+     * 小程序通知消息类型
+     */
+    public static final String MINI_PROGRAM_MSG_TYPE = "miniprogram_notice";
+
+    /**
+     * 小程序页面路径,不带前导 /,参数总长上限 1024 字节
+     * 占位符顺序:流程实例ID、活动页签、业务状态、接收消息的userId
+     */
+    public static final String MINI_PROGRAM_DETAIL_PAGE = "pages/index/index?processInstanceId=%s&activeName=%s&status=%s&userId=%s";
+
+    /**
+     * 小程序通知消息的描述,官方限制 4-12 个汉字
+     */
+    public static final String MINI_PROGRAM_NOTICE_DESCRIPTION = "点击查看详情";
 
-    public static final String BPM_DETAIL_URL = "/pages/approval/detail/index&processInstanceId=%s&activeName=%s&status=%s";
+    /**
+     * 小程序通知消息 title 最大长度(汉字)
+     */
+    public static final int MINI_PROGRAM_TITLE_MAX_LENGTH = 12;
+
+    /**
+     * 小程序通知消息 content_item 的 key 最大长度(汉字)
+     */
+    public static final int MINI_PROGRAM_CONTENT_ITEM_KEY_MAX_LENGTH = 10;
+
+    /**
+     * 小程序通知消息 content_item 的 value 最大长度(汉字)
+     */
+    public static final int MINI_PROGRAM_CONTENT_ITEM_MAX_LENGTH = 30;
 
 
 }

+ 4 - 6
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/bpm/BpmCheckInApiImpl.java

@@ -16,7 +16,7 @@ import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi;
 import cn.iocoder.yudao.module.system.api.notify.convert.message.UserMessageConvert;
 import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO;
 import cn.iocoder.yudao.module.system.api.wechat.dto.CorpSendMessage;
-import cn.iocoder.yudao.module.system.api.wechat.dto.CorpWechatTextcard;
+import cn.iocoder.yudao.module.system.api.wechat.dto.CorpWechatMiniProgramNotice;
 import cn.iocoder.yudao.module.system.api.wechat.dto.WechatMessageSaveReq;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.ElderlyInformationStatistics;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.ExpenseSaveReqVO;
@@ -235,11 +235,9 @@ public class BpmCheckInApiImpl implements BpmCheckInApi {
         String title = CorpMsgUtil.TITLE_TODO;
         CorpSendMessage corpSendMessage = new CorpSendMessage();
         WechatMessageSaveReq wechatMessageSaveReq = new WechatMessageSaveReq();
-        CorpWechatTextcard corpTextCard = new CorpWechatTextcard();
-        corpTextCard.setTitle(title);
-        corpTextCard.setBtntxt(CorpMsgUtil.BTNTXT_DETAIL_NOTICE);
-        corpTextCard.setUrl("");
-        corpSendMessage.setTextcard(corpTextCard);
+        CorpWechatMiniProgramNotice corpNotice = new CorpWechatMiniProgramNotice();
+        corpNotice.setTitle(title);
+        corpSendMessage.setMiniProgramNotice(corpNotice);
         corpSendMessage.setContentType(1);
         wechatMessageSaveReq.setTitle(title);
         wechatMessageSaveReq.setPkMessage(elderlyInfoDO.getId().toString());

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

@@ -20,7 +20,7 @@ import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi;
 import cn.iocoder.yudao.module.system.api.notify.convert.message.UserMessageConvert;
 import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO;
 import cn.iocoder.yudao.module.system.api.wechat.dto.CorpSendMessage;
-import cn.iocoder.yudao.module.system.api.wechat.dto.CorpWechatTextcard;
+import cn.iocoder.yudao.module.system.api.wechat.dto.CorpWechatMiniProgramNotice;
 import cn.iocoder.yudao.module.system.api.wechat.dto.WechatMessageSaveReq;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.ElderlyInformationStatistics;
 import cn.iocoder.yudao.module.system.dal.dataobject.biz.*;
@@ -60,7 +60,6 @@ import java.util.stream.Collectors;
 
 import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
 import static cn.iocoder.yudao.module.system.enums.NoticeConstants.*;
-import static cn.iocoder.yudao.module.system.enums.oauth2.EnterpriseWeChatConstants.BPM_DETAIL_URL;
 import static net.sf.jsqlparser.util.validation.metadata.NamedObject.index;
 import static net.sf.jsqlparser.util.validation.metadata.NamedObject.user;
 import java.time.LocalDate;
@@ -181,11 +180,9 @@ public class BpmElderlyApiImpl implements BpmElderlyApi {
         String title = CorpMsgUtil.TITLE_TODO;
         CorpSendMessage corpSendMessage = new CorpSendMessage();
         WechatMessageSaveReq wechatMessageSaveReq = new WechatMessageSaveReq();
-        CorpWechatTextcard corpTextCard = new CorpWechatTextcard();
-        corpTextCard.setTitle(title);
-        corpTextCard.setBtntxt(CorpMsgUtil.BTNTXT_DETAIL_NOTICE);
-        corpTextCard.setUrl("");
-        corpSendMessage.setTextcard(corpTextCard);
+        CorpWechatMiniProgramNotice corpNotice = new CorpWechatMiniProgramNotice();
+        corpNotice.setTitle(title);
+        corpSendMessage.setMiniProgramNotice(corpNotice);
         corpSendMessage.setContentType(1);
         wechatMessageSaveReq.setTitle(title);
         wechatMessageSaveReq.setPkMessage(elderId.toString());

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

@@ -16,7 +16,6 @@ import cn.iocoder.yudao.module.system.api.bpm.vo.*;
 import cn.iocoder.yudao.module.system.api.notify.convert.message.UserMessageConvert;
 import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO;
 import cn.iocoder.yudao.module.system.api.wechat.dto.CorpSendMessage;
-import cn.iocoder.yudao.module.system.api.wechat.dto.CorpWechatTextcard;
 import cn.iocoder.yudao.module.system.api.wechat.dto.WechatMessageSaveReq;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.BedChangeCategoryVO;
 import cn.iocoder.yudao.module.system.convert.tenant.TenantConvert;

+ 4 - 6
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/bpm/BpmElderlyExpenseApiImpl.java

@@ -14,7 +14,7 @@ import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi;
 import cn.iocoder.yudao.module.system.api.notify.convert.message.UserMessageConvert;
 import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO;
 import cn.iocoder.yudao.module.system.api.wechat.dto.CorpSendMessage;
-import cn.iocoder.yudao.module.system.api.wechat.dto.CorpWechatTextcard;
+import cn.iocoder.yudao.module.system.api.wechat.dto.CorpWechatMiniProgramNotice;
 import cn.iocoder.yudao.module.system.api.wechat.dto.WechatMessageSaveReq;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.ExpenseOrderItemVO;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.ExpenseOrderSaveVO;
@@ -199,11 +199,9 @@ public class BpmElderlyExpenseApiImpl implements BpmElderlyExpenseApi {
         String title = CorpMsgUtil.TITLE_TODO;
         CorpSendMessage corpSendMessage = new CorpSendMessage();
         WechatMessageSaveReq wechatMessageSaveReq = new WechatMessageSaveReq();
-        CorpWechatTextcard corpTextCard = new CorpWechatTextcard();
-        corpTextCard.setTitle(title);
-        corpTextCard.setBtntxt(CorpMsgUtil.BTNTXT_DETAIL_NOTICE);
-        corpTextCard.setUrl("");
-        corpSendMessage.setTextcard(corpTextCard);
+        CorpWechatMiniProgramNotice corpNotice = new CorpWechatMiniProgramNotice();
+        corpNotice.setTitle(title);
+        corpSendMessage.setMiniProgramNotice(corpNotice);
         corpSendMessage.setContentType(1);
         wechatMessageSaveReq.setTitle(title);
         wechatMessageSaveReq.setPkMessage(elderlyInfo.getId().toString());

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

@@ -27,10 +27,12 @@ import com.alibaba.fastjson.JSONObject;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
+import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -89,6 +91,12 @@ public class CorpWechatApiImpl implements CorpWechatApi{
     @Resource
     private TenantMapper tenantMapper;
 
+    /**
+     * 微信小程序 appid,用于发送小程序通知消息
+     */
+    @Value("${wechat.miniapp.app-id:}")
+    private String miniProgramAppId;
+
     @Override
     @TenantIgnore
     public String getAccessToken(Long tenantId) {
@@ -262,8 +270,12 @@ public class CorpWechatApiImpl implements CorpWechatApi{
     @Async
     public String sendMessage(CorpSendMessage req) {
         WechatMessageSaveReq wechatMessageSaveReq = req.getWechatMessageSaveReq();
-        CorpWechatTextcard textCard = req.getTextcard();
+        CorpWechatMiniProgramNotice notice = req.getMiniProgramNotice();
         String msgRes = StringUtil.EMPY_STRING;
+        if (StringUtil.isEmptyORNull(req.getTouser())) {
+            log.warn("企微消息接收人为空,跳过发送: {}", wechatMessageSaveReq);
+            return msgRes;
+        }
         SocialClientDO client = socialClientMapper.selectOne(SocialTypeEnum.WECHAT_ENTERPRISE.getType(),
                 UserTypeEnum.ADMIN.getValue(), wechatMessageSaveReq.getStartTenantId());
         Assert.notNull(client, String.format("第三方(%d) 不存在", SocialTypeEnum.WECHAT_ENTERPRISE.getType()));
@@ -274,43 +286,65 @@ public class CorpWechatApiImpl implements CorpWechatApi{
             try {
                 JSONObject jsonObject = new JSONObject();
                 jsonObject.put("touser", req.getTouser());
-                jsonObject.put("toparty", StringUtil.EMPY_STRING);
-                jsonObject.put("totag",  StringUtil.EMPY_STRING);
-                jsonObject.put("msgtype", "textcard");
+                jsonObject.put("msgtype", MINI_PROGRAM_MSG_TYPE);
                 jsonObject.put("enable_duplicate_check", 0);
                 jsonObject.put("agentid", Integer.valueOf(client.getAgentId()));
 
-                Map<String, Object> textcard = new HashMap<>();
-                textcard.put("title", textCard.getTitle());
-                textcard.put("description", textCard.getDescription());
-                String ssourl = SSO_URL;
-                if(StringUtil.isNotEmptyORNull(textCard.getUrl())){
-                    ssourl = ssourl + "?todoUrl=" +textCard.getUrl();
+                JSONObject miniProgramNotice = new JSONObject();
+                miniProgramNotice.put("appid", miniProgramAppId);
+                miniProgramNotice.put("title", truncateByCharLength(notice.getTitle(), MINI_PROGRAM_TITLE_MAX_LENGTH));
+                miniProgramNotice.put("description", MINI_PROGRAM_NOTICE_DESCRIPTION);
+                if (StringUtil.isNotEmptyORNull(notice.getPage())) {
+                    miniProgramNotice.put("page", notice.getPage());
+                }
+                miniProgramNotice.put("emphasis_first_item", false);
+                List<JSONObject> contentItems = new ArrayList<>();
+                if (notice.getContentItem() != null) {
+                    for (CorpWechatContentItem item : notice.getContentItem()) {
+                        String value = truncateByCharLength(item.getValue(), MINI_PROGRAM_CONTENT_ITEM_MAX_LENGTH);
+                        if (StringUtil.isEmptyORNull(value)) {
+                            continue;
+                        }
+                        JSONObject contentItem = new JSONObject();
+                        contentItem.put("key", truncateByCharLength(item.getKey(), MINI_PROGRAM_CONTENT_ITEM_KEY_MAX_LENGTH));
+                        contentItem.put("value", value);
+                        contentItems.add(contentItem);
+                    }
                 }
-                log.info("ssourl:" + ssourl);
-                textcard.put("url", ssourl);
-                textcard.put("btntxt", textCard.getBtntxt());
+                miniProgramNotice.put("content_item", contentItems);
 
-                jsonObject.put("textcard", textcard);
+                jsonObject.put("miniprogram_notice", miniProgramNotice);
                 jsonObject.put("result", StringUtil.EMPY_STRING);
                 String msg = jsonObject.toJSONString();
                 log.info("msg:" + msg);
                 HttpClientResult httpClientResult = HttpClientUtil.doPostJson(url, msg, null);
                 msgRes = httpClientResult.getContent();
                 log.info("result:" + msgRes);
-                if (0 == httpClientResult.getCode()) {
+                // 以企微返回的 errcode 为准:HTTP 状态码恒为 200,无法反映真实结果
+                int errcode = -1;
+                String errmsg = StringUtil.EMPY_STRING;
+                String msgId = StringUtil.EMPY_STRING;
+                try {
+                    JSONObject resJson = JSONObject.parseObject(msgRes);
+                    errcode = resJson.getIntValue("errcode");
+                    errmsg = resJson.getString("errmsg");
+                    msgId = resJson.getString("msgid");
+                } catch (Exception parseException) {
+                    log.error("解析企微消息发送响应失败: {}", msgRes);
+                }
+                if (0 == errcode) {
                     wechatMessageSaveReq.setState(1);
                     wechatMessageSaveReq.setParams(msg);
-                    wechatMessageSaveReq.setCorpMsgId(JSONObject.parseObject(msgRes).getString("msgid"));
+                    wechatMessageSaveReq.setCorpMsgId(msgId);
                     WechatMessageDO wechatMessageDO = BeanUtils.toBean(wechatMessageSaveReq, WechatMessageDO.class);
                     wechatMessageMapper.insert(wechatMessageDO);
                 } else {
                     wechatMessageSaveReq.setState(2);
                     wechatMessageSaveReq.setParams(msg);
-                    wechatMessageSaveReq.setCorpMsgId(JSONObject.parseObject(msgRes).getString("msgid"));
+                    wechatMessageSaveReq.setReason(errmsg);
                     WechatMessageDO wechatMessageDO = BeanUtils.toBean(wechatMessageSaveReq, WechatMessageDO.class);
                     wechatMessageMapper.insert(wechatMessageDO);
-                    log.error("发送应用消息错误,错误编码为:{},错误描述为:{}", httpClientResult.getCode(), "");
+                    log.error("发送应用消息失败,errcode:{},errmsg:{},接收人:{}", errcode, errmsg, req.getTouser());
                 }
             } catch (Exception e) {
                 log.error("发送应用消息错误:", e);
@@ -322,6 +356,19 @@ public class CorpWechatApiImpl implements CorpWechatApi{
         return msgRes;
     }
 
+    /**
+     * 按字符数截断,超出部分用省略号替代,保证截断后总长度不超过 maxLength
+     */
+    private String truncateByCharLength(String value, int maxLength) {
+        if (value == null) {
+            return StringUtil.EMPY_STRING;
+        }
+        if (value.length() <= maxLength) {
+            return value;
+        }
+        return value.substring(0, maxLength - 1) + "…";
+    }
+
     @Override
     public String getUserIdByMobile(String mobile) {
         String accessToken = getAccessToken(null);

+ 8 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/AuthController.http

@@ -21,6 +21,14 @@ tenant-id: {{adminTenentId}}
   "password": "admin123"
 }
 
+### 请求 /login-by-user-id 接口 => 使用用户编号登录(免鉴权、不校验机构)
+POST {{baseUrl}}/system/auth/login-by-user-id
+Content-Type: application/json
+
+{
+  "userId": 1
+}
+
 ### 请求 /get-permission-info 接口 => 成功
 GET {{baseUrl}}/system/auth/get-permission-info
 Authorization: Bearer {{token}}

+ 7 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/AuthController.java

@@ -85,6 +85,13 @@ public class AuthController {
     }
 
 
+    @PostMapping("/login-by-user-id")
+    @PermitAll
+    @Operation(summary = "使用用户编号登录", description = "免鉴权,且不校验机构编号,直接根据用户编号下发令牌")
+    public CommonResult<AuthLoginRespVO> loginByUserId(@RequestBody @Valid AuthUserIdLoginReqVO reqVO) {
+        return success(authService.loginByUserId(reqVO));
+    }
+
     @PostMapping("/logout")
     @PermitAll
     @Operation(summary = "登出系统")

+ 22 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthUserIdLoginReqVO.java

@@ -0,0 +1,22 @@
+package cn.iocoder.yudao.module.system.controller.admin.auth.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import javax.validation.constraints.NotNull;
+
+@Schema(description = "管理后台 - 用户编号登录 Request VO")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class AuthUserIdLoginReqVO {
+
+    @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+    @NotNull(message = "用户编号不能为空")
+    private Long userId;
+
+}

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

@@ -1,12 +1,14 @@
 package cn.iocoder.yudao.module.system.controller.admin.biz;
 
 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
 import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
 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.*;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.excel.BuildImportExcelVO;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.excel.BuildScreenExcel;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.excel.EmptyBedExcelVO;
 import cn.iocoder.yudao.module.system.dal.dataobject.biz.BuildDO;
 import cn.iocoder.yudao.module.system.dal.dataobject.biz.BuildRoomDO;
 import cn.iocoder.yudao.module.system.service.biz.BuildService;
@@ -274,4 +276,44 @@ public class BuildController {
             @RequestParam(value = "keyWord", required = false) String keyWord) {
         return success(buildService.getBedListByTenantIdAndKeyword(tenantId,keyWord));
     }
+
+    // ========== 入住运营报表:楼栋 / 楼层床位统计、空床明细 ==========
+
+    @GetMapping("/statistics")
+    @Operation(summary = "统计楼栋 / 楼层床位数据",
+            description = "一次返回机构下各楼栋(level = 1)与各楼层(level = 2)的房间数、整间空房数、" +
+                    "总床位数、在住床位数、空闲床位数、临时占用数与入住率、利用率;" +
+                    "机构范围按登录人权限过滤,超范围的 tenantIds 会被忽略。")
+    @TenantIgnore
+    public CommonResult<BuildStatisticsRespVO> getBuildStatistics(
+            @RequestParam(value = "tenantIds", required = false) Long[] tenantIds,
+            @RequestParam(value = "buildIds", required = false) Long[] buildIds,
+            @RequestParam(value = "orgType", required = false, defaultValue = "1") Integer orgType) {
+        return success(buildService.getBuildStatistics(tenantIds, buildIds, orgType));
+    }
+
+    @GetMapping("/empty-bed/page")
+    @Operation(summary = "空床明细分页",
+            description = "行粒度为一张床,冗余返回机构 / 楼栋 / 楼层 / 房间属性与房间「已住 / 总数」," +
+                    "支持房间区域、朝向、设置、长者类型与床号关键字筛选;分页大小上限 200。")
+    @TenantIgnore
+    public CommonResult<PageResult<EmptyBedRespVO>> getEmptyBedPage(@Valid EmptyBedPageReqVO reqVO) {
+        return success(buildService.getEmptyBedPage(reqVO));
+    }
+
+    @GetMapping("/empty-bed/summary")
+    @Operation(summary = "空床汇总",
+            description = "返回空床总数、涉及空床的房间数、整间空房数;汇总恒按空床统计,不受床位状态筛选影响。")
+    @TenantIgnore
+    public CommonResult<EmptyBedSummaryRespVO> getEmptyBedSummary(EmptyBedPageReqVO reqVO) {
+        return success(buildService.getEmptyBedSummary(reqVO));
+    }
+
+    @GetMapping("/empty-bed/export-excel")
+    @Operation(summary = "导出空床明细")
+    @TenantIgnore
+    public void exportEmptyBedExcel(EmptyBedPageReqVO reqVO, HttpServletResponse response) throws IOException {
+        List<EmptyBedExcelVO> list = buildService.getEmptyBedExcelList(reqVO);
+        ExcelUtils.write(response, "空床明细.xls", "空床明细", EmptyBedExcelVO.class, list);
+    }
 }

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

@@ -2,6 +2,10 @@ 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.OperationAnalysisCheckTrendReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisCheckTrendRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisElderStructureReqVO;
+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.service.biz.OperationAnalysisService;
@@ -45,4 +49,27 @@ public class OperationAnalysisController {
         return success(operationAnalysisService.getOverviewDynamics(reqVO));
     }
 
+    @GetMapping("/check-trend")
+    @Operation(summary = "入住 / 退住趋势",
+            description = "返回期初在院、期间入住、期间退住、净增、期末在院与期末入住率;" +
+                    "按月(默认近 12 个月)或按日统计,无数据期间补零,恒满足「期初 + 入住 - 退住 = 期末」。" +
+                    "期末入住率依赖历史床位数快照,当前无快照表,历史期间返回 null。")
+    @TenantIgnore
+    @PermitAll
+    public CommonResult<OperationAnalysisCheckTrendRespVO> getCheckTrend(
+            OperationAnalysisCheckTrendReqVO reqVO) {
+        return success(operationAnalysisService.getCheckTrend(reqVO));
+    }
+
+    @GetMapping("/elder-structure")
+    @Operation(summary = "长者结构聚合",
+            description = "统计在院长者的性别、年龄段、入住时长、护理等级、文化程度、缴费方式分布与各院区明细;" +
+                    "空值归入「未设置」但计入总数,各分段人数之和 = 在院总数。")
+    @TenantIgnore
+    @PermitAll
+    public CommonResult<OperationAnalysisElderStructureRespVO> getElderStructure(
+            OperationAnalysisElderStructureReqVO reqVO) {
+        return success(operationAnalysisService.getElderStructure(reqVO));
+    }
+
 }

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

@@ -27,6 +27,9 @@ public class BuildFloorDataRespVO {
     @Schema(description = "临时占用数")
     private Integer temporaryOccupationCount;
 
+    @Schema(description = "整间空房数(房内全部床位空闲)")
+    private Integer emptyRoomCount;
+
     @Schema(description = "统计信息")
     private Statistics statistics;
 

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

@@ -0,0 +1,74 @@
+package cn.iocoder.yudao.module.system.controller.admin.biz.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 楼栋 / 楼层床位统计响应 VO
+ *
+ * <p>items 同时包含楼栋级(level = 1)与楼层级(level = 2)数据,前端可直接拼三级树表。</p>
+ *
+ * @author kyj
+ */
+@Data
+@Schema(description = "管理后台 - 楼栋楼层床位统计 Response VO")
+public class BuildStatisticsRespVO {
+
+    @Schema(description = "统计明细")
+    private List<BuildStatisticsItemVO> items;
+
+    @Data
+    @Schema(description = "楼栋 / 楼层床位统计项")
+    public static class BuildStatisticsItemVO {
+
+        @Schema(description = "层级:1 - 楼栋,2 - 楼层", example = "1")
+        private Integer level;
+
+        @Schema(description = "机构ID(租户ID)", example = "196")
+        private Long tenantId;
+
+        @Schema(description = "机构名称", example = "颐年天河")
+        private String tenantName;
+
+        @Schema(description = "楼栋ID", example = "116")
+        private Long buildId;
+
+        @Schema(description = "楼栋名称", example = "1号楼")
+        private String buildName;
+
+        @Schema(description = "楼层ID,楼栋级为空", example = "423")
+        private Long floorId;
+
+        @Schema(description = "楼层名称,楼栋级为空", example = "2层")
+        private String floorName;
+
+        @Schema(description = "房间总数", example = "12")
+        private Long totalRoomCount;
+
+        @Schema(description = "整间空房数(房内全部床位空闲)", example = "3")
+        private Long emptyRoomCount;
+
+        @Schema(description = "总床位数", example = "50")
+        private Long totalBedCount;
+
+        @Schema(description = "在住床位数", example = "39")
+        private Long occupiedBedCount;
+
+        @Schema(description = "空闲床位数", example = "11")
+        private Long emptyBedCount;
+
+        @Schema(description = "临时占用床位数", example = "0")
+        private Long temporaryOccupationCount;
+
+        @Schema(description = "入住率 %(在住床位 / 总床位),总床位为 0 时返回 null", example = "78.0")
+        private BigDecimal occupancyRate;
+
+        @Schema(description = "利用率 %((在住 + 临时占用) / 总床位),总床位为 0 时返回 null", example = "78.0")
+        private BigDecimal utilizationRate;
+
+    }
+
+}

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

@@ -0,0 +1,44 @@
+package cn.iocoder.yudao.module.system.controller.admin.biz.vo;
+
+import cn.iocoder.yudao.framework.common.pojo.PageParam;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 空床明细分页查询 ReqVO
+ *
+ * <p>沿用现有拼写的 {@code floodIds}(楼层ID)。</p>
+ *
+ * @author kyj
+ */
+@Data
+@Schema(description = "管理后台 - 空床明细分页查询 Request VO")
+public class EmptyBedPageReqVO extends PageParam {
+
+    @Schema(description = "楼栋ID数组")
+    private Long[] buildIds;
+
+    @Schema(description = "楼层ID数组(沿用 flood 拼写)")
+    private Long[] floodIds;
+
+    @Schema(description = "床位状态:0 - 空床,1 - 已住,2 - 临时占用;不传表示全部", example = "0")
+    private Integer status;
+
+    @Schema(description = "房间区域数组")
+    private List<String> roomAreaArray;
+
+    @Schema(description = "长者类型数组")
+    private List<String> elderTypeArray;
+
+    @Schema(description = "房间朝向数组")
+    private List<String> orientationArray;
+
+    @Schema(description = "房间设置数组")
+    private List<String> roomSettingsArray;
+
+    @Schema(description = "床位名称 / 房间名称关键字(模糊匹配)")
+    private String bedName;
+
+}

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

@@ -0,0 +1,68 @@
+package cn.iocoder.yudao.module.system.controller.admin.biz.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 空床明细行 VO(行粒度:一张床)
+ *
+ * @author kyj
+ */
+@Data
+@Schema(description = "管理后台 - 空床明细 Response VO")
+public class EmptyBedRespVO implements Serializable {
+
+    @Schema(description = "床位ID", example = "1024")
+    private Long bedId;
+
+    @Schema(description = "床位名称", example = "1号床")
+    private String bedName;
+
+    @Schema(description = "床位状态:0 - 空床,1 - 已住,2 - 临时占用", example = "0")
+    private Integer bedStatus;
+
+    @Schema(description = "房间ID", example = "512")
+    private Long roomId;
+
+    @Schema(description = "房间名称", example = "201")
+    private String roomName;
+
+    @Schema(description = "房间区域")
+    private String roomArea;
+
+    @Schema(description = "房间朝向")
+    private String orientation;
+
+    @Schema(description = "房间设置")
+    private String roomSettings;
+
+    @Schema(description = "房间用途")
+    private String roomUsage;
+
+    @Schema(description = "房间总床位数", example = "4")
+    private Long roomBedCount;
+
+    @Schema(description = "房间已住床位数", example = "3")
+    private Long roomUsedCount;
+
+    @Schema(description = "楼层ID", example = "423")
+    private Long floorId;
+
+    @Schema(description = "楼层名称", example = "2层")
+    private String floorName;
+
+    @Schema(description = "楼栋ID", example = "116")
+    private Long buildId;
+
+    @Schema(description = "楼栋名称", example = "1号楼")
+    private String buildName;
+
+    @Schema(description = "机构ID(租户ID)", example = "196")
+    private Long tenantId;
+
+    @Schema(description = "机构名称", example = "颐年天河")
+    private String tenantName;
+
+}

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

@@ -0,0 +1,28 @@
+package cn.iocoder.yudao.module.system.controller.admin.biz.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 空床汇总 VO
+ *
+ * <p>汇总恒按空床统计:忽略床位状态筛选,只统计状态为 0 的床位。</p>
+ *
+ * @author kyj
+ */
+@Data
+@Schema(description = "管理后台 - 空床汇总 Response VO")
+public class EmptyBedSummaryRespVO implements Serializable {
+
+    @Schema(description = "空床总数", example = "1244")
+    private Long emptyBedCount;
+
+    @Schema(description = "涉及空床的房间数", example = "656")
+    private Long emptyRoomCount;
+
+    @Schema(description = "整间空房数(房内全部床位空闲)", example = "358")
+    private Long fullEmptyRoomCount;
+
+}

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

@@ -0,0 +1,47 @@
+package cn.iocoder.yudao.module.system.controller.admin.biz.vo.excel;
+
+import com.alibaba.excel.annotation.ExcelProperty;
+import lombok.Data;
+
+/**
+ * 空床明细导出 Excel VO
+ *
+ * @author kyj
+ */
+@Data
+public class EmptyBedExcelVO {
+
+    @ExcelProperty(value = "机构名称")
+    private String tenantName;
+
+    @ExcelProperty(value = "楼栋名称")
+    private String buildName;
+
+    @ExcelProperty(value = "楼层名称")
+    private String floorName;
+
+    @ExcelProperty(value = "房间名称")
+    private String roomName;
+
+    @ExcelProperty(value = "床位名称")
+    private String bedName;
+
+    @ExcelProperty(value = "床位状态")
+    private String bedStatusText;
+
+    @ExcelProperty(value = "房间已住/总数")
+    private String roomUsedText;
+
+    @ExcelProperty(value = "房间区域")
+    private String roomArea;
+
+    @ExcelProperty(value = "房间朝向")
+    private String orientation;
+
+    @ExcelProperty(value = "房间设置")
+    private String roomSettings;
+
+    @ExcelProperty(value = "房间用途")
+    private String roomUsage;
+
+}

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

@@ -0,0 +1,39 @@
+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
+ *
+ * @author kyj
+ */
+@Data
+public class OperationAnalysisCheckTrendReqVO {
+
+    @Schema(description = "机构ID数组(即租户ID),不传时取当前登录机构范围")
+    private Long[] tenantIds;
+
+    @Schema(description = "统计粒度:month - 按月(默认),day - 按日", example = "month")
+    private String granularity = "month";
+
+    @Schema(description = "开始日期 yyyy-MM-dd;granularity = day 时必填")
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY)
+    private LocalDate startDate;
+
+    @Schema(description = "结束日期 yyyy-MM-dd;granularity = day 时必填")
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY)
+    private LocalDate endDate;
+
+    @Schema(description = "按月统计的月份数量,默认 12(仅在未传 startDate / endDate 时生效)", example = "12")
+    private Integer months = 12;
+
+    @Schema(description = "系统类型", example = "1")
+    private Integer orgType = 1;
+
+}

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

@@ -0,0 +1,52 @@
+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>items 按期间升序,无数据期间补零,且恒满足 期初 + 入住 - 退住 = 期末。</p>
+ *
+ * @author kyj
+ */
+@Data
+public class OperationAnalysisCheckTrendRespVO {
+
+    @Schema(description = "趋势明细(按期间升序)")
+    private List<CheckTrendItemVO> items;
+
+    /**
+     * 单期趋势
+     */
+    @Data
+    @Schema(description = "单期入住 / 退住趋势")
+    public static class CheckTrendItemVO {
+
+        @Schema(description = "期间:按月为 yyyy-MM,按日为 yyyy-MM-dd", example = "2026-09")
+        private String period;
+
+        @Schema(description = "期初在院人数", example = "3000")
+        private Long beginInHospital;
+
+        @Schema(description = "期间入住人数", example = "26")
+        private Long checkInCount;
+
+        @Schema(description = "期间退住人数", example = "18")
+        private Long checkOutCount;
+
+        @Schema(description = "净增人数(入住 - 退住)", example = "8")
+        private Long netGrowth;
+
+        @Schema(description = "期末在院人数(期初 + 入住 - 退住)", example = "3008")
+        private Long endInHospital;
+
+        @Schema(description = "期末入住率 %;无历史快照的期间返回 null", example = "71.1")
+        private BigDecimal endOccupancyRate;
+
+    }
+
+}

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

@@ -0,0 +1,20 @@
+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 OperationAnalysisElderStructureReqVO {
+
+    @Schema(description = "机构ID数组(即租户ID),不传时取当前登录机构范围")
+    private Long[] tenantIds;
+
+    @Schema(description = "系统类型", example = "1")
+    private Integer orgType = 1;
+
+}

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

@@ -0,0 +1,149 @@
+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>口径:仅统计在住长者({@code in_status = 1}),空值归入「未设置」但计入总数,
+ * 因此各分段人数之和 = 在院总数。</p>
+ *
+ * @author kyj
+ */
+@Data
+public class OperationAnalysisElderStructureRespVO {
+
+    @Schema(description = "在院总数", example = "3060")
+    private Long total;
+
+    @Schema(description = "男性人数", example = "1127")
+    private Long maleCount;
+
+    @Schema(description = "女性人数", example = "1933")
+    private Long femaleCount;
+
+    @Schema(description = "平均年龄", example = "80.7")
+    private BigDecimal avgAge;
+
+    @Schema(description = "80 岁以上人数", example = "2100")
+    private Long over80Count;
+
+    @Schema(description = "90 岁以上人数", example = "501")
+    private Long over90Count;
+
+    @Schema(description = "平均入住月数", example = "26.4")
+    private BigDecimal avgStayMonths;
+
+    @Schema(description = "性别分布")
+    private List<BucketVO> sexBuckets;
+
+    @Schema(description = "年龄段分布")
+    private List<BucketVO> ageBuckets;
+
+    @Schema(description = "入住时长分布")
+    private List<BucketVO> stayBuckets;
+
+    @Schema(description = "护理等级分布(按人数降序)")
+    private List<NurseLevelBucketVO> nurseLevelBuckets;
+
+    @Schema(description = "文化程度分布(value 为字典原值)")
+    private List<DictBucketVO> educationBuckets;
+
+    @Schema(description = "缴费方式分布(value 为字典原值)")
+    private List<DictBucketVO> paymentBuckets;
+
+    @Schema(description = "各院区明细")
+    private List<CampusStatVO> campusStats;
+
+    /**
+     * 通用分段(label + count)
+     */
+    @Data
+    @Schema(description = "分段统计")
+    public static class BucketVO {
+
+        @Schema(description = "分段名称", example = "80-89")
+        private String label;
+
+        @Schema(description = "人数", example = "1200")
+        private Long count;
+
+    }
+
+    /**
+     * 护理等级分段
+     */
+    @Data
+    @Schema(description = "护理等级分段统计")
+    public static class NurseLevelBucketVO {
+
+        @Schema(description = "护理等级ID", example = "1")
+        private Long nurseLevelId;
+
+        @Schema(description = "护理等级名称", example = "一级护理")
+        private String nurseLevelName;
+
+        @Schema(description = "人数", example = "452")
+        private Long count;
+
+    }
+
+    /**
+     * 字典分段(value 返回字典原值,由前端翻译)
+     */
+    @Data
+    @Schema(description = "字典分段统计")
+    public static class DictBucketVO {
+
+        @Schema(description = "字典原值", example = "1")
+        private Integer value;
+
+        @Schema(description = "展示名称", example = "1")
+        private String label;
+
+        @Schema(description = "人数", example = "800")
+        private Long count;
+
+    }
+
+    /**
+     * 院区明细
+     */
+    @Data
+    @Schema(description = "各院区长者结构")
+    public static class CampusStatVO {
+
+        @Schema(description = "机构ID(租户ID)", example = "196")
+        private Long tenantId;
+
+        @Schema(description = "机构名称", example = "颐年天河")
+        private String tenantName;
+
+        @Schema(description = "在院人数", example = "727")
+        private Long total;
+
+        @Schema(description = "男性人数", example = "235")
+        private Long maleCount;
+
+        @Schema(description = "女性人数", example = "492")
+        private Long femaleCount;
+
+        @Schema(description = "平均年龄", example = "81.3")
+        private BigDecimal avgAge;
+
+        @Schema(description = "80 岁以上人数", example = "520")
+        private Long over80Count;
+
+        @Schema(description = "平均入住月数", example = "26.4")
+        private BigDecimal avgStayMonths;
+
+        @Schema(description = "主要护理等级名称", example = "一级护理")
+        private String mainNurseLevelName;
+
+    }
+
+}

+ 3 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/doctororder/vo/DoctorOrderExecutionPageReqVO.java

@@ -25,6 +25,9 @@ public class DoctorOrderExecutionPageReqVO extends PageParam {
     @Schema(description = "医嘱类型 1长期 2临时")
     private Integer orderType;
 
+    @Schema(description = "执行类型 1常规执行 2停止确认,不传则不过滤")
+    private Integer execType;
+
     @Schema(description = "执行状态 0待执行 1已执行 2已跳过")
     private Integer status;
 

+ 5 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/doctororder/vo/DoctorOrderExecutionRespVO.java

@@ -24,6 +24,8 @@ public class DoctorOrderExecutionRespVO {
     private Integer orderType;
     @Schema(description = "关联医嘱条目ID")
     private Long orderEntryId;
+    @Schema(description = "执行类型 1=常规执行 2=停止确认")
+    private Integer execType;
     @Schema(description = "长者ID")
     private Long elderId;
     @Schema(description = "长者姓名")
@@ -48,8 +50,10 @@ public class DoctorOrderExecutionRespVO {
     @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
     @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
     private LocalDateTime execTime;
-    @Schema(description = "执行护士签名")
+    @Schema(description = "执行护士签名(停止确认时为停止执行护士签名)")
     private String execNurseSign;
+    @Schema(description = "医生签名(常规执行=开嘱医生;停止确认=停止医生,展示用)")
+    private String doctorSign;
     @Schema(description = "状态 0待执行 1已执行 2已跳过")
     private Integer status;
     @Schema(description = "备注 / 跳过原因")

+ 5 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/doctororder/vo/DoctorOrderExecutionSaveReqVO.java

@@ -24,6 +24,8 @@ public class DoctorOrderExecutionSaveReqVO {
     private Integer orderType;
     @Schema(description = "关联医嘱条目ID", requiredMode = Schema.RequiredMode.REQUIRED)
     private Long orderEntryId;
+    @Schema(description = "执行类型 1=常规执行 2=停止确认(长期医嘱停止需护士确认),不传按 1 处理")
+    private Integer execType;
     @Schema(description = "长者ID")
     private Long elderId;
     @Schema(description = "长者姓名")
@@ -46,8 +48,10 @@ public class DoctorOrderExecutionSaveReqVO {
     @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
     @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
     private LocalDateTime execTime;
-    @Schema(description = "执行护士签名")
+    @Schema(description = "执行护士签名(停止确认时为停止执行护士签名)")
     private String execNurseSign;
+    @Schema(description = "医生签名(常规执行=开嘱医生;停止确认=停止医生,展示用)")
+    private String doctorSign;
     @Schema(description = "状态 0待执行 1已执行 2已跳过", requiredMode = Schema.RequiredMode.REQUIRED)
     private Integer status;
     @Schema(description = "备注 / 跳过原因")

+ 4 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/biz/DoctorOrderExecutionDO.java

@@ -27,6 +27,8 @@ public class DoctorOrderExecutionDO extends BaseNoDeleteDO {
     private Integer orderType;
     /** 关联 long/short_doctor_order_entry.id */
     private Long orderEntryId;
+    /** 执行类型 1=常规执行 2=停止确认(长期医嘱停止需护士确认签名) */
+    private Integer execType;
     /** 长者ID */
     private Long elderId;
     /** 长者姓名 */
@@ -47,6 +49,8 @@ public class DoctorOrderExecutionDO extends BaseNoDeleteDO {
     private LocalDateTime execTime;
     /** 执行护士签名 */
     private String execNurseSign;
+    /** 医生签名(常规执行=开嘱医生;停止确认=停止医生,展示用) */
+    private String doctorSign;
     /** 状态 0待执行 1已执行 2已跳过 */
     private Integer status;
     /** 执行备注 / 跳过原因 */

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

@@ -4,7 +4,11 @@ import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.BuildBedVO;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.CheckInWaitBedSimpleRespVO;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.ElderlyInfoHomeDataReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.EmptyBedPageReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.EmptyBedRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.EmptyBedSummaryRespVO;
 import cn.iocoder.yudao.module.system.dal.dataobject.biz.BuildBedDO;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 
@@ -35,4 +39,29 @@ public interface BuildBedMapper extends BaseMapperX<BuildBedDO> {
                                      @Param("status") Integer status,
                                      @Param("orgType") Integer orgType,
                                      @Param("tenantIds") Long[] tenantIds);
+
+    /**
+     * 空床明细分页查询(行粒度:一张床)
+     *
+     * @param page  分页参数
+     * @param reqVO 查询条件
+     * @return 空床明细
+     */
+    List<EmptyBedRespVO> selectEmptyBedPage(Page<EmptyBedRespVO> page, @Param("reqVO") EmptyBedPageReqVO reqVO);
+
+    /**
+     * 空床明细全量查询(用于导出)
+     *
+     * @param reqVO 查询条件
+     * @return 空床明细
+     */
+    List<EmptyBedRespVO> selectEmptyBedList(@Param("reqVO") EmptyBedPageReqVO reqVO);
+
+    /**
+     * 空床汇总:恒按空床统计,忽略床位状态筛选
+     *
+     * @param reqVO 查询条件
+     * @return 空床总数 / 涉及空床房间数 / 整间空房数
+     */
+    EmptyBedSummaryRespVO selectEmptyBedSummary(@Param("reqVO") EmptyBedPageReqVO reqVO);
 }

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

@@ -0,0 +1,32 @@
+package cn.iocoder.yudao.module.system.dal.mysql.biz;
+
+import cn.iocoder.yudao.module.system.dal.mysql.biz.bo.BuildFloorBedStatisticsBO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * 楼栋 / 楼层床位统计 Mapper
+ *
+ * <p>一次性按「楼层」聚合出房间数、整间空房数与各状态床位数,避免前端按楼栋逐个请求。</p>
+ *
+ * @author kyj
+ */
+@Mapper
+public interface BuildStatisticsMapper {
+
+    /**
+     * 按楼层聚合床位统计
+     *
+     * @param tenantIds 机构ID
+     * @param buildIds  楼栋ID,为空表示不限制
+     * @param orgType   系统类型
+     * @return 每个楼层一条统计
+     */
+    List<BuildFloorBedStatisticsBO> selectFloorBedStatistics(@Param("tenantIds") Collection<Long> tenantIds,
+                                                             @Param("buildIds") Collection<Long> buildIds,
+                                                             @Param("orgType") Integer orgType);
+
+}

+ 51 - 2
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/DoctorOrderExecutionMapper.java

@@ -5,6 +5,9 @@ import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
 import cn.iocoder.yudao.module.system.controller.admin.doctororder.vo.DoctorOrderExecutionPageReqVO;
 import cn.iocoder.yudao.module.system.dal.dataobject.biz.DoctorOrderExecutionDO;
+import cn.iocoder.yudao.module.system.enums.biz.DoctorOrderExecTypeEnum;
+import cn.iocoder.yudao.module.system.enums.biz.DoctorOrderExecutionStatusEnum;
+import cn.iocoder.yudao.module.system.enums.biz.DoctorOrderTypeEnum;
 import org.apache.ibatis.annotations.Mapper;
 
 import java.time.LocalDate;
@@ -24,6 +27,7 @@ public interface DoctorOrderExecutionMapper extends BaseMapperX<DoctorOrderExecu
         LambdaQueryWrapperX<DoctorOrderExecutionDO> query = new LambdaQueryWrapperX<DoctorOrderExecutionDO>()
                 .likeIfPresent(DoctorOrderExecutionDO::getElderName, reqVO.getElderName())
                 .eqIfPresent(DoctorOrderExecutionDO::getOrderType, reqVO.getOrderType())
+                .eqIfPresent(DoctorOrderExecutionDO::getExecType, reqVO.getExecType())
                 .eqIfPresent(DoctorOrderExecutionDO::getStatus, reqVO.getStatus())
                 .inIfPresent(DoctorOrderExecutionDO::getTenantId, reqVO.getTenantIds())
                 .orderByDesc(DoctorOrderExecutionDO::getPlanTime);
@@ -41,11 +45,56 @@ public interface DoctorOrderExecutionMapper extends BaseMapperX<DoctorOrderExecu
         return selectPage(reqVO, query);
     }
 
-    default DoctorOrderExecutionDO selectByUniqueKey(Integer orderType, Long orderEntryId, java.time.LocalDateTime planTime) {
+    /**
+     * 按唯一键 (orderType, orderEntryId, planTime, execType) 查询,用于执行/确认的幂等 upsert
+     */
+    default DoctorOrderExecutionDO selectByUniqueKey(Integer orderType, Long orderEntryId,
+                                                     java.time.LocalDateTime planTime, Integer execType) {
         return selectOne(new LambdaQueryWrapperX<DoctorOrderExecutionDO>()
                 .eq(DoctorOrderExecutionDO::getOrderType, orderType)
                 .eq(DoctorOrderExecutionDO::getOrderEntryId, orderEntryId)
-                .eq(DoctorOrderExecutionDO::getPlanTime, planTime));
+                .eq(DoctorOrderExecutionDO::getPlanTime, planTime)
+                .eq(DoctorOrderExecutionDO::getExecType, execType)
+                .last("LIMIT 1"));
+    }
+
+    /**
+     * 查询某长期医嘱条目待确认的停止确认任务(execType=2 且 status=0)
+     */
+    default DoctorOrderExecutionDO selectPendingStopConfirm(Long orderEntryId) {
+        return selectOne(new LambdaQueryWrapperX<DoctorOrderExecutionDO>()
+                .eq(DoctorOrderExecutionDO::getOrderType, DoctorOrderTypeEnum.LONG.getType())
+                .eq(DoctorOrderExecutionDO::getOrderEntryId, orderEntryId)
+                .eq(DoctorOrderExecutionDO::getExecType, DoctorOrderExecTypeEnum.STOP_CONFIRM.getType())
+                .eq(DoctorOrderExecutionDO::getStatus, DoctorOrderExecutionStatusEnum.PENDING.getStatus())
+                .last("LIMIT 1"));
+    }
+
+    /**
+     * 查询某长期医嘱条目最近一条停止确认任务(不限状态,用于重新启用后再次停止时复用)
+     */
+    default DoctorOrderExecutionDO selectLatestStopConfirm(Long orderEntryId) {
+        return selectOne(new LambdaQueryWrapperX<DoctorOrderExecutionDO>()
+                .eq(DoctorOrderExecutionDO::getOrderType, DoctorOrderTypeEnum.LONG.getType())
+                .eq(DoctorOrderExecutionDO::getOrderEntryId, orderEntryId)
+                .eq(DoctorOrderExecutionDO::getExecType, DoctorOrderExecTypeEnum.STOP_CONFIRM.getType())
+                .orderByDesc(DoctorOrderExecutionDO::getId)
+                .last("LIMIT 1"));
+    }
+
+    /**
+     * 失效某长期医嘱条目待确认的停止确认任务(医嘱重新启用时调用,置为已跳过)
+     */
+    default void invalidPendingStopConfirm(Long orderEntryId) {
+        List<DoctorOrderExecutionDO> list = selectList(new LambdaQueryWrapperX<DoctorOrderExecutionDO>()
+                .eq(DoctorOrderExecutionDO::getOrderType, DoctorOrderTypeEnum.LONG.getType())
+                .eq(DoctorOrderExecutionDO::getOrderEntryId, orderEntryId)
+                .eq(DoctorOrderExecutionDO::getExecType, DoctorOrderExecTypeEnum.STOP_CONFIRM.getType())
+                .eq(DoctorOrderExecutionDO::getStatus, DoctorOrderExecutionStatusEnum.PENDING.getStatus()));
+        for (DoctorOrderExecutionDO item : list) {
+            item.setStatus(DoctorOrderExecutionStatusEnum.SKIPPED.getStatus());
+            updateById(item);
+        }
     }
 
     default List<DoctorOrderExecutionDO> selectByEntryIds(Integer orderType, List<Long> orderEntryIds) {

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

@@ -1,5 +1,6 @@
 package cn.iocoder.yudao.module.system.dal.mysql.biz;
 
+import cn.iocoder.yudao.module.system.dal.mysql.biz.bo.ElderStructureRowBO;
 import cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationNurseLevelCountBO;
 import cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationTenantBedCountBO;
 import cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationTenantCountBO;
@@ -144,4 +145,66 @@ public interface OperationAnalysisMapper {
      */
     List<OperationTenantBedCountBO> selectBedStructure(@Param("tenantIds") Collection<Long> tenantIds);
 
+    /**
+     * 区间入住数(按天分组):用于入住 / 退住趋势
+     *
+     * @param tenantIds 机构ID
+     * @param orgType   系统类型
+     * @param begin     区间起始日(含)
+     * @param end       区间结束日次日(不含)
+     * @return 按机构 + 日期(yyyy-MM-dd)分组的入住人数
+     */
+    List<OperationTenantMonthCountBO> selectCheckInDailyTrend(@Param("tenantIds") Collection<Long> tenantIds,
+                                                              @Param("orgType") Integer orgType,
+                                                              @Param("begin") LocalDate begin,
+                                                              @Param("end") LocalDate end);
+
+    /**
+     * 区间退住数(按天分组):仅统计「已完成」的退住
+     *
+     * @param tenantIds 机构ID
+     * @param orgType   系统类型
+     * @param begin     区间起始日(含)
+     * @param end       区间结束日次日(不含)
+     * @return 按机构 + 日期(yyyy-MM-dd)分组的退住人数
+     */
+    List<OperationTenantMonthCountBO> selectCheckOutDailyTrend(@Param("tenantIds") Collection<Long> tenantIds,
+                                                               @Param("orgType") Integer orgType,
+                                                               @Param("begin") LocalDate begin,
+                                                               @Param("end") LocalDate end);
+
+    /**
+     * 区间起始日之前的累计入住数
+     *
+     * @param tenantIds 机构ID
+     * @param orgType   系统类型
+     * @param begin     区间起始日(不含)
+     * @return 累计入住人数
+     */
+    Long selectCheckInCountBefore(@Param("tenantIds") Collection<Long> tenantIds,
+                                  @Param("orgType") Integer orgType,
+                                  @Param("begin") LocalDate begin);
+
+    /**
+     * 区间起始日之前的累计退住数(仅「已完成」的退住)
+     *
+     * @param tenantIds 机构ID
+     * @param orgType   系统类型
+     * @param begin     区间起始日(不含)
+     * @return 累计退住人数
+     */
+    Long selectCheckOutCountBefore(@Param("tenantIds") Collection<Long> tenantIds,
+                                   @Param("orgType") Integer orgType,
+                                   @Param("begin") LocalDate begin);
+
+    /**
+     * 长者结构聚合明细行:只取聚合所需字段
+     *
+     * @param tenantIds 机构ID
+     * @param orgType   系统类型
+     * @return 在住长者明细行
+     */
+    List<ElderStructureRowBO> selectElderStructureRows(@Param("tenantIds") Collection<Long> tenantIds,
+                                                       @Param("orgType") Integer orgType);
+
 }

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

@@ -0,0 +1,75 @@
+package cn.iocoder.yudao.module.system.dal.mysql.biz.bo;
+
+import lombok.Data;
+
+/**
+ * 楼栋 / 楼层床位统计 BO
+ *
+ * <p>床位状态:0 - 空闲,1 - 已住,2 - 临时占用。</p>
+ *
+ * @author kyj
+ */
+@Data
+public class BuildFloorBedStatisticsBO {
+
+    /**
+     * 机构ID(租户ID)
+     */
+    private Long tenantId;
+
+    /**
+     * 机构名称
+     */
+    private String tenantName;
+
+    /**
+     * 楼栋ID
+     */
+    private Long buildId;
+
+    /**
+     * 楼栋名称
+     */
+    private String buildName;
+
+    /**
+     * 楼层ID
+     */
+    private Long floorId;
+
+    /**
+     * 楼层名称
+     */
+    private String floorName;
+
+    /**
+     * 房间总数
+     */
+    private Long totalRoomCount;
+
+    /**
+     * 整间空房数(房内全部床位空闲)
+     */
+    private Long emptyRoomCount;
+
+    /**
+     * 总床位数
+     */
+    private Long totalBedCount;
+
+    /**
+     * 在住床位数
+     */
+    private Long occupiedBedCount;
+
+    /**
+     * 空闲床位数
+     */
+    private Long emptyBedCount;
+
+    /**
+     * 临时占用床位数
+     */
+    private Long temporaryOccupationCount;
+
+}

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

@@ -0,0 +1,62 @@
+package cn.iocoder.yudao.module.system.dal.mysql.biz.bo;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 长者结构聚合 —— 明细行 BO
+ *
+ * <p>只查询聚合所需的少量字段,避免在院长者全量建档字段的传输开销。</p>
+ *
+ * @author kyj
+ */
+@Data
+public class ElderStructureRowBO {
+
+    /**
+     * 机构ID(租户ID)
+     */
+    private Long tenantId;
+
+    /**
+     * 长者性别:1 - 男,2 - 女
+     */
+    private Integer elderSex;
+
+    /**
+     * 长者年龄,可能为空
+     */
+    private Integer elderAge;
+
+    /**
+     * 出生日期,用于年龄为空时兜底计算
+     */
+    private Date birthday;
+
+    /**
+     * 入住时间,用于计算入住时长
+     */
+    private Date checkInTime;
+
+    /**
+     * 护理等级ID
+     */
+    private Long nurseLevelId;
+
+    /**
+     * 护理等级名称
+     */
+    private String nurseLevelName;
+
+    /**
+     * 文化程度(字典原值)
+     */
+    private Integer degreeEducation;
+
+    /**
+     * 缴费方式(字典原值)
+     */
+    private Integer paymentMethod;
+
+}

+ 34 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/enums/biz/DoctorOrderExecTypeEnum.java

@@ -0,0 +1,34 @@
+package cn.iocoder.yudao.module.system.enums.biz;
+
+import lombok.Getter;
+
+/**
+ * 护士执行记录类型枚举
+ * 1=常规执行(日常给药/治疗) 2=停止确认(长期医嘱停止需护士确认签名)
+ *
+ * @author 系统
+ */
+@Getter
+public enum DoctorOrderExecTypeEnum {
+
+    NORMAL(1, "常规执行"),
+    STOP_CONFIRM(2, "停止确认");
+
+    private final Integer type;
+    private final String name;
+
+    DoctorOrderExecTypeEnum(Integer type, String name) {
+        this.type = type;
+        this.name = name;
+    }
+
+    public static DoctorOrderExecTypeEnum getByType(Integer type) {
+        for (DoctorOrderExecTypeEnum value : values()) {
+            if (value.getType().equals(type)) {
+                return value;
+            }
+        }
+        return null;
+    }
+
+}

+ 10 - 16
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/job/BusinessConfigJob.java

@@ -14,7 +14,7 @@ import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi;
 import cn.iocoder.yudao.module.system.api.notify.convert.message.UserMessageConvert;
 import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO;
 import cn.iocoder.yudao.module.system.api.wechat.dto.CorpSendMessage;
-import cn.iocoder.yudao.module.system.api.wechat.dto.CorpWechatTextcard;
+import cn.iocoder.yudao.module.system.api.wechat.dto.CorpWechatMiniProgramNotice;
 import cn.iocoder.yudao.module.system.api.wechat.dto.WechatMessageSaveReq;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.ByodRegistrationRespVO;
 import cn.iocoder.yudao.module.system.dal.dataobject.biz.ByodRegistrationDrugDO;
@@ -164,11 +164,9 @@ public class BusinessConfigJob implements JobHandler {
                             String title = CorpMsgUtil.TITLE_TODO;
                             CorpSendMessage corpSendMessage = new CorpSendMessage();
                             WechatMessageSaveReq wechatMessageSaveReq = new WechatMessageSaveReq();
-                            CorpWechatTextcard corpTextCard = new CorpWechatTextcard();
-                            corpTextCard.setTitle(title);
-                            corpTextCard.setBtntxt(CorpMsgUtil.BTNTXT_DETAIL_NOTICE);
-                            corpTextCard.setUrl("");
-                            corpSendMessage.setTextcard(corpTextCard);
+                            CorpWechatMiniProgramNotice corpNotice = new CorpWechatMiniProgramNotice();
+                            corpNotice.setTitle(title);
+                            corpSendMessage.setMiniProgramNotice(corpNotice);
                             corpSendMessage.setContentType(1);
                             wechatMessageSaveReq.setTitle(title);
                             wechatMessageSaveReq.setPkMessage2("");
@@ -219,11 +217,9 @@ public class BusinessConfigJob implements JobHandler {
                         String title = CorpMsgUtil.TITLE_TODO;
                         CorpSendMessage corpSendMessage = new CorpSendMessage();
                         WechatMessageSaveReq wechatMessageSaveReq = new WechatMessageSaveReq();
-                        CorpWechatTextcard corpTextCard = new CorpWechatTextcard();
-                        corpTextCard.setTitle(title);
-                        corpTextCard.setBtntxt(CorpMsgUtil.BTNTXT_DETAIL_NOTICE);
-                        corpTextCard.setUrl("");
-                        corpSendMessage.setTextcard(corpTextCard);
+                        CorpWechatMiniProgramNotice corpNotice = new CorpWechatMiniProgramNotice();
+                        corpNotice.setTitle(title);
+                        corpSendMessage.setMiniProgramNotice(corpNotice);
                         corpSendMessage.setContentType(1);
                         wechatMessageSaveReq.setTitle(title);
                         wechatMessageSaveReq.setPkMessage2("");
@@ -281,11 +277,9 @@ public class BusinessConfigJob implements JobHandler {
                         String title = CorpMsgUtil.TITLE_TODO;
                         CorpSendMessage corpSendMessage = new CorpSendMessage();
                         WechatMessageSaveReq wechatMessageSaveReq = new WechatMessageSaveReq();
-                        CorpWechatTextcard corpTextCard = new CorpWechatTextcard();
-                        corpTextCard.setTitle(title);
-                        corpTextCard.setBtntxt(CorpMsgUtil.BTNTXT_DETAIL_NOTICE);
-                        corpTextCard.setUrl("");
-                        corpSendMessage.setTextcard(corpTextCard);
+                        CorpWechatMiniProgramNotice corpNotice = new CorpWechatMiniProgramNotice();
+                        corpNotice.setTitle(title);
+                        corpSendMessage.setMiniProgramNotice(corpNotice);
                         corpSendMessage.setContentType(1);
                         wechatMessageSaveReq.setTitle(title);
                         wechatMessageSaveReq.setPkMessage2("");

+ 8 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthService.java

@@ -78,5 +78,13 @@ public interface AdminAuthService {
 
     AuthLoginRespVO mobileLogin(AuthLoginReqVO reqVO);
 
+    /**
+     * 使用用户编号登录,免鉴权、且不校验机构(租户)编号
+     *
+     * @param reqVO 登录信息
+     * @return 登录结果
+     */
+    AuthLoginRespVO loginByUserId(AuthUserIdLoginReqVO reqVO);
+
     AuthLoginRespVO checkTenantLogin(Long tenantId, String tenantName, String loginUserId, Integer loginPlatform);
 }

+ 31 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImpl.java

@@ -192,6 +192,37 @@ public class AdminAuthServiceImpl implements AdminAuthService {
         return createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME);
     }
 
+    @Override
+    @TenantIgnore
+    public AuthLoginRespVO loginByUserId(AuthUserIdLoginReqVO reqVO) {
+        // 1. 忽略租户(机构)查询用户
+        AdminUserDO user = userService.getUserTenantIgnore(reqVO.getUserId());
+        if (user == null) {
+            createLoginLog(null, String.valueOf(reqVO.getUserId()),
+                    LoginLogTypeEnum.LOGIN_USERNAME, LoginResultEnum.BAD_CREDENTIALS);
+            throw exception(USER_NOT_EXISTS);
+        }
+        // 2. 校验是否禁用
+        if (CommonStatusEnum.isDisable(user.getStatus())) {
+            createLoginLog(user.getId(), user.getUsername(),
+                    LoginLogTypeEnum.LOGIN_USERNAME, LoginResultEnum.USER_DISABLED);
+            throw exception(AUTH_LOGIN_USER_DISABLED);
+        }
+
+        // 3. 切换到用户所属的机构,创建 Token 令牌,记录登录日志
+        TenantContextHolder.setTenantId(user.getTenantId());
+        AuthLoginRespVO respVO = createTokenAfterLoginSuccess(user.getId(), user.getUsername(),
+                LoginLogTypeEnum.LOGIN_USERNAME);
+
+        // 4. 补充返回信息
+        TenantDO tenant = tenantMapper.selectById(user.getTenantId());
+        respVO.setTenantId(user.getTenantId());
+        respVO.setUsername(user.getUsername());
+        respVO.setTenantName(tenant == null ? null : tenant.getName());
+        respVO.setLoginPlatform(tenant == null ? null : tenant.getGroupsManager());
+        return respVO;
+    }
+
     @Override
     public void sendSmsCode(AuthSmsSendReqVO reqVO) {
         // 登录场景,验证是否存在

+ 36 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/BuildService.java

@@ -4,8 +4,10 @@ package cn.iocoder.yudao.module.system.service.biz;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.*;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.excel.BuildImportExcelVO;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.excel.BuildScreenExcel;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.excel.EmptyBedExcelVO;
 import cn.iocoder.yudao.module.system.dal.dataobject.biz.BuildDO;
 import cn.iocoder.yudao.module.system.dal.dataobject.biz.BuildRoomDO;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
 
 import java.util.List;
 import java.util.Map;
@@ -42,6 +44,40 @@ public interface BuildService {
 
     List<BuildBedVO> getBedList(String bedName, Integer status, Integer orgType, Long[] tenantIds);
 
+    /**
+     * 楼栋 / 楼层床位统计:一次返回楼栋级与楼层级统计,前端可直接拼三级树表
+     *
+     * @param tenantIds 机构ID数组,为空时取登录账号可见范围
+     * @param buildIds  楼栋ID数组,为空表示不限制
+     * @param orgType   系统类型,默认 1
+     * @return 楼栋 / 楼层床位统计
+     */
+    BuildStatisticsRespVO getBuildStatistics(Long[] tenantIds, Long[] buildIds, Integer orgType);
+
+    /**
+     * 空床明细分页(行粒度:一张床)
+     *
+     * @param reqVO 查询条件
+     * @return 分页结果
+     */
+    PageResult<EmptyBedRespVO> getEmptyBedPage(EmptyBedPageReqVO reqVO);
+
+    /**
+     * 空床汇总:空床总数 / 涉及空床房间数 / 整间空房数(恒按空床统计)
+     *
+     * @param reqVO 查询条件
+     * @return 空床汇总
+     */
+    EmptyBedSummaryRespVO getEmptyBedSummary(EmptyBedPageReqVO reqVO);
+
+    /**
+     * 空床明细导出数据
+     *
+     * @param reqVO 查询条件
+     * @return Excel 行
+     */
+    List<EmptyBedExcelVO> getEmptyBedExcelList(EmptyBedPageReqVO reqVO);
+
     BuildBedVO getBedInfoById(Long bedId);
 
     String getFullBedName(Long elderId);

+ 299 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/BuildServiceImpl.java

@@ -10,6 +10,7 @@ import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.*;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.excel.BuildImportExcelVO;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.excel.BuildScreenExcel;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.excel.EmptyBedExcelVO;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.excel.ScreenBuildBedInfoVO;
 import cn.iocoder.yudao.module.system.controller.admin.nursing.vo.NursingPlanItemVO;
 import cn.iocoder.yudao.module.system.dal.dataobject.biz.*;
@@ -24,13 +25,16 @@ import cn.iocoder.yudao.module.system.dal.dataobject.biz.StaffingPlanRecordDO;
 import cn.iocoder.yudao.module.system.dal.mysql.biz.CheckInApplyMapper;
 import cn.iocoder.yudao.module.system.dal.dataobject.biz.CheckInApplyDO;
 import cn.iocoder.yudao.module.system.dal.mysql.biz.*;
+import cn.iocoder.yudao.module.system.dal.mysql.biz.bo.BuildFloorBedStatisticsBO;
 import cn.iocoder.yudao.module.system.dal.mysql.dict.DictDataMapper;
 import cn.iocoder.yudao.module.system.dal.mysql.tenant.TenantMapper;
 import cn.iocoder.yudao.module.system.enums.BuildBusinessConstants;
 import cn.iocoder.yudao.module.system.enums.ErrorCodeConstants;
 import cn.iocoder.yudao.module.system.enums.common.BooleanEnum;
 import com.alibaba.fastjson.JSON;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
 import cn.iocoder.yudao.framework.common.util.date.DateUtils;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import org.apache.commons.lang3.StringUtils;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -42,6 +46,8 @@ import javax.annotation.Resource;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.validation.annotation.Validated;
 
+import java.math.BigDecimal;
+import java.math.RoundingMode;
 import java.time.LocalDate;
 import java.time.YearMonth;
 import java.time.ZoneId;
@@ -62,6 +68,31 @@ import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.BED_IS_USE
 @Validated
 public class BuildServiceImpl implements BuildService {
 
+    /**
+     * 统计层级:楼栋
+     */
+    private static final Integer BUILD_LEVEL = 1;
+
+    /**
+     * 统计层级:楼层
+     */
+    private static final Integer FLOOR_LEVEL = 2;
+
+    /**
+     * 默认系统类型
+     */
+    private static final Integer DEFAULT_ORG_TYPE = 1;
+
+    /**
+     * 空床明细分页大小上限
+     */
+    private static final Integer MAX_EMPTY_BED_PAGE_SIZE = 200;
+
+    /**
+     * 未知床位状态的展示文案
+     */
+    private static final String UNSET_STATUS_TEXT = "未知";
+
     @Value("${spring.profiles.active}")
     private String activeProfile;
 
@@ -94,6 +125,12 @@ public class BuildServiceImpl implements BuildService {
     @Resource
     private NursingPlanItemMapper nursingPlanItemMapper;
 
+    @Resource
+    private BuildStatisticsMapper buildStatisticsMapper;
+
+    @Resource
+    private TenantScopeService tenantScopeService;
+
     @Resource
     private DictDataMapper dictDataMapper;
 
@@ -384,6 +421,243 @@ public class BuildServiceImpl implements BuildService {
         return buildBedMapper.selectBedVOList(bedName, status, orgType, tenantIds);
     }
 
+    @Override
+    @TenantIgnore
+    public BuildStatisticsRespVO getBuildStatistics(Long[] tenantIds, Long[] buildIds, Integer orgType) {
+        BuildStatisticsRespVO respVO = new BuildStatisticsRespVO();
+        respVO.setItems(new ArrayList<>());
+        List<Long> resolvedTenantIds = tenantScopeService.resolveTenantIds(tenantIds);
+        if (CollectionUtil.isEmpty(resolvedTenantIds)) {
+            return respVO;
+        }
+        List<Long> resolvedBuildIds = buildIds == null ? Collections.emptyList() : Arrays.asList(buildIds);
+        List<BuildFloorBedStatisticsBO> floorList = buildStatisticsMapper.selectFloorBedStatistics(
+                resolvedTenantIds, resolvedBuildIds, orgType == null ? DEFAULT_ORG_TYPE : orgType);
+        return buildStatisticsRespVO(floorList);
+    }
+
+    /**
+     * 组装楼栋 / 楼层床位统计:楼栋级(level = 1)汇总行 + 楼层级(level = 2)明细行
+     *
+     * @param floorList 楼层级统计
+     * @return 统计响应
+     */
+    private BuildStatisticsRespVO buildStatisticsRespVO(List<BuildFloorBedStatisticsBO> floorList) {
+        BuildStatisticsRespVO respVO = new BuildStatisticsRespVO();
+        List<BuildStatisticsRespVO.BuildStatisticsItemVO> items = new ArrayList<>();
+        if (CollectionUtil.isEmpty(floorList)) {
+            respVO.setItems(items);
+            return respVO;
+        }
+        BuildStatisticsRespVO.BuildStatisticsItemVO buildItem = null;
+        Long currentBuildId = null;
+        for (BuildFloorBedStatisticsBO bo : floorList) {
+            if (!Objects.equals(currentBuildId, bo.getBuildId())) {
+                currentBuildId = bo.getBuildId();
+                buildItem = newBuildStatisticsItem(bo, BUILD_LEVEL);
+                items.add(buildItem);
+            }
+            BuildStatisticsRespVO.BuildStatisticsItemVO floorItem = newBuildStatisticsItem(bo, FLOOR_LEVEL);
+            floorItem.setFloorId(bo.getFloorId());
+            floorItem.setFloorName(bo.getFloorName());
+            items.add(floorItem);
+            accumulateStatistics(buildItem, bo);
+        }
+        items.forEach(this::fillStatisticsRate);
+        respVO.setItems(items);
+        return respVO;
+    }
+
+    /**
+     * 创建统计项(楼栋级 / 楼层级)
+     *
+     * @param bo    楼层级统计
+     * @param level 层级:1 - 楼栋,2 - 楼层
+     * @return 统计项
+     */
+    private BuildStatisticsRespVO.BuildStatisticsItemVO newBuildStatisticsItem(BuildFloorBedStatisticsBO bo,
+                                                                               Integer level) {
+        BuildStatisticsRespVO.BuildStatisticsItemVO item = new BuildStatisticsRespVO.BuildStatisticsItemVO();
+        item.setLevel(level);
+        item.setTenantId(bo.getTenantId());
+        item.setTenantName(bo.getTenantName());
+        item.setBuildId(bo.getBuildId());
+        item.setBuildName(bo.getBuildName());
+        if (FLOOR_LEVEL.equals(level)) {
+            item.setTotalRoomCount(nullToZero(bo.getTotalRoomCount()));
+            item.setEmptyRoomCount(nullToZero(bo.getEmptyRoomCount()));
+            item.setTotalBedCount(nullToZero(bo.getTotalBedCount()));
+            item.setOccupiedBedCount(nullToZero(bo.getOccupiedBedCount()));
+            item.setEmptyBedCount(nullToZero(bo.getEmptyBedCount()));
+            item.setTemporaryOccupationCount(nullToZero(bo.getTemporaryOccupationCount()));
+        } else {
+            item.setTotalRoomCount(0L);
+            item.setEmptyRoomCount(0L);
+            item.setTotalBedCount(0L);
+            item.setOccupiedBedCount(0L);
+            item.setEmptyBedCount(0L);
+            item.setTemporaryOccupationCount(0L);
+        }
+        return item;
+    }
+
+    /**
+     * 把楼层统计累加到楼栋级统计行
+     *
+     * @param buildItem 楼栋级统计行
+     * @param bo        楼层级统计
+     */
+    private void accumulateStatistics(BuildStatisticsRespVO.BuildStatisticsItemVO buildItem,
+                                      BuildFloorBedStatisticsBO bo) {
+        if (buildItem == null) {
+            return;
+        }
+        buildItem.setTotalRoomCount(nullToZero(buildItem.getTotalRoomCount()) + nullToZero(bo.getTotalRoomCount()));
+        buildItem.setEmptyRoomCount(nullToZero(buildItem.getEmptyRoomCount()) + nullToZero(bo.getEmptyRoomCount()));
+        buildItem.setTotalBedCount(nullToZero(buildItem.getTotalBedCount()) + nullToZero(bo.getTotalBedCount()));
+        buildItem.setOccupiedBedCount(nullToZero(buildItem.getOccupiedBedCount()) + nullToZero(bo.getOccupiedBedCount()));
+        buildItem.setEmptyBedCount(nullToZero(buildItem.getEmptyBedCount()) + nullToZero(bo.getEmptyBedCount()));
+        buildItem.setTemporaryOccupationCount(nullToZero(buildItem.getTemporaryOccupationCount())
+                + nullToZero(bo.getTemporaryOccupationCount()));
+    }
+
+    /**
+     * 计算入住率与利用率
+     *
+     * @param item 统计项
+     */
+    private void fillStatisticsRate(BuildStatisticsRespVO.BuildStatisticsItemVO item) {
+        long totalBedCount = nullToZero(item.getTotalBedCount());
+        long occupiedBedCount = nullToZero(item.getOccupiedBedCount());
+        long temporaryOccupationCount = nullToZero(item.getTemporaryOccupationCount());
+        if (totalBedCount <= 0) {
+            return;
+        }
+        item.setOccupancyRate(calcRate(occupiedBedCount, totalBedCount));
+        item.setUtilizationRate(calcRate(occupiedBedCount + temporaryOccupationCount, totalBedCount));
+    }
+
+    private static BigDecimal calcRate(long numerator, long denominator) {
+        if (denominator <= 0) {
+            return null;
+        }
+        return BigDecimal.valueOf(numerator)
+                .multiply(BigDecimal.valueOf(100))
+                .divide(BigDecimal.valueOf(denominator), 1, RoundingMode.HALF_UP);
+    }
+
+    private static Long nullToZero(Long value) {
+        return value == null ? 0L : value;
+    }
+
+    @Override
+    @TenantIgnore
+    public PageResult<EmptyBedRespVO> getEmptyBedPage(EmptyBedPageReqVO reqVO) {
+        List<Long> tenantIds = resolveEmptyBedTenantIds(reqVO);
+        if (CollectionUtil.isEmpty(tenantIds)) {
+            return new PageResult<>(Collections.emptyList(), 0L);
+        }
+        // 上限保护,避免一次全量拉取
+        if (reqVO.getPageSize() == null || reqVO.getPageSize() > MAX_EMPTY_BED_PAGE_SIZE) {
+            reqVO.setPageSize(MAX_EMPTY_BED_PAGE_SIZE);
+        }
+        Page<EmptyBedRespVO> page = new Page<>(reqVO.getPageNo(), reqVO.getPageSize());
+        List<EmptyBedRespVO> list = buildBedMapper.selectEmptyBedPage(page, reqVO);
+        return new PageResult<>(list, page.getTotal());
+    }
+
+    @Override
+    @TenantIgnore
+    public EmptyBedSummaryRespVO getEmptyBedSummary(EmptyBedPageReqVO reqVO) {
+        List<Long> tenantIds = resolveEmptyBedTenantIds(reqVO);
+        if (CollectionUtil.isEmpty(tenantIds)) {
+            return newEmptyBedSummary();
+        }
+        EmptyBedSummaryRespVO summary = buildBedMapper.selectEmptyBedSummary(reqVO);
+        if (summary == null) {
+            return newEmptyBedSummary();
+        }
+        summary.setEmptyBedCount(nullToZero(summary.getEmptyBedCount()));
+        summary.setEmptyRoomCount(nullToZero(summary.getEmptyRoomCount()));
+        summary.setFullEmptyRoomCount(nullToZero(summary.getFullEmptyRoomCount()));
+        return summary;
+    }
+
+    @Override
+    @TenantIgnore
+    public List<EmptyBedExcelVO> getEmptyBedExcelList(EmptyBedPageReqVO reqVO) {
+        List<Long> tenantIds = resolveEmptyBedTenantIds(reqVO);
+        if (CollectionUtil.isEmpty(tenantIds)) {
+            return Collections.emptyList();
+        }
+        List<EmptyBedRespVO> list = buildBedMapper.selectEmptyBedList(reqVO);
+        if (CollectionUtil.isEmpty(list)) {
+            return Collections.emptyList();
+        }
+        return list.stream().map(this::toEmptyBedExcelVO).collect(Collectors.toList());
+    }
+
+    /**
+     * 解析空床报表的机构范围,并把结果回写到查询条件
+     *
+     * @param reqVO 查询条件
+     * @return 机构ID列表
+     */
+    private List<Long> resolveEmptyBedTenantIds(EmptyBedPageReqVO reqVO) {
+        List<Long> tenantIds = tenantScopeService.resolveTenantIds(reqVO.getTenantIds());
+        reqVO.setTenantIds(tenantIds.toArray(new Long[0]));
+        if (reqVO.getOrgType() == null) {
+            reqVO.setOrgType(DEFAULT_ORG_TYPE);
+        }
+        return tenantIds;
+    }
+
+    private static EmptyBedSummaryRespVO newEmptyBedSummary() {
+        EmptyBedSummaryRespVO summary = new EmptyBedSummaryRespVO();
+        summary.setEmptyBedCount(0L);
+        summary.setEmptyRoomCount(0L);
+        summary.setFullEmptyRoomCount(0L);
+        return summary;
+    }
+
+    /**
+     * 空床明细转 Excel 行
+     *
+     * @param vo 空床明细
+     * @return Excel 行
+     */
+    private EmptyBedExcelVO toEmptyBedExcelVO(EmptyBedRespVO vo) {
+        EmptyBedExcelVO excelVO = new EmptyBedExcelVO();
+        excelVO.setTenantName(vo.getTenantName());
+        excelVO.setBuildName(vo.getBuildName());
+        excelVO.setFloorName(vo.getFloorName());
+        excelVO.setRoomName(vo.getRoomName());
+        excelVO.setBedName(vo.getBedName());
+        excelVO.setBedStatusText(resolveBedStatusText(vo.getBedStatus()));
+        excelVO.setRoomUsedText(nullToZero(vo.getRoomUsedCount()) + "/" + nullToZero(vo.getRoomBedCount()));
+        excelVO.setRoomArea(vo.getRoomArea());
+        excelVO.setOrientation(vo.getOrientation());
+        excelVO.setRoomSettings(vo.getRoomSettings());
+        excelVO.setRoomUsage(vo.getRoomUsage());
+        return excelVO;
+    }
+
+    private static String resolveBedStatusText(Integer status) {
+        if (status == null) {
+            return UNSET_STATUS_TEXT;
+        }
+        switch (status) {
+            case 0:
+                return "空床";
+            case 1:
+                return "已住";
+            case 2:
+                return "临时占用";
+            default:
+                return UNSET_STATUS_TEXT;
+        }
+    }
+
     @Override
     @TenantIgnore
     public BuildBedVO getBedInfoById(Long bedId) {
@@ -448,6 +722,7 @@ public class BuildServiceImpl implements BuildService {
     public Map<String, Object> getBuildData(Long buildId) {
         List<BuildFloorDO> buildFloorList = buildFloorMapper.selectList(new LambdaQueryWrapperX<BuildFloorDO>().eq(BuildFloorDO::getBuildId, buildId));
         int totalRoomCount = 0;  //总房间数
+        int emptyRoomCount = 0;  //整间空房数
         int totalBedCount = 0;   //总床位数
         int usedBedCount = 0;    //占用床位数
         int unusedBedCount = 0;  //空闲床位数
@@ -461,12 +736,17 @@ public class BuildServiceImpl implements BuildService {
                 usedBedCount += (int) bedList.stream().filter(e -> e.getStatus() == 1).count();
                 // 添加对临时占用状态的统计(假设状态值为2)
                 temporaryOccupationCount += (int) bedList.stream().filter(e -> e.getStatus() == 2).count();
+                // 整间空房:房内有床位且全部床位空闲
+                if (countEmptyRoom(bedList)) {
+                    emptyRoomCount++;
+                }
             }
             totalRoomCount += roomList.size();
         }
 
         Map<String, Object> dataMap = new HashMap<>();
         dataMap.put("totalRoomCount", totalRoomCount);
+        dataMap.put("emptyRoomCount", emptyRoomCount);
         dataMap.put("totalBedCount", totalBedCount);
         dataMap.put("usedBedCount", usedBedCount);
         dataMap.put("unusedBedCount", unusedBedCount);
@@ -474,9 +754,23 @@ public class BuildServiceImpl implements BuildService {
         return dataMap;
     }
 
+    /**
+     * 判断房间是否为整间空房:房内有床位且全部床位空闲
+     *
+     * @param bedList 房间床位列表
+     * @return 是否为整间空房
+     */
+    private static boolean countEmptyRoom(List<BuildBedDO> bedList) {
+        if (CollectionUtil.isEmpty(bedList)) {
+            return false;
+        }
+        return bedList.stream().allMatch(bed -> bed.getStatus() != null && bed.getStatus() == 0);
+    }
+
     @Override
     public BuildFloorDataRespVO getBuildFloorData(Long floodId) {
         int totalRoomCount;  //总房间数
+        int emptyRoomCount = 0;  //整间空房数
         int totalBedCount = 0;   //总床位数
         int usedBedCount = 0;    //占用床位数
         int unusedBedCount = 0;  //空闲床位数
@@ -493,6 +787,10 @@ public class BuildServiceImpl implements BuildService {
             unusedBedCount += (int) bedList.stream().filter(e -> e.getStatus() == 0).count();
             usedBedCount += (int) bedList.stream().filter(e -> e.getStatus() == 1).count();
             temporaryOccupationCount += (int) bedList.stream().filter(e -> e.getStatus() == 2).count();
+            // 整间空房:房内有床位且全部床位空闲
+            if (countEmptyRoom(bedList)) {
+                emptyRoomCount++;
+            }
 
             // 根据床位数确定房间类型(动态生成,如:2人间、4人间、6人间)
             int bedCount = bedList.size();
@@ -557,6 +855,7 @@ public class BuildServiceImpl implements BuildService {
         respVO.setUsedBedCount(usedBedCount);
         respVO.setUnusedBedCount(unusedBedCount);
         respVO.setTemporaryOccupationCount(temporaryOccupationCount);
+        respVO.setEmptyRoomCount(emptyRoomCount);
         respVO.setStatistics(statistics);
 
         return respVO;

+ 3 - 2
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/ExpenseOrderServiceImpl.java

@@ -1029,7 +1029,7 @@ public class ExpenseOrderServiceImpl implements ExpenseOrderService {
             respVO.setEndTime(endDate.toString());
             if (markUsed) {
                 voucher.setOrderNumber(expenseOrder.getBillOrderNumber());
-                voucher.setId(expenseOrder.getId());
+                voucher.setOrderId(expenseOrder.getId());
                 voucher.setStatus(BooleanEnum.TRUE.getValue());
                 updateList.add(voucher);
             }
@@ -2966,7 +2966,8 @@ public class ExpenseOrderServiceImpl implements ExpenseOrderService {
         // 长护险更新为未抵扣状态
         // tips:长护险部分数据缺失所以不能关联账单号进行更新
         expenseSubsidyMapper.update(new ExpenseSubsidyDO()
-                        .setStatus(BooleanEnum.FALSE.getValue()),
+                        .setStatus(BooleanEnum.FALSE.getValue())
+                        .setOrderNumber(""),
                 new LambdaUpdateWrapper<ExpenseSubsidyDO>()
                         .eq(ExpenseSubsidyDO::getElderId, expenseOrder.getElderId())
                         .eq(ExpenseSubsidyDO::getDeductionBillMonth, expenseOrder.getBillingMonth())

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

@@ -1,5 +1,9 @@
 package cn.iocoder.yudao.module.system.service.biz;
 
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisCheckTrendReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisCheckTrendRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisElderStructureReqVO;
+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;
 
@@ -20,4 +24,24 @@ public interface OperationAnalysisService {
      */
     List<OperationAnalysisOverviewDynamicsRespVO> getOverviewDynamics(OperationAnalysisOverviewDynamicsReqVO reqVO);
 
+    /**
+     * 运营分析 - 概况:入住 / 退住趋势
+     *
+     * <p>按期间升序返回,无数据期间补零,恒满足 期初 + 入住 - 退住 = 期末。</p>
+     *
+     * @param reqVO 请求参数
+     * @return 入住 / 退住趋势
+     */
+    OperationAnalysisCheckTrendRespVO getCheckTrend(OperationAnalysisCheckTrendReqVO reqVO);
+
+    /**
+     * 运营分析 - 概况:长者结构聚合(性别 / 年龄 / 入住时长 / 护理等级 / 文化程度 / 缴费方式 / 院区)
+     *
+     * <p>仅统计在住长者,空值归入「未设置」但计入总数。</p>
+     *
+     * @param reqVO 请求参数
+     * @return 长者结构聚合结果
+     */
+    OperationAnalysisElderStructureRespVO getElderStructure(OperationAnalysisElderStructureReqVO reqVO);
+
 }

+ 651 - 39
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/OperationAnalysisServiceImpl.java

@@ -1,23 +1,28 @@
 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.OperationAnalysisCheckTrendReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisCheckTrendRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisCheckTrendRespVO.CheckTrendItemVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisElderStructureReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisElderStructureRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisElderStructureRespVO.BucketVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisElderStructureRespVO.CampusStatVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisElderStructureRespVO.DictBucketVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.operationanalysis.OperationAnalysisElderStructureRespVO.NurseLevelBucketVO;
 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.ElderStructureRowBO;
 import cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationNurseLevelCountBO;
 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.tenant.GroupTenantMapper;
 import cn.iocoder.yudao.module.system.service.tenant.TenantService;
 import org.springframework.stereotype.Service;
 import org.springframework.validation.annotation.Validated;
@@ -28,18 +33,20 @@ import java.math.RoundingMode;
 import java.time.DayOfWeek;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
+import java.time.Period;
 import java.time.YearMonth;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoUnit;
 import java.time.temporal.TemporalAdjusters;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Date;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.stream.Collectors;
 
 /**
@@ -79,12 +86,51 @@ public class OperationAnalysisServiceImpl implements OperationAnalysisService {
     private static final String UNRATED_NURSE_LEVEL_NAME = "未定级";
 
     /**
-     * 集团账号的租户ID,集团账号需要展开为旗下所有机构
+     * 分组值为空时展示的名称
      */
-    private static final Long GROUP_TENANT_ID = 1L;
+    private static final String UNSET_LABEL = "未设置";
+
+    /**
+     * 按日统计时最多返回的天数
+     */
+    private static final int MAX_TREND_DAYS = 366;
+
+    /**
+     * 按日统计时默认返回的天数
+     */
+    private static final int DEFAULT_TREND_DAYS = 30;
+
+    /**
+     * 按月统计时最多返回的月份数
+     */
+    private static final int MAX_TREND_MONTHS = 120;
+
+    /**
+     * 护理等级分布最多返回的等级数量
+     */
+    private static final int MAX_NURSE_LEVEL_BUCKETS = 10;
 
     private static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM");
 
+    private static final DateTimeFormatter DAY_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+    /**
+     * 性别分段骨架
+     */
+    private static final List<String> SEX_BUCKET_LABELS = buildLabels("男", "女");
+
+    /**
+     * 年龄段分段骨架
+     */
+    private static final List<String> AGE_BUCKET_LABELS =
+            buildLabels("<60", "60-69", "70-79", "80-89", "90-99", "≥100");
+
+    /**
+     * 入住时长分段骨架
+     */
+    private static final List<String> STAY_BUCKET_LABELS =
+            buildLabels("<1年", "1-3年", "3-5年", "5-10年", "≥10年");
+
     @Resource
     private OperationAnalysisMapper operationAnalysisMapper;
 
@@ -92,12 +138,12 @@ public class OperationAnalysisServiceImpl implements OperationAnalysisService {
     private TenantService tenantService;
 
     @Resource
-    private GroupTenantMapper groupTenantMapper;
+    private TenantScopeService tenantScopeService;
 
     @Override
     @TenantIgnore
     public List<OperationAnalysisOverviewDynamicsRespVO> getOverviewDynamics(OperationAnalysisOverviewDynamicsReqVO reqVO) {
-        List<Long> tenantIds = resolveTenantIds(reqVO.getTenantIds());
+        List<Long> tenantIds = tenantScopeService.resolveTenantIds(reqVO.getTenantIds());
         if (CollUtil.isEmpty(tenantIds)) {
             return Collections.emptyList();
         }
@@ -259,35 +305,6 @@ public class OperationAnalysisServiceImpl implements OperationAnalysisService {
                 .toPlainString() + "%";
     }
 
-    /**
-     * 解析要统计的机构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
      *
@@ -371,4 +388,599 @@ public class OperationAnalysisServiceImpl implements OperationAnalysisService {
         return value == null ? 0L : value;
     }
 
+    private static Date toDate(LocalDate date) {
+        return Date.from(date.atStartOfDay(ZoneId.systemDefault()).toInstant());
+    }
+
+    /**
+     * 构造分段骨架:固定分段 + 「未设置」,保证各分段人数之和 = 总数
+     *
+     * @param labels 固定分段名称
+     * @return 分段名称列表
+     */
+    private static List<String> buildLabels(String... labels) {
+        List<String> result = new ArrayList<>(Arrays.asList(labels));
+        result.add(UNSET_LABEL);
+        return result;
+    }
+
+    // ========== 入住 / 退住趋势 ==========
+
+    @Override
+    @TenantIgnore
+    public OperationAnalysisCheckTrendRespVO getCheckTrend(OperationAnalysisCheckTrendReqVO reqVO) {
+        OperationAnalysisCheckTrendRespVO respVO = new OperationAnalysisCheckTrendRespVO();
+        respVO.setItems(new ArrayList<>());
+        List<Long> tenantIds = tenantScopeService.resolveTenantIds(reqVO.getTenantIds());
+        if (CollUtil.isEmpty(tenantIds)) {
+            return respVO;
+        }
+        boolean byDay = "day".equalsIgnoreCase(reqVO.getGranularity());
+        LocalDate[] range = resolveTrendRange(reqVO, byDay);
+        LocalDate begin = range[0];
+        LocalDate end = range[1];
+        int orgType = reqVO.getOrgType() == null ? 1 : reqVO.getOrgType();
+
+        // 按天取数,Java 侧再按月 / 按日归集,避免 DATE_FORMAT 传参带来的方言与注入问题
+        Map<String, Long> checkInMap = toDayCountMap(
+                operationAnalysisMapper.selectCheckInDailyTrend(tenantIds, orgType, begin, end.plusDays(1)));
+        Map<String, Long> checkOutMap = toDayCountMap(
+                operationAnalysisMapper.selectCheckOutDailyTrend(tenantIds, orgType, begin, end.plusDays(1)));
+        long checkInBefore = nullToZero(operationAnalysisMapper.selectCheckInCountBefore(tenantIds, orgType, begin));
+        long checkOutBefore = nullToZero(operationAnalysisMapper.selectCheckOutCountBefore(tenantIds, orgType, begin));
+        long totalBedCount = operationAnalysisMapper.selectBedStructure(tenantIds)
+                .stream()
+                .mapToLong(bo -> nullToZero(bo.getTotalBedCount()))
+                .sum();
+
+        // 期末入住率依赖历史床位数快照,当前无快照表,故仅当前期间(含)之后返回数值,历史期间返回 null
+        String currentPeriod = byDay ? LocalDate.now().format(DAY_FORMATTER)
+                : YearMonth.now().format(MONTH_FORMATTER);
+
+        long inHospital = checkInBefore - checkOutBefore;
+        List<String> periods = buildPeriods(begin, end, byDay);
+        List<CheckTrendItemVO> items = new ArrayList<>(periods.size());
+        for (String period : periods) {
+            long checkIn = sumPeriod(checkInMap, period, byDay);
+            long checkOut = sumPeriod(checkOutMap, period, byDay);
+            CheckTrendItemVO item = new OperationAnalysisCheckTrendRespVO.CheckTrendItemVO();
+            item.setPeriod(period);
+            item.setBeginInHospital(inHospital);
+            item.setCheckInCount(checkIn);
+            item.setCheckOutCount(checkOut);
+            item.setNetGrowth(checkIn - checkOut);
+            // 恒满足 期初 + 入住 - 退住 = 期末
+            inHospital = inHospital + checkIn - checkOut;
+            item.setEndInHospital(inHospital);
+            item.setEndOccupancyRate(period.compareTo(currentPeriod) >= 0
+                    ? calcRate(inHospital, totalBedCount) : null);
+            items.add(item);
+        }
+        respVO.setItems(items);
+        return respVO;
+    }
+
+    /**
+     * 解析趋势统计区间
+     *
+     * @param reqVO 请求参数
+     * @param byDay 是否按日
+     * @return [0] 起始日(含),[1] 结束日(含)
+     */
+    private LocalDate[] resolveTrendRange(OperationAnalysisCheckTrendReqVO reqVO, boolean byDay) {
+        if (byDay) {
+            LocalDate end = reqVO.getEndDate() != null ? reqVO.getEndDate() : LocalDate.now();
+            LocalDate begin = reqVO.getStartDate() != null ? reqVO.getStartDate()
+                    : end.minusDays(DEFAULT_TREND_DAYS - 1);
+            if (begin.isAfter(end)) {
+                begin = end;
+            }
+            if (ChronoUnit.DAYS.between(begin, end) >= MAX_TREND_DAYS) {
+                begin = end.minusDays(MAX_TREND_DAYS - 1);
+            }
+            return new LocalDate[]{begin, end};
+        }
+        YearMonth endMonth = reqVO.getEndDate() != null ? YearMonth.from(reqVO.getEndDate()) : YearMonth.now();
+        YearMonth beginMonth;
+        if (reqVO.getStartDate() != null) {
+            beginMonth = YearMonth.from(reqVO.getStartDate());
+        } else {
+            int months = (reqVO.getMonths() == null || reqVO.getMonths() <= 0)
+                    ? TREND_MONTHS : Math.min(reqVO.getMonths(), MAX_TREND_MONTHS);
+            beginMonth = endMonth.minusMonths(months - 1);
+        }
+        if (beginMonth.isAfter(endMonth)) {
+            beginMonth = endMonth;
+        }
+        return new LocalDate[]{beginMonth.atDay(1), endMonth.atEndOfMonth()};
+    }
+
+    /**
+     * 生成期间列表(升序)
+     *
+     * @param begin 起始日(含)
+     * @param end   结束日(含)
+     * @param byDay 是否按日
+     * @return 期间列表
+     */
+    private List<String> buildPeriods(LocalDate begin, LocalDate end, boolean byDay) {
+        List<String> periods = new ArrayList<>();
+        if (byDay) {
+            for (LocalDate date = begin; !date.isAfter(end); date = date.plusDays(1)) {
+                periods.add(date.format(DAY_FORMATTER));
+            }
+            return periods;
+        }
+        YearMonth endMonth = YearMonth.from(end);
+        for (YearMonth month = YearMonth.from(begin); !month.isAfter(endMonth); month = month.plusMonths(1)) {
+            periods.add(month.format(MONTH_FORMATTER));
+        }
+        return periods;
+    }
+
+    /**
+     * 按天统计结果转成「日期 -> 数量」
+     *
+     * @param list 按天统计结果
+     * @return 日期统计
+     */
+    private Map<String, Long> toDayCountMap(List<OperationTenantMonthCountBO> list) {
+        Map<String, Long> map = new HashMap<>();
+        if (CollUtil.isEmpty(list)) {
+            return map;
+        }
+        for (OperationTenantMonthCountBO bo : list) {
+            if (StrUtil.isBlank(bo.getMonth())) {
+                continue;
+            }
+            map.merge(bo.getMonth(), nullToZero(bo.getCount()), Long::sum);
+        }
+        return map;
+    }
+
+    /**
+     * 汇总某个期间的入住 / 退住数量
+     *
+     * @param map   按天统计
+     * @param period 期间
+     * @param byDay 是否按日
+     * @return 期间数量
+     */
+    private long sumPeriod(Map<String, Long> map, String period, boolean byDay) {
+        if (byDay) {
+            return map.getOrDefault(period, 0L);
+        }
+        long sum = 0;
+        for (Map.Entry<String, Long> entry : map.entrySet()) {
+            if (entry.getKey().startsWith(period)) {
+                sum += entry.getValue();
+            }
+        }
+        return sum;
+    }
+
+    /**
+     * 计算比率(保留 1 位小数)
+     *
+     * @param numerator   分子
+     * @param denominator 分母
+     * @return 百分比,分母为 0 时返回 null
+     */
+    private static BigDecimal calcRate(long numerator, long denominator) {
+        if (denominator <= 0) {
+            return null;
+        }
+        return BigDecimal.valueOf(numerator)
+                .multiply(BigDecimal.valueOf(100))
+                .divide(BigDecimal.valueOf(denominator), 1, RoundingMode.HALF_UP);
+    }
+
+    // ========== 长者结构聚合 ==========
+
+    @Override
+    @TenantIgnore
+    public OperationAnalysisElderStructureRespVO getElderStructure(OperationAnalysisElderStructureReqVO reqVO) {
+        List<Long> tenantIds = tenantScopeService.resolveTenantIds(reqVO.getTenantIds());
+        int orgType = reqVO.getOrgType() == null ? 1 : reqVO.getOrgType();
+        List<ElderStructureRowBO> rows = CollUtil.isEmpty(tenantIds) ? Collections.emptyList()
+                : operationAnalysisMapper.selectElderStructureRows(tenantIds, orgType);
+
+        LocalDate today = LocalDate.now();
+        StructureAgg totalAgg = new StructureAgg();
+        Map<Long, StructureAgg> campusAggMap = new LinkedHashMap<>();
+        for (Long tenantId : tenantIds) {
+            campusAggMap.put(tenantId, new StructureAgg());
+        }
+        for (ElderStructureRowBO row : rows) {
+            StructureAgg campusAgg = campusAggMap.computeIfAbsent(row.getTenantId(), k -> new StructureAgg());
+            fillAgg(totalAgg, row, today);
+            fillAgg(campusAgg, row, today);
+        }
+        return buildElderStructureRespVO(totalAgg, campusAggMap);
+    }
+
+    /**
+     * 把一条在住长者明细累加进聚合器
+     *
+     * @param agg  聚合器
+     * @param row  明细行
+     * @param today 当前日期
+     */
+    private void fillAgg(StructureAgg agg, ElderStructureRowBO row, LocalDate today) {
+        agg.total++;
+        Integer sex = row.getElderSex();
+        if (sex != null && sex == 1) {
+            agg.male++;
+        } else if (sex != null && sex == 2) {
+            agg.female++;
+        }
+        agg.sexBuckets.merge(resolveSexLabel(sex), 1L, Long::sum);
+
+        Integer age = resolveAge(row, today);
+        if (age != null) {
+            agg.ageSum += age;
+            agg.ageCount++;
+            if (age >= 80) {
+                agg.over80++;
+            }
+            if (age >= 90) {
+                agg.over90++;
+            }
+        }
+        agg.ageBuckets.merge(resolveAgeBucket(age), 1L, Long::sum);
+
+        Long stayMonths = resolveStayMonths(row, today);
+        if (stayMonths != null) {
+            agg.staySum += stayMonths;
+            agg.stayCount++;
+        }
+        agg.stayBuckets.merge(resolveStayBucket(stayMonths), 1L, Long::sum);
+
+        Long nurseLevelId = row.getNurseLevelId();
+        String nurseLevelName = StrUtil.isNotBlank(row.getNurseLevelName())
+                ? row.getNurseLevelName() : UNRATED_NURSE_LEVEL_NAME;
+        agg.nurseBuckets.computeIfAbsent(nurseLevelId, k -> new NurseCounter(nurseLevelName)).count++;
+        agg.educationBuckets.merge(row.getDegreeEducation(), 1L, Long::sum);
+        agg.paymentBuckets.merge(row.getPaymentMethod(), 1L, Long::sum);
+    }
+
+    /**
+     * 组装长者结构响应
+     *
+     * @param totalAgg     汇总聚合器
+     * @param campusAggMap 各院区聚合器
+     * @return 长者结构聚合结果
+     */
+    private OperationAnalysisElderStructureRespVO buildElderStructureRespVO(StructureAgg totalAgg,
+                                                                            Map<Long, StructureAgg> campusAggMap) {
+        OperationAnalysisElderStructureRespVO respVO = new OperationAnalysisElderStructureRespVO();
+        respVO.setTotal(totalAgg.total);
+        respVO.setMaleCount(totalAgg.male);
+        respVO.setFemaleCount(totalAgg.female);
+        respVO.setAvgAge(calcAvg(totalAgg.ageSum, totalAgg.ageCount));
+        respVO.setAvgStayMonths(calcAvg(totalAgg.staySum, totalAgg.stayCount));
+        respVO.setOver80Count(totalAgg.over80);
+        respVO.setOver90Count(totalAgg.over90);
+        respVO.setSexBuckets(toBucketVOList(SEX_BUCKET_LABELS, totalAgg.sexBuckets));
+        respVO.setAgeBuckets(toBucketVOList(AGE_BUCKET_LABELS, totalAgg.ageBuckets));
+        respVO.setStayBuckets(toBucketVOList(STAY_BUCKET_LABELS, totalAgg.stayBuckets));
+        respVO.setNurseLevelBuckets(toNurseLevelBucketList(totalAgg.nurseBuckets));
+        respVO.setEducationBuckets(toDictBucketList(totalAgg.educationBuckets));
+        respVO.setPaymentBuckets(toDictBucketList(totalAgg.paymentBuckets));
+        respVO.setCampusStats(toCampusStatList(campusAggMap));
+        return respVO;
+    }
+
+    /**
+     * 解析长者年龄:优先取档案年龄,为空时用出生日期兜底
+     *
+     * @param row   明细行
+     * @param today 当前日期
+     * @return 年龄,为空表示未知
+     */
+    private Integer resolveAge(ElderStructureRowBO row, LocalDate today) {
+        if (row.getElderAge() != null) {
+            return row.getElderAge();
+        }
+        if (row.getBirthday() == null) {
+            return null;
+        }
+        LocalDate birthday = row.getBirthday().toInstant()
+                .atZone(ZoneId.systemDefault())
+                .toLocalDate();
+        if (birthday.isAfter(today)) {
+            return null;
+        }
+        return Period.between(birthday, today).getYears();
+    }
+
+    /**
+     * 解析入住月数
+     *
+     * @param row   明细行
+     * @param today 当前日期
+     * @return 入住月数,为空表示未知
+     */
+    private Long resolveStayMonths(ElderStructureRowBO row, LocalDate today) {
+        if (row.getCheckInTime() == null) {
+            return null;
+        }
+        LocalDate checkInDate = row.getCheckInTime().toInstant()
+                .atZone(ZoneId.systemDefault())
+                .toLocalDate();
+        if (checkInDate.isAfter(today)) {
+            return null;
+        }
+        return ChronoUnit.MONTHS.between(checkInDate, today);
+    }
+
+    private static String resolveSexLabel(Integer sex) {
+        if (sex == null) {
+            return UNSET_LABEL;
+        }
+        if (sex == 1) {
+            return "男";
+        }
+        if (sex == 2) {
+            return "女";
+        }
+        return UNSET_LABEL;
+    }
+
+    private static String resolveAgeBucket(Integer age) {
+        if (age == null) {
+            return UNSET_LABEL;
+        }
+        int value = Math.max(age, 0);
+        if (value < 60) {
+            return "<60";
+        }
+        if (value <= 69) {
+            return "60-69";
+        }
+        if (value <= 79) {
+            return "70-79";
+        }
+        if (value <= 89) {
+            return "80-89";
+        }
+        if (value <= 99) {
+            return "90-99";
+        }
+        return "≥100";
+    }
+
+    private static String resolveStayBucket(Long stayMonths) {
+        if (stayMonths == null) {
+            return UNSET_LABEL;
+        }
+        long value = Math.max(stayMonths, 0L);
+        if (value < 12) {
+            return "<1年";
+        }
+        if (value < 36) {
+            return "1-3年";
+        }
+        if (value < 60) {
+            return "3-5年";
+        }
+        if (value < 120) {
+            return "5-10年";
+        }
+        return "≥10年";
+    }
+
+    /**
+     * 按固定骨架输出分段,缺失分段补 0
+     *
+     * @param labels 分段骨架
+     * @param counts 实际统计
+     * @return 分段列表
+     */
+    private static List<BucketVO> toBucketVOList(List<String> labels, Map<String, Long> counts) {
+        List<BucketVO> result = new ArrayList<>(labels.size());
+        for (String label : labels) {
+            BucketVO vo = new BucketVO();
+            vo.setLabel(label);
+            vo.setCount(counts.getOrDefault(label, 0L));
+            result.add(vo);
+        }
+        return result;
+    }
+
+    /**
+     * 护理等级分布:按人数降序取 TOP N
+     *
+     * @param counters 护理等级统计
+     * @return 护理等级分布
+     */
+    private static List<NurseLevelBucketVO> toNurseLevelBucketList(Map<Long, NurseCounter> counters) {
+        return counters.entrySet().stream()
+                .sorted((o1, o2) -> {
+                    int compare = Long.compare(o2.getValue().count, o1.getValue().count);
+                    return compare != 0 ? compare : o1.getValue().name.compareTo(o2.getValue().name);
+                })
+                .limit(MAX_NURSE_LEVEL_BUCKETS)
+                .map(entry -> {
+                    NurseLevelBucketVO vo = new NurseLevelBucketVO();
+                    vo.setNurseLevelId(entry.getKey());
+                    vo.setNurseLevelName(entry.getValue().name);
+                    vo.setCount(entry.getValue().count);
+                    return vo;
+                })
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 字典分段:value 返回字典原值,由前端翻译
+     *
+     * @param counts 字典值统计
+     * @return 字典分段列表
+     */
+    private static List<DictBucketVO> toDictBucketList(Map<Integer, Long> counts) {
+        return counts.entrySet().stream()
+                .sorted((o1, o2) -> Long.compare(o2.getValue(), o1.getValue()))
+                .map(entry -> {
+                    DictBucketVO vo = new DictBucketVO();
+                    vo.setValue(entry.getKey());
+                    vo.setLabel(String.valueOf(entry.getKey()));
+                    vo.setCount(entry.getValue());
+                    return vo;
+                })
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 各院区明细
+     *
+     * @param campusAggMap 各院区聚合器
+     * @return 院区明细列表
+     */
+    private List<CampusStatVO> toCampusStatList(Map<Long, StructureAgg> campusAggMap) {
+        List<CampusStatVO> result = new ArrayList<>(campusAggMap.size());
+        for (Map.Entry<Long, StructureAgg> entry : campusAggMap.entrySet()) {
+            StructureAgg agg = entry.getValue();
+            CampusStatVO vo = new CampusStatVO();
+            vo.setTenantId(entry.getKey());
+            vo.setTenantName(getTenantName(entry.getKey()));
+            vo.setTotal(agg.total);
+            vo.setMaleCount(agg.male);
+            vo.setFemaleCount(agg.female);
+            vo.setAvgAge(calcAvg(agg.ageSum, agg.ageCount));
+            vo.setAvgStayMonths(calcAvg(agg.staySum, agg.stayCount));
+            vo.setOver80Count(agg.over80);
+            vo.setMainNurseLevelName(findMainNurseLevelName(agg.nurseBuckets));
+            result.add(vo);
+        }
+        return result;
+    }
+
+    /**
+     * 取人数最多的护理等级名称
+     *
+     * @param counters 护理等级统计
+     * @return 主要护理等级名称,无数据时返回 null
+     */
+    private static String findMainNurseLevelName(Map<Long, NurseCounter> counters) {
+        NurseCounter main = null;
+        for (NurseCounter counter : counters.values()) {
+            if (main == null || counter.count > main.count) {
+                main = counter;
+            }
+        }
+        return main == null ? null : main.name;
+    }
+
+    /**
+     * 计算平均值(保留 1 位小数)
+     *
+     * @param sum   合计
+     * @param count 数量
+     * @return 平均值,数量为 0 时返回 null
+     */
+    private static BigDecimal calcAvg(long sum, long count) {
+        if (count <= 0) {
+            return null;
+        }
+        return BigDecimal.valueOf(sum)
+                .divide(BigDecimal.valueOf(count), 1, RoundingMode.HALF_UP);
+    }
+
+    /**
+     * 长者结构聚合器
+     */
+    private static class StructureAgg {
+
+        /**
+         * 总数
+         */
+        private long total;
+
+        /**
+         * 男性数
+         */
+        private long male;
+
+        /**
+         * 女性数
+         */
+        private long female;
+
+        /**
+         * 80 岁以上人数
+         */
+        private long over80;
+
+        /**
+         * 90 岁以上人数
+         */
+        private long over90;
+
+        /**
+         * 年龄合计 / 有效年龄数
+         */
+        private long ageSum;
+
+        private long ageCount;
+
+        /**
+         * 入住月数合计 / 有效入住数
+         */
+        private long staySum;
+
+        private long stayCount;
+
+        /**
+         * 性别分段
+         */
+        private final Map<String, Long> sexBuckets = new HashMap<>();
+
+        /**
+         * 年龄段分段
+         */
+        private final Map<String, Long> ageBuckets = new HashMap<>();
+
+        /**
+         * 入住时长分段
+         */
+        private final Map<String, Long> stayBuckets = new HashMap<>();
+
+        /**
+         * 护理等级分段
+         */
+        private final Map<Long, NurseCounter> nurseBuckets = new LinkedHashMap<>();
+
+        /**
+         * 文化程度分段
+         */
+        private final Map<Integer, Long> educationBuckets = new HashMap<>();
+
+        /**
+         * 缴费方式分段
+         */
+        private final Map<Integer, Long> paymentBuckets = new HashMap<>();
+
+    }
+
+    /**
+     * 护理等级计数器
+     */
+    private static class NurseCounter {
+
+        /**
+         * 护理等级名称
+         */
+        private final String name;
+
+        /**
+         * 人数
+         */
+        private long count;
+
+        NurseCounter(String name) {
+            this.name = name;
+        }
+
+    }
+
 }

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

@@ -0,0 +1,23 @@
+package cn.iocoder.yudao.module.system.service.biz;
+
+import java.util.List;
+
+/**
+ * 机构(租户)数据范围 Service
+ *
+ * <p>统一处理报表类接口「按登录人机构范围过滤」的口径:
+ * 入参为空取登录账号可见范围,入参超范围的机构直接忽略。</p>
+ *
+ * @author kyj
+ */
+public interface TenantScopeService {
+
+    /**
+     * 解析可统计的机构ID
+     *
+     * @param tenantIds 入参机构ID数组,为空时取登录账号可见范围
+     * @return 机构ID列表,超范围的入参会被忽略
+     */
+    List<Long> resolveTenantIds(Long[] tenantIds);
+
+}

+ 78 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/TenantScopeServiceImpl.java

@@ -0,0 +1,78 @@
+package cn.iocoder.yudao.module.system.service.biz;
+
+import cn.hutool.core.util.ArrayUtil;
+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.dal.dataobject.tenant.GroupTenantDO;
+import cn.iocoder.yudao.module.system.dal.mysql.tenant.GroupTenantMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+import javax.annotation.Resource;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 机构(租户)数据范围 Service 实现
+ *
+ * <p>集团账号(租户ID = 1)展开为旗下所有机构,普通机构账号只能看本机构。</p>
+ *
+ * @author kyj
+ */
+@Service
+@Validated
+public class TenantScopeServiceImpl implements TenantScopeService {
+
+    /**
+     * 集团账号的租户ID,集团账号需要展开为旗下所有机构
+     */
+    private static final Long GROUP_TENANT_ID = 1L;
+
+    @Resource
+    private GroupTenantMapper groupTenantMapper;
+
+    @Override
+    @TenantIgnore
+    public List<Long> resolveTenantIds(Long[] tenantIds) {
+        List<Long> scope = listScopeTenantIds();
+        if (ArrayUtil.isEmpty(tenantIds)) {
+            return scope;
+        }
+        Set<Long> scopeSet = new HashSet<>(scope);
+        // 超范围的机构直接忽略,保证集团账号 / 单机构账号两种权限下筛选范围正确
+        return Arrays.stream(tenantIds)
+                .filter(Objects::nonNull)
+                .filter(scopeSet::contains)
+                .distinct()
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 查询当前登录账号可见的机构ID
+     *
+     * @return 机构ID列表
+     */
+    private List<Long> listScopeTenantIds() {
+        Long currentTenantId = TenantContextHolder.getTenantId();
+        if (currentTenantId == null) {
+            return Collections.emptyList();
+        }
+        if (!GROUP_TENANT_ID.equals(currentTenantId)) {
+            return Collections.singletonList(currentTenantId);
+        }
+        return groupTenantMapper.selectList(new LambdaQueryWrapperX<GroupTenantDO>()
+                        .eq(GroupTenantDO::getDeleted, 0))
+                .stream()
+                .map(GroupTenantDO::getOrgTenantId)
+                .filter(Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+    }
+
+}

+ 72 - 4
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/doctororder/DoctorOrderExecutionServiceImpl.java

@@ -8,9 +8,14 @@ import cn.iocoder.yudao.module.system.controller.admin.doctororder.vo.DoctorOrde
 import cn.iocoder.yudao.module.system.controller.admin.doctororder.vo.DoctorOrderExecutionSaveReqVO;
 import cn.iocoder.yudao.module.system.dal.dataobject.biz.DoctorOrderExecutionDO;
 import cn.iocoder.yudao.module.system.dal.dataobject.biz.ElderlyInfoDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.biz.LongDoctorOrderEntryDO;
 import cn.iocoder.yudao.module.system.dal.mysql.biz.DoctorOrderExecutionMapper;
 import cn.iocoder.yudao.module.system.dal.mysql.biz.ElderlyInfoMapper;
+import cn.iocoder.yudao.module.system.dal.mysql.biz.LongDoctorOrderEntryMapper;
 import cn.iocoder.yudao.module.system.enums.ErrorCodeConstants;
+import cn.iocoder.yudao.module.system.enums.biz.DoctorOrderExecTypeEnum;
+import cn.iocoder.yudao.module.system.enums.biz.DoctorOrderExecutionStatusEnum;
+import cn.iocoder.yudao.module.system.enums.biz.DoctorOrderTypeEnum;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.validation.annotation.Validated;
@@ -36,6 +41,8 @@ public class DoctorOrderExecutionServiceImpl implements DoctorOrderExecutionServ
     private DoctorOrderExecutionMapper doctorOrderExecutionMapper;
     @Resource
     private ElderlyInfoMapper elderlyInfoMapper;
+    @Resource
+    private LongDoctorOrderEntryMapper longDoctorOrderEntryMapper;
 
     @Override
     public PageResult<DoctorOrderExecutionRespVO> getDoctorOrderExecutionPage(DoctorOrderExecutionPageReqVO reqVO) {
@@ -49,15 +56,27 @@ public class DoctorOrderExecutionServiceImpl implements DoctorOrderExecutionServ
     @Override
     @Transactional(rollbackFor = Exception.class)
     public DoctorOrderExecutionRespVO executeDoctorOrder(DoctorOrderExecutionSaveReqVO reqVO) {
-        // 幂等 upsert:按唯一键 (orderType, orderEntryId, planTime)
-        DoctorOrderExecutionDO exist = doctorOrderExecutionMapper.selectByUniqueKey(
-                reqVO.getOrderType(), reqVO.getOrderEntryId(), reqVO.getPlanTime());
+        // 兼容老请求:未传 execType 一律按常规执行处理
+        Integer execType = reqVO.getExecType() == null
+                ? DoctorOrderExecTypeEnum.NORMAL.getType() : reqVO.getExecType();
+        reqVO.setExecType(execType);
+        // 停止确认任务仅用于长期医嘱
+        if (DoctorOrderExecTypeEnum.STOP_CONFIRM.getType().equals(execType)
+                && !DoctorOrderTypeEnum.LONG.getType().equals(reqVO.getOrderType())) {
+            throw new ServiceException(ErrorCodeConstants.ELDERLY_DOCTOR_ORDER_EXEC_STOP_CONFIRM_ONLY_LONG);
+        }
+
+        DoctorOrderExecutionDO exist = findExecRecord(reqVO, execType);
         if (exist != null) {
             exist.setStatus(reqVO.getStatus());
             exist.setExecTime(reqVO.getExecTime());
             exist.setExecNurseSign(reqVO.getExecNurseSign());
             exist.setRemark(reqVO.getRemark());
+            if (reqVO.getDoctorSign() != null) {
+                exist.setDoctorSign(reqVO.getDoctorSign());
+            }
             doctorOrderExecutionMapper.updateById(exist);
+            writeBackStopConfirm(exist);
             return convertToResp(exist);
         }
 
@@ -65,8 +84,10 @@ public class DoctorOrderExecutionServiceImpl implements DoctorOrderExecutionServ
         org.springframework.beans.BeanUtils.copyProperties(reqVO, insert);
         // 避免将请求中的 id 复制到新记录,确保自增主键生效
         insert.setId(null);
+        insert.setExecType(execType);
         insert.setTenantId(TenantContextHolder.getTenantId());
         doctorOrderExecutionMapper.insert(insert);
+        writeBackStopConfirm(insert);
         return convertToResp(insert);
     }
 
@@ -77,10 +98,57 @@ public class DoctorOrderExecutionServiceImpl implements DoctorOrderExecutionServ
         if (exist == null) {
             throw new ServiceException(ErrorCodeConstants.ELDERLY_DOCTOR_ORDER_EXEC_NOT_EXISTS);
         }
-        exist.setStatus(2);
+        // 停止确认任务属于医嘱闭环的强制环节,不允许跳过
+        if (DoctorOrderExecTypeEnum.STOP_CONFIRM.getType().equals(exist.getExecType())) {
+            throw new ServiceException(ErrorCodeConstants.ELDERLY_DOCTOR_ORDER_EXEC_STOP_CONFIRM_CANNOT_SKIP);
+        }
+        exist.setStatus(DoctorOrderExecutionStatusEnum.SKIPPED.getStatus());
         doctorOrderExecutionMapper.updateById(exist);
     }
 
+    /**
+     * 定位待执行/确认的记录:停止确认优先按 id、其次按条目查待确认任务;常规执行沿用唯一键 upsert
+     */
+    private DoctorOrderExecutionDO findExecRecord(DoctorOrderExecutionSaveReqVO reqVO, Integer execType) {
+        if (DoctorOrderExecTypeEnum.STOP_CONFIRM.getType().equals(execType)) {
+            if (reqVO.getId() != null) {
+                DoctorOrderExecutionDO byId = doctorOrderExecutionMapper.selectById(reqVO.getId());
+                if (byId != null) {
+                    return byId;
+                }
+            }
+            if (reqVO.getOrderEntryId() != null) {
+                return doctorOrderExecutionMapper.selectPendingStopConfirm(reqVO.getOrderEntryId());
+            }
+            return null;
+        }
+        return doctorOrderExecutionMapper.selectByUniqueKey(
+                reqVO.getOrderType(), reqVO.getOrderEntryId(), reqVO.getPlanTime(), execType);
+    }
+
+    /**
+     * 护士确认停止后回写长期医嘱条目:停止护士签名,stopTime 缺失时用停止执行时间回填
+     */
+    private void writeBackStopConfirm(DoctorOrderExecutionDO execDO) {
+        if (!DoctorOrderExecTypeEnum.STOP_CONFIRM.getType().equals(execDO.getExecType())) {
+            return;
+        }
+        if (!DoctorOrderExecutionStatusEnum.DONE.getStatus().equals(execDO.getStatus())) {
+            return;
+        }
+        LongDoctorOrderEntryDO entryDO = longDoctorOrderEntryMapper.selectById(execDO.getOrderEntryId());
+        if (entryDO == null) {
+            return;
+        }
+        if (execDO.getExecNurseSign() != null) {
+            entryDO.setStopNurseSign(execDO.getExecNurseSign());
+        }
+        if (entryDO.getStopTime() == null) {
+            entryDO.setStopTime(execDO.getExecTime() != null ? execDO.getExecTime() : execDO.getPlanTime());
+        }
+        longDoctorOrderEntryMapper.updateById(entryDO);
+    }
+
     private DoctorOrderExecutionRespVO convertToResp(DoctorOrderExecutionDO execDO) {
         DoctorOrderExecutionRespVO respVO = new DoctorOrderExecutionRespVO();
         org.springframework.beans.BeanUtils.copyProperties(execDO, respVO);

+ 86 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/doctororder/LongDoctorOrderServiceImpl.java

@@ -7,15 +7,22 @@ import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
 import cn.iocoder.yudao.module.system.controller.admin.doctororder.vo.LongDoctorOrderPageReqVO;
 import cn.iocoder.yudao.module.system.controller.admin.doctororder.vo.LongDoctorOrderRespVO;
 import cn.iocoder.yudao.module.system.controller.admin.doctororder.vo.LongDoctorOrderSaveReqVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.biz.DoctorOrderExecutionDO;
 import cn.iocoder.yudao.module.system.dal.dataobject.biz.ElderlyInfoDO;
 import cn.iocoder.yudao.module.system.dal.dataobject.biz.LongDoctorOrderDO;
 import cn.iocoder.yudao.module.system.dal.dataobject.biz.LongDoctorOrderEntryDO;
+import cn.iocoder.yudao.module.system.dal.mysql.biz.DoctorOrderExecutionMapper;
 import cn.iocoder.yudao.module.system.dal.mysql.biz.ElderlyInfoMapper;
 import cn.iocoder.yudao.module.system.dal.mysql.biz.LongDoctorOrderEntryMapper;
 import cn.iocoder.yudao.module.system.dal.mysql.biz.LongDoctorOrderMapper;
+import cn.iocoder.yudao.module.system.enums.biz.DoctorOrderExecTypeEnum;
+import cn.iocoder.yudao.module.system.enums.biz.DoctorOrderExecutionStatusEnum;
+import cn.iocoder.yudao.module.system.enums.biz.DoctorOrderTypeEnum;
 import cn.iocoder.yudao.module.system.enums.biz.LongOrderEntryStatusEnum;
 import cn.iocoder.yudao.module.system.enums.ErrorCodeConstants;
 import cn.iocoder.yudao.module.system.service.biz.BuildService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DuplicateKeyException;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.validation.annotation.Validated;
@@ -35,6 +42,7 @@ import java.util.stream.Collectors;
  */
 @Service
 @Validated
+@Slf4j
 public class LongDoctorOrderServiceImpl implements LongDoctorOrderService {
 
     @Resource
@@ -45,6 +53,8 @@ public class LongDoctorOrderServiceImpl implements LongDoctorOrderService {
     private ElderlyInfoMapper elderlyInfoMapper;
     @Resource
     private BuildService buildService;
+    @Resource
+    private DoctorOrderExecutionMapper doctorOrderExecutionMapper;
 
     @Override
     public PageResult<LongDoctorOrderRespVO> getLongDoctorOrderPage(LongDoctorOrderPageReqVO reqVO) {
@@ -161,6 +171,82 @@ public class LongDoctorOrderServiceImpl implements LongDoctorOrderService {
             }
         }
         longDoctorOrderEntryMapper.updateById(entryDO);
+
+        if (LongOrderEntryStatusEnum.STOPPED.getStatus().equals(status)) {
+            // 停止医嘱:生成护士「停止确认」任务,由护士在执行医嘱页填写停止执行时间并签名
+            createStopConfirmTask(entryDO);
+        } else {
+            // 重新启用:失效该条目未确认的停止确认任务,避免护士确认到过期任务
+            doctorOrderExecutionMapper.invalidPendingStopConfirm(entryDO.getId());
+        }
+    }
+
+    /**
+     * 生成「停止确认」执行任务
+     * 幂等口径(严格按 3.1):仅当已存在 execType=2 AND status=0 的任务时才跳过;
+     * 已失效(status=2)的任务在再次停止时必须重新可用,保证护士端能收到第二次停止的确认任务。
+     */
+    private void createStopConfirmTask(LongDoctorOrderEntryDO entryDO) {
+        // 1. 已存在待确认任务,直接返回
+        if (doctorOrderExecutionMapper.selectPendingStopConfirm(entryDO.getId()) != null) {
+            return;
+        }
+        LongDoctorOrderDO orderDO = entryDO.getLongDoctorOrderId() == null ? null
+                : longDoctorOrderMapper.selectById(entryDO.getLongDoctorOrderId());
+        DoctorOrderExecutionDO history = doctorOrderExecutionMapper.selectLatestStopConfirm(entryDO.getId());
+
+        // 2. 存在被作废的旧任务(重新启用后置 status=2):重置为待确认后复用,避免与唯一键冲突
+        if (history != null && DoctorOrderExecutionStatusEnum.SKIPPED.getStatus().equals(history.getStatus())) {
+            fillStopConfirmTask(history, entryDO, orderDO);
+            history.setExecTime(null);
+            history.setExecNurseSign(null);
+            history.setRemark(null);
+            doctorOrderExecutionMapper.updateById(history);
+            return;
+        }
+
+        // 3. 首次停止 / 已确认后再次停止:新增一条待确认任务;
+        //    若计划时间与既有记录撞唯一键,顺延 1 秒重试,确保护士端一定有任务可确认
+        for (int i = 0; i < 5; i++) {
+            DoctorOrderExecutionDO execDO = new DoctorOrderExecutionDO();
+            fillStopConfirmTask(execDO, entryDO, orderDO);
+            if (entryDO.getStopTime() != null) {
+                execDO.setPlanTime(entryDO.getStopTime().plusSeconds(i));
+            }
+            try {
+                doctorOrderExecutionMapper.insert(execDO);
+                return;
+            } catch (DuplicateKeyException ex) {
+                log.warn("[createStopConfirmTask][医嘱条目({}) 停止确认任务计划时间({}) 冲突,顺延重试]",
+                        entryDO.getId(), execDO.getPlanTime());
+            }
+        }
+    }
+
+    /**
+     * 填充停止确认任务的字段(新增与重置复用同一口径)
+     */
+    private void fillStopConfirmTask(DoctorOrderExecutionDO execDO, LongDoctorOrderEntryDO entryDO,
+                                     LongDoctorOrderDO orderDO) {
+        execDO.setOrderType(DoctorOrderTypeEnum.LONG.getType());
+        execDO.setExecType(DoctorOrderExecTypeEnum.STOP_CONFIRM.getType());
+        execDO.setOrderEntryId(entryDO.getId());
+        if (orderDO != null) {
+            execDO.setElderId(orderDO.getElderId());
+            execDO.setElderName(orderDO.getElderName());
+            execDO.setBedName(orderDO.getBedName());
+        }
+        execDO.setOrderContent(entryDO.getOrderContent());
+        execDO.setFrequency(entryDO.getFrequency());
+        execDO.setMedTime(entryDO.getMedTime());
+        execDO.setRoute(entryDO.getRoute());
+        // 计划时间 = 停止时间;执行时间/执行护士签名留空,待护士确认
+        execDO.setPlanTime(entryDO.getStopTime());
+        execDO.setStatus(DoctorOrderExecutionStatusEnum.PENDING.getStatus());
+        execDO.setDoctorSign(entryDO.getStopDoctorSign() != null
+                ? entryDO.getStopDoctorSign() : entryDO.getDoctorSign());
+        execDO.setTenantId(orderDO != null && orderDO.getTenantId() != null
+                ? orderDO.getTenantId() : TenantContextHolder.getTenantId());
     }
 
     // ======================== 私有方法 ========================

+ 28 - 18
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/notify/NotifySendServiceImpl.java

@@ -13,7 +13,8 @@ import cn.iocoder.yudao.module.bpm.enums.message.BpmMessageEnum;
 import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO;
 import cn.iocoder.yudao.module.system.api.wechat.CorpWechatApi;
 import cn.iocoder.yudao.module.system.api.wechat.dto.CorpSendMessage;
-import cn.iocoder.yudao.module.system.api.wechat.dto.CorpWechatTextcard;
+import cn.iocoder.yudao.module.system.api.wechat.dto.CorpWechatContentItem;
+import cn.iocoder.yudao.module.system.api.wechat.dto.CorpWechatMiniProgramNotice;
 import cn.iocoder.yudao.module.system.api.wechat.dto.WechatMessageSaveReq;
 import cn.iocoder.yudao.module.system.dal.dataobject.notify.NotifyMessageDO;
 import cn.iocoder.yudao.module.system.dal.dataobject.notify.NotifyTemplateDO;
@@ -27,13 +28,13 @@ import org.springframework.validation.annotation.Validated;
 
 import javax.annotation.Resource;
 import java.time.LocalDateTime;
+import java.util.ArrayList;
 import java.util.Date;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 
 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
-import static cn.iocoder.yudao.framework.common.util.corp.CorpMsgUtil.BTNTXT_DETAIL_NOTICE;
 import static cn.iocoder.yudao.framework.common.util.corp.CorpMsgUtil.TITLE_CANCEL;
 import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
 import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*;
@@ -200,22 +201,25 @@ public class NotifySendServiceImpl implements NotifySendService {
                 .setPriority(reqDTO.getPriority()).setCompletedStatus(reqDTO.getCompletedStatus()).setTenantId(reqDTO.getTenantId());
         notifyMessageMapper.insert(message);
 
-        CorpWechatTextcard textcard = corpSendMessage.getTextcard();
+        CorpWechatMiniProgramNotice notice = corpSendMessage.getMiniProgramNotice();
         WechatMessageSaveReq req = corpSendMessage.getWechatMessageSaveReq();
         req.setContent(content);
-        String corpContent = "";
+        List<CorpWechatContentItem> contentItems = new ArrayList<>();
         if (0 == corpSendMessage.getContentType()) {
-            corpContent = CorpMsgUtil.getContent(CorpMsgUtil.builderHandledContent(
-                    req.getTenantName(),
-                    req.getTypeName(),
-                    content,
-                    req.getApplyTime(),
-                    req.getReason()), CorpMsgUtil.NORMAL);
+            contentItems.add(new CorpWechatContentItem("所属组织", req.getTenantName()));
+            contentItems.add(new CorpWechatContentItem("申请类型", req.getTypeName()));
+            contentItems.add(new CorpWechatContentItem("申请事由", content));
+            if (StringUtil.isNotEmptyORNull(req.getApplyTime())) {
+                contentItems.add(new CorpWechatContentItem("申请时间", req.getApplyTime()));
+            }
+            if (StringUtil.isNotEmptyORNull(req.getReason())) {
+                contentItems.add(new CorpWechatContentItem("处理意见", req.getReason()));
+            }
         }
         if (1 == corpSendMessage.getContentType()) {
-            corpContent = CorpMsgUtil.getContent(CorpMsgUtil.builderTodoContent(content), CorpMsgUtil.NORMAL);
+            contentItems.add(new CorpWechatContentItem("消息内容", content));
         }
-        textcard.setDescription(CorpMsgUtil.getDescription(null, corpContent));
+        notice.setContentItem(contentItems);
         corpWechatApi.sendMessage(corpSendMessage);
         return message.getId();
     }
@@ -264,9 +268,12 @@ public class NotifySendServiceImpl implements NotifySendService {
         // 发送站内信
         String content = notifyTemplateService.formatNotifyTemplateContent(template.getContent(), notifyDTO.getTemplateParams());
         corpList.forEach(corpSendMessage -> {
-            CorpWechatTextcard textcard = corpSendMessage.getTextcard();
+            CorpWechatMiniProgramNotice notice = corpSendMessage.getMiniProgramNotice();
             WechatMessageSaveReq req = corpSendMessage.getWechatMessageSaveReq();
-            textcard.setDescription(CorpMsgUtil.getDescription(null, CorpMsgUtil.getContent(CorpMsgUtil.builderCopyContent(req.getTenantName(), content), CorpMsgUtil.NORMAL)));
+            List<CorpWechatContentItem> contentItems = new ArrayList<>();
+            contentItems.add(new CorpWechatContentItem("所属组织", req.getTenantName()));
+            contentItems.add(new CorpWechatContentItem("抄送内容", content));
+            notice.setContentItem(contentItems);
             req.setContent(content);
             corpWechatApi.sendMessage(corpSendMessage);
         });
@@ -285,11 +292,14 @@ public class NotifySendServiceImpl implements NotifySendService {
         // 校验参数
         validateTemplateParams(template, corpSendMessage.getTemplateParams());
         String content = notifyTemplateService.formatNotifyTemplateContent(template.getContent(), corpSendMessage.getTemplateParams());
-        CorpWechatTextcard textcard = corpSendMessage.getTextcard();
+        CorpWechatMiniProgramNotice notice = corpSendMessage.getMiniProgramNotice();
         WechatMessageSaveReq req = corpSendMessage.getWechatMessageSaveReq();
-        textcard.setTitle(TITLE_CANCEL);
-        textcard.setBtntxt(BTNTXT_DETAIL_NOTICE);
-        textcard.setDescription(CorpMsgUtil.getDescription(null, CorpMsgUtil.getContent(CorpMsgUtil.builderCancelContent(req.getTenantName(), req.getTypeName(), content), CorpMsgUtil.NORMAL)));
+        notice.setTitle(TITLE_CANCEL);
+        List<CorpWechatContentItem> contentItems = new ArrayList<>();
+        contentItems.add(new CorpWechatContentItem("所属组织", req.getTenantName()));
+        contentItems.add(new CorpWechatContentItem("申请类型", req.getTypeName()));
+        contentItems.add(new CorpWechatContentItem("申请事由", content));
+        notice.setContentItem(contentItems);
         req.setTitle(TITLE_CANCEL);
         req.setContent(content);
         corpWechatApi.sendMessage(corpSendMessage);

+ 201 - 0
yudao-module-system/yudao-module-system-biz/src/main/resources/mapper/BuildBedMapper.xml

@@ -87,4 +87,205 @@
         ORDER BY build.sort ASC, floor.sort ASC, room.id ASC, bed.sort ASC
     </select>
 
+    <!-- ========== 空床明细报表 ========== -->
+
+    <!-- 房间级床位统计:房间总床位数与已住床位数,用于表格「房间 已住/总数」列 -->
+    <sql id="emptyBedRoomStatsJoin">
+        LEFT JOIN (
+            SELECT r.id                                     AS room_id,
+                   COUNT(b.id)                               AS bed_count,
+                   COUNT(CASE WHEN b.status = 1 THEN 1 END)  AS used_count
+            FROM org_build_room r
+                     LEFT JOIN org_build_bed b ON b.room_id = r.id
+            WHERE 1 = 1
+            <if test="reqVO.tenantIds != null and reqVO.tenantIds.length > 0">
+                AND r.tenant_id IN
+                <foreach item="id" collection="reqVO.tenantIds" open="(" separator="," close=")">
+                    #{id}
+                </foreach>
+            </if>
+            <if test="reqVO.buildIds != null and reqVO.buildIds.length > 0">
+                AND b.build_id IN
+                <foreach item="id" collection="reqVO.buildIds" open="(" separator="," close=")">
+                    #{id}
+                </foreach>
+            </if>
+            GROUP BY r.id
+            ) rs ON rs.room_id = room.id
+    </sql>
+
+    <sql id="emptyBedColumns">
+        bed.id                  AS bedId,
+        bed.bed_name            AS bedName,
+        bed.status              AS bedStatus,
+        room.id                 AS roomId,
+        room.room_name          AS roomName,
+        room.room_area          AS roomArea,
+        room.orientation        AS orientation,
+        room.room_settings      AS roomSettings,
+        room.room_usage         AS roomUsage,
+        COALESCE(rs.bed_count, 0)  AS roomBedCount,
+        COALESCE(rs.used_count, 0) AS roomUsedCount,
+        floor.id                AS floorId,
+        floor.floor_name        AS floorName,
+        build.id                AS buildId,
+        build.build_name        AS buildName,
+        bed.tenant_id           AS tenantId,
+        st.name                 AS tenantName
+    </sql>
+
+    <sql id="emptyBedFrom">
+        FROM org_build_bed AS bed
+                 INNER JOIN org_build_room AS room ON bed.room_id = room.id AND bed.tenant_id = room.tenant_id
+                 INNER JOIN org_build_floor AS floor ON room.floor_id = floor.id AND room.tenant_id = floor.tenant_id
+                 INNER JOIN org_build AS build ON floor.build_id = build.id AND floor.tenant_id = build.tenant_id
+                 LEFT JOIN system_tenant AS st ON st.id = bed.tenant_id
+    </sql>
+
+    <sql id="emptyBedWhere">
+        <if test="reqVO.orgType != null">
+            AND bed.org_type = #{reqVO.orgType}
+        </if>
+        <if test="reqVO.tenantIds != null and reqVO.tenantIds.length > 0">
+            AND bed.tenant_id IN
+            <foreach item="id" collection="reqVO.tenantIds" open="(" separator="," close=")">
+                #{id}
+            </foreach>
+        </if>
+        <if test="reqVO.buildIds != null and reqVO.buildIds.length > 0">
+            AND bed.build_id IN
+            <foreach item="id" collection="reqVO.buildIds" open="(" separator="," close=")">
+                #{id}
+            </foreach>
+        </if>
+        <if test="reqVO.floodIds != null and reqVO.floodIds.length > 0">
+            AND bed.floor_id IN
+            <foreach item="id" collection="reqVO.floodIds" open="(" separator="," close=")">
+                #{id}
+            </foreach>
+        </if>
+        <if test="reqVO.status != null">
+            AND bed.status = #{reqVO.status}
+        </if>
+        <if test="reqVO.roomAreaArray != null and reqVO.roomAreaArray.size() > 0">
+            AND room.room_area IN
+            <foreach item="item" collection="reqVO.roomAreaArray" open="(" separator="," close=")">
+                #{item}
+            </foreach>
+        </if>
+        <if test="reqVO.elderTypeArray != null and reqVO.elderTypeArray.size() > 0">
+            AND room.elder_type IN
+            <foreach item="item" collection="reqVO.elderTypeArray" open="(" separator="," close=")">
+                #{item}
+            </foreach>
+        </if>
+        <if test="reqVO.orientationArray != null and reqVO.orientationArray.size() > 0">
+            AND room.orientation IN
+            <foreach item="item" collection="reqVO.orientationArray" open="(" separator="," close=")">
+                #{item}
+            </foreach>
+        </if>
+        <if test="reqVO.roomSettingsArray != null and reqVO.roomSettingsArray.size() > 0">
+            AND (
+            <foreach item="item" collection="reqVO.roomSettingsArray" separator=" OR ">
+                room.room_settings LIKE CONCAT('%', #{item}, '%')
+            </foreach>
+            )
+        </if>
+        <if test="reqVO.bedName != null and reqVO.bedName != ''">
+            AND (bed.bed_name LIKE CONCAT('%', #{reqVO.bedName}, '%')
+            OR room.room_name LIKE CONCAT('%', #{reqVO.bedName}, '%'))
+        </if>
+    </sql>
+
+    <select id="selectEmptyBedPage" resultType="cn.iocoder.yudao.module.system.controller.admin.biz.vo.EmptyBedRespVO">
+        SELECT
+        <include refid="emptyBedColumns"/>
+        <include refid="emptyBedFrom"/>
+        <include refid="emptyBedRoomStatsJoin"/>
+        WHERE 1 = 1
+        <include refid="emptyBedWhere"/>
+        ORDER BY build.sort ASC, floor.sort ASC, room.id ASC, bed.sort ASC
+    </select>
+
+    <select id="selectEmptyBedList" resultType="cn.iocoder.yudao.module.system.controller.admin.biz.vo.EmptyBedRespVO">
+        SELECT
+        <include refid="emptyBedColumns"/>
+        <include refid="emptyBedFrom"/>
+        <include refid="emptyBedRoomStatsJoin"/>
+        WHERE 1 = 1
+        <include refid="emptyBedWhere"/>
+        ORDER BY build.sort ASC, floor.sort ASC, room.id ASC, bed.sort ASC
+    </select>
+
+    <!-- 空床汇总:忽略床位状态筛选,只统计状态为 0 的床位 -->
+    <select id="selectEmptyBedSummary"
+            resultType="cn.iocoder.yudao.module.system.controller.admin.biz.vo.EmptyBedSummaryRespVO">
+        SELECT COALESCE(SUM(t.empty_bed_count), 0)                                              AS emptyBedCount,
+               COUNT(CASE WHEN t.empty_bed_count &gt; 0 THEN 1 END)                                AS emptyRoomCount,
+               COUNT(CASE WHEN t.bed_count &gt; 0 AND t.bed_count = t.empty_bed_count THEN 1 END)   AS fullEmptyRoomCount
+        FROM (
+            SELECT r.id                                    AS room_id,
+                   COUNT(b.id)                             AS bed_count,
+                   COUNT(CASE WHEN b.status = 0 THEN 1 END) AS empty_bed_count
+            FROM org_build_room AS r
+                     INNER JOIN org_build_floor AS f ON r.floor_id = f.id AND r.tenant_id = f.tenant_id
+                     INNER JOIN org_build AS build ON f.build_id = build.id AND f.tenant_id = build.tenant_id
+                     LEFT JOIN org_build_bed AS b ON b.room_id = r.id AND b.tenant_id = r.tenant_id
+            WHERE 1 = 1
+            <if test="reqVO.orgType != null">
+                AND r.org_type = #{reqVO.orgType}
+            </if>
+            <if test="reqVO.tenantIds != null and reqVO.tenantIds.length > 0">
+                AND r.tenant_id IN
+                <foreach item="id" collection="reqVO.tenantIds" open="(" separator="," close=")">
+                    #{id}
+                </foreach>
+            </if>
+            <if test="reqVO.buildIds != null and reqVO.buildIds.length > 0">
+                AND f.build_id IN
+                <foreach item="id" collection="reqVO.buildIds" open="(" separator="," close=")">
+                    #{id}
+                </foreach>
+            </if>
+            <if test="reqVO.floodIds != null and reqVO.floodIds.length > 0">
+                AND r.floor_id IN
+                <foreach item="id" collection="reqVO.floodIds" open="(" separator="," close=")">
+                    #{id}
+                </foreach>
+            </if>
+            <if test="reqVO.roomAreaArray != null and reqVO.roomAreaArray.size() > 0">
+                AND r.room_area IN
+                <foreach item="item" collection="reqVO.roomAreaArray" open="(" separator="," close=")">
+                    #{item}
+                </foreach>
+            </if>
+            <if test="reqVO.elderTypeArray != null and reqVO.elderTypeArray.size() > 0">
+                AND r.elder_type IN
+                <foreach item="item" collection="reqVO.elderTypeArray" open="(" separator="," close=")">
+                    #{item}
+                </foreach>
+            </if>
+            <if test="reqVO.orientationArray != null and reqVO.orientationArray.size() > 0">
+                AND r.orientation IN
+                <foreach item="item" collection="reqVO.orientationArray" open="(" separator="," close=")">
+                    #{item}
+                </foreach>
+            </if>
+            <if test="reqVO.roomSettingsArray != null and reqVO.roomSettingsArray.size() > 0">
+                AND (
+                <foreach item="item" collection="reqVO.roomSettingsArray" separator=" OR ">
+                    r.room_settings LIKE CONCAT('%', #{item}, '%')
+                </foreach>
+                )
+            </if>
+            <if test="reqVO.bedName != null and reqVO.bedName != ''">
+                AND (r.room_name LIKE CONCAT('%', #{reqVO.bedName}, '%')
+                OR EXISTS (SELECT 1 FROM org_build_bed AS b2
+                WHERE b2.room_id = r.id AND b2.bed_name LIKE CONCAT('%', #{reqVO.bedName}, '%')))
+            </if>
+            GROUP BY r.id
+            ) t
+    </select>
+
 </mapper>

+ 56 - 0
yudao-module-system/yudao-module-system-biz/src/main/resources/mapper/BuildStatisticsMapper.xml

@@ -0,0 +1,56 @@
+<?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.BuildStatisticsMapper">
+
+    <!-- 按楼层聚合:
+         1)房间级子查询先算出每个房间的总床位数与三种状态的床位数;
+         2)外层再按楼层汇总,整间空房 = 房间内有床位且全部床位空闲。
+         说明:楼栋、楼层、房间、床位表均无 deleted 字段,故不做删除过滤。 -->
+    <select id="selectFloorBedStatistics"
+            resultType="cn.iocoder.yudao.module.system.dal.mysql.biz.bo.BuildFloorBedStatisticsBO">
+        SELECT st.name                                                                      AS tenantName,
+               t.tenant_id                                                                 AS tenantId,
+               t.build_id                                                                  AS buildId,
+               t.build_name                                                                AS buildName,
+               t.floor_id                                                                  AS floorId,
+               t.floor_name                                                                AS floorName,
+               COUNT(t.room_id)                                                            AS totalRoomCount,
+               COUNT(CASE WHEN t.bed_count &gt; 0 AND t.bed_count = t.empty_bed_count THEN 1 END) AS emptyRoomCount,
+               COALESCE(SUM(t.bed_count), 0)                                               AS totalBedCount,
+               COALESCE(SUM(t.occupied_bed_count), 0)                                      AS occupiedBedCount,
+               COALESCE(SUM(t.empty_bed_count), 0)                                         AS emptyBedCount,
+               COALESCE(SUM(t.temporary_bed_count), 0)                                     AS temporaryOccupationCount
+        FROM (
+            SELECT bu.tenant_id                                     AS tenant_id,
+                   bu.id                                            AS build_id,
+                   bu.build_name                                    AS build_name,
+                   f.id                                             AS floor_id,
+                   f.floor_name                                     AS floor_name,
+                   r.id                                             AS room_id,
+                   COUNT(b.id)                                      AS bed_count,
+                   COUNT(CASE WHEN b.status = 1 THEN 1 END)         AS occupied_bed_count,
+                   COUNT(CASE WHEN b.status = 0 THEN 1 END)         AS empty_bed_count,
+                   COUNT(CASE WHEN b.status = 2 THEN 1 END)         AS temporary_bed_count
+            FROM org_build bu
+                     INNER JOIN org_build_floor f ON f.build_id = bu.id
+                     LEFT JOIN org_build_room r ON r.floor_id = f.id
+                     LEFT JOIN org_build_bed b ON b.room_id = r.id
+            WHERE bu.org_type = #{orgType}
+              AND bu.tenant_id IN
+            <foreach collection="tenantIds" item="tenantId" open="(" separator="," close=")">
+                #{tenantId}
+            </foreach>
+            <if test="buildIds != null and buildIds.size() > 0">
+                AND bu.id IN
+                <foreach collection="buildIds" item="buildId" open="(" separator="," close=")">
+                    #{buildId}
+                </foreach>
+            </if>
+            GROUP BY bu.tenant_id, bu.id, bu.build_name, f.id, f.floor_name, r.id
+        ) t
+                 LEFT JOIN system_tenant st ON st.id = t.tenant_id
+        GROUP BY t.tenant_id, t.build_id, t.build_name, t.floor_id, t.floor_name, st.name
+        ORDER BY t.tenant_id, t.build_id, t.floor_id
+    </select>
+
+</mapper>

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

@@ -228,4 +228,96 @@
         GROUP BY t.tenant_id
     </select>
 
+    <!-- 区间入住数(按天分组):以长者档案的入住时间为准,end 传「结束日次日」以便覆盖整天 -->
+    <select id="selectCheckInDailyTrend"
+            resultType="cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationTenantMonthCountBO">
+        SELECT t.tenant_id                                  AS tenantId,
+               DATE_FORMAT(t.check_in_time, '%Y-%m-%d')     AS month,
+               COUNT(*)                                     AS count
+        FROM elderly_info t
+        WHERE t.deleted = 0
+          AND t.org_type = #{orgType}
+          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, DATE_FORMAT(t.check_in_time, '%Y-%m-%d')
+    </select>
+
+    <!-- 区间退住数(按天分组):以退住登记表的退住时间为准,且必须是「已完成」的退住 -->
+    <select id="selectCheckOutDailyTrend"
+            resultType="cn.iocoder.yudao.module.system.dal.mysql.biz.bo.OperationTenantMonthCountBO">
+        SELECT t.tenant_id                                  AS tenantId,
+               DATE_FORMAT(t.retreat_date, '%Y-%m-%d')      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.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>
+        GROUP BY t.tenant_id, DATE_FORMAT(t.retreat_date, '%Y-%m-%d')
+    </select>
+
+    <!-- 区间起始日之前的累计入住数 -->
+    <select id="selectCheckInCountBefore" resultType="java.lang.Long">
+        SELECT COUNT(*)
+        FROM elderly_info t
+        WHERE t.deleted = 0
+          AND t.org_type = #{orgType}
+          AND t.check_in_time &lt; #{begin}
+          AND t.tenant_id IN
+        <foreach collection="tenantIds" item="tenantId" open="(" separator="," close=")">
+            #{tenantId}
+        </foreach>
+    </select>
+
+    <!-- 区间起始日之前的累计退住数(仅「已完成」的退住) -->
+    <select id="selectCheckOutCountBefore" resultType="java.lang.Long">
+        SELECT COUNT(*)
+        FROM elderly_retreat_record t
+        WHERE t.deleted = 0
+          AND t.apply_type = 1
+          AND t.current_flag = 0
+          AND t.org_type = #{orgType}
+          AND t.retreat_date &lt; #{begin}
+          AND
+        <include refid="retreatFinishedCondition"/>
+          AND t.tenant_id IN
+        <foreach collection="tenantIds" item="tenantId" open="(" separator="," close=")">
+            #{tenantId}
+        </foreach>
+    </select>
+
+    <!-- 长者结构聚合明细行:仅统计在住长者(in_status = 1) -->
+    <select id="selectElderStructureRows"
+            resultType="cn.iocoder.yudao.module.system.dal.mysql.biz.bo.ElderStructureRowBO">
+        SELECT e.tenant_id          AS tenantId,
+               e.elder_sex          AS elderSex,
+               e.elder_age          AS elderAge,
+               e.birthday           AS birthday,
+               e.check_in_time      AS checkInTime,
+               e.nurse_level_id     AS nurseLevelId,
+               e.nurse_level_name   AS nurseLevelName,
+               e.degree_education   AS degreeEducation,
+               e.payment_method     AS paymentMethod
+        FROM elderly_info e
+        WHERE e.deleted = 0
+          AND e.org_type = #{orgType}
+          AND e.in_status = 1
+          AND e.tenant_id IN
+        <foreach collection="tenantIds" item="tenantId" open="(" separator="," close=")">
+            #{tenantId}
+        </foreach>
+    </select>
+
 </mapper>

+ 1 - 0
yudao-server/src/main/resources/application.yaml

@@ -264,6 +264,7 @@ yudao:
       - /admin-api/elderly-build-floor-order-food/get
       - /admin-api/elderly-build-floor-order-food/page
       - /admin-api/system/tenant/get
+      - /admin-api/system/auth/login-by-user-id
     ignore-tables:
       - system_tenant
       - system_groups