1
0

3 Commits 26f42cec03 ... a0a7efe5ca

Autor SHA1 Nachricht Datum
  liangwenxuan a0a7efe5ca 新增 vor 2 Wochen
  liangwenxuan f38a320cf2 新增 vor 2 Wochen
  liangwenxuan f75b167168 新增 vor 2 Wochen
100 geänderte Dateien mit 4538 neuen und 283 gelöschten Zeilen
  1. 13 0
      sql/mysql/alter_elderly_doctor_order_execution_add_exec_type.sql
  2. 3 1
      sql/mysql/elderly_doctor_order.sql
  3. 9 0
      sql/mysql/notice.sql
  4. 12 0
      sql/mysql/system_common_seal.sql
  5. 8 83
      yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/corp/CorpMsgUtil.java
  6. 4 0
      yudao-module-bpm/yudao-module-bpm-api/src/main/java/cn/iocoder/yudao/module/bpm/enums/message/BpmMessageEnum.java
  7. 8 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/controller/admin/business/BpmElderlyController.java
  8. 120 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/controller/admin/business/vo/FeeItemChangeRecordCreateReqVO.java
  9. 25 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/controller/admin/task/BpmProcessInstanceController.java
  10. 66 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/controller/admin/task/vo/excel/FeeItemChangeRecordExcelVO.java
  11. 2 1
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/enums/BpmBusinessTypeEnum.java
  12. 2 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/business/BpmBusinessFormService.java
  13. 18 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/business/BpmBusinessFormServiceImpl.java
  14. 2 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/business/BpmElderlApplyService.java
  15. 29 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/business/BpmElderlApplyServiceImpl.java
  16. 52 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/business/listener/BpmFeeItemChangeStatusListener.java
  17. 76 24
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/message/BpmMessageServiceImpl.java
  18. 98 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmProcessInstanceCopyServiceImpl.java
  19. 2 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmProcessInstanceService.java
  20. 24 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmProcessInstanceServiceImpl.java
  21. 2 0
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/bpm/BpmElderlyChangeApi.java
  22. 61 0
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/bpm/vo/FeeItemChangeRecordRespVO.java
  23. 2 2
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/wechat/dto/CorpSendMessage.java
  24. 27 0
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/wechat/dto/CorpWechatContentItem.java
  25. 41 0
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/wechat/dto/CorpWechatMiniProgramNotice.java
  26. 0 37
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/wechat/dto/CorpWechatTextcard.java
  27. 5 0
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java
  28. 5 0
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/LogRecordConstants.java
  29. 2 1
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/change/BusinessTypeEnum.java
  30. 14 0
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/change/ChangeConstants.java
  31. 2 1
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/change/ChangeTypeEnum.java
  32. 29 2
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/oauth2/EnterpriseWeChatConstants.java
  33. 4 6
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/bpm/BpmCheckInApiImpl.java
  34. 4 7
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/bpm/BpmElderlyApiImpl.java
  35. 35 2
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/bpm/BpmElderlyChangeApiImpl.java
  36. 4 6
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/bpm/BpmElderlyExpenseApiImpl.java
  37. 66 18
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/wechat/CorpWechatApiImpl.java
  38. 8 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/AuthController.http
  39. 7 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/AuthController.java
  40. 22 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthUserIdLoginReqVO.java
  41. 42 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/BuildController.java
  42. 85 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/CommonSealController.java
  43. 104 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/JiashuNoticeController.java
  44. 27 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/OperationAnalysisController.java
  45. 3 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/BuildFloorDataRespVO.java
  46. 74 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/BuildStatisticsRespVO.java
  47. 44 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/EmptyBedPageReqVO.java
  48. 68 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/EmptyBedRespVO.java
  49. 28 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/EmptyBedSummaryRespVO.java
  50. 23 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/JiashuNoticePageReqVO.java
  51. 27 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/JiashuNoticeRespVO.java
  52. 33 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/JiashuNoticeSaveReqVO.java
  53. 47 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/excel/EmptyBedExcelVO.java
  54. 39 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/operationanalysis/OperationAnalysisCheckTrendReqVO.java
  55. 52 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/operationanalysis/OperationAnalysisCheckTrendRespVO.java
  56. 20 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/operationanalysis/OperationAnalysisElderStructureReqVO.java
  57. 149 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/operationanalysis/OperationAnalysisElderStructureRespVO.java
  58. 21 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/seal/CommonSealListReqVO.java
  59. 41 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/seal/CommonSealRespVO.java
  60. 32 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/seal/CommonSealSaveReqVO.java
  61. 3 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/doctororder/vo/DoctorOrderExecutionPageReqVO.java
  62. 5 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/doctororder/vo/DoctorOrderExecutionRespVO.java
  63. 5 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/doctororder/vo/DoctorOrderExecutionSaveReqVO.java
  64. 51 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/biz/CommonSealDO.java
  65. 4 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/biz/DoctorOrderExecutionDO.java
  66. 40 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/miniapp/JiashuNoticeDO.java
  67. 29 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/BuildBedMapper.java
  68. 32 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/BuildStatisticsMapper.java
  69. 32 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/CommonSealMapper.java
  70. 51 2
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/DoctorOrderExecutionMapper.java
  71. 3 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/ElderlyChangeRecordMapper.java
  72. 13 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/ExpenseMapper.java
  73. 63 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/OperationAnalysisMapper.java
  74. 75 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/bo/BuildFloorBedStatisticsBO.java
  75. 62 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/bo/ElderStructureRowBO.java
  76. 43 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/miniapp/JiashuNoticeMapper.java
  77. 34 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/enums/biz/DoctorOrderExecTypeEnum.java
  78. 10 16
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/job/BusinessConfigJob.java
  79. 8 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthService.java
  80. 31 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImpl.java
  81. 36 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/BuildService.java
  82. 299 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/BuildServiceImpl.java
  83. 55 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/CommonSealService.java
  84. 80 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/CommonSealServiceImpl.java
  85. 3 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/ElderlyConsumerVouchersServiceImpl.java
  86. 30 7
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/ExpenseOrderServiceImpl.java
  87. 24 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/OperationAnalysisService.java
  88. 651 39
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/OperationAnalysisServiceImpl.java
  89. 23 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/TenantScopeService.java
  90. 78 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/TenantScopeServiceImpl.java
  91. 72 4
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/doctororder/DoctorOrderExecutionServiceImpl.java
  92. 86 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/doctororder/LongDoctorOrderServiceImpl.java
  93. 64 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/miniapp/JiashuNoticeService.java
  94. 82 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/miniapp/JiashuNoticeServiceImpl.java
  95. 54 21
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/notify/NotifySendServiceImpl.java
  96. 201 0
      yudao-module-system/yudao-module-system-biz/src/main/resources/mapper/BuildBedMapper.xml
  97. 56 0
      yudao-module-system/yudao-module-system-biz/src/main/resources/mapper/BuildStatisticsMapper.xml
  98. 65 0
      yudao-module-system/yudao-module-system-biz/src/main/resources/mapper/ElderlyChangeRecordMapper.xml
  99. 21 0
      yudao-module-system/yudao-module-system-biz/src/main/resources/mapper/ExpenseMapper.xml
  100. 92 0
      yudao-module-system/yudao-module-system-biz/src/main/resources/mapper/OperationAnalysisMapper.xml

+ 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 = '护士执行记录';

+ 9 - 0
sql/mysql/notice.sql

@@ -0,0 +1,9 @@
+DROP TABLE IF EXISTS `notice`;
+CREATE TABLE `notice`  (
+  `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
+  `type` varchar(16) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '须知类型:APPOINTMENT或者VISIT',
+  `content` longtext CHARACTER SET utf8 COLLATE utf8_general_ci NULL COMMENT '须知内容',
+  `tenant_id` bigint(20) NULL DEFAULT NULL COMMENT '机构id',
+  PRIMARY KEY (`id`) USING BTREE,
+  INDEX `idx_type`(`type`) USING BTREE
+) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '须知表' ROW_FORMAT = Dynamic;

+ 12 - 0
sql/mysql/system_common_seal.sql

@@ -0,0 +1,12 @@
+DROP TABLE IF EXISTS `system_common_seal`;
+CREATE TABLE `system_common_seal`  (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
+  `type` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '类型',
+  `url` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '公章url',
+  `tenant_id` bigint(20) NULL DEFAULT NULL COMMENT '机构id',
+  `update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
+  `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
+  `updater` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '更新人',
+  `creator` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '创建人',
+  PRIMARY KEY (`id`) USING BTREE
+) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '公章管理' ROW_FORMAT = Dynamic;

+ 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);
-
-    }
-
 }

+ 4 - 0
yudao-module-bpm/yudao-module-bpm-api/src/main/java/cn/iocoder/yudao/module/bpm/enums/message/BpmMessageEnum.java

@@ -33,6 +33,10 @@ public enum BpmMessageEnum {
     CATERING_CHANGE_PROCESS_INSTANCE_REJECT("catering_change_process_instance_reject"),
     CATERING_CHANGE_PROCESS_INSTANCE_APPROVE("catering_change_process_instance_approve"),
 
+    FEE_ITEM_CHANGE_PROCESS_TASK_ASSIGNED("fee_item_change_process_task_assigned"),
+    FEE_ITEM_CHANGE_PROCESS_INSTANCE_REJECT("fee_item_change_process_instance_reject"),
+    FEE_ITEM_CHANGE_PROCESS_INSTANCE_APPROVE("fee_item_change_process_instance_approve"),
+
     PRICE_CHANGE_PROCESS_TASK_ASSIGNED("price_change_process_task_assigned"),
     PRICE_CHANGE_PROCESS_INSTANCE_REJECT("price_change_process_instance_reject"),
     PRICE_CHANGE_PROCESS_INSTANCE_APPROVE("price_change_process_instance_approve"),

+ 8 - 0
yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/controller/admin/business/BpmElderlyController.java

@@ -105,6 +105,14 @@ public class BpmElderlyController {
         return success(elderlApplyService.cateringChangeBatchCreate(getLoginUserId(), reqVO));
     }
 
+    @PostMapping("/feeItemChangeCreate")
+    @PreAuthorize("@ss.hasAnyPermissions('fee-item-change:add', 'fee-item-change:relaunch')")
+    @Operation(summary = "创建费用项目变更")
+    @TenantIgnore
+    public CommonResult<Long> feeItemChangeCreate(@Valid @RequestBody FeeItemChangeRecordCreateReqVO createReqVO) {
+        return success(elderlApplyService.feeItemChangeCreate(getLoginUserId(), createReqVO));
+    }
+
     @PostMapping("/priceChangeCreate")
     @PreAuthorize("@ss.hasAnyPermissions('price-change:add', 'price-change:relaunch')")
     @Operation(summary = "创建价格变更")

+ 120 - 0
yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/controller/admin/business/vo/FeeItemChangeRecordCreateReqVO.java

@@ -0,0 +1,120 @@
+package cn.iocoder.yudao.module.bpm.controller.admin.business.vo;
+
+import cn.iocoder.yudao.module.bpm.enums.BpmBusinessTypeEnum;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 管理后台 - 费用项目变更记录新增/修改 Request VO
+ * <p>
+ * 参考 {@link CateringChangeRecordCreateReqVO} 实现
+ *
+ * @author 养老项目
+ */
+@Schema(description = "管理后台 - 费用项目变更记录新增/修改 Request VO")
+@Data
+public class FeeItemChangeRecordCreateReqVO {
+
+    @Schema(description = "主键ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "14680")
+    private Long id;
+
+    @Schema(description = "长者id", example = "23841")
+    @NotNull(message = "长者id不能为空")
+    private Long elderlyId;
+
+    @Schema(description = "原费用项目id", example = "20769")
+    private Long originalId;
+
+    @Schema(description = "原费用项目名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "水电费")
+    private String originalName;
+
+    @Schema(description = "变更生效日期", requiredMode = Schema.RequiredMode.REQUIRED)
+    @NotNull(message = "变更生效日期不能为空")
+    private LocalDate changeDate;
+
+    @Schema(description = "新费用项目id", example = "16757")
+    @NotNull(message = "新费用项目id不能为空")
+    private Long expectId;
+
+    @Schema(description = "新费用项目名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "水电费套餐二")
+    private String expectName;
+
+    @Schema(description = "收费项目名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "16757")
+    @NotEmpty(message = "收费项目名称不能为空")
+    private String categoryName;
+
+    @Schema(description = "收费项目id", requiredMode = Schema.RequiredMode.REQUIRED, example = "16757")
+    @NotNull(message = "收费项目id不能为空")
+    private Long overheadChargeId;
+
+    @Schema(description = "新收费项目id", requiredMode = Schema.RequiredMode.REQUIRED, example = "16757")
+    @NotNull(message = "新收费项目id不能为空")
+    private Long expectOverheadChargeId;
+
+    @Schema(description = "金额", example = "31331")
+    private BigDecimal amount;
+
+    /**
+     * 实际金额
+     */
+    @Schema(description = "实际金额", example = "31331")
+    private BigDecimal actualAmount;
+
+    /**
+     * 折扣金额
+     */
+    @Schema(description = "折扣金额", example = "31331")
+    private BigDecimal discountAmount;
+
+    /**
+     * 折扣
+     */
+    @Schema(description = "折扣", example = "31331")
+    private String discount;
+
+    @Schema(description = "是否折扣", example = "7779")
+    private Integer isDiscount;
+
+    @Schema(description = "变更文件")
+    private String changeFiles;
+
+    @Schema(description = "变更原因", requiredMode = Schema.RequiredMode.REQUIRED, example = "不好")
+    private String reason;
+
+    /**
+     * 关联 {@link BpmBusinessTypeEnum}
+     */
+    @Schema(description = "申请类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "11-费用项目变更")
+    @NotNull(message = "申请类型不能为空")
+    private Integer type;
+
+    @Schema(description = "流程实例的编号", example = "11303")
+    private String processInstanceId;
+
+    @Schema(description = "发起人自选审批人 Map", example = "{taskKey1: [1, 2]}")
+    private Map<String, List<Long>> startUserSelectAssignees;
+
+    @Schema(description = "业务ID,申请记录id", example = "6489")
+    private Long businessId;
+
+    @Schema(description = "业务ID2", example = "6489")
+    private Long businessId2;
+
+    private Long tenantId;
+
+    /**
+     * 原始金额
+     */
+    private BigDecimal originalAmount;
+
+    @Schema(description = "滞后至月份", example = "2026-01")
+    private String hysteresisMonth;
+
+}

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

@@ -344,6 +344,31 @@ public class BpmProcessInstanceController {
                 BeanUtils.toBean(list, CateringChangeRecordExcelVO.class));
     }
 
+    @GetMapping("/fee-item-change-page")
+    @Operation(summary = "获取费用项目变更流程实例", description = "在【我的流程】菜单中,进行调用")
+    @TenantIgnore
+    public CommonResult<PageResult<FeeItemChangeRecordRespVO>> getProcessInstanceFeeItemChangePage(
+            @Valid BedChangePageReqVO pageReqVO) {
+        pageReqVO.setTenantIds(Objects.isNull(pageReqVO.getTenantIds()) ? new Long[]{TenantContextHolder.getTenantId()} : pageReqVO.getTenantIds());
+        PageResult<FeeItemChangeRecordRespVO> pageResult = processInstanceService.getProcessInstanceFeeItemChangePage(getLoginUserId(), pageReqVO);
+        return success(pageResult);
+    }
+
+    @GetMapping("/fee-item-change-export-excel")
+    @Operation(summary = "导出费用项目变更流程实例 Excel", description = "内容为费用项目变更分页的全量数据")
+    @ApiAccessLog(operateType = EXPORT)
+    @TenantIgnore
+    public void exportProcessInstanceFeeItemChangeExcel(@Valid BedChangePageReqVO pageReqVO,
+                                                        HttpServletResponse response) throws IOException {
+        pageReqVO.setTenantIds(Objects.isNull(pageReqVO.getTenantIds()) ? new Long[]{TenantContextHolder.getTenantId()} : pageReqVO.getTenantIds());
+        pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
+        List<FeeItemChangeRecordRespVO> list = processInstanceService.getProcessInstanceFeeItemChangePage(
+                getLoginUserId(), pageReqVO).getList();
+        // 导出 Excel
+        ExcelUtils.write(response, "费用项目变更.xls", "数据", FeeItemChangeRecordExcelVO.class,
+                BeanUtils.toBean(list, FeeItemChangeRecordExcelVO.class));
+    }
+
     @GetMapping("/price-change-page")
     @Operation(summary = "获取价格变更流程实例", description = "在【我的流程】菜单中,进行调用")
 //    @PreAuthorize("@ss.hasPermission('bpm:process-instance:query')")

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

@@ -0,0 +1,66 @@
+package cn.iocoder.yudao.module.bpm.controller.admin.task.vo.excel;
+
+import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat;
+import cn.iocoder.yudao.framework.excel.core.convert.DictConvert;
+import cn.iocoder.yudao.module.bpm.convert.excel.BpmProcessInstanceStatusConvert;
+import cn.iocoder.yudao.module.bpm.convert.excel.LocalDateConvert;
+import cn.iocoder.yudao.module.system.enums.DictTypeConstants;
+import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
+import com.alibaba.excel.annotation.ExcelProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDate;
+
+/**
+ * 管理后台 - 费用项目变更导出 Excel VO
+ *
+ * @author 养老项目
+ */
+@Schema(description = "管理后台 - 费用项目变更导出 Excel VO")
+@Data
+@ExcelIgnoreUnannotated
+public class FeeItemChangeRecordExcelVO {
+
+    @Schema(description = "长者姓名")
+    @ExcelProperty("长者姓名")
+    private String elderlyName;
+
+    @Schema(description = "长者性别")
+    @ExcelProperty(value = "长者性别", converter = DictConvert.class)
+    @DictFormat(DictTypeConstants.USER_SEX)
+    private Integer elderSex;
+
+    @Schema(description = "原费用项目")
+    @ExcelProperty("原费用项目")
+    private String originalName;
+
+    @Schema(description = "变更后费用项目")
+    @ExcelProperty("变更后费用项目")
+    private String expectName;
+
+    @Schema(description = "费用类别")
+    @ExcelProperty("费用类别")
+    private String categoryName;
+
+    @Schema(description = "收费项目")
+    @ExcelProperty("收费项目")
+    private String chargeName;
+
+    @Schema(description = "收费项目名称")
+    @ExcelProperty("收费项目名称")
+    private String originalItemName;
+
+    @Schema(description = "变更日期")
+    @ExcelProperty(value = "变更日期", converter = LocalDateConvert.class)
+    private LocalDate changeDate;
+
+    @Schema(description = "审批状态")
+    @ExcelProperty(value = "审批状态", converter = BpmProcessInstanceStatusConvert.class)
+    private Integer status;
+
+    @Schema(description = "所属机构")
+    @ExcelProperty("所属机构")
+    private String tenantName;
+
+}

+ 2 - 1
yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/enums/BpmBusinessTypeEnum.java

@@ -25,7 +25,8 @@ public enum BpmBusinessTypeEnum implements ArrayValuable<Integer> {
     PRICE_CHANGE(7, "price_change"), //价格变更
     TRANSFER_CHANGE(8, "transfer_change"), //转院变更
     CHECK_IN(9, "check_in"), //办理入住
-    CHECK_IN_WAIT(10, "check_in_wait"); //轮候入住
+    CHECK_IN_WAIT(10, "check_in_wait"), //轮候入住
+    FEE_ITEM_CHANGE(11, "fee_item_change"); //费用项目变更
 
     public static final Integer[] ARRAYS = Arrays.stream(values()).map(BpmBusinessTypeEnum::getValue).toArray(Integer[]::new);
 

+ 2 - 0
yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/business/BpmBusinessFormService.java

@@ -68,6 +68,8 @@ public interface BpmBusinessFormService {
 
     void completeCateringChange(BpmBusinessFormDO businessFormDO);
 
+    void completeFeeItemChange(BpmBusinessFormDO businessFormDO);
+
 
     void completePriceChange(BpmBusinessFormDO businessFormDO);
 

+ 18 - 0
yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/business/BpmBusinessFormServiceImpl.java

@@ -45,6 +45,7 @@ import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionU
 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
 import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
 import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.ELDERLY_BUSINESS_FORM_NOT_EXISTS;
+import static cn.iocoder.yudao.module.system.enums.change.ChangeConstants.IDENTIFICATION_FEE_ITEM;
 
 /**
  * @Author: jinyd
@@ -138,6 +139,10 @@ public class BpmBusinessFormServiceImpl implements BpmBusinessFormService {
                     completeCateringChange(businessFormDO);
                     break;
 
+                case FEE_ITEM_CHANGE:
+                    completeFeeItemChange(businessFormDO);
+                    break;
+
                 case PRICE_CHANGE:
 //                    changeApi.generatePriceChangeBill(businessFormDO.getBusinessId());
                     completePriceChange(businessFormDO);
@@ -326,6 +331,19 @@ public class BpmBusinessFormServiceImpl implements BpmBusinessFormService {
         }
     }
 
+    @Override
+    public void completeFeeItemChange(BpmBusinessFormDO businessFormDO) {
+        LocalDate nowLocalDate = LocalDate.now();
+        ElderlyChangeRecordReqVO feeItemChangeRecord = changeApi.getChangeRecord(businessFormDO.getBusinessId());
+        if (nowLocalDate.isEqual(feeItemChangeRecord.getChangeDate()) || feeItemChangeRecord.getChangeDate().isBefore(nowLocalDate)) {
+            changeApi.generateBill(businessFormDO.getBusinessId(), IDENTIFICATION_FEE_ITEM, businessFormDO.getStartTenantId());
+            BpmBusinessFormDO updateDO = new BpmBusinessFormDO();
+            updateDO.setId(businessFormDO.getId());
+            updateDO.setCompleteStatus(1);
+            bpmBusinessFormMapper.updateById(updateDO);
+        }
+    }
+
     @Override
     public void completePriceChange(BpmBusinessFormDO businessFormDO) {
         LocalDate nowLocalDate = LocalDate.now();

+ 2 - 0
yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/business/BpmElderlApplyService.java

@@ -46,6 +46,8 @@ public interface BpmElderlApplyService {
 
     Map<String, Object> cateringChangeBatchCreate(Long loginUserId, CateringChangeBatchCreateReqVO reqVO);
 
+    Long feeItemChangeCreate(Long loginUserId, FeeItemChangeRecordCreateReqVO createReqVO);
+
     Long priceChangeCreate(Long loginUserId, PriceChangeRecordCreateReqVO createReqVO);
 
     Long transferChangeCreate(Long loginUserId, TransferChangeRecordCreateReqVO createReqVO);

+ 29 - 0
yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/business/BpmElderlApplyServiceImpl.java

@@ -455,6 +455,35 @@ public class BpmElderlApplyServiceImpl implements BpmElderlApplyService {
         return result;
     }
 
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    @LogRecord(type = ELDER_FEE_ITEM_CHANGE_TYPE, subType = ELDER_FEE_ITEM_CHANGE_SUB_TYPE, bizNo = "{{#createReqVO.elderlyId}}",
+            success = ELDER_FEE_ITEM_CHANGE_SUCCESS, extra = "{{#createReqVO.elderlyId}}")
+    public Long feeItemChangeCreate(Long loginUserId, FeeItemChangeRecordCreateReqVO createReqVO) {
+        ElderlyChangeRecordReqVO changeRecordReqVO = BeanUtils.toBean(createReqVO, ElderlyChangeRecordReqVO.class);
+        changeRecordReqVO.setChangeType(ChangeTypeEnum.FEE_ITEM.getValue());
+        Long id;
+        if (changeRecordReqVO.getId() == null) {
+            id = elderlyChangeApi.addElderlyBedChangeRecord(changeRecordReqVO);
+        } else {
+            // 重新发起:将原记录置为非最新,再新增一条记录
+            elderlyChangeApi.updateCurrentFlag(changeRecordReqVO.getId());
+            changeRecordReqVO.setId(null);
+            id = elderlyChangeApi.addElderlyBedChangeRecord(changeRecordReqVO);
+        }
+        createReqVO.setBusinessId(id);
+        createReqVO.setBusinessId2(changeRecordReqVO.getElderlyId());
+        createReqVO.setId(null);
+        ElderlyInfoReqVO elderlyInfo = bpmElderlyApi.getElderlyInfo(changeRecordReqVO.getElderlyId());
+        if (null != elderlyInfo) {
+            LogRecordContext.putVariable("id", elderlyInfo.getId());
+            LogRecordContext.putVariable("elderName", elderlyInfo.getElderName());
+        }
+        LogRecordContext.putVariable("nickname", SecurityFrameworkUtils.getLoginUserNickname());
+        BpmBusinessFormCreateReqVO businessFormCreateReqVO = BeanUtils.toBean(createReqVO, BpmBusinessFormCreateReqVO.class);
+        return bpmBusinessFormService.create(businessFormCreateReqVO);
+    }
+
     @Override
     @Transactional(rollbackFor = Exception.class)
     @LogRecord(type = ELDER_PRICE_CHANGE_TYPE, subType = ELDER_PRICE_CHANGE_SUB_TYPE, bizNo = "{{#createReqVO.elderlyId}}",

+ 52 - 0
yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/business/listener/BpmFeeItemChangeStatusListener.java

@@ -0,0 +1,52 @@
+package cn.iocoder.yudao.module.bpm.service.business.listener;
+
+import cn.iocoder.yudao.module.bpm.dal.dataobject.business.BpmBusinessFormDO;
+import cn.iocoder.yudao.module.bpm.enums.BpmBusinessTypeEnum;
+import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum;
+import cn.iocoder.yudao.module.bpm.event.BpmProcessInstanceStatusEvent;
+import cn.iocoder.yudao.module.bpm.event.BpmProcessInstanceStatusEventListener;
+import cn.iocoder.yudao.module.bpm.service.business.BpmBusinessFormService;
+import cn.iocoder.yudao.module.system.api.bpm.BpmElderlyChangeApi;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+
+import static cn.iocoder.yudao.module.system.enums.change.ChangeConstants.IDENTIFICATION_FEE_ITEM;
+
+/**
+ * 费用项目变更的监听器实现类
+ * <p>
+ * 参考 {@link BpmCateringChangeStatusListener} 实现
+ *
+ * @author 养老项目
+ */
+@Component
+public class BpmFeeItemChangeStatusListener extends BpmProcessInstanceStatusEventListener {
+
+    @Resource
+    private BpmBusinessFormService businessFormService;
+
+    @Resource
+    private BpmElderlyChangeApi changeApi;
+
+    @Override
+    protected String getProcessDefinitionKey() {
+        return BpmBusinessTypeEnum.FEE_ITEM_CHANGE.getKey();
+    }
+
+    @Override
+    protected void onEvent(BpmProcessInstanceStatusEvent event) {
+        // 监听Spring事件修改状态
+        BpmBusinessFormDO businessFormDO = businessFormService.getBusinessFormByInstanceId(event.getId());
+        businessFormDO.setStatus(event.getStatus());
+        businessFormService.updateBusinessForm(businessFormDO);
+        if (BpmProcessInstanceStatusEnum.APPROVE.getStatus().equals(event.getStatus())) {
+            changeApi.generateBill(businessFormDO.getBusinessId(), IDENTIFICATION_FEE_ITEM, businessFormDO.getStartTenantId());
+            BpmBusinessFormDO updateDO = new BpmBusinessFormDO();
+            updateDO.setId(businessFormDO.getId());
+            updateDO.setCompleteStatus(1);
+            businessFormService.updateById(updateDO);
+        }
+    }
+
+}

+ 76 - 24
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 实现类
@@ -348,6 +348,18 @@ public class BpmMessageServiceImpl implements BpmMessageService {
                         NotieBusinessTypeEnum.BPM.getValue(), NotifyTypeEnum.NOTIFY_TODO.getValue(),
                         NotifyPriorityEnum.MEDIUM.getValue(), 0, assigneeUser.getTenantId());
                 break;
+            case FEE_ITEM_CHANGE:
+                elderlyInfo = bpmElderlyChangeApi.getElderlyInfoByChangeId(businessFormDO.getBusinessId());
+                templateParams.put("nickname", user.getNickname());
+                templateParams.put("elderName", elderlyInfo.getElderName());
+                detailUrl = getProcessInstanceDetailUrl(reqDTO.getProcessInstanceId()) + "&businessId=" + businessFormDO.getBusinessId();
+                templateParams.put("detailUrl", detailUrl);
+                notifySendSingleToUserReqDTO = BpmMessageConvert.INSTANCE.messageConvert(reqDTO.getStartUserId(),
+                        reqDTO.getProcessInstanceId(), "", detailUrl,
+                        BpmMessageEnum.FEE_ITEM_CHANGE_PROCESS_INSTANCE_APPROVE.getTemplateCode(), templateParams,
+                        NotieBusinessTypeEnum.BPM.getValue(), NotifyTypeEnum.NOTIFY_TODO.getValue(),
+                        NotifyPriorityEnum.MEDIUM.getValue(), 0, assigneeUser.getTenantId());
+                break;
             case PRICE_CHANGE:
                 elderlyInfo = bpmElderlyChangeApi.getElderlyInfoByChangeId(businessFormDO.getBusinessId());
                 templateParams.put("nickname", user.getNickname());
@@ -404,12 +416,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("");
@@ -477,14 +490,19 @@ public class BpmMessageServiceImpl implements BpmMessageService {
             copyList.add(copyDO);
             CorpSendMessage corpSendMessage = new CorpSendMessage();
             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()));
+            // 抄送人可能属于其他租户,必须忽略租户过滤查询:
+            // 若用 getUser,跨租户时查不到会返回 null,触发 NPE 中断整个抄送循环,
+            // 导致只有排在异常之前的抄送人(消息已异步投出)能收到,其余人全部丢失
+            AdminUserRespDTO promoter = adminUserApi.getUserTenantIgnore(taskCopyUserDO.getId());
+            String corpUserId = promoter == null || promoter.getCorpUserId() == null
+                    ? "" : promoter.getCorpUserId().toString();
+            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()),
+                    taskCopyUserDO.getId() == null ? "" : String.valueOf(taskCopyUserDO.getId())));
             corpSendMessage.setTouser(corpUserId);
-            corpSendMessage.setTextcard(corpTextCard);
+            corpSendMessage.setMiniProgramNotice(corpNotice);
             wechatMessageSaveReq.setTitle(CorpMsgUtil.TITLE_COPY);
             wechatMessageSaveReq.setTenantName(tenant.getAbbreviation());
             wechatMessageSaveReq.setPkMessage(reqDTO.getProcessInstanceId());
@@ -588,6 +606,18 @@ public class BpmMessageServiceImpl implements BpmMessageService {
                         NotieBusinessTypeEnum.BPM.getValue(), notifyType,
                         NotifyPriorityEnum.MEDIUM.getValue(), 0, assigneeUser.getTenantId());
                 break;
+            case FEE_ITEM_CHANGE:
+                detailUrl = getInstanceDetailUrl(reqDTO.getProcessInstanceId()) + "&businessId=" + businessFormDO.getBusinessId();
+                elderlyInfo = bpmElderlyChangeApi.getElderlyInfoByChangeId(businessFormDO.getBusinessId());
+                templateParams.put("nickname", user.getNickname());
+                templateParams.put("elderName", elderlyInfo.getElderName());
+                templateParams.put("detailUrl", detailUrl);
+                notifySendSingleToUserReqDTO = BpmMessageConvert.INSTANCE.messageConvert(reqDTO.getStartUserId(),
+                        reqDTO.getProcessInstanceId(), "", detailUrl,
+                        BpmMessageEnum.FEE_ITEM_CHANGE_PROCESS_INSTANCE_REJECT.getTemplateCode(), templateParams,
+                        NotieBusinessTypeEnum.BPM.getValue(), notifyType,
+                        NotifyPriorityEnum.MEDIUM.getValue(), 0, assigneeUser.getTenantId());
+                break;
             case PRICE_CHANGE:
                 detailUrl = getInstanceDetailUrl(reqDTO.getProcessInstanceId()) + "&businessId=" + businessFormDO.getBusinessId();
                 elderlyInfo = bpmElderlyChangeApi.getElderlyInfoByChangeId(businessFormDO.getBusinessId());
@@ -642,12 +672,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("");
@@ -780,6 +811,26 @@ public class BpmMessageServiceImpl implements BpmMessageService {
             }
         }
 
+        if (BpmBusinessTypeEnum.FEE_ITEM_CHANGE.getValue().equals(businessFormDO.getType())) {
+            elderlyInfo = bpmElderlyChangeApi.getElderlyInfoByChangeId(businessFormDO.getBusinessId());
+            templateParams.put("nickname", user.getNickname());
+            templateParams.put("elderName", elderlyInfo.getElderName());
+            detailUrl = getInstanceDetailUrl(reqDTO.getProcessInstanceId()) + "&businessId=" + businessFormDO.getBusinessId();
+            templateParams.put("detailUrl", detailUrl);
+            notifySendSingleToUserReqDTO = BpmMessageConvert.INSTANCE.messageConvert(reqDTO.getAssigneeUserId(),
+                    reqDTO.getProcessInstanceId(), reqDTO.getTaskId(), detailUrl,
+                    BpmMessageEnum.FEE_ITEM_CHANGE_PROCESS_TASK_ASSIGNED.getTemplateCode(), templateParams,
+                    NotieBusinessTypeEnum.BPM.getValue(), NotifyTypeEnum.NOTIFY_TODO.getValue(),
+                    NotifyPriorityEnum.MEDIUM.getValue(), 0, assigneeUser.getTenantId());
+            if (reqDTO.getStartUserId().equals(loginUserId)) {
+                notifyMessageSendApi.sendSingleMessageToAdminByDTO(BpmMessageConvert.INSTANCE.messageConvert(loginUserId,
+                        reqDTO.getProcessInstanceId(), reqDTO.getTaskId(), detailUrl,
+                        BpmMessageEnum.FEE_ITEM_CHANGE_PROCESS_TASK_ASSIGNED.getTemplateCode(), templateParams,
+                        NotieBusinessTypeEnum.BPM.getValue(), NotifyTypeEnum.NOTIFY_TODO.getValue(),
+                        NotifyPriorityEnum.MEDIUM.getValue(), 1, assigneeUser.getTenantId()));
+            }
+        }
+
         if (BpmBusinessTypeEnum.PRICE_CHANGE.getValue().equals(businessFormDO.getType())) {
             elderlyInfo = bpmElderlyChangeApi.getElderlyInfoByChangeId(businessFormDO.getBusinessId());
             templateParams.put("nickname", user.getNickname());
@@ -900,12 +951,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());

+ 98 - 0
yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmProcessInstanceCopyServiceImpl.java

@@ -32,12 +32,22 @@ import org.springframework.validation.annotation.Validated;
 import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO;
 import cn.iocoder.yudao.module.system.api.bpm.vo.ElderlyInfoReqVO;
 import cn.iocoder.yudao.module.system.enums.notice.NotieBusinessTypeEnum;
+import cn.hutool.core.collection.CollUtil;
+import cn.iocoder.yudao.framework.common.util.corp.CorpMsgUtil;
+import cn.iocoder.yudao.module.system.api.tenant.TenantApi;
+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.CorpWechatMiniProgramNotice;
+import cn.iocoder.yudao.module.system.api.wechat.dto.WechatMessageSaveReq;
 
 import javax.annotation.Resource;
 import java.util.*;
 
 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
+import static cn.iocoder.yudao.module.system.enums.oauth2.EnterpriseWeChatConstants.MINI_PROGRAM_DETAIL_PAGE;
 
 /**
  * 流程抄送 Service 实现类
@@ -66,6 +76,12 @@ public class BpmProcessInstanceCopyServiceImpl implements BpmProcessInstanceCopy
     @Resource
     private NotifyMessageSendApi notifyMessageSendApi;
 
+    @Resource
+    private AdminUserApi adminUserApi;
+
+    @Resource
+    private TenantApi tenantApi;
+
     @Resource
     private BpmBusinessFormMapper bpmBusinessFormMapper;
 
@@ -150,6 +166,11 @@ public class BpmProcessInstanceCopyServiceImpl implements BpmProcessInstanceCopy
                     templateParams.put("elderName", elderlyInfo.getElderName());
                     break;
 
+                case FEE_ITEM_CHANGE:
+                    elderlyInfo = bpmElderlyChangeApi.getElderlyInfoByChangeId(businessFormDO.getBusinessId());
+                    templateParams.put("elderName", elderlyInfo.getElderName());
+                    break;
+
                 case PRICE_CHANGE:
                     elderlyInfo = bpmElderlyChangeApi.getElderlyInfoByChangeId(businessFormDO.getBusinessId());
                     templateParams.put("elderName", elderlyInfo.getElderName());
@@ -173,6 +194,76 @@ public class BpmProcessInstanceCopyServiceImpl implements BpmProcessInstanceCopy
             copyNotifyList.add(copyDO);
         }
         notifyMessageSendApi.sendSingleMessageToAdminByList(copyNotifyList);
+        // 3. 发送企微抄送消息:站内信之外,抄送人同样需要收到企微通知
+        sendCorpCopyMessage(copyList, copyNotifyList, processInstanceId, businessFormDO);
+    }
+
+    /**
+     * 给抄送人发送企微通知。
+     * <p>
+     * 抄送人由 {@link cn.iocoder.yudao.module.bpm.framework.flowable.core.listener.BpmCopyTaskDelegate}
+     * 在流程执行到【抄送节点】时动态计算得出,支持全部候选策略(指定成员、部门负责人、表单用户等),
+     * 因此只能在运行时逐个解析企微账号,无法在部署时预先落库。
+     *
+     * @param copyList         抄送记录
+     * @param copyNotifyList   站内信请求(复用其模板与参数)
+     * @param processInstanceId 流程实例编号
+     * @param businessFormDO   业务表单,可能为 null
+     */
+    private void sendCorpCopyMessage(List<BpmProcessInstanceCopyDO> copyList,
+                                     List<NotifySendSingleToUserReqDTO> copyNotifyList,
+                                     String processInstanceId, BpmBusinessFormDO businessFormDO) {
+        if (CollUtil.isEmpty(copyList) || CollUtil.isEmpty(copyNotifyList)) {
+            return;
+        }
+        try {
+            Long tenantId = TenantContextHolder.getTenantId();
+            if (tenantId == null && copyList.get(0).getTenantId() != null) {
+                tenantId = copyList.get(0).getTenantId();
+            }
+            TenantRespDTO tenant = tenantId == null ? null : tenantApi.getTenant(tenantId);
+            String tenantName = tenant == null ? "" : tenant.getAbbreviation();
+
+            List<CorpSendMessage> corpList = new ArrayList<>();
+            for (BpmProcessInstanceCopyDO copy : copyList) {
+                // 抄送人可能跨租户,必须忽略租户过滤;未绑定企微账号的跳过,绝不能中断其余抄送人
+                AdminUserRespDTO user = adminUserApi.getUserTenantIgnore(copy.getUserId());
+                if (user == null || user.getCorpUserId() == null
+                        || user.getCorpUserId().toString().isEmpty()) {
+                    log.warn("[sendCorpCopyMessage][抄送用户({})未绑定企微账号,跳过]", copy.getUserId());
+                    continue;
+                }
+                String corpUserId = user.getCorpUserId().toString();
+
+                CorpWechatMiniProgramNotice notice = new CorpWechatMiniProgramNotice();
+                notice.setTitle(CorpMsgUtil.TITLE_COPY);
+                notice.setPage(String.format(MINI_PROGRAM_DETAIL_PAGE, processInstanceId, 4,
+                        businessFormDO == null || businessFormDO.getStatus() == null
+                                ? "" : String.valueOf(businessFormDO.getStatus()),
+                        copy.getUserId() == null ? "" : String.valueOf(copy.getUserId())));
+
+                WechatMessageSaveReq req = new WechatMessageSaveReq();
+                req.setTitle(CorpMsgUtil.TITLE_COPY);
+                req.setTenantName(tenantName);
+                req.setPkMessage(processInstanceId);
+                req.setPkMessage2("");
+                req.setCorpUserId(corpUserId);
+                req.setStartTenantId(tenantId);
+
+                CorpSendMessage corpSendMessage = new CorpSendMessage();
+                corpSendMessage.setTouser(corpUserId);
+                corpSendMessage.setMiniProgramNotice(notice);
+                corpSendMessage.setWechatMessageSaveReq(req);
+                corpList.add(corpSendMessage);
+            }
+            if (CollUtil.isEmpty(corpList)) {
+                return;
+            }
+            notifyMessageSendApi.sendCorpMessageList(corpList, copyNotifyList.get(0));
+        } catch (Exception e) {
+            // 消息发送失败不能阻断流程推进:本方法在 Flowable 事务内执行,异常会导致整个流程回滚
+            log.error("[sendCorpCopyMessage][流程实例({}) 抄送企微消息发送失败]", processInstanceId, e);
+        }
     }
 
     @Override
@@ -258,6 +349,11 @@ public class BpmProcessInstanceCopyServiceImpl implements BpmProcessInstanceCopy
                     templateParams.put("elderName", elderlyInfo.getElderName());
                     break;
 
+                case FEE_ITEM_CHANGE:
+                    elderlyInfo = bpmElderlyChangeApi.getElderlyInfoByChangeId(businessFormDO.getBusinessId());
+                    templateParams.put("elderName", elderlyInfo.getElderName());
+                    break;
+
                 case PRICE_CHANGE:
                     elderlyInfo = bpmElderlyChangeApi.getElderlyInfoByChangeId(businessFormDO.getBusinessId());
                     templateParams.put("elderName", elderlyInfo.getElderName());
@@ -277,6 +373,8 @@ public class BpmProcessInstanceCopyServiceImpl implements BpmProcessInstanceCopy
             copyNotifyList.add(copyDO);
         }
         notifyMessageSendApi.sendSingleMessageToAdminByList(copyNotifyList);
+        // 3. 发送企微抄送消息:站内信之外,抄送人同样需要收到企微通知
+        sendCorpCopyMessage(copyList, copyNotifyList, processInstanceId, businessFormDO);
     }
 
     @Override

+ 2 - 0
yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmProcessInstanceService.java

@@ -174,6 +174,8 @@ public interface BpmProcessInstanceService {
 
     PageResult<CateringChangeRecordRespVO> getProcessInstanceCateringChangePage(Long loginUserId, BedChangePageReqVO pageReqVO);
 
+    PageResult<FeeItemChangeRecordRespVO> getProcessInstanceFeeItemChangePage(Long loginUserId, BedChangePageReqVO pageReqVO);
+
     PageResult<PriceChangeRecordRespVO> getProcessInstancePriceChangePage(Long loginUserId, BedChangePageReqVO pageReqVO);
 
     PageResult<BpmElderlyInfoVO> getMyBpmPage(Long loginUserId, String nickname, BpmMyPageReqVO pageReqVO);

+ 24 - 0
yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmProcessInstanceServiceImpl.java

@@ -1022,6 +1022,30 @@ public class BpmProcessInstanceServiceImpl implements BpmProcessInstanceService
         return pageResult;
     }
 
+    @Override
+    public PageResult<FeeItemChangeRecordRespVO> getProcessInstanceFeeItemChangePage(Long loginUserId, BedChangePageReqVO pageReqVO) {
+        ChangeRecordPageReqApiVO apiVO = BeanUtils.toBean(pageReqVO, ChangeRecordPageReqApiVO.class);
+        PageResult<FeeItemChangeRecordRespVO> pageResult = changeApi.getFeeItemChangeRecordPage(apiVO);
+        if (pageResult.getList().isEmpty()) {
+            return PageResult.empty();
+        }
+        List<FeeItemChangeRecordRespVO> list = pageResult.getList();
+        Set<String> processInstanceIds = new HashSet<>();
+        list.forEach((item) -> {
+            processInstanceIds.add(item.getProcessInstanceId());
+        });
+        List<HistoricProcessInstance> processInstanceList = historyService.createHistoricProcessInstanceQuery()
+                .processInstanceIds(processInstanceIds).list();
+        processInstanceList.forEach((processInstance) -> {
+            list.forEach((feeItemChangeRecord) -> {
+                if (processInstance.getId().equals(feeItemChangeRecord.getProcessInstanceId())) {
+                    feeItemChangeRecord.setStartTaskId(processInstance.getId());
+                }
+            });
+        });
+        return pageResult;
+    }
+
     @Override
     public PageResult<PriceChangeRecordRespVO> getProcessInstancePriceChangePage(Long loginUserId, BedChangePageReqVO pageReqVO) {
         ChangeRecordPageReqApiVO apiVO = BeanUtils.toBean(pageReqVO, ChangeRecordPageReqApiVO.class);

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

@@ -39,6 +39,8 @@ public interface BpmElderlyChangeApi {
 
     PageResult<CateringChangeRecordRespVO> getCateringChangeRecordPage(ChangeRecordPageReqApiVO apiVO);
 
+    PageResult<FeeItemChangeRecordRespVO> getFeeItemChangeRecordPage(ChangeRecordPageReqApiVO apiVO);
+
     PageResult<PriceChangeRecordRespVO> getPriceChangeRecordPage(ChangeRecordPageReqApiVO apiVO);
 
     void convertBed(Long businessId);

+ 61 - 0
yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/bpm/vo/FeeItemChangeRecordRespVO.java

@@ -0,0 +1,61 @@
+package cn.iocoder.yudao.module.system.api.bpm.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.time.LocalDate;
+
+import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY;
+
+/**
+ * 费用项目变更记录分页 RespVO
+ * <p>
+ * 参考 {@link CateringChangeRecordRespVO} 实现
+ *
+ * @author 养老项目
+ */
+@Data
+public class FeeItemChangeRecordRespVO {
+
+    private Long id;
+
+    private Long elderlyId;
+
+    private String elderlyName;
+
+    private Integer elderSex;
+
+    private Long originalId;
+
+    private String originalName;
+
+    @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY)
+    private LocalDate changeDate;
+
+    private Long expectId;
+
+    private String expectName;
+
+    private Long overheadChargeId;
+
+    private String categoryName;
+
+    private String originalItemName;
+
+    private String chargeName;
+
+    private Integer status;
+
+    private String processInstanceId;
+
+    private String startTaskId;
+
+    private Integer type;
+
+    private Integer changeType;
+
+    private String tenantName;
+
+    private Long tenantId;
+
+}

+ 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;
-
-}

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

@@ -330,6 +330,9 @@ public interface ErrorCodeConstants {
     ErrorCode VISIT_APPOINTMENT_NOT_FOUND = new ErrorCode(1_002_041_000, "探访预约记录不存在");
     ErrorCode VISIT_APPOINTMENT_CANCELED = new ErrorCode(1_002_041_001, "该预约已取消或已删除,无法重复取消");
 
+    // ========== 须知(家属小程序) 1-002-042-000 ==========
+    ErrorCode JIASHU_NOTICE_NOT_FOUND = new ErrorCode(1_002_042_000, "须知不存在");
+
     // ========== 建行支付相关 1-008-025-000 ==========
     ErrorCode EXPENSE_ORDER_PAY_AMOUNT_INVALID = new ErrorCode(1_008_025_000, "账单金额异常,无法发起支付,请联系院方核对账单!");
 
@@ -344,6 +347,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, "出院小结不存在");

+ 5 - 0
yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/LogRecordConstants.java

@@ -213,6 +213,11 @@ public interface LogRecordConstants {
     String ELDER_CATERING_CHANGE_SUCCESS = "用户【{{#nickname}}】{{#createReqVO.id == null ? '发起' : '重新发起'}}长者【{{#elderName}}】的餐饮变更";
 
 
+    String ELDER_FEE_ITEM_CHANGE_TYPE = "费用项目变更";
+    String ELDER_FEE_ITEM_CHANGE_SUB_TYPE = "发起费用项目变更";
+    String ELDER_FEE_ITEM_CHANGE_SUCCESS = "用户【{{#nickname}}】{{#createReqVO.id == null ? '发起' : '重新发起'}}长者【{{#elderName}}】的费用项目变更";
+
+
     String ELDER_PRICE_CHANGE_TYPE = "价格变更";
     String ELDER_PRICE_CHANGE_SUB_TYPE = "发起价格变更";
     String ELDER_PRICE_CHANGE_SUCCESS = "用户【{{#nickname}}】{{#createReqVO.id == null ? '发起' : '重新发起'}}长者【{{#elderName}}】的价格变更";

+ 2 - 1
yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/change/BusinessTypeEnum.java

@@ -25,7 +25,8 @@ public enum BusinessTypeEnum implements ArrayValuable<Integer> {
     PRICE_CHANGE(7, "price_change"), //价格变更
     TRANSFER_CHANGE(8, "transfer_change"), //转院变更
     CHECK_IN(9, "check_in"),
-    CHECK_IN_WAIT(10, "check_in_wait"); //轮候入住
+    CHECK_IN_WAIT(10, "check_in_wait"), //轮候入住
+    FEE_ITEM_CHANGE(11, "fee_item_change"); //费用项目变更
 
     public static final Integer[] ARRAYS = Arrays.stream(values()).map(BusinessTypeEnum::getValue).toArray(Integer[]::new);
 

+ 14 - 0
yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/change/ChangeConstants.java

@@ -7,6 +7,18 @@ package cn.iocoder.yudao.module.system.enums.change;
  */
 public interface ChangeConstants {
 
+    /*==================收费类别标识 sys_charge_category.identification=============================*/
+    Integer IDENTIFICATION_BED = 1; // 床位
+    Integer IDENTIFICATION_NURSE = 2; // 护理
+    Integer IDENTIFICATION_CATERING = 3; // 餐饮
+    /**
+     * 费用项目的默认标识。
+     * <p>
+     * 费用项目的类别标识并不固定,除 1-床位、2-护理、3-餐饮 之外的类别都属于费用项目,
+     * 该常量仅作为费用项目变更流程传入的代表值,实际费用项按收费项目 id 定位。
+     */
+    Integer IDENTIFICATION_FEE_ITEM = 4; // 费用项目
+
     /*==================床位变更,日常费用备注=============================*/
 //    String BED_CHANGE_DAILY_REFUND_REMARK =  "%s日%s类型发生变更,变更前月固定费为%s元;变更后%s类型,月固定费为%s元,%s至%s日产生金额为%s元,变更后%s至%s日应付%s元";
 
@@ -18,6 +30,8 @@ public interface ChangeConstants {
 
     String CATERING_CHANGE_DAILY_PAY_REMARK =  "餐饮价格变更:合同价格:%s 元/月,原价格:%s 元/月,新价格:%s元/月,原价格使用天数:%s 天,新价格使用天数:%s 天";
 
+    String FEE_ITEM_CHANGE_DAILY_PAY_REMARK =  "费用项目变更:合同价格:%s 元/月,原价格:%s 元/月,新价格:%s元/月,原价格使用天数:%s 天,新价格使用天数:%s 天";
+
     String BED_SWAP_DAILY_PAY_REMARK =  "床位对调费用变更:合同价格:%s 元/月,原价格:%s 元/月,新价格:%s元/月,原价格使用天数:%s 天,新价格使用天数:%s 天";
 
     String PRICE_CHANGE_DAILY_PAY_REMARK =  "价格变更:合同价格:%s 元/月,原价格:%s 元/月,新价格:%s元/月,原价格使用天数:%s 天,新价格使用天数:%s 天";

+ 2 - 1
yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/change/ChangeTypeEnum.java

@@ -21,7 +21,8 @@ public enum ChangeTypeEnum implements ArrayValuable<Integer> {
     CATERING(2, "餐饮"),//餐饮
     PRICE(3, "价格"),
     TRANSFER(4, "转院"), //价格
-    CHECK_IN(5, "办理入住");
+    CHECK_IN(5, "办理入住"),
+    FEE_ITEM(6, "费用项目"); //费用项目
 
     public static final Integer[] ARRAYS = Arrays.stream(values()).map(ChangeTypeEnum::getValue).toArray(Integer[]::new);
 

+ 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());

+ 35 - 2
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;
@@ -166,6 +165,15 @@ public class BpmElderlyChangeApiImpl implements BpmElderlyChangeApi {
         return new PageResult<>(list, page.getTotal());
     }
 
+    @Override
+    public PageResult<FeeItemChangeRecordRespVO> getFeeItemChangeRecordPage(ChangeRecordPageReqApiVO pageReqVO) {
+        Page<FeeItemChangeRecordRespVO> page = new Page<>(pageReqVO.getPageNo(), pageReqVO.getPageSize());
+        pageReqVO.setType(BusinessTypeEnum.FEE_ITEM_CHANGE.getValue());
+        pageReqVO.setChangeType(ChangeTypeEnum.FEE_ITEM.getValue());
+        List<FeeItemChangeRecordRespVO> list = changeRecordMapper.selectFeeItemListPage(page, pageReqVO);
+        return new PageResult<>(list, page.getTotal());
+    }
+
     @Override
     public PageResult<PriceChangeRecordRespVO> getPriceChangeRecordPage(ChangeRecordPageReqApiVO pageReqVO) {
         Page<PriceChangeRecordRespVO> page = new Page<>(pageReqVO.getPageNo(), pageReqVO.getPageSize());
@@ -283,7 +291,12 @@ public class BpmElderlyChangeApiImpl implements BpmElderlyChangeApi {
             }
         }
         checkChangeStartDate(elderlyId,bedChangeRecordDO.getChangeDate());
-        BedChangeCategoryVO chargeCategory =  expenseMapper.getChargeCategory(elderlyId, identification);
+        // 费用项目的收费类别标识不固定(1-床位、2-护理、3-餐饮 之外均属于费用项目),
+        // 无法按 identification 精确匹配,改用变更记录的收费项目 id 定位
+        BedChangeCategoryVO chargeCategory = isFeeItemIdentification(identification)
+                && bedChangeRecordDO.getOverheadChargeId() != null
+                ? expenseMapper.getChargeCategoryByOverheadChargeId(elderlyId, bedChangeRecordDO.getOverheadChargeId())
+                : expenseMapper.getChargeCategory(elderlyId, identification);
 
         if (null == chargeCategory) return;
 
@@ -335,6 +348,10 @@ public class BpmElderlyChangeApiImpl implements BpmElderlyChangeApi {
             preRemark = CATERING_CHANGE_DAILY_PAY_REMARK;
             bpmType = 6;
             changeType = 2;
+        } else if (isFeeItemIdentification(identification)) {
+            preRemark = FEE_ITEM_CHANGE_DAILY_PAY_REMARK;
+            bpmType = BusinessTypeEnum.FEE_ITEM_CHANGE.getValue();
+            changeType = ChangeTypeEnum.FEE_ITEM.getValue();
         } else {
             preRemark = "";
         }
@@ -1096,6 +1113,22 @@ public class BpmElderlyChangeApiImpl implements BpmElderlyChangeApi {
         }
         return null;
     }
+    /**
+     * 判断收费类别标识是否属于「费用项目」
+     * <p>
+     * 1-床位、2-护理、3-餐饮 是固定标识,除这三者之外的类别均归为费用项目,
+     * 其标识不固定(可能为 4、5、6……),因此不能用具体值相等判断。
+     *
+     * @param identification 收费类别标识
+     * @return true 表示属于费用项目
+     */
+    private boolean isFeeItemIdentification(Integer identification) {
+        return identification != null
+                && !IDENTIFICATION_BED.equals(identification)
+                && !IDENTIFICATION_NURSE.equals(identification)
+                && !IDENTIFICATION_CATERING.equals(identification);
+    }
+
     private void checkChangeStartDate(Long elderId,LocalDate changeStartDate){
         List<ElderlyContractDO> elderlyContractDOS = elderlyContractMapper.selectList(new LambdaQueryWrapperX<ElderlyContractDO>()
                 .eq(ElderlyContractDO::getElderId, elderId)

+ 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());

+ 66 - 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);
@@ -356,6 +403,7 @@ public class CorpWechatApiImpl implements CorpWechatApi{
                 .ne(AdminUserDO::getMobile,"")
                 .ne(AdminUserDO::getMobile,"18888888888")
                 .eq(AdminUserDO::getDeleted, false).orderByDesc(AdminUserDO::getUsername).last("LIMIT 10"));
+        log.info("机构id:{},同步人数:{}",TenantContextHolder.getTenantId(),list.size());
         list.forEach(user -> {
             String mobile = user.getMobile();
             String corpUserId = getUserIdByMobile(mobile);

+ 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);
+    }
 }

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

@@ -0,0 +1,85 @@
+package cn.iocoder.yudao.module.system.controller.admin.biz;
+
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.seal.CommonSealListReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.seal.CommonSealRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.seal.CommonSealSaveReqVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.biz.CommonSealDO;
+import cn.iocoder.yudao.module.system.service.biz.CommonSealService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import javax.validation.Valid;
+import java.util.List;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+
+/**
+ * 管理后台 - 公章管理
+ *
+ * @author system
+ */
+@Tag(name = "管理后台 - 公章管理")
+@RestController
+@RequestMapping("/system/common-seal")
+@Validated
+public class CommonSealController {
+
+    @Resource
+    private CommonSealService commonSealService;
+
+    @PostMapping("/create")
+    @Operation(summary = "创建公章")
+    @TenantIgnore // 机构id 由前端传入
+    public CommonResult<Long> createSeal(@Valid @RequestBody CommonSealSaveReqVO createReqVO) {
+        return success(commonSealService.createSeal(createReqVO));
+    }
+
+    @PutMapping("/update")
+    @Operation(summary = "更新公章")
+    @TenantIgnore
+    public CommonResult<Boolean> updateSeal(@Valid @RequestBody CommonSealSaveReqVO updateReqVO) {
+        commonSealService.updateSeal(updateReqVO);
+        return success(true);
+    }
+
+    @DeleteMapping("/delete")
+    @Operation(summary = "删除公章")
+    @Parameter(name = "id", description = "公章编号", required = true, example = "1024")
+    @TenantIgnore
+    public CommonResult<Boolean> deleteSeal(@RequestParam("id") Long id) {
+        commonSealService.deleteSeal(id);
+        return success(true);
+    }
+
+    @GetMapping("/get")
+    @Operation(summary = "获得公章")
+    @Parameter(name = "id", description = "公章编号", required = true, example = "1024")
+    @TenantIgnore
+    public CommonResult<CommonSealRespVO> getSeal(@RequestParam("id") Long id) {
+        CommonSealDO seal = commonSealService.getSeal(id);
+        return success(BeanUtils.toBean(seal, CommonSealRespVO.class));
+    }
+
+    @GetMapping("/list")
+    @Operation(summary = "获得公章列表")
+    @TenantIgnore // 机构id 前端可传,不传则默认当前租户
+    public CommonResult<List<CommonSealRespVO>> getSealList(@Valid CommonSealListReqVO listReqVO) {
+        List<CommonSealDO> list = commonSealService.getSealList(listReqVO);
+        return success(BeanUtils.toBean(list, CommonSealRespVO.class));
+    }
+
+}

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

@@ -0,0 +1,104 @@
+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.common.util.object.BeanUtils;
+import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.JiashuNoticePageReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.JiashuNoticeRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.JiashuNoticeSaveReqVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.miniapp.JiashuNoticeDO;
+import cn.iocoder.yudao.module.system.service.miniapp.JiashuNoticeService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import javax.annotation.security.PermitAll;
+import javax.validation.Valid;
+import java.util.List;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+
+/**
+ * 管理后台 - 须知(yanglao-jiashu 数据源)
+ *
+ * @author system
+ */
+@Tag(name = "管理后台 - 须知")
+@RestController
+@RequestMapping("/jiashu/notice")
+@Validated
+public class JiashuNoticeController {
+
+    @Resource
+    private JiashuNoticeService jiashuNoticeService;
+
+    @PostMapping("/create")
+    @Operation(summary = "创建须知")
+    @PermitAll
+    @TenantIgnore
+    public CommonResult<Long> createNotice(@Valid @RequestBody JiashuNoticeSaveReqVO createReqVO) {
+        return success(jiashuNoticeService.createNotice(createReqVO));
+    }
+
+    @PutMapping("/update")
+    @Operation(summary = "更新须知")
+    @PermitAll
+    @TenantIgnore
+    public CommonResult<Boolean> updateNotice(@Valid @RequestBody JiashuNoticeSaveReqVO updateReqVO) {
+        jiashuNoticeService.updateNotice(updateReqVO);
+        return success(true);
+    }
+
+    @DeleteMapping("/delete")
+    @Operation(summary = "删除须知")
+    @Parameter(name = "id", description = "须知编号", required = true, example = "1024")
+    @PermitAll
+    @TenantIgnore
+    public CommonResult<Boolean> deleteNotice(@RequestParam("id") Long id) {
+        jiashuNoticeService.deleteNotice(id);
+        return success(true);
+    }
+
+    @GetMapping("/get")
+    @Operation(summary = "获得须知")
+    @Parameter(name = "id", description = "须知编号", required = true, example = "1024")
+    @PermitAll
+    @TenantIgnore
+    public CommonResult<JiashuNoticeRespVO> getNotice(@RequestParam("id") Long id) {
+        JiashuNoticeDO notice = jiashuNoticeService.getNotice(id);
+        return success(BeanUtils.toBean(notice, JiashuNoticeRespVO.class));
+    }
+
+    @GetMapping("/page")
+    @Operation(summary = "获得须知分页")
+    @PermitAll
+    @TenantIgnore
+    public CommonResult<PageResult<JiashuNoticeRespVO>> getNoticePage(@Valid JiashuNoticePageReqVO pageReqVO) {
+        PageResult<JiashuNoticeDO> pageResult = jiashuNoticeService.getNoticePage(pageReqVO);
+        return success(BeanUtils.toBean(pageResult, JiashuNoticeRespVO.class));
+    }
+
+    @GetMapping("/listByType")
+    @Operation(summary = "根据须知类型 + 机构id 查询须知列表")
+    @Parameter(name = "type", description = "须知类型:APPOINTMENT 或者 VISIT", required = true, example = "APPOINTMENT")
+    @Parameter(name = "tenantId", description = "机构id", required = true, example = "1024")
+    @PermitAll
+    @TenantIgnore
+    public CommonResult<List<JiashuNoticeRespVO>> getNoticeListByType(@RequestParam("type") String type,
+                                                                     @RequestParam("tenantId") Long tenantId) {
+        List<JiashuNoticeDO> list = jiashuNoticeService.getNoticeListByType(type, tenantId);
+        return success(BeanUtils.toBean(list, JiashuNoticeRespVO.class));
+    }
+
+}

+ 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;
+
+}

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

@@ -0,0 +1,23 @@
+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 lombok.EqualsAndHashCode;
+import lombok.ToString;
+
+/**
+ * 管理后台 - 须知分页 Request VO
+ *
+ * @author system
+ */
+@Schema(description = "管理后台 - 须知分页 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class JiashuNoticePageReqVO extends PageParam {
+
+    @Schema(description = "须知类型:APPOINTMENT 或者 VISIT", example = "APPOINTMENT")
+    private String type;
+
+}

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

@@ -0,0 +1,27 @@
+package cn.iocoder.yudao.module.system.controller.admin.biz.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 管理后台 - 须知 Response VO
+ *
+ * @author system
+ */
+@Schema(description = "管理后台 - 须知 Response VO")
+@Data
+public class JiashuNoticeRespVO {
+
+    @Schema(description = "须知编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+    private Long id;
+
+    @Schema(description = "须知类型:APPOINTMENT 或者 VISIT", requiredMode = Schema.RequiredMode.REQUIRED, example = "APPOINTMENT")
+    private String type;
+
+    @Schema(description = "须知内容", requiredMode = Schema.RequiredMode.REQUIRED)
+    private String content;
+
+    @Schema(description = "机构id", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+    private Long tenantId;
+
+}

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

@@ -0,0 +1,33 @@
+package cn.iocoder.yudao.module.system.controller.admin.biz.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+
+/**
+ * 管理后台 - 须知新增/修改 Request VO
+ *
+ * @author system
+ */
+@Schema(description = "管理后台 - 须知新增/修改 Request VO")
+@Data
+public class JiashuNoticeSaveReqVO {
+
+    @Schema(description = "须知编号,修改时必传", example = "1024")
+    private Long id;
+
+    @Schema(description = "须知类型:APPOINTMENT 或者 VISIT", requiredMode = Schema.RequiredMode.REQUIRED, example = "APPOINTMENT")
+    @NotEmpty(message = "须知类型不能为空")
+    private String type;
+
+    @Schema(description = "须知内容", requiredMode = Schema.RequiredMode.REQUIRED)
+    @NotEmpty(message = "须知内容不能为空")
+    private String content;
+
+    @Schema(description = "机构id", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+    @NotNull(message = "机构id不能为空")
+    private Long tenantId;
+
+}

+ 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;
+
+    }
+
+}

+ 21 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/seal/CommonSealListReqVO.java

@@ -0,0 +1,21 @@
+package cn.iocoder.yudao.module.system.controller.admin.biz.vo.seal;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 管理后台 - 公章列表 Request VO
+ *
+ * @author system
+ */
+@Schema(description = "管理后台 - 公章列表 Request VO")
+@Data
+public class CommonSealListReqVO {
+
+    @Schema(description = "类型", example = "CONTRACT")
+    private String type;
+
+    @Schema(description = "机构id,不传则默认当前登录租户", example = "1024")
+    private Long tenantId;
+
+}

+ 41 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/seal/CommonSealRespVO.java

@@ -0,0 +1,41 @@
+package cn.iocoder.yudao.module.system.controller.admin.biz.vo.seal;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 管理后台 - 公章 Response VO
+ *
+ * @author system
+ */
+@Schema(description = "管理后台 - 公章 Response VO")
+@Data
+public class CommonSealRespVO {
+
+    @Schema(description = "id", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+    private Long id;
+
+    @Schema(description = "类型", example = "CONTRACT")
+    private String type;
+
+    @Schema(description = "公章url", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://xxx.com/seal.png")
+    private String url;
+
+    @Schema(description = "机构id", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+    private Long tenantId;
+
+    @Schema(description = "创建时间")
+    private LocalDateTime createTime;
+
+    @Schema(description = "更新时间")
+    private LocalDateTime updateTime;
+
+    @Schema(description = "创建人")
+    private String creator;
+
+    @Schema(description = "更新人")
+    private String updater;
+
+}

+ 32 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/seal/CommonSealSaveReqVO.java

@@ -0,0 +1,32 @@
+package cn.iocoder.yudao.module.system.controller.admin.biz.vo.seal;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+
+/**
+ * 管理后台 - 公章新增/修改 Request VO
+ *
+ * @author system
+ */
+@Schema(description = "管理后台 - 公章新增/修改 Request VO")
+@Data
+public class CommonSealSaveReqVO {
+
+    @Schema(description = "id,修改时必传", example = "1024")
+    private Long id;
+
+    @Schema(description = "类型", example = "CONTRACT")
+    private String type;
+
+    @Schema(description = "公章url", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://xxx.com/seal.png")
+    @NotEmpty(message = "公章url不能为空")
+    private String url;
+
+    @Schema(description = "机构id", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
+    @NotNull(message = "机构id不能为空")
+    private Long tenantId;
+
+}

+ 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 = "备注 / 跳过原因")

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

@@ -0,0 +1,51 @@
+package cn.iocoder.yudao.module.system.dal.dataobject.biz;
+
+import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseNoDeleteDO;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.KeySequence;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.ToString;
+
+/**
+ * 公章管理 DO
+ *
+ * 对应 system_common_seal 表(无 deleted 字段,因此继承 {@link BaseNoDeleteDO})
+ *
+ * @author system
+ */
+@TableName("system_common_seal")
+@KeySequence("system_common_seal_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
+@Data
+@ToString(callSuper = true)
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CommonSealDO extends BaseNoDeleteDO {
+
+    /**
+     * id
+     */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 类型
+     */
+    private String type;
+
+    /**
+     * 公章url
+     */
+    private String url;
+
+    /**
+     * 机构id
+     */
+    private Long tenantId;
+
+}

+ 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;
     /** 执行备注 / 跳过原因 */

+ 40 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/miniapp/JiashuNoticeDO.java

@@ -0,0 +1,40 @@
+package cn.iocoder.yudao.module.system.dal.dataobject.miniapp;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+/**
+ * 须知 DO(yanglao-jiashu 数据源)
+ *
+ * 对应 yanglao-jiashu 库中的 notice 表
+ *
+ * @author system
+ */
+@TableName("notice")
+@Data
+public class JiashuNoticeDO {
+
+    /**
+     * 主键ID
+     */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 须知类型:APPOINTMENT 或者 VISIT
+     */
+    private String type;
+
+    /**
+     * 须知内容
+     */
+    private String content;
+
+    /**
+     * 机构id
+     */
+    private Long tenantId;
+
+}

+ 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);
+
+}

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

@@ -0,0 +1,32 @@
+package cn.iocoder.yudao.module.system.dal.mysql.biz;
+
+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.biz.vo.seal.CommonSealListReqVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.biz.CommonSealDO;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+/**
+ * 公章管理 Mapper
+ *
+ * @author system
+ */
+@Mapper
+public interface CommonSealMapper extends BaseMapperX<CommonSealDO> {
+
+    /**
+     * 查询公章列表
+     *
+     * @param reqVO 查询条件
+     * @return 公章列表
+     */
+    default List<CommonSealDO> selectSealList(CommonSealListReqVO reqVO) {
+        return selectList(new LambdaQueryWrapperX<CommonSealDO>()
+                .eqIfPresent(CommonSealDO::getType, reqVO.getType())
+                .eqIfPresent(CommonSealDO::getTenantId, reqVO.getTenantId())
+                .orderByDesc(CommonSealDO::getId));
+    }
+
+}

+ 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) {

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

@@ -83,6 +83,9 @@ public interface ElderlyChangeRecordMapper extends BaseMapperX<ElderlyChangeReco
     List<CateringChangeRecordRespVO> selectCateringListPage(Page<CateringChangeRecordRespVO> page,
                                                       @Param("pageVO") ChangeRecordPageReqApiVO pageReqVO);
 
+    List<FeeItemChangeRecordRespVO> selectFeeItemListPage(Page<FeeItemChangeRecordRespVO> page,
+                                                          @Param("pageVO") ChangeRecordPageReqApiVO pageReqVO);
+
     List<PriceChangeRecordRespVO> selectPriceListPage(Page<PriceChangeRecordRespVO> page,
                                                       @Param("pageVO") ChangeRecordPageReqApiVO pageReqVO);
 

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

@@ -22,6 +22,19 @@ public interface ExpenseMapper extends BaseMapperX<ExpenseDO> {
 
     BedChangeCategoryVO getChargeCategory(@Param("elderlyId") Long elderlyId, @Param("identification") Integer identification);
 
+    /**
+     * 通过收费项目 id 定位长者的费用项
+     * <p>
+     * 费用项目的收费类别标识({@code sys_charge_category.identification})不固定,
+     * 1-床位、2-护理、3-餐饮 之外均属于费用项目,无法用 identification 精确匹配,
+     * 因此按变更记录的收费项目 id({@code elderly_change_record.overhead_charge_id})定位。
+     *
+     * @param elderlyId        长者 id
+     * @param overheadChargeId 收费项目 id
+     * @return 费用项信息
+     */
+    BedChangeCategoryVO getChargeCategoryByOverheadChargeId(@Param("elderlyId") Long elderlyId,
+                                                            @Param("overheadChargeId") Long overheadChargeId);
 
     List<BedChangeCategoryVO> getChargeCategoryList(Long elderlyId);
 }

+ 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;
+
+}

+ 43 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/miniapp/JiashuNoticeMapper.java

@@ -0,0 +1,43 @@
+package cn.iocoder.yudao.module.system.dal.mysql.miniapp;
+
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+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.biz.vo.JiashuNoticePageReqVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.miniapp.JiashuNoticeDO;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+/**
+ * 须知 Mapper(yanglao-jiashu 数据源)
+ *
+ * 注意:notice 位于 yanglao-jiashu 数据源,调用方需自行切换 @DS("miniapp")
+ *
+ * @author system
+ */
+@Mapper
+public interface JiashuNoticeMapper extends BaseMapperX<JiashuNoticeDO> {
+
+    default PageResult<JiashuNoticeDO> selectPage(JiashuNoticePageReqVO reqVO) {
+        return selectPage(reqVO, new LambdaQueryWrapperX<JiashuNoticeDO>()
+                .eqIfPresent(JiashuNoticeDO::getType, reqVO.getType())
+                .eqIfPresent(JiashuNoticeDO::getTenantId, reqVO.getTenantId())
+                .orderByDesc(JiashuNoticeDO::getId));
+    }
+
+    /**
+     * 根据须知类型 + 机构id 查询须知列表
+     *
+     * @param type     须知类型:APPOINTMENT 或者 VISIT
+     * @param tenantId 机构id
+     * @return 须知列表
+     */
+    default List<JiashuNoticeDO> selectListByTypeAndTenantId(String type, Long tenantId) {
+        return selectList(new LambdaQueryWrapperX<JiashuNoticeDO>()
+                .eq(JiashuNoticeDO::getType, type)
+                .eq(JiashuNoticeDO::getTenantId, tenantId)
+                .orderByDesc(JiashuNoticeDO::getId));
+    }
+
+}

+ 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;

+ 55 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/CommonSealService.java

@@ -0,0 +1,55 @@
+package cn.iocoder.yudao.module.system.service.biz;
+
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.seal.CommonSealListReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.seal.CommonSealSaveReqVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.biz.CommonSealDO;
+
+import javax.validation.Valid;
+import java.util.List;
+
+/**
+ * 公章管理 Service 接口
+ *
+ * @author system
+ */
+public interface CommonSealService {
+
+    /**
+     * 创建公章
+     *
+     * @param createReqVO 创建信息
+     * @return 公章编号
+     */
+    Long createSeal(@Valid CommonSealSaveReqVO createReqVO);
+
+    /**
+     * 更新公章
+     *
+     * @param updateReqVO 更新信息
+     */
+    void updateSeal(@Valid CommonSealSaveReqVO updateReqVO);
+
+    /**
+     * 删除公章
+     *
+     * @param id 公章编号
+     */
+    void deleteSeal(Long id);
+
+    /**
+     * 获得公章
+     *
+     * @param id 公章编号
+     * @return 公章
+     */
+    CommonSealDO getSeal(Long id);
+
+    /**
+     * 获得公章列表
+     *
+     * @param listReqVO 查询条件
+     * @return 公章列表
+     */
+    List<CommonSealDO> getSealList(CommonSealListReqVO listReqVO);
+
+}

+ 80 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/CommonSealServiceImpl.java

@@ -0,0 +1,80 @@
+package cn.iocoder.yudao.module.system.service.biz;
+
+import cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.seal.CommonSealListReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.seal.CommonSealSaveReqVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.biz.CommonSealDO;
+import cn.iocoder.yudao.module.system.dal.mysql.biz.CommonSealMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.COMMON_NOT_FOUND;
+
+/**
+ * 公章管理 Service 实现类
+ *
+ * @author system
+ */
+@Service
+@Validated
+public class CommonSealServiceImpl implements CommonSealService {
+
+    @Resource
+    private CommonSealMapper commonSealMapper;
+
+    @Override
+    public Long createSeal(CommonSealSaveReqVO createReqVO) {
+        // 插入
+        CommonSealDO seal = BeanUtils.toBean(createReqVO, CommonSealDO.class);
+        if (seal.getTenantId() == null) {
+            seal.setTenantId(TenantContextHolder.getTenantId());
+        }
+        seal.setId(null);
+        commonSealMapper.insert(seal);
+        // 返回
+        return seal.getId();
+    }
+
+    @Override
+    public void updateSeal(CommonSealSaveReqVO updateReqVO) {
+        // 校验存在
+        validateSealExists(updateReqVO.getId());
+        // 更新
+        CommonSealDO updateObj = BeanUtils.toBean(updateReqVO, CommonSealDO.class);
+        commonSealMapper.updateById(updateObj);
+    }
+
+    @Override
+    public void deleteSeal(Long id) {
+        // 校验存在
+        validateSealExists(id);
+        // 删除
+        commonSealMapper.deleteById(id);
+    }
+
+    @Override
+    public CommonSealDO getSeal(Long id) {
+        return commonSealMapper.selectById(id);
+    }
+
+    @Override
+    public List<CommonSealDO> getSealList(CommonSealListReqVO listReqVO) {
+        // 机构id 前端可传,不传则默认当前租户
+        if (listReqVO.getTenantId() == null) {
+            listReqVO.setTenantId(TenantContextHolder.getTenantId());
+        }
+        return commonSealMapper.selectSealList(listReqVO);
+    }
+
+    private void validateSealExists(Long id) {
+        if (commonSealMapper.selectById(id) == null) {
+            throw ServiceExceptionUtil.exception(COMMON_NOT_FOUND);
+        }
+    }
+
+}

+ 3 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/ElderlyConsumerVouchersServiceImpl.java

@@ -79,7 +79,9 @@ public class ElderlyConsumerVouchersServiceImpl implements ElderlyConsumerVouche
     @Transactional
     public void delete(Long id) {
         validateExists(id);
-        ExpenseOrderItemDO expenseOrderItemDO = expenseOrderItemMapper.selectOne(new LambdaQueryWrapperX<ExpenseOrderItemDO>().eq(ExpenseOrderItemDO::getSourceExpenseItemId, id));
+        ExpenseOrderItemDO expenseOrderItemDO = expenseOrderItemMapper.selectOne(new LambdaQueryWrapperX<ExpenseOrderItemDO>()
+                .eq(ExpenseOrderItemDO::getSourceExpenseItemId, id)
+                .eq(ExpenseOrderItemDO::getExpenseSource,BusinessConstants.CONSUMER_VOUCHER));
         if(expenseOrderItemDO != null){
             ExpenseOrderDO expenseOrderDO = expenseOrderMapper.selectById(expenseOrderItemDO.getExpenseOrderId());
             if(expenseOrderDO != null){

+ 30 - 7
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())
@@ -4047,12 +4048,34 @@ public class ExpenseOrderServiceImpl implements ExpenseOrderService {
                 respVO.setExpenseSource(BusinessConstants.EXPENSE_ITEM);
                 respVO.setSourceExpenseItemId(item.getId());
                 long monthDays = ChronoUnit.DAYS.between(monthStart, monthEnd) + 1;
-                // 按次(inSequence)收费且仅限月度费用项:固定月金额,不乘天数、不按实际在住区间比例折算
-                if ("inSequence".equals(chargeTypeMap.get(item.getItemId()))
-                        && item.getIsMonthlyExpense() != null && item.getIsMonthlyExpense() == 1) {
+                // 赠送(阶段)费用:与月度费用一样按 chargeType 处理,金额统一使用实际金额 actualAmount
+                boolean isFreeGiftItem = BooleanEnum.TRUE.getValue().equals(item.getIsFreeGift());
+                String chargeType = chargeTypeMap.get(item.getItemId());
+                // 按次(inSequence)收费:月度费用项与赠送费用项均为固定月金额,不乘天数、不按实际在住区间比例折算
+                if ("inSequence".equals(chargeType)
+                        && (isFreeGiftItem || (item.getIsMonthlyExpense() != null && item.getIsMonthlyExpense() == 1))) {
                     respVO.setPrice(item.getActualAmount());
-                    respVO.setTotalAmount(item.getTotalAmount());
-                } else if (item.getIsDayCalculate() == 1){
+                    respVO.setTotalAmount(isFreeGiftItem ? item.getActualAmount() : item.getTotalAmount());
+                } else if (isFreeGiftItem) {
+                    BigDecimal giftAmount = item.getActualAmount();
+                    if (Integer.valueOf(1).equals(item.getIsDayCalculate())) {
+                        // 按天计费:日价 * 当月天数
+                        respVO.setPrice(giftAmount);
+                        respVO.setTotalAmount(giftAmount.multiply(new BigDecimal(monthDays)));
+                    } else {
+                        // 非整月(离住/返院/合同到期等):按实际在住区间比例折算
+                        if (finalActualStart.isAfter(monthStart) || finalActualEnd.isBefore(monthEnd)) {
+                            long actualDays = ChronoUnit.DAYS.between(finalActualStart, finalActualEnd) + 1;
+                            if (monthDays > 0 && actualDays > 0) {
+                                BigDecimal ratio = BigDecimal.valueOf(actualDays)
+                                        .divide(BigDecimal.valueOf(monthDays), 8, RoundingMode.HALF_UP);
+                                giftAmount = giftAmount.multiply(ratio).setScale(2, RoundingMode.HALF_UP);
+                            }
+                        }
+                        respVO.setPrice(giftAmount);
+                        respVO.setTotalAmount(giftAmount);
+                    }
+                } else if (Integer.valueOf(1).equals(item.getIsDayCalculate())){
                     respVO.setPrice(item.getActualAmount());
                     respVO.setTotalAmount(respVO.getPrice().multiply(new BigDecimal(monthDays)));
                 }else {

+ 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());
     }
 
     // ======================== 私有方法 ========================

+ 64 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/miniapp/JiashuNoticeService.java

@@ -0,0 +1,64 @@
+package cn.iocoder.yudao.module.system.service.miniapp;
+
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.JiashuNoticePageReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.JiashuNoticeSaveReqVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.miniapp.JiashuNoticeDO;
+
+import java.util.List;
+
+/**
+ * 须知 Service(yanglao-jiashu 数据源)
+ *
+ * @author system
+ */
+public interface JiashuNoticeService {
+
+    /**
+     * 创建须知
+     *
+     * @param createReqVO 创建信息
+     * @return 须知编号
+     */
+    Long createNotice(JiashuNoticeSaveReqVO createReqVO);
+
+    /**
+     * 更新须知
+     *
+     * @param updateReqVO 更新信息
+     */
+    void updateNotice(JiashuNoticeSaveReqVO updateReqVO);
+
+    /**
+     * 删除须知
+     *
+     * @param id 须知编号
+     */
+    void deleteNotice(Long id);
+
+    /**
+     * 获得须知
+     *
+     * @param id 须知编号
+     * @return 须知
+     */
+    JiashuNoticeDO getNotice(Long id);
+
+    /**
+     * 获得须知分页
+     *
+     * @param pageReqVO 分页查询条件
+     * @return 须知分页
+     */
+    PageResult<JiashuNoticeDO> getNoticePage(JiashuNoticePageReqVO pageReqVO);
+
+    /**
+     * 根据须知类型 + 机构id 查询须知列表
+     *
+     * @param type     须知类型:APPOINTMENT 或者 VISIT
+     * @param tenantId 机构id
+     * @return 须知列表
+     */
+    List<JiashuNoticeDO> getNoticeListByType(String type, Long tenantId);
+
+}

+ 82 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/miniapp/JiashuNoticeServiceImpl.java

@@ -0,0 +1,82 @@
+package cn.iocoder.yudao.module.system.service.miniapp;
+
+import cn.iocoder.yudao.framework.common.exception.ServiceException;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.JiashuNoticePageReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.JiashuNoticeSaveReqVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.miniapp.JiashuNoticeDO;
+import cn.iocoder.yudao.module.system.dal.mysql.miniapp.JiashuNoticeMapper;
+import cn.iocoder.yudao.module.system.enums.ErrorCodeConstants;
+import com.baomidou.dynamic.datasource.annotation.DS;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+/**
+ * 须知 Service 实现(yanglao-jiashu 数据源)
+ *
+ * @author system
+ */
+@Service
+@DS("miniapp")
+public class JiashuNoticeServiceImpl implements JiashuNoticeService {
+
+    @Resource
+    private JiashuNoticeMapper jiashuNoticeMapper;
+
+    @Override
+    public Long createNotice(JiashuNoticeSaveReqVO createReqVO) {
+        JiashuNoticeDO notice = BeanUtils.toBean(createReqVO, JiashuNoticeDO.class);
+        jiashuNoticeMapper.insert(notice);
+        return notice.getId();
+    }
+
+    @Override
+    public void updateNotice(JiashuNoticeSaveReqVO updateReqVO) {
+        // 校验是否存在
+        validateNoticeExists(updateReqVO.getId());
+        // 更新须知
+        JiashuNoticeDO updateObj = BeanUtils.toBean(updateReqVO, JiashuNoticeDO.class);
+        if(updateObj.getTenantId() == null){
+            updateObj.setId(null);
+            jiashuNoticeMapper.insert(updateObj);
+        }else {
+            jiashuNoticeMapper.updateById(updateObj);
+        }
+    }
+
+    @Override
+    public void deleteNotice(Long id) {
+        // 校验是否存在
+        validateNoticeExists(id);
+        // 删除须知
+        jiashuNoticeMapper.deleteById(id);
+    }
+
+    @Override
+    public JiashuNoticeDO getNotice(Long id) {
+        return jiashuNoticeMapper.selectById(id);
+    }
+
+    @Override
+    public PageResult<JiashuNoticeDO> getNoticePage(JiashuNoticePageReqVO pageReqVO) {
+        return jiashuNoticeMapper.selectPage(pageReqVO);
+    }
+
+    @Override
+    public List<JiashuNoticeDO> getNoticeListByType(String type, Long tenantId) {
+        return jiashuNoticeMapper.selectListByTypeAndTenantId(type, tenantId);
+    }
+
+    private void validateNoticeExists(Long id) {
+        if (id == null) {
+            return;
+        }
+        if (jiashuNoticeMapper.selectById(id) == null) {
+            throw new ServiceException(ErrorCodeConstants.JIASHU_NOTICE_NOT_FOUND);
+        }
+    }
+
+}

+ 54 - 21
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;
@@ -23,17 +24,19 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.google.common.annotations.VisibleForTesting;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
 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,26 +203,50 @@ 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));
-        corpWechatApi.sendMessage(corpSendMessage);
+        notice.setContentItem(contentItems);
+        sendCorpMessageAfterCommit(corpSendMessage);
         return message.getId();
     }
 
+    /**
+     * 事务提交后再投递企微消息。
+     *
+     * 原因:CorpWechatApi#sendMessage 标注了 @Async,在独立线程执行,不受当前事务回滚控制。
+     * 若在业务尚未完成时投递,一旦后续逻辑抛异常导致事务回滚,流程实际并未通过,
+     * 但 HTTP 请求已经发出且无法撤回,就会出现“流程未通过却收到审批通知”的情况。
+     */
+    private void sendCorpMessageAfterCommit(CorpSendMessage corpSendMessage) {
+        if (TransactionSynchronizationManager.isSynchronizationActive()) {
+            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                @Override
+                public void afterCommit() {
+                    corpWechatApi.sendMessage(corpSendMessage);
+                }
+            });
+        } else {
+            // 无事务同步时(如单元测试、定时任务),保持原有行为,立即发送
+            corpWechatApi.sendMessage(corpSendMessage);
+        }
+    }
+
     private String matchBuginessName(String templateCode) {
         String buginessName = "";
         if (StringUtil.isEmptyORNull(templateCode)) return buginessName;
@@ -264,11 +291,14 @@ 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);
+            sendCorpMessageAfterCommit(corpSendMessage);
         });
         return null;
     }
@@ -285,14 +315,17 @@ 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);
+        sendCorpMessageAfterCommit(corpSendMessage);
         return null;
     }
 

+ 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>

+ 65 - 0
yudao-module-system/yudao-module-system-biz/src/main/resources/mapper/ElderlyChangeRecordMapper.xml

@@ -408,6 +408,71 @@
         ORDER BY ecr.create_time DESC , ecr.update_time DESC
     </select>
 
+    <select id="selectFeeItemListPage" resultType="cn.iocoder.yudao.module.system.api.bpm.vo.FeeItemChangeRecordRespVO">
+        SELECT
+        ecr.id,
+        eei.item_name AS originalItemName,
+        soc.charge_name,
+        ei.id AS elderlyId,
+        ei.elder_name AS elderlyName,
+        ei.id_card,
+        ei.elder_sex,
+        ecr.original_id,
+        ecr.original_name,
+        ecr.expect_id,
+        ecr.expect_name,
+        ecr.change_date,
+        ecr.is_private_room,
+        ecr.amount,
+        ecr.overhead_charge_id,
+        ecr.category_name,
+        bbf.`status`,
+        bbf.process_instance_id,
+        st.name as tenantName,
+        st.id as tenantId
+        FROM elderly_change_record ecr
+        INNER JOIN elderly_info ei ON ecr.elderly_id = ei.id
+        INNER JOIN elderly_expense ee ON ei.id = ee.elder_id
+        INNER JOIN elderly_expense_item eei ON eei.expense_id = ee.id
+        INNER JOIN sys_charge_category scc ON scc.id = eei.item_category_id AND scc.identification NOT IN (1,2,3)
+        INNER JOIN sys_overhead_charge soc ON soc.id = ecr.overhead_charge_id
+        INNER JOIN bpm_business_form bbf ON ecr.id = bbf.business_id
+        INNER JOIN system_tenant st ON st.id = ei.tenant_id
+        WHERE ecr.current_flag = 0
+        AND ee.created_time = (SELECT MAX(ee2.created_time) FROM elderly_expense ee2 WHERE ee2.elder_id = ei.id)
+        <if test="pageVO.expectName != null">
+            AND ecr.expect_name LIKE CONCAT('%', #{pageVO.expectName}, '%')
+        </if>
+        <if test="pageVO.originalName != null">
+            AND ecr.original_name LIKE CONCAT('%', #{pageVO.originalName}, '%')
+        </if>
+        <if test="pageVO.elderName != null">
+            AND ei.elder_name LIKE CONCAT('%', #{pageVO.elderName}, '%')
+        </if>
+        <if test="pageVO.changeDate != null">
+            AND ecr.change_date between #{pageVO.changeDate[0]} and #{pageVO.changeDate[1]}
+        </if>
+        <if test="pageVO.status != null">
+            and bbf.`status` = #{pageVO.status}
+        </if>
+        <if test="pageVO.type != null">
+            and bbf.`type` = #{pageVO.type}
+        </if>
+        <if test="pageVO.changeType != null">
+            and ecr.change_type = #{pageVO.changeType}
+        </if>
+        <if test="pageVO.orgType != null">
+            and ei.org_type = #{pageVO.orgType}
+        </if>
+        <if test="pageVO.tenantIds != null">
+            and ei.tenant_id in
+            <foreach item="id" collection="pageVO.tenantIds" open="(" separator="," close=")">
+                #{id}
+            </foreach>
+        </if>
+        ORDER BY ecr.create_time DESC , ecr.update_time DESC
+    </select>
+
     <select id="selectPriceListPage" resultType="cn.iocoder.yudao.module.system.api.bpm.vo.PriceChangeRecordRespVO">
         SELECT
             ecr.id,

+ 21 - 0
yudao-module-system/yudao-module-system-biz/src/main/resources/mapper/ExpenseMapper.xml

@@ -23,6 +23,27 @@
         LIMIT 1
     </select>
 
+    <select id="getChargeCategoryByOverheadChargeId"
+            resultType="cn.iocoder.yudao.module.system.controller.admin.biz.vo.BedChangeCategoryVO">
+        SELECT
+            soc.id AS overheadChargeId,
+            eei.item_category_id AS itemCategoryId,
+            soc.charge_name AS "itemName",
+            eei.actual_amount,
+            ee.id AS "expenseId"
+        FROM
+            elderly_info ei
+                INNER JOIN elderly_expense ee ON ei.id = ee.elder_id
+                INNER JOIN elderly_expense_item eei ON eei.expense_id = ee.id
+                INNER JOIN sys_charge_category scc ON scc.id = eei.item_category_id
+                INNER JOIN sys_overhead_charge soc ON soc.id = eei.item_id
+        WHERE ee.created_time = (SELECT MAX(ee2.created_time) FROM elderly_expense ee2 WHERE ee2.elder_id = ei.id)
+          AND ei.id = #{elderlyId}
+          AND eei.item_id = #{overheadChargeId}
+        ORDER BY ee.id DESC
+        LIMIT 1
+    </select>
+
     <select id="getChargeCategoryList" resultType="cn.iocoder.yudao.module.system.controller.admin.biz.vo.BedChangeCategoryVO">
         SELECT distinct
             soc.id AS overheadChargeId,

+ 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>

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.