Jelajahi Sumber

增加
1、增加预约名额预占模块,校验预约数量是否超额
2、增加床位对调记录模块
3、增加获取超时停留的流程实例列表接口
4、增加默认第一个家属为账单推送人
5、增加查询账单起始月(含)起、指定机构下未缴费账单的长者名称与账单月,并同时返回该机构下退住未结算账单接口

liangwenxuan 1 bulan lalu
induk
melakukan
e704d92cd9
36 mengubah file dengan 1486 tambahan dan 7 penghapusan
  1. 3 0
      sql/mysql/appointment_config.sql
  2. 9 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/controller/admin/task/BpmProcessInstanceController.java
  3. 20 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/controller/admin/task/vo/instance/BpmProcessOverdueReqVO.java
  4. 34 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/controller/admin/task/vo/instance/BpmProcessOverdueRespVO.java
  5. 11 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmProcessInstanceService.java
  6. 62 1
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmProcessInstanceServiceImpl.java
  7. 10 0
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java
  8. 41 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/BedSwapRecordController.java
  9. 8 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/ExpenseOrderController.java
  10. 35 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/BedSwapRecordPageReqVO.java
  11. 72 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/BedSwapRecordRespVO.java
  12. 80 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/BedSwapRecordSaveReqVO.java
  13. 19 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/ExpenseOrderUnpaidPageReqVO.java
  14. 23 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/biz/vo/ExpenseOrderUnpaidPageRespVO.java
  15. 2 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/pay/WeChatPayController.java
  16. 53 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/app/appointment/AppAppointmentQuotaController.java
  17. 44 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/app/appointment/vo/AppointmentQuotaOccupyReqVO.java
  18. 34 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/app/appointment/vo/AppointmentQuotaOccupyRespVO.java
  19. 15 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/biz/AppointmentGroupDO.java
  20. 120 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/biz/BedSwapRecordDO.java
  21. 15 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/BedSwapRecordMapper.java
  22. 3 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/ExpenseOrderMapper.java
  23. 2 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/RefundSettlementOrderMapper.java
  24. 35 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/miniapp/JiashuVisitAppointmentMapper.java
  25. 37 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/redis/RedisKeyConstants.java
  26. 142 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/redis/appointment/AppointmentQuotaRedisDAO.java
  27. 32 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/appointment/AppAppointmentQuotaService.java
  28. 329 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/appointment/AppAppointmentQuotaServiceImpl.java
  29. 24 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/BedSwapRecordService.java
  30. 47 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/BedSwapRecordServiceImpl.java
  31. 5 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/ElderlyInfoServiceImpl.java
  32. 10 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/ExpenseOrderService.java
  33. 22 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/ExpenseOrderServiceImpl.java
  34. 53 3
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/NurseChangeRecordServiceImpl.java
  35. 21 0
      yudao-module-system/yudao-module-system-biz/src/main/resources/mapper/ExpenseOrderMapper.xml
  36. 14 0
      yudao-module-system/yudao-module-system-biz/src/main/resources/mapper/RefundSettlementOrderMapper.xml

+ 3 - 0
sql/mysql/appointment_config.sql

@@ -25,6 +25,9 @@ CREATE TABLE `appointment_group`  (
   `week_days` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '当date_type=WEEKLY时,存储周几,用逗号分隔,如"1,3,5"(1=周一)',
   `month_days` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '当date_type=MONTHLY时,存储几号,如"1,15"(可包含负数表示倒数日)',
   `custom_dates` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '当date_type=CUSTOM时,存储具体日期数组,如["2026-07-15","2026-07-20"]',
+  `limit_count` int(11) NULL DEFAULT NULL COMMENT '限制人数(null 表示不限制)',
+  `begin_time` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '可预约开始时间(HH:mm),null 表示不限',
+  `end_time` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '可预约结束时间(HH:mm),null 表示不限',
   `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
   `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
   `creator` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '创建人',

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

@@ -398,6 +398,15 @@ public class BpmProcessInstanceController {
         return success(Boolean.TRUE);
     }
 
+    @GetMapping("/get-overdue-list")
+    @Operation(summary = "获取超时停留的流程实例列表", description = "根据租户编号与超时天数,获取所有在该节点停留时间超过超时天数的流程信息")
+    @TenantIgnore
+    public CommonResult<List<BpmProcessOverdueRespVO>> getOverdueProcessInstanceList(
+            @Valid BpmProcessOverdueReqVO reqVO) {
+        return success(processInstanceService.getOverdueProcessInstanceList(
+                reqVO.getTenantId(), reqVO.getTimeoutDays()));
+    }
+
     @DeleteMapping("/timedClosureProcess")
     public CommonResult<Boolean> timedClosureProcess(
             @Valid @RequestBody BpmProcessInstanceCancelReqVO cancelReqVO) {

+ 20 - 0
yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/controller/admin/task/vo/instance/BpmProcessOverdueReqVO.java

@@ -0,0 +1,20 @@
+package cn.iocoder.yudao.module.bpm.controller.admin.task.vo.instance;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+
+@Schema(description = "管理后台 - 超时停留流程实例 Request VO")
+@Data
+public class BpmProcessOverdueReqVO {
+
+    @Schema(description = "租户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+    @NotNull(message = "租户编号不能为空")
+    private Long tenantId;
+
+    @Schema(description = "超时天数,流程在某节点停留超过该天数即视为超时", requiredMode = Schema.RequiredMode.REQUIRED, example = "3")
+    @NotNull(message = "超时天数不能为空")
+    private Integer timeoutDays;
+
+}

+ 34 - 0
yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/controller/admin/task/vo/instance/BpmProcessOverdueRespVO.java

@@ -0,0 +1,34 @@
+package cn.iocoder.yudao.module.bpm.controller.admin.task.vo.instance;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+
+@Schema(description = "管理后台 - 超时停留流程实例 Response VO")
+@Data
+public class BpmProcessOverdueRespVO {
+
+    @Schema(description = "流程实例编号", example = "bd728f76-5a11-11ef-98ac-00155d6a6c52")
+    private String processInstanceId;
+
+    @Schema(description = "当前停留的节点名称", example = "院长审批")
+    private String taskName;
+
+    @Schema(description = "长者姓名", example = "张三")
+    private String elderName;
+
+    @Schema(description = "流程类型", example = "入住申请")
+    private String processType;
+
+    @Schema(description = "到达该节点的时间", example = "2024-01-01 12:00:00")
+    @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+    private LocalDateTime arriveTime;
+
+    @Schema(description = "审核人", example = "李四")
+    private String reviewer;
+
+}

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

@@ -186,6 +186,17 @@ public interface BpmProcessInstanceService {
 
     BpmMyPageCountRespVO getBpmListCount(Long loginUserId, Integer value, Integer queryType);
 
+    /**
+     * 获得指定租户下、停留时间超过超时天数的流程实例列表
+     * <p>
+     * 仅统计“进行中”的流程(即当前停留在某个审批节点),且在该节点的停留时间已超过传入的超时天数
+     *
+     * @param tenantId     租户编号
+     * @param timeoutDays  超时天数(天)
+     * @return 超时流程实例列表,每条包含:长者姓名、流程类型、到达该节点的时间、审核人
+     */
+    List<BpmProcessOverdueRespVO> getOverdueProcessInstanceList(Long tenantId, Integer timeoutDays);
+
     void deleteAllHistoricProcessInstance();
 
     PageResult<BpmProcessInstanceRespVO> getDoneProcessInstancePage(Long loginUserId, BpmProcessInstancePageReqVO pageVO);

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

@@ -78,6 +78,7 @@ import org.flowable.engine.history.HistoricProcessInstanceQuery;
 import org.flowable.engine.repository.ProcessDefinition;
 import org.flowable.engine.runtime.ProcessInstance;
 import org.flowable.engine.runtime.ProcessInstanceBuilder;
+import org.flowable.task.api.Task;
 import org.flowable.task.api.TaskQuery;
 import org.flowable.task.api.history.HistoricTaskInstance;
 import org.flowable.task.api.history.HistoricTaskInstanceQuery;
@@ -780,7 +781,7 @@ public class BpmProcessInstanceServiceImpl implements BpmProcessInstanceService
     private void validateStartUserSelectAssignees(ProcessDefinition definition, Map<String, List<Long>> startUserSelectAssignees) {
         // 1. 获得发起人自选审批人的 UserTask/ServiceTask 列表
         BpmnModel bpmnModel = processDefinitionService.getProcessDefinitionBpmnModel(definition.getId());
-        List<Task> tasks = BpmTaskCandidateStartUserSelectStrategy.getStartUserSelectTaskList(bpmnModel);
+        List<org.flowable.bpmn.model.Task> tasks = BpmTaskCandidateStartUserSelectStrategy.getStartUserSelectTaskList(bpmnModel);
         if (CollUtil.isEmpty(tasks)) {
             return;
         }
@@ -1286,6 +1287,66 @@ public class BpmProcessInstanceServiceImpl implements BpmProcessInstanceService
         return bpmMyPageCountRespVO;
     }
 
+    @Override
+    @TenantIgnore
+    public List<BpmProcessOverdueRespVO> getOverdueProcessInstanceList(Long tenantId, Integer timeoutDays) {
+        // 1. 查询该租户下所有进行中的任务(即流程当前停留的节点)
+        List<Task> tasks = taskService.createTaskQuery()
+                .active()
+                .taskTenantId(String.valueOf(tenantId))
+                .orderByTaskCreateTime().asc()
+                .list();
+        if (CollUtil.isEmpty(tasks)) {
+            return Collections.emptyList();
+        }
+        // 2. 过滤出停留时间超过超时天数的任务:到达节点时间 + 超时天数 < 当前时间
+        Date deadline = DateUtil.offsetDay(new Date(), -timeoutDays);
+        List<Task> overdueTasks = filterList(tasks, task -> task.getCreateTime().before(deadline));
+        if (CollUtil.isEmpty(overdueTasks)) {
+            return Collections.emptyList();
+        }
+        // 3. 批量补充长者信息(用于过滤长者姓名为空、以及轮候入住流程)
+        Set<String> processInstanceIds = convertSet(overdueTasks, Task::getProcessInstanceId);
+        Map<String, BpmElderlyInfoVO> elderMap = businessFormService.getElderMapByProcessInstanceId(processInstanceIds);
+        // 3.1 过滤:长者姓名为空,或业务类型为轮候入住(check_in_wait)的不查询
+        overdueTasks = filterList(overdueTasks, task -> {
+            BpmElderlyInfoVO elderlyInfoVO = elderMap.get(task.getProcessInstanceId());
+            if (elderlyInfoVO == null || StrUtil.isBlank(elderlyInfoVO.getElderName())) {
+                return false;
+            }
+            return !BpmBusinessTypeEnum.CHECK_IN_WAIT.getValue().equals(elderlyInfoVO.getType());
+        });
+        if (CollUtil.isEmpty(overdueTasks)) {
+            return Collections.emptyList();
+        }
+        // 4. 批量补充流程定义(流程类型)、审核人
+        Set<String> processDefinitionIds = convertSet(overdueTasks, Task::getProcessDefinitionId);
+        Map<String, ProcessDefinition> processDefinitionMap = processDefinitionService.getProcessDefinitionMap(processDefinitionIds);
+        Set<Long> assigneeIds = new HashSet<>();
+        overdueTasks.forEach(task -> {
+            if (StrUtil.isNotBlank(task.getAssignee())) {
+                assigneeIds.add(Long.valueOf(task.getAssignee()));
+            }
+        });
+        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(assigneeIds);
+        // 4. 拼接返回
+        return convertList(overdueTasks, task -> {
+            BpmProcessOverdueRespVO respVO = new BpmProcessOverdueRespVO();
+            respVO.setProcessInstanceId(task.getProcessInstanceId());
+            respVO.setTaskName(task.getName());
+            respVO.setArriveTime(DateUtils.of(task.getCreateTime()));
+            ProcessDefinition definition = processDefinitionMap.get(task.getProcessDefinitionId());
+            respVO.setProcessType(definition == null ? null : definition.getName());
+            BpmElderlyInfoVO elderlyInfoVO = elderMap.get(task.getProcessInstanceId());
+            respVO.setElderName(elderlyInfoVO == null ? null : elderlyInfoVO.getElderName());
+            if (StrUtil.isNotBlank(task.getAssignee())) {
+                AdminUserRespDTO user = userMap.get(Long.valueOf(task.getAssignee()));
+                respVO.setReviewer(user == null ? null : user.getNickname());
+            }
+            return respVO;
+        });
+    }
+
     @Override
     public void deleteAllHistoricProcessInstance() {
         List<HistoricProcessInstance> historicProcessInstances = historyService.createHistoricProcessInstanceQuery()

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

@@ -311,4 +311,14 @@ public interface ErrorCodeConstants {
     ErrorCode ELDERLY_EXTRA_FIRST_SERVICE_CONFIRMATION_NOT_EXISTS = new ErrorCode(1_008_021_000, "首次服务项目确认表不存在");
     // ========== 变更事项确认表 1-008-022-000 ==========
     ErrorCode ELDERLY_EXTRA_CHANGE_CONFIRMATION_NOT_EXISTS = new ErrorCode(1_008_022_000, "变更事项确认表不存在");
+
+    // ========== 预约名额预占 1-002-040-000 ==========
+    ErrorCode APPOINTMENT_CONFIG_NOT_FOUND = new ErrorCode(1_002_040_000, "预约配置不存在");
+    ErrorCode APPOINTMENT_DISABLED = new ErrorCode(1_002_040_001, "预约未开启");
+    ErrorCode APPOINTMENT_DATE_NOT_IN_RANGE = new ErrorCode(1_002_040_002, "不在可预约时间范围内");
+    ErrorCode APPOINTMENT_DATE_NOT_ALLOWED = new ErrorCode(1_002_040_003, "该日期不可预约");
+    ErrorCode APPOINTMENT_TOTAL_LIMIT = new ErrorCode(1_002_040_004, "预约名额已满");
+    ErrorCode APPOINTMENT_PERSON_LIMIT = new ErrorCode(1_002_040_005, "您预约次数已达上限");
+    ErrorCode APPOINTMENT_GROUP_LIMIT = new ErrorCode(1_002_040_006, "该时段名额已满");
+    ErrorCode APPOINTMENT_TIME_NOT_ALLOWED = new ErrorCode(1_002_040_007, "预约时间段不在可预约范围内");
 }

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

@@ -0,0 +1,41 @@
+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.BedSwapRecordPageReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.BedSwapRecordRespVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.BedSwapRecordSaveReqVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.biz.BedSwapRecordDO;
+import cn.iocoder.yudao.module.system.service.biz.BedSwapRecordService;
+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.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import javax.validation.Valid;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+
+@Tag(name = "管理后台 - 床位对调记录")
+@RestController
+@RequestMapping("/elderly/bedSwapRecord")
+@Validated
+public class BedSwapRecordController {
+
+    @Resource
+    private BedSwapRecordService bedSwapRecordService;
+
+    @GetMapping("/page")
+    @Operation(summary = "获得床位对调记录分页")
+    @PreAuthorize("@ss.hasPermission('bed-swap:query')")
+    @TenantIgnore
+    public CommonResult<PageResult<BedSwapRecordRespVO>> getBedSwapRecordPage(@Valid BedSwapRecordPageReqVO pageReqVO) {
+        PageResult<BedSwapRecordDO> pageResult = bedSwapRecordService.getBedSwapRecordPage(pageReqVO);
+        return success(BeanUtils.toBean(pageResult, BedSwapRecordRespVO.class));
+    }
+}

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

@@ -175,6 +175,14 @@ public class ExpenseOrderController {
         return success(expenseOrderService.getExpenseOrderPageByElderId(pageReqVO));
     }
 
+    @GetMapping("/getUnpaidExpenseOrderPage")
+    @Operation(summary = "查询账单起始月(含)起、指定机构下未缴费账单的长者名称与账单月,并同时返回该机构下退住未结算账单(分页)")
+    @TenantIgnore
+    public CommonResult<ExpenseOrderUnpaidPageRespVO> getUnpaidExpenseOrderPage(@Valid ExpenseOrderUnpaidPageReqVO pageReqVO) {
+        pageReqVO.setTenantId(Objects.isNull(pageReqVO.getTenantId()) ? TenantContextHolder.getTenantId() : pageReqVO.getTenantId());
+        return success(expenseOrderService.getUnpaidExpenseOrderPage(pageReqVO));
+    }
+
     @GetMapping("/getExpenseOrderAmountByElderId")
     @Operation(summary = "根据长者ID查询账单缴费总额和欠费总额")
     public CommonResult<Map<String, Object>> getExpenseOrderAmountByElderId(@RequestParam("elderId") Long elderId) {

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

@@ -0,0 +1,35 @@
+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;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.time.LocalDate;
+
+import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY;
+
+@Schema(description = "管理后台 - 床位对调记录分页 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class BedSwapRecordPageReqVO extends PageParam {
+
+    @Schema(description = "租户ID列表(用于跨租户过滤)", example = "[1, 2]")
+    private Long[] tenantIds;
+
+    @Schema(description = "对调生效日期范围")
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY)
+    private LocalDate[] swapDate;
+
+    @Schema(description = "长者A姓名(模糊匹配)", example = "张三")
+    private String elderNameA;
+
+    @Schema(description = "长者B姓名(模糊匹配)", example = "李四")
+    private String elderNameB;
+
+    @Schema(description = "是否涉及费用变更(0-否, 1-是)", example = "1")
+    private Integer hasExpenseChange;
+}

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

@@ -0,0 +1,72 @@
+package cn.iocoder.yudao.module.system.controller.admin.biz.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Schema(description = "管理后台 - 床位对调记录 Response VO")
+@Data
+public class BedSwapRecordRespVO {
+
+    @Schema(description = "主键ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+    private Long id;
+
+    @Schema(description = "对调生效日期", example = "2024-10-01")
+    private LocalDate swapDate;
+
+    @Schema(description = "备注说明", example = "协商一致对调")
+    private String remark;
+
+    @Schema(description = "长者A ID", example = "1001")
+    private Long elderIdA;
+
+    @Schema(description = "长者A 姓名", example = "张三")
+    private String elderNameA;
+
+    @Schema(description = "长者A 原床位ID", example = "2001")
+    private Long originalBedIdA;
+
+    @Schema(description = "长者A 原床位名称", example = "101室-1床")
+    private String originalBedNameA;
+
+    @Schema(description = "长者A 新床位ID", example = "2002")
+    private Long newBedIdA;
+
+    @Schema(description = "长者A 新床位名称", example = "102室-2床")
+    private String newBedNameA;
+
+    @Schema(description = "长者A 是否包房(0-否, 1-是)", example = "0")
+    private Integer isPrivateRoomA;
+
+    @Schema(description = "长者B ID", example = "1002")
+    private Long elderIdB;
+
+    @Schema(description = "长者B 姓名", example = "李四")
+    private String elderNameB;
+
+    @Schema(description = "长者B 原床位ID", example = "2002")
+    private Long originalBedIdB;
+
+    @Schema(description = "长者B 原床位名称", example = "102室-2床")
+    private String originalBedNameB;
+
+    @Schema(description = "长者B 新床位ID", example = "2001")
+    private Long newBedIdB;
+
+    @Schema(description = "长者B 新床位名称", example = "101室-1床")
+    private String newBedNameB;
+
+    @Schema(description = "长者B 是否包房(0-否, 1-是)", example = "0")
+    private Integer isPrivateRoomB;
+
+    @Schema(description = "是否涉及费用变更(0-否, 1-是)", example = "1")
+    private Integer hasExpenseChange;
+
+    @Schema(description = "租户ID", example = "1")
+    private Long tenantId;
+
+    @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
+    private LocalDateTime createTime;
+}

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

@@ -0,0 +1,80 @@
+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;
+import java.time.LocalDate;
+
+@Schema(description = "管理后台 - 床位对调记录新增/修改 Request VO")
+@Data
+public class BedSwapRecordSaveReqVO {
+
+    @Schema(description = "主键ID(更新时必填)", example = "1")
+    private Long id;
+
+    @Schema(description = "对调生效日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2024-10-01")
+    @NotNull(message = "对调生效日期不能为空")
+    private LocalDate swapDate;
+
+    @Schema(description = "备注说明", example = "协商一致对调")
+    private String remark;
+
+    @Schema(description = "长者A ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001")
+    @NotNull(message = "长者A ID不能为空")
+    private Long elderIdA;
+
+    @Schema(description = "长者A 姓名", requiredMode = Schema.RequiredMode.REQUIRED, example = "张三")
+    @NotEmpty(message = "长者A 姓名不能为空")
+    private String elderNameA;
+
+    @Schema(description = "长者A 原床位ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "2001")
+    @NotNull(message = "长者A 原床位ID不能为空")
+    private Long originalBedIdA;
+
+    @Schema(description = "长者A 原床位名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "101室-1床")
+    @NotEmpty(message = "长者A 原床位名称不能为空")
+    private String originalBedNameA;
+
+    @Schema(description = "长者A 新床位ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "2002")
+    @NotNull(message = "长者A 新床位ID不能为空")
+    private Long newBedIdA;
+
+    @Schema(description = "长者A 新床位名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "102室-2床")
+    @NotEmpty(message = "长者A 新床位名称不能为空")
+    private String newBedNameA;
+
+    @Schema(description = "长者A 是否包房(0-否, 1-是)", example = "0")
+    private Integer isPrivateRoomA;
+
+    @Schema(description = "长者B ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1002")
+    @NotNull(message = "长者B ID不能为空")
+    private Long elderIdB;
+
+    @Schema(description = "长者B 姓名", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四")
+    @NotEmpty(message = "长者B 姓名不能为空")
+    private String elderNameB;
+
+    @Schema(description = "长者B 原床位ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "2002")
+    @NotNull(message = "长者B 原床位ID不能为空")
+    private Long originalBedIdB;
+
+    @Schema(description = "长者B 原床位名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "102室-2床")
+    @NotEmpty(message = "长者B 原床位名称不能为空")
+    private String originalBedNameB;
+
+    @Schema(description = "长者B 新床位ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "2001")
+    @NotNull(message = "长者B 新床位ID不能为空")
+    private Long newBedIdB;
+
+    @Schema(description = "长者B 新床位名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "101室-1床")
+    @NotEmpty(message = "长者B 新床位名称不能为空")
+    private String newBedNameB;
+
+    @Schema(description = "长者B 是否包房(0-否, 1-是)", example = "0")
+    private Integer isPrivateRoomB;
+
+    @Schema(description = "是否涉及费用变更(0-否, 1-是)", example = "1")
+    private Integer hasExpenseChange;
+}

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

@@ -0,0 +1,19 @@
+package cn.iocoder.yudao.module.system.controller.admin.biz.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.*;
+import cn.iocoder.yudao.framework.common.pojo.PageParam;
+
+@Schema(description = "管理后台 - 未缴费账单(按账单起始月起)分页 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class ExpenseOrderUnpaidPageReqVO extends PageParam {
+
+    @Schema(description = "账单起始月(含),格式 yyyy-MM", example = "2024-01")
+    private String billStartMonth;
+
+    @Schema(description = "机构id(租户id)", example = "1")
+    private Long tenantId;
+
+}

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

@@ -0,0 +1,23 @@
+package cn.iocoder.yudao.module.system.controller.admin.biz.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.*;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.module.system.dal.dataobject.biz.ExpenseOrderDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.biz.RefundSettlementOrderDO;
+
+@Schema(description = "管理后台 - 未缴费账单&退住未结算账单 分页 Response VO")
+@Data
+@ToString
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ExpenseOrderUnpaidPageRespVO {
+
+    @Schema(description = "未缴费账单(账单起始月起,含当月),含长者名称与账单月")
+    private PageResult<ExpenseOrderDO> unpaidExpenseOrders;
+
+    @Schema(description = "退住未结算账单(仅按机构id),含长者名称")
+    private PageResult<RefundSettlementOrderDO> unsettledRefundSettlements;
+
+}

+ 2 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/pay/WeChatPayController.java

@@ -209,6 +209,7 @@ public class WeChatPayController {
         }
         StringBuilder description = new StringBuilder();
         wechatMiniOrderDO.setStatus(0);
+        wechatMiniOrderDO.setCreateTime(LocalDateTime.now());
         wechatMiniOrderMapper.insert(wechatMiniOrderDO);
         List<WechatMiniOrderItemDO> items = wechatMiniOrderDO.getItems();
         for (WechatMiniOrderItemDO item : items) {
@@ -234,6 +235,7 @@ public class WeChatPayController {
         try {
             if(tradeState.equals(Transaction.TradeStateEnum.SUCCESS)){
                 wechatMiniOrderMapper.update(new LambdaUpdateWrapper<WechatMiniOrderDO>()
+                        .eq(WechatMiniOrderDO::getUpdateTime,LocalDateTime.now())
                         .eq(WechatMiniOrderDO::getOutTradeNo,outTradeNo)
                         .set(WechatMiniOrderDO::getStatus,1));
             }

+ 53 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/app/appointment/AppAppointmentQuotaController.java

@@ -0,0 +1,53 @@
+package cn.iocoder.yudao.module.system.controller.app.appointment;
+
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import javax.annotation.security.PermitAll;
+import cn.iocoder.yudao.module.system.controller.app.appointment.vo.AppointmentQuotaOccupyReqVO;
+import cn.iocoder.yudao.module.system.controller.app.appointment.vo.AppointmentQuotaOccupyRespVO;
+import cn.iocoder.yudao.module.system.service.appointment.AppAppointmentQuotaService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import javax.validation.Valid;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+
+/**
+ * 用户 App - 预约名额预占
+ *
+ * <p>前端在真正调用预约接口前,先调用 {@code /occupy} 校验名额并占用,避免并发超卖;
+ * 校验通过后前端再调用实际预约接口完成入库。</p>
+ *
+ * @author system
+ */
+@Tag(name = "用户 App - 预约名额预占")
+@RestController
+@RequestMapping("/system/appointment-quota")
+@Validated
+public class AppAppointmentQuotaController {
+
+    @Resource
+    private AppAppointmentQuotaService appointmentQuotaService;
+
+    @PostMapping("/occupy")
+    @Operation(summary = "校验并预占预约名额(调用实际预约接口前先调用)")
+    @PermitAll
+    public CommonResult<AppointmentQuotaOccupyRespVO> occupy(@Valid @RequestBody AppointmentQuotaOccupyReqVO reqVO) {
+        return success(appointmentQuotaService.occupy(reqVO));
+    }
+
+    @PostMapping("/release")
+    @Operation(summary = "释放预约名额预占")
+    @PermitAll
+    public CommonResult<Boolean> release(@Valid @RequestBody AppointmentQuotaOccupyReqVO reqVO) {
+        appointmentQuotaService.release(reqVO);
+        return success(true);
+    }
+
+}

+ 44 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/app/appointment/vo/AppointmentQuotaOccupyReqVO.java

@@ -0,0 +1,44 @@
+package cn.iocoder.yudao.module.system.controller.app.appointment.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+import java.time.LocalDate;
+
+/**
+ * 用户 App - 预约名额预占 Request VO
+ *
+ * 前端在真正调用预约接口前,先调用"预占"接口校验名额并占用,避免并发超卖。
+ *
+ * @author system
+ */
+@Schema(description = "用户 App - 预约名额预占 Request VO")
+@Data
+public class AppointmentQuotaOccupyReqVO {
+
+    @Schema(description = "养老机构 id(即预约配置中的 tenantId)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
+    @NotNull(message = "养老机构 id 不能为空")
+    private Long orgId;
+
+    @Schema(description = "预约日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-07-16")
+    @NotNull(message = "预约日期不能为空")
+    private LocalDate visitDate;
+
+    @Schema(description = "预约开始时间(HH:mm)", requiredMode = Schema.RequiredMode.REQUIRED, example = "09:00")
+    @NotEmpty(message = "预约开始时间不能为空")
+    private String visitBeginTime;
+
+    @Schema(description = "预约结束时间(HH:mm)", requiredMode = Schema.RequiredMode.REQUIRED, example = "10:00")
+    @NotEmpty(message = "预约结束时间不能为空")
+    private String visitEndTime;
+
+    @Schema(description = "访客微信 openId(用于单人名额限制)", requiredMode = Schema.RequiredMode.REQUIRED, example = "oUpF8uxxxx")
+    @NotEmpty(message = "访客 openId 不能为空")
+    private String visitorOpenId;
+
+    @Schema(description = "随行人数", example = "0")
+    private Integer accompanyNum;
+
+}

+ 34 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/app/appointment/vo/AppointmentQuotaOccupyRespVO.java

@@ -0,0 +1,34 @@
+package cn.iocoder.yudao.module.system.controller.app.appointment.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 用户 App - 预约名额预占 Response VO
+ *
+ * 前端先调用"预占"接口校验名额并占用,成功后再调用实际预约接口。
+ *
+ * @author system
+ */
+@Schema(description = "用户 App - 预约名额预占 Response VO")
+@Data
+public class AppointmentQuotaOccupyRespVO {
+
+    @Schema(description = "是否预占成功", requiredMode = Schema.RequiredMode.REQUIRED, example = "true")
+    private Boolean success;
+
+    @Schema(description = "匹配到的预约组 id(命中的日期类型 + 时间段配置),可随实际预约接口回写落库关联", example = "1")
+    private Long matchedGroupId;
+
+    @Schema(description = "提示信息", example = "ok")
+    private String message;
+
+    public static AppointmentQuotaOccupyRespVO success(Long matchedGroupId) {
+        AppointmentQuotaOccupyRespVO vo = new AppointmentQuotaOccupyRespVO();
+        vo.setSuccess(Boolean.TRUE);
+        vo.setMatchedGroupId(matchedGroupId);
+        vo.setMessage("预占成功。实际预约落库后请立即调用 /release 释放总名额/单人名额的预占计数,避免 DB+Redis 双算。");
+        return vo;
+    }
+
+}

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

@@ -40,6 +40,21 @@ public class AppointmentGroupDO extends BaseNoDeleteDO {
      */
     private String customDates;
 
+    /**
+     * 限制人数(null 表示不限制)
+     */
+    private Integer limitCount;
+
+    /**
+     * 可预约开始时间(HH:mm),null 表示不限
+     */
+    private String beginTime;
+
+    /**
+     * 可预约结束时间(HH:mm),null 表示不限
+     */
+    private String endTime;
+
     /**
      * 机构id
      */

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

@@ -0,0 +1,120 @@
+package cn.iocoder.yudao.module.system.dal.dataobject.biz;
+
+import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseNoDeleteDO;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.*;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+/**
+ * 床位对调记录 DO
+ *
+ * @author 系统管理员
+ */
+@TableName("elderly_bed_swap_record")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class BedSwapRecordDO extends BaseNoDeleteDO {
+
+    /**
+     * 主键ID
+     */
+    @TableId
+    private Long id;
+
+    /**
+     * 对调生效日期
+     */
+    private LocalDate swapDate;
+
+    /**
+     * 备注说明
+     */
+    private String remark;
+
+    /**
+     * 长者A ID
+     */
+    private Long elderIdA;
+
+    /**
+     * 长者A 姓名
+     */
+    private String elderNameA;
+
+    /**
+     * 长者A 原床位ID
+     */
+    private Long originalBedIdA;
+
+    /**
+     * 长者A 原床位名称
+     */
+    private String originalBedNameA;
+
+    /**
+     * 长者A 新床位ID(即长者B原床位)
+     */
+    private Long newBedIdA;
+
+    /**
+     * 长者A 新床位名称(即长者B原床位)
+     */
+    private String newBedNameA;
+
+    /**
+     * 长者A 是否包房(0-否, 1-是)
+     */
+    private Integer isPrivateRoomA;
+
+    /**
+     * 长者B ID
+     */
+    private Long elderIdB;
+
+    /**
+     * 长者B 姓名
+     */
+    private String elderNameB;
+
+    /**
+     * 长者B 原床位ID
+     */
+    private Long originalBedIdB;
+
+    /**
+     * 长者B 原床位名称
+     */
+    private String originalBedNameB;
+
+    /**
+     * 长者B 新床位ID(即长者A原床位)
+     */
+    private Long newBedIdB;
+
+    /**
+     * 长者B 新床位名称(即长者A原床位)
+     */
+    private String newBedNameB;
+
+    /**
+     * 长者B 是否包房(0-否, 1-是)
+     */
+    private Integer isPrivateRoomB;
+
+    /**
+     * 是否涉及费用变更(0-否, 1-是)
+     */
+    private Integer hasExpenseChange;
+
+    /**
+     * 租户ID
+     */
+    private Long tenantId;
+}

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

@@ -0,0 +1,15 @@
+package cn.iocoder.yudao.module.system.dal.mysql.biz;
+
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.module.system.dal.dataobject.biz.BedSwapRecordDO;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 床位对调记录 Mapper
+ *
+ * @author 系统管理员
+ */
+@Mapper
+public interface BedSwapRecordMapper extends BaseMapperX<BedSwapRecordDO> {
+
+}

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

@@ -7,6 +7,7 @@ import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
 import cn.iocoder.yudao.module.system.api.order.DTO.OrderItemRespDTO;
 import cn.iocoder.yudao.module.system.api.order.VO.OrderItemRespVO;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.ExpenseBillPageReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.ExpenseOrderUnpaidPageReqVO;
 import cn.iocoder.yudao.module.system.dal.dataobject.biz.ExpenseOrderDO;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import org.apache.ibatis.annotations.Mapper;
@@ -26,6 +27,8 @@ public interface ExpenseOrderMapper extends BaseMapperX<ExpenseOrderDO> {
 
     List<ExpenseOrderDO> getExpenseBillPage(Page<ExpenseOrderDO> page, @Param("params") ExpenseBillPageReqVO pageReqVO);
 
+    List<ExpenseOrderDO> getUnpaidExpenseOrderPage(Page<ExpenseOrderDO> page, @Param("params") ExpenseOrderUnpaidPageReqVO reqVO);
+
     default PageResult<ExpenseOrderDO> selectPage(ExpenseBillPageReqVO reqVO) {
         return selectPage(reqVO, new LambdaQueryWrapperX<ExpenseOrderDO>()
                 .eqIfPresent(ExpenseOrderDO::getElderId, reqVO.getElderId())

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

@@ -20,6 +20,8 @@ import java.util.List;
 public interface RefundSettlementOrderMapper extends BaseMapperX<RefundSettlementOrderDO> {
     List<RefundSettlementOrderDO> getRefundSettlementOrderPage(Page<RefundSettlementOrderDO> page,@Param("params") RefundSettlementOrderPageReqVO pageReqVO);
 
+    List<RefundSettlementOrderDO> getUnsettledRefundSettlementPage(Page<RefundSettlementOrderDO> page, @Param("tenantId") Long tenantId);
+
 //    default PageResult<RefundSettlementOrderDO> selectPage(RefundSettlementOrderPageReqVO reqVO) {
 //        return selectPage(reqVO, new LambdaQueryWrapperX<RefundSettlementOrderDO>()
 //                .eqIfPresent(RefundSettlementOrderDO::getElderId, reqVO.getElderId())

+ 35 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/miniapp/JiashuVisitAppointmentMapper.java

@@ -20,7 +20,41 @@ public interface JiashuVisitAppointmentMapper extends BaseMapperX<JiashuVisitApp
     default Long countByVisitDateRange(LocalDate startDate, LocalDate endDate) {
         return selectCount(new LambdaQueryWrapperX<JiashuVisitAppointmentDO>()
                 .between(JiashuVisitAppointmentDO::getVisitDate, startDate, endDate)
-                .ne(JiashuVisitAppointmentDO::getStatus, 4));
+                .in(JiashuVisitAppointmentDO::getStatus, 1,3));
+    }
+
+    /**
+     * 统计某养老机构在指定日期范围内的预约数量(status in 1,3:已预约/已完成)
+     * <p>
+     * 注意:visit_appointment 位于 yanglao-jiashu 数据源,调用方需自行切换 @DS("miniapp")
+     *
+     * @param orgId      养老机构 id(对应配置的 tenantId)
+     * @param startDate  开始日期(含)
+     * @param endDate    结束日期(含)
+     * @return 预约数量
+     */
+    default Long countByOrgAndDateRange(Long orgId, LocalDate startDate, LocalDate endDate) {
+        return selectCount(new LambdaQueryWrapperX<JiashuVisitAppointmentDO>()
+                .eq(JiashuVisitAppointmentDO::getOrgId, orgId)
+                .between(JiashuVisitAppointmentDO::getVisitDate, startDate, endDate)
+                .in(JiashuVisitAppointmentDO::getStatus, 1, 3));
+    }
+
+    /**
+     * 统计指定访客在指定日期范围内的预约数量(status in 1,3)
+     * <p>
+     * 注意:visit_appointment 位于 yanglao-jiashu 数据源,调用方需自行切换 @DS("miniapp")
+     *
+     * @param visitorOpenId 访客微信 openId
+     * @param startDate     开始日期(含)
+     * @param endDate       结束日期(含)
+     * @return 预约数量
+     */
+    default Long countByOpenIdAndDateRange(String visitorOpenId, LocalDate startDate, LocalDate endDate) {
+        return selectCount(new LambdaQueryWrapperX<JiashuVisitAppointmentDO>()
+                .eq(JiashuVisitAppointmentDO::getVisitorOpenId, visitorOpenId)
+                .between(JiashuVisitAppointmentDO::getVisitDate, startDate, endDate)
+                .in(JiashuVisitAppointmentDO::getStatus, 1, 3));
     }
 
 }

+ 37 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/redis/RedisKeyConstants.java

@@ -110,4 +110,41 @@ public interface RedisKeyConstants {
 
     String SOCIAL_CLIENT_REDIS_KEY = "socialClient:%s:%s";
 
+    // ========== 预约名额预占计数 ==========
+    /**
+     * 预约总名额-每日预占计数
+     * <p>KEY 格式:appointment:quota:total:daily:{tenantId}:{yyyyMMdd};VALUE:String 预占数量;TTL 到次日 0 点</p>
+     */
+    String APPOINTMENT_QUOTA_TOTAL_DAILY = "appointment:quota:total:daily:%d:%s";
+    /**
+     * 预约总名额-每周预占计数
+     * <p>KEY 格式:appointment:quota:total:weekly:{tenantId}:{yyyyww};VALUE:String 预占数量;TTL 到下周一 0 点</p>
+     */
+    String APPOINTMENT_QUOTA_TOTAL_WEEKLY = "appointment:quota:total:weekly:%d:%s";
+    /**
+     * 预约总名额-每月预占计数
+     * <p>KEY 格式:appointment:quota:total:monthly:{tenantId}:{yyyyMM};VALUE:String 预占数量;TTL 到下月 1 号 0 点</p>
+     */
+    String APPOINTMENT_QUOTA_TOTAL_MONTHLY = "appointment:quota:total:monthly:%d:%s";
+    /**
+     * 单人名额-每日预占计数
+     * <p>KEY 格式:appointment:quota:person:daily:{tenantId}:{openId}:{yyyyMMdd};TTL 到次日 0 点</p>
+     */
+    String APPOINTMENT_QUOTA_PERSON_DAILY = "appointment:quota:person:daily:%d:%s:%s";
+    /**
+     * 单人名额-每周预占计数
+     * <p>KEY 格式:appointment:quota:person:weekly:{tenantId}:{openId}:{yyyyww};TTL 到下周一 0 点</p>
+     */
+    String APPOINTMENT_QUOTA_PERSON_WEEKLY = "appointment:quota:person:weekly:%d:%s:%s";
+    /**
+     * 单人名额-每月预占计数
+     * <p>KEY 格式:appointment:quota:person:monthly:{tenantId}:{openId}:{yyyyMM};TTL 到下月 1 号 0 点</p>
+     */
+    String APPOINTMENT_QUOTA_PERSON_MONTHLY = "appointment:quota:person:monthly:%d:%s:%s";
+    /**
+     * 预约组-每日预占计数(每组每天人数上限 limitCount)
+     * <p>KEY 格式:appointment:quota:group:{groupId}:{yyyyMMdd};TTL 到次日 0 点</p>
+     */
+    String APPOINTMENT_QUOTA_GROUP_DAILY = "appointment:quota:group:%d:%s";
+
 }

+ 142 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/redis/appointment/AppointmentQuotaRedisDAO.java

@@ -0,0 +1,142 @@
+package cn.iocoder.yudao.module.system.dal.redis.appointment;
+
+import cn.iocoder.yudao.module.system.dal.redis.RedisKeyConstants;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Repository;
+
+import javax.annotation.Resource;
+import java.time.DayOfWeek;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoUnit;
+import java.time.temporal.WeekFields;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 预约名额预占 RedisDAO
+ *
+ * <p>预占计数仅记录「已占用但尚未正式预约」的数量,用于并发场景下防止超卖;
+ * 计数按周期自动过期(daily 次日 0 点 / weekly 下周一 0 点 / monthly 下月 1 号 0 点),
+ * 到期后自动释放,避免幽灵占用长期堆积(符合「按天过期」的诉求)。</p>
+ *
+ * <p>已用名额 = visit_appointment 中正式预约数量(DB) + Redis 预占计数。预占只增(占用时 +1),
+ * 实际预约入库后由到期自动释放,故当天会略微偏严(宁可少接、绝不超卖)。</p>
+ *
+ * @author system
+ */
+@Repository
+public class AppointmentQuotaRedisDAO {
+
+    @Resource
+    private StringRedisTemplate stringRedisTemplate;
+
+    // ========== key 格式化 ==========
+
+    public String formatTotalDailyKey(Long tenantId, LocalDate date) {
+        return String.format(RedisKeyConstants.APPOINTMENT_QUOTA_TOTAL_DAILY, tenantId, fmtDate(date));
+    }
+
+    public String formatTotalWeeklyKey(Long tenantId, LocalDate date) {
+        return String.format(RedisKeyConstants.APPOINTMENT_QUOTA_TOTAL_WEEKLY, tenantId, fmtWeek(date));
+    }
+
+    public String formatTotalMonthlyKey(Long tenantId, LocalDate date) {
+        return String.format(RedisKeyConstants.APPOINTMENT_QUOTA_TOTAL_MONTHLY, tenantId, fmtMonth(date));
+    }
+
+    public String formatPersonDailyKey(Long tenantId, String openId, LocalDate date) {
+        return String.format(RedisKeyConstants.APPOINTMENT_QUOTA_PERSON_DAILY, tenantId, openId, fmtDate(date));
+    }
+
+    public String formatPersonWeeklyKey(Long tenantId, String openId, LocalDate date) {
+        return String.format(RedisKeyConstants.APPOINTMENT_QUOTA_PERSON_WEEKLY, tenantId, openId, fmtWeek(date));
+    }
+
+    public String formatPersonMonthlyKey(Long tenantId, String openId, LocalDate date) {
+        return String.format(RedisKeyConstants.APPOINTMENT_QUOTA_PERSON_MONTHLY, tenantId, openId, fmtMonth(date));
+    }
+
+    public String formatGroupDailyKey(Long groupId, LocalDate date) {
+        return String.format(RedisKeyConstants.APPOINTMENT_QUOTA_GROUP_DAILY, groupId, fmtDate(date));
+    }
+
+    // ========== 预占 +1(带周期 TTL) ==========
+
+    public long incrTotalDaily(Long tenantId, LocalDate date, int delta) {
+        return incr(formatTotalDailyKey(tenantId, date), date.plusDays(1), delta);
+    }
+
+    public long incrTotalWeekly(Long tenantId, LocalDate date, int delta) {
+        return incr(formatTotalWeeklyKey(tenantId, date), date.with(DayOfWeek.SUNDAY).plusDays(1), delta);
+    }
+
+    public long incrTotalMonthly(Long tenantId, LocalDate date, int delta) {
+        return incr(formatTotalMonthlyKey(tenantId, date), date.withDayOfMonth(date.lengthOfMonth()).plusDays(1), delta);
+    }
+
+    public long incrPersonDaily(Long tenantId, String openId, LocalDate date, int delta) {
+        return incr(formatPersonDailyKey(tenantId, openId, date), date.plusDays(1), delta);
+    }
+
+    public long incrPersonWeekly(Long tenantId, String openId, LocalDate date, int delta) {
+        return incr(formatPersonWeeklyKey(tenantId, openId, date), date.with(DayOfWeek.SUNDAY).plusDays(1), delta);
+    }
+
+    public long incrPersonMonthly(Long tenantId, String openId, LocalDate date, int delta) {
+        return incr(formatPersonMonthlyKey(tenantId, openId, date), date.withDayOfMonth(date.lengthOfMonth()).plusDays(1), delta);
+    }
+
+    public long incrGroupDaily(Long groupId, LocalDate date, int delta) {
+        return incr(formatGroupDailyKey(groupId, date), date.plusDays(1), delta);
+    }
+
+    // ========== 释放 -1 / 查询 / 删除 ==========
+
+    public long decr(String key, int delta) {
+        Long v = stringRedisTemplate.opsForValue().decrement(key, delta);
+        return v == null ? 0 : v;
+    }
+
+    public long getCount(String key) {
+        String v = stringRedisTemplate.opsForValue().get(key);
+        return v == null ? 0L : Long.parseLong(v);
+    }
+
+    public void delete(String key) {
+        stringRedisTemplate.delete(key);
+    }
+
+    // ========== 内部工具 ==========
+
+    private long incr(String key, LocalDate expireExclusive, int delta) {
+        Long v = stringRedisTemplate.opsForValue().increment(key, delta);
+        applyExpire(key, expireExclusive);
+        return v == null ? 0 : v;
+    }
+
+    private void applyExpire(String key, LocalDate expireExclusive) {
+        // 过期时间统一为周期末的次日 0 点(如 daily:次日 0 点;weekly:下周一 0 点)
+        LocalDateTime expireAt = expireExclusive.atStartOfDay();
+        long ttl = ChronoUnit.SECONDS.between(LocalDateTime.now(), expireAt);
+        if (ttl > 0) {
+            stringRedisTemplate.expire(key, ttl, TimeUnit.SECONDS);
+        }
+    }
+
+    private static String fmtDate(LocalDate d) {
+        return d.format(DateTimeFormatter.BASIC_ISO_DATE); // yyyyMMdd
+    }
+
+    private static String fmtWeek(LocalDate d) {
+        WeekFields wf = WeekFields.ISO;
+        int week = d.get(wf.weekOfWeekBasedYear());
+        int year = d.get(wf.weekBasedYear());
+        return String.format("%d%02d", year, week); // yyyyww
+    }
+
+    private static String fmtMonth(LocalDate d) {
+        return String.format("%d%02d", d.getYear(), d.getMonthValue()); // yyyyMM
+    }
+
+}

+ 32 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/appointment/AppAppointmentQuotaService.java

@@ -0,0 +1,32 @@
+package cn.iocoder.yudao.module.system.service.appointment;
+
+import cn.iocoder.yudao.module.system.controller.app.appointment.vo.AppointmentQuotaOccupyReqVO;
+import cn.iocoder.yudao.module.system.controller.app.appointment.vo.AppointmentQuotaOccupyRespVO;
+
+/**
+ * 预约名额预占 Service
+ *
+ * <p>前端调用顺序:occupy(预占) → 实际预约接口(落库) → release(释放总名额/单人名额预占)</p>
+ *
+ * @author system
+ */
+public interface AppAppointmentQuotaService {
+
+    /**
+     * 校验预约名额并预占:结合预约配置与预约组进行校验,全部通过后各维度 Redis +1(预占),返回命中的预约组
+     *
+     * @param reqVO 预占请求(机构、预约日期、开始/结束时间、访客 openId)
+     * @return 预占结果(含命中的预约组 id),成功方可继续调实际预约接口
+     */
+    AppointmentQuotaOccupyRespVO occupy(AppointmentQuotaOccupyReqVO reqVO);
+
+    /**
+     * 释放总名额/单人名额的预占计数(实际预约落库后立即调用)
+     *
+     * <p>注意:组维度不在此递减(visit_appointment 缺 groupId,DB 无法按组统计,Redis 即正式计数,次日过期释放)。</p>
+     *
+     * @param reqVO 与 occupy 相同的请求参数
+     */
+    void release(AppointmentQuotaOccupyReqVO reqVO);
+
+}

+ 329 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/appointment/AppAppointmentQuotaServiceImpl.java

@@ -0,0 +1,329 @@
+package cn.iocoder.yudao.module.system.service.appointment;
+
+import cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil;
+import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.AppointmentConfigRespVO;
+import cn.iocoder.yudao.module.system.controller.app.appointment.vo.AppointmentQuotaOccupyReqVO;
+import cn.iocoder.yudao.module.system.controller.app.appointment.vo.AppointmentQuotaOccupyRespVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.biz.AppointmentConfigDO;
+import cn.iocoder.yudao.module.system.dal.dataobject.biz.AppointmentGroupDO;
+import cn.iocoder.yudao.module.system.dal.mysql.miniapp.JiashuVisitAppointmentMapper;
+import cn.iocoder.yudao.module.system.dal.redis.appointment.AppointmentQuotaRedisDAO;
+import cn.iocoder.yudao.module.system.enums.ErrorCodeConstants;
+import cn.iocoder.yudao.module.system.service.biz.AppointmentConfigService;
+import com.baomidou.dynamic.datasource.toolkit.DynamicDataSourceContextHolder;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.time.DayOfWeek;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.Objects;
+
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+
+/**
+ * 预约名额预占 Service 实现
+ *
+ * <p>校验维度:① 总开关 + 时间窗(begin_book_days / advance_days)
+ * ② 预约组日期匹配(dateType) + 时间段匹配(beginTime~endTime) + 每组 limitCount
+ * ③ 总名额(total_daily/weekly/monthly) ④ 单人名额(person_daily/weekly/monthly)</p>
+ *
+ * <h4>双算处理(核心)</h4>
+ * <pre>
+ * 前端调用顺序:occupy → 实际预约接口(落库 visit_appointment) → release
+ *
+ * occupy 时 Redis 各维度 +1(预占);
+ * release 时仅总名额/单人名额 Redis -1(因 DB 可统计,DB+1 后 Redis 预占多余);
+ * 组维度不递减——visit_appointment 缺 groupId,DB 无法按组统计,Redis 即正式计数,次日过期自动释放。
+ *
+ * 结果:
+ * - 总名额/单人名额:DB 正式 + Redis 预占 = 1 + 0 = 1(正确,不双算)
+ * - 组维度:Redis 即正式计数(不会双算),次日过期自动释放名额
+ * </pre>
+ *
+ * @author system
+ */
+@Service
+public class AppAppointmentQuotaServiceImpl implements AppAppointmentQuotaService {
+
+    /**
+     * 每次预约占用 1 个名额(订单维度)。若需按实际人数(1 + 随行)占用,改此处即可。
+     */
+    private static final int OCCUPY_COUNT = 1;
+
+    @Resource
+    private AppointmentConfigService appointmentConfigService;
+    @Resource
+    private JiashuVisitAppointmentMapper jiashuVisitAppointmentMapper;
+    @Resource
+    private AppointmentQuotaRedisDAO appointmentQuotaRedisDAO;
+
+    @Override
+    public AppointmentQuotaOccupyRespVO occupy(AppointmentQuotaOccupyReqVO reqVO) {
+        Long tenantId = reqVO.getOrgId();
+        LocalDate visitDate = reqVO.getVisitDate();
+        String openId = reqVO.getVisitorOpenId();
+
+        // 1. 配置存在 & 开关
+        AppointmentConfigRespVO configResp = appointmentConfigService.getByTenant(tenantId);
+        AppointmentConfigDO config = configResp.getConfig();
+        if (config == null) {
+            throw exception(ErrorCodeConstants.APPOINTMENT_CONFIG_NOT_FOUND);
+        }
+        if (!Objects.equals(config.getEnabled(), 1)) {
+            throw exception(ErrorCodeConstants.APPOINTMENT_DISABLED);
+        }
+
+        // 2. 时间窗(最早可约=今天+beginBookDays,最晚可约=今天+advanceDays)
+        LocalDate today = LocalDate.now();
+        LocalDate earliest = today.plusDays(config.getBeginBookDays() == null ? 0 : config.getBeginBookDays());
+        LocalDate latest = today.plusDays(config.getAdvanceDays() == null ? 0 : config.getAdvanceDays());
+        if (visitDate.isBefore(earliest) || visitDate.isAfter(latest)) {
+            throw exception(ErrorCodeConstants.APPOINTMENT_DATE_NOT_IN_RANGE);
+        }
+
+        // 3. 预约组日期匹配 + 时间段匹配
+        LocalTime begin = parseTime(reqVO.getVisitBeginTime());
+        LocalTime end = parseTime(reqVO.getVisitEndTime());
+        if (!end.isAfter(begin)) {
+            throw exception(ErrorCodeConstants.APPOINTMENT_TIME_NOT_ALLOWED);
+        }
+        AppointmentGroupDO matchedGroup = matchGroup(configResp.getGroup(), visitDate, begin, end);
+        if (matchedGroup == null) {
+            // 区分「日期不可约」与「时间段不可约」
+            if (hasDateMatchedGroup(configResp.getGroup(), visitDate)) {
+                throw exception(ErrorCodeConstants.APPOINTMENT_TIME_NOT_ALLOWED);
+            }
+            throw exception(ErrorCodeConstants.APPOINTMENT_DATE_NOT_ALLOWED);
+        }
+
+        // 4. 总名额(daily / weekly / monthly)
+        checkTotalLimit(config, tenantId, visitDate);
+        // 5. 单人名额(daily / weekly / monthly)
+        checkPersonLimit(config, tenantId, openId, visitDate);
+        // 6. 组名额(每组每天 limitCount)
+        checkGroupLimit(matchedGroup, visitDate);
+
+        // 7. 校验通过,预占 +1(各维度)
+        appointmentQuotaRedisDAO.incrTotalDaily(tenantId, visitDate, OCCUPY_COUNT);
+        appointmentQuotaRedisDAO.incrTotalWeekly(tenantId, visitDate, OCCUPY_COUNT);
+        appointmentQuotaRedisDAO.incrTotalMonthly(tenantId, visitDate, OCCUPY_COUNT);
+        appointmentQuotaRedisDAO.incrPersonDaily(tenantId, openId, visitDate, OCCUPY_COUNT);
+        appointmentQuotaRedisDAO.incrPersonWeekly(tenantId, openId, visitDate, OCCUPY_COUNT);
+        appointmentQuotaRedisDAO.incrPersonMonthly(tenantId, openId, visitDate, OCCUPY_COUNT);
+        appointmentQuotaRedisDAO.incrGroupDaily(matchedGroup.getId(), visitDate, OCCUPY_COUNT);
+
+        return AppointmentQuotaOccupyRespVO.success(matchedGroup.getId());
+    }
+
+    @Override
+    public void release(AppointmentQuotaOccupyReqVO reqVO) {
+        Long tenantId = reqVO.getOrgId();
+        LocalDate visitDate = reqVO.getVisitDate();
+        String openId = reqVO.getVisitorOpenId();
+
+        /*
+         * release 调用时机:前端调 occupy 通过 → 调实际预约接口落库 → 立即调 release
+         *
+         * 维度处理差异:
+         * - 总名额 / 单人名额:DB(visit_appointment)可统计,实际预约 DB+1 后需要通过 release 把 Redis 预占 -1,
+         *   避免 DB+Redis 双算(最终计数 = DB 正式 + Redis 预占 = 1 + 0 = 1,正确)。
+         * - 组维度:visit_appointment 无 groupId 字段,DB 无法按组统计,因此 Redis 就是正式计数,
+         *   release 时不做递减,保留到次日 TTL 过期自动释放。
+         */
+
+        appointmentQuotaRedisDAO.decr(appointmentQuotaRedisDAO.formatTotalDailyKey(tenantId, visitDate), OCCUPY_COUNT);
+        appointmentQuotaRedisDAO.decr(appointmentQuotaRedisDAO.formatTotalWeeklyKey(tenantId, visitDate), OCCUPY_COUNT);
+        appointmentQuotaRedisDAO.decr(appointmentQuotaRedisDAO.formatTotalMonthlyKey(tenantId, visitDate), OCCUPY_COUNT);
+        appointmentQuotaRedisDAO.decr(appointmentQuotaRedisDAO.formatPersonDailyKey(tenantId, openId, visitDate), OCCUPY_COUNT);
+        appointmentQuotaRedisDAO.decr(appointmentQuotaRedisDAO.formatPersonWeeklyKey(tenantId, openId, visitDate), OCCUPY_COUNT);
+        appointmentQuotaRedisDAO.decr(appointmentQuotaRedisDAO.formatPersonMonthlyKey(tenantId, openId, visitDate), OCCUPY_COUNT);
+        // 注意:组维度不递减——visit_appointment 缺 groupId 无法从 DB 按组统计,Redis 即正式计数,到期自动释放
+    }
+
+    // ========== 各维度校验 ==========
+
+    private void checkTotalLimit(AppointmentConfigDO config, Long tenantId, LocalDate date) {
+        if (config.getTotalDailyLimit() != null) {
+            Long db = countByOrgInMiniapp(tenantId, date, date);
+            Long redis = appointmentQuotaRedisDAO.getCount(appointmentQuotaRedisDAO.formatTotalDailyKey(tenantId, date));
+            if (db + redis >= config.getTotalDailyLimit()) {
+                throw exception(ErrorCodeConstants.APPOINTMENT_TOTAL_LIMIT);
+            }
+        }
+        if (config.getTotalWeeklyLimit() != null) {
+            Long db = countByOrgInMiniapp(tenantId, date.with(DayOfWeek.MONDAY), date.with(DayOfWeek.SUNDAY));
+            Long redis = appointmentQuotaRedisDAO.getCount(appointmentQuotaRedisDAO.formatTotalWeeklyKey(tenantId, date));
+            if (db + redis >= config.getTotalWeeklyLimit()) {
+                throw exception(ErrorCodeConstants.APPOINTMENT_TOTAL_LIMIT);
+            }
+        }
+        if (config.getTotalMonthlyLimit() != null) {
+            Long db = countByOrgInMiniapp(tenantId, date.withDayOfMonth(1), date.withDayOfMonth(date.lengthOfMonth()));
+            Long redis = appointmentQuotaRedisDAO.getCount(appointmentQuotaRedisDAO.formatTotalMonthlyKey(tenantId, date));
+            if (db + redis >= config.getTotalMonthlyLimit()) {
+                throw exception(ErrorCodeConstants.APPOINTMENT_TOTAL_LIMIT);
+            }
+        }
+    }
+
+    private void checkPersonLimit(AppointmentConfigDO config, Long tenantId, String openId, LocalDate date) {
+        if (config.getPersonDailyLimit() != null) {
+            Long db = countByOpenIdInMiniapp(openId, date, date);
+            Long redis = appointmentQuotaRedisDAO.getCount(appointmentQuotaRedisDAO.formatPersonDailyKey(tenantId, openId, date));
+            if (db + redis >= config.getPersonDailyLimit()) {
+                throw exception(ErrorCodeConstants.APPOINTMENT_PERSON_LIMIT);
+            }
+        }
+        if (config.getPersonWeeklyLimit() != null) {
+            Long db = countByOpenIdInMiniapp(openId, date.with(DayOfWeek.MONDAY), date.with(DayOfWeek.SUNDAY));
+            Long redis = appointmentQuotaRedisDAO.getCount(appointmentQuotaRedisDAO.formatPersonWeeklyKey(tenantId, openId, date));
+            if (db + redis >= config.getPersonWeeklyLimit()) {
+                throw exception(ErrorCodeConstants.APPOINTMENT_PERSON_LIMIT);
+            }
+        }
+        if (config.getPersonMonthlyLimit() != null) {
+            Long db = countByOpenIdInMiniapp(openId, date.withDayOfMonth(1), date.withDayOfMonth(date.lengthOfMonth()));
+            Long redis = appointmentQuotaRedisDAO.getCount(appointmentQuotaRedisDAO.formatPersonMonthlyKey(tenantId, openId, date));
+            if (db + redis >= config.getPersonMonthlyLimit()) {
+                throw exception(ErrorCodeConstants.APPOINTMENT_PERSON_LIMIT);
+            }
+        }
+    }
+
+    private void checkGroupLimit(AppointmentGroupDO group, LocalDate date) {
+        if (group.getLimitCount() == null) {
+            return;
+        }
+        // 注意:visit_appointment 缺少 groupId 字段,组维度无法从 DB 统计;
+        // Redis 即正式计数(occupy 时 +1 后不再减,次日过期自动释放),因此不会双算。
+        Long redis = appointmentQuotaRedisDAO.getCount(appointmentQuotaRedisDAO.formatGroupDailyKey(group.getId(), date));
+        if (redis >= group.getLimitCount()) {
+            throw exception(ErrorCodeConstants.APPOINTMENT_GROUP_LIMIT);
+        }
+    }
+
+    // ========== 数据源切换:visit_appointment 在 yanglao-jiashu 数据源 ==========
+
+    private Long countByOrgInMiniapp(Long orgId, LocalDate start, LocalDate end) {
+        DynamicDataSourceContextHolder.push("miniapp");
+        try {
+            return jiashuVisitAppointmentMapper.countByOrgAndDateRange(orgId, start, end);
+        } finally {
+            DynamicDataSourceContextHolder.poll();
+        }
+    }
+
+    private Long countByOpenIdInMiniapp(String openId, LocalDate start, LocalDate end) {
+        DynamicDataSourceContextHolder.push("miniapp");
+        try {
+            return jiashuVisitAppointmentMapper.countByOpenIdAndDateRange(openId, start, end);
+        } finally {
+            DynamicDataSourceContextHolder.poll();
+        }
+    }
+
+    // ========== 预约组日期匹配 ==========
+
+    private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("HH:mm");
+
+    private AppointmentGroupDO matchGroup(List<AppointmentGroupDO> groups, LocalDate date, LocalTime begin, LocalTime end) {
+        if (groups == null) {
+            return null;
+        }
+        for (AppointmentGroupDO g : groups) {
+            if (matchDate(g, date) && matchTime(g, begin, end)) {
+                return g;
+            }
+        }
+        return null;
+    }
+
+    private boolean hasDateMatchedGroup(List<AppointmentGroupDO> groups, LocalDate date) {
+        if (groups == null) {
+            return false;
+        }
+        for (AppointmentGroupDO g : groups) {
+            if (matchDate(g, date)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean matchTime(AppointmentGroupDO g, LocalTime begin, LocalTime end) {
+        if (g.getBeginTime() == null || g.getEndTime() == null) {
+            return true; // 组未配置时间段,不做时间段限制
+        }
+        LocalTime gBegin = LocalTime.parse(g.getBeginTime(), TIME_FMT);
+        LocalTime gEnd = LocalTime.parse(g.getEndTime(), TIME_FMT);
+        // 请求时间段须完全落在组时间段内(子集)
+        return !begin.isBefore(gBegin) && !end.isAfter(gEnd);
+    }
+
+    private LocalTime parseTime(String time) {
+        try {
+            return LocalTime.parse(time, TIME_FMT);
+        } catch (Exception e) {
+            throw exception(ErrorCodeConstants.APPOINTMENT_TIME_NOT_ALLOWED);
+        }
+    }
+
+    private boolean matchDate(AppointmentGroupDO g, LocalDate date) {
+        String type = g.getDateType();
+        if (type == null) {
+            return false;
+        }
+        switch (type) {
+            case "DAILY":
+                return true;
+            case "WEEKLY":
+                return matchInCommaList(g.getWeekDays(), date.getDayOfWeek().getValue());
+            case "MONTHLY":
+                return matchInCommaList(g.getMonthDays(), date.getDayOfMonth(), date.lengthOfMonth());
+            case "CUSTOM":
+                return matchCustomDates(g.getCustomDates(), date);
+            default:
+                return false;
+        }
+    }
+
+    private boolean matchInCommaList(String csv, int target) {
+        return matchInCommaList(csv, target, -1);
+    }
+
+    private boolean matchInCommaList(String csv, int target, int monthLength) {
+        if (csv == null || csv.trim().isEmpty()) {
+            return false;
+        }
+        for (String s : csv.split(",")) {
+            Integer v = Integer.valueOf(s.trim());
+            if (v > 0) {
+                if (v == target) {
+                    return true;
+                }
+            } else if (monthLength > 0) { // 负数表示倒数日
+                if (monthLength + 1 + v == target) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private boolean matchCustomDates(String customDates, LocalDate date) {
+        if (customDates == null || customDates.trim().isEmpty()) {
+            return false;
+        }
+        List<String> dates = JsonUtils.parseArray(customDates, String.class);
+        if (dates == null) {
+            return false;
+        }
+        String ds = date.format(DateTimeFormatter.ISO_LOCAL_DATE);
+        return dates.contains(ds);
+    }
+
+}

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

@@ -0,0 +1,24 @@
+package cn.iocoder.yudao.module.system.service.biz;
+
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.BedSwapRecordPageReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.BedSwapRecordSaveReqVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.biz.BedSwapRecordDO;
+
+import javax.validation.Valid;
+
+/**
+ * 床位对调记录 Service 接口
+ *
+ * @author 系统管理员
+ */
+public interface BedSwapRecordService {
+
+    /**
+     * 获得床位对调记录分页
+     *
+     * @param pageReqVO 分页查询
+     * @return 床位对调记录分页
+     */
+    PageResult<BedSwapRecordDO> getBedSwapRecordPage(BedSwapRecordPageReqVO pageReqVO);
+}

+ 47 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/BedSwapRecordServiceImpl.java

@@ -0,0 +1,47 @@
+package cn.iocoder.yudao.module.system.service.biz;
+
+import cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
+import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.BedSwapRecordPageReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.BedSwapRecordSaveReqVO;
+import cn.iocoder.yudao.module.system.dal.dataobject.biz.BedSwapRecordDO;
+import cn.iocoder.yudao.module.system.dal.mysql.biz.BedSwapRecordMapper;
+import cn.iocoder.yudao.module.system.enums.ErrorCodeConstants;
+import org.springframework.stereotype.Service;
+import org.springframework.validation.annotation.Validated;
+
+import javax.annotation.Resource;
+
+/**
+ * 床位对调记录 Service 实现类
+ *
+ * @author 系统管理员
+ */
+@Service
+@Validated
+public class BedSwapRecordServiceImpl implements BedSwapRecordService {
+
+    @Resource
+    private BedSwapRecordMapper bedSwapRecordMapper;
+
+    @Override
+    public PageResult<BedSwapRecordDO> getBedSwapRecordPage(BedSwapRecordPageReqVO pageReqVO) {
+        // 默认按当前租户过滤;若前端传入 tenantIds 则按传入范围过滤
+        Long[] tenantIds = pageReqVO.getTenantIds();
+        if (tenantIds == null || tenantIds.length == 0) {
+            tenantIds = new Long[]{TenantContextHolder.getTenantId()};
+        }
+        final Long[] finalTenantIds = tenantIds;
+        return bedSwapRecordMapper.selectPage(pageReqVO, new LambdaQueryWrapperX<BedSwapRecordDO>()
+                .inIfPresent(BedSwapRecordDO::getTenantId, finalTenantIds)
+                .betweenIfPresent(BedSwapRecordDO::getSwapDate, pageReqVO.getSwapDate())
+                .likeIfPresent(BedSwapRecordDO::getElderNameA, pageReqVO.getElderNameA())
+                .likeIfPresent(BedSwapRecordDO::getElderNameB, pageReqVO.getElderNameB())
+                .eqIfPresent(BedSwapRecordDO::getHasExpenseChange, pageReqVO.getHasExpenseChange())
+                .orderByDesc(BedSwapRecordDO::getId));
+    }
+
+}

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

@@ -362,7 +362,11 @@ public class ElderlyInfoServiceImpl implements ElderlyInfoService {
         }
         elderlyInfoMapper.updateById(elderlyInfo);
         relativesMapper.delete(new LambdaQueryWrapperX<RelativesDO>().eq(RelativesDO::getPeopleId,elderlyInfo.getId()));
-        for (RelativesDO relativesDO : elderlyInfo.getRelativesList()) {
+        for (int i = 0; i < elderlyInfo.getRelativesList().size(); i++) {
+            RelativesDO relativesDO = elderlyInfo.getRelativesList().get(i);
+            if(elderlyInfo.getRelativesList().size() == 1){
+                relativesDO.setIsBillSender(true);
+            }
             relativesDO.setPeopleId(elderlyInfo.getId());
             relativesDO.setPeopleName(elderlyInfo.getElderName());
             relativesDO.setCreateDate(LocalDateTime.now());

+ 10 - 0
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/ExpenseOrderService.java

@@ -3,6 +3,8 @@ package cn.iocoder.yudao.module.system.service.biz;
 
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.*;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.ExpenseOrderUnpaidPageReqVO;
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.ExpenseOrderUnpaidPageRespVO;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.excel.ElderlyExpenseOrderExportExcelVO;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.excel.ExpenseOrderBatchPayExcelVO;
 import cn.iocoder.yudao.module.system.controller.admin.biz.vo.excel.ExpenseOrderExcelVO;
@@ -37,6 +39,14 @@ public interface ExpenseOrderService {
 
     PageResult<ExpenseOrderDO> getExpenseBillPage(ExpenseBillPageReqVO pageReqVO);
 
+    /**
+     * 查询账单起始月(含)起、指定机构下未缴费账单(长者名称与账单月),
+     * 并同时返回该机构下退住未结算账单,均分页
+     *
+     * @param reqVO 查询条件(账单起始月、机构id)
+     */
+    ExpenseOrderUnpaidPageRespVO getUnpaidExpenseOrderPage(ExpenseOrderUnpaidPageReqVO reqVO);
+
     void arrearsNote(Long id, String remarks);
 
     ExpenseBillRespVO getExpenseBill(Long id);

+ 22 - 1
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/ExpenseOrderServiceImpl.java

@@ -131,6 +131,9 @@ public class ExpenseOrderServiceImpl implements ExpenseOrderService {
     @Resource
     private TenantMapper tenantMapper;
 
+    @Resource
+    private RefundSettlementOrderMapper refundSettlementOrderMapper;
+
     @Resource
     private SysOverheadChargeMapper sysOverheadChargeMapper;
 
@@ -250,6 +253,24 @@ public class ExpenseOrderServiceImpl implements ExpenseOrderService {
         return new PageResult<>(list, page.getTotal());
     }
 
+    @Override
+    public ExpenseOrderUnpaidPageRespVO getUnpaidExpenseOrderPage(ExpenseOrderUnpaidPageReqVO reqVO) {
+        // 1. 未缴费账单(账单起始月起,含当月),按机构id过滤
+        Page<ExpenseOrderDO> page = new Page<>(reqVO.getPageNo(), reqVO.getPageSize());
+        List<ExpenseOrderDO> list = expenseOrderMapper.getUnpaidExpenseOrderPage(page, reqVO);
+        PageResult<ExpenseOrderDO> unpaidExpenseOrders = new PageResult<>(list, page.getTotal());
+
+        // 2. 退住未结算账单(status=0),仅按机构id过滤
+        Page<RefundSettlementOrderDO> refundPage = new Page<>(reqVO.getPageNo(), reqVO.getPageSize());
+        List<RefundSettlementOrderDO> refundList = refundSettlementOrderMapper.getUnsettledRefundSettlementPage(refundPage, reqVO.getTenantId());
+        PageResult<RefundSettlementOrderDO> unsettledRefundSettlements = new PageResult<>(refundList, refundPage.getTotal());
+
+        return ExpenseOrderUnpaidPageRespVO.builder()
+                .unpaidExpenseOrders(unpaidExpenseOrders)
+                .unsettledRefundSettlements(unsettledRefundSettlements)
+                .build();
+    }
+
     @Override
     public void arrearsNote(Long id, String arrearsNote) {
         ExpenseOrderDO expenseOrderDO = expenseOrderMapper.selectById(id);
@@ -3621,7 +3642,7 @@ public class ExpenseOrderServiceImpl implements ExpenseOrderService {
 
         List<ExpenseOrderDO> expenseOrders = expenseOrderMapper.selectList(new LambdaQueryWrapperX<ExpenseOrderDO>()
                 .eq(ExpenseOrderDO::getElderId, relativesDO.getPeopleId())
-                .betweenIfPresent(ExpenseOrderDO::getCreatedTime, pageReqVO.getCreatedTime())
+                .eqIfPresent(ExpenseOrderDO::getBillingMonth,pageReqVO.getAttributionBillTime())
                 .eqIfPresent(ExpenseOrderDO::getPayStatus, pageReqVO.getPayStatus())
                 .orderByDesc(ExpenseOrderDO::getBillingMonth)
         );

+ 53 - 3
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/biz/NurseChangeRecordServiceImpl.java

@@ -70,6 +70,9 @@ public class NurseChangeRecordServiceImpl implements ElderlyChangeRecordService
     @Resource
     private SysOverheadChargeMapper sysOverheadChargeMapper;
 
+    @Resource
+    private BedSwapRecordMapper bedSwapRecordMapper;
+
     @Override
     public Long createBedChangeRecord(BedChangeRecordSaveReqVO createReqVO) {
         // 插入
@@ -293,12 +296,51 @@ public class NurseChangeRecordServiceImpl implements ElderlyChangeRecordService
         elderlyInfoMapper.updateById(elderA);
         elderlyInfoMapper.updateById(elderB);
 
+        boolean hasExpenseChange = false;
         if (reqVO.getNewItemIdA() != null) {
-            handleExpenseItemSwap(reqVO, true);
+            hasExpenseChange = handleExpenseItemSwap(reqVO, true) || hasExpenseChange;
         }
         if (reqVO.getNewItemIdB() != null) {
-            handleExpenseItemSwap(reqVO, false);
+            hasExpenseChange = handleExpenseItemSwap(reqVO, false) || hasExpenseChange;
         }
+
+        // 记录床位对调记录(bedA/bedB 为对调前的原床位快照)
+        saveBedSwapRecord(reqVO, elderA, elderB, bedA, bedB, hasExpenseChange);
+    }
+
+    /**
+     * 保存床位对调记录
+     *
+     * @param reqVO             对调请求参数
+     * @param elderA            长者A信息
+     * @param elderB            长者B信息
+     * @param bedA              长者A对调前的原床位
+     * @param bedB              长者B对调前的原床位
+     * @param hasExpenseChange  是否涉及费用变更
+     */
+    private void saveBedSwapRecord(BedSwapReqVO reqVO, ElderlyInfoDO elderA, ElderlyInfoDO elderB,
+                                   BuildBedDO bedA, BuildBedDO bedB, boolean hasExpenseChange) {
+        BedSwapRecordDO record = new BedSwapRecordDO();
+        record.setSwapDate(reqVO.getStartDate());
+        // 长者A:原床位 bedA,新床位 bedB
+        record.setElderIdA(elderA.getId());
+        record.setElderNameA(elderA.getElderName());
+        record.setOriginalBedIdA(bedA.getId());
+        record.setOriginalBedNameA(bedA.getBedName());
+        record.setNewBedIdA(bedB.getId());
+        record.setNewBedNameA(bedB.getBedName());
+        record.setIsPrivateRoomA(reqVO.getIsPrivateA());
+        // 长者B:原床位 bedB,新床位 bedA
+        record.setElderIdB(elderB.getId());
+        record.setElderNameB(elderB.getElderName());
+        record.setOriginalBedIdB(bedB.getId());
+        record.setOriginalBedNameB(bedB.getBedName());
+        record.setNewBedIdB(bedA.getId());
+        record.setNewBedNameB(bedA.getBedName());
+        record.setIsPrivateRoomB(reqVO.getIsPrivateB());
+        record.setHasExpenseChange(hasExpenseChange ? BooleanEnum.TRUE.getValue() : BooleanEnum.FALSE.getValue());
+        record.setTenantId(TenantContextHolder.getTenantId());
+        bedSwapRecordMapper.insert(record);
     }
 
     private void validateExpenseParams(BedSwapReqVO reqVO) {
@@ -331,7 +373,7 @@ public class NurseChangeRecordServiceImpl implements ElderlyChangeRecordService
         }
     }
 
-    private void handleExpenseItemSwap(BedSwapReqVO reqVO, boolean isElderA) {
+    private boolean handleExpenseItemSwap(BedSwapReqVO reqVO, boolean isElderA) {
         Long originalExpenseItemId = isElderA ? reqVO.getOriginalExpenseItemIdA() : reqVO.getOriginalExpenseItemIdB();
         Long elderId = isElderA ? reqVO.getElderIdA() : reqVO.getElderIdB();
         if (originalExpenseItemId == null) {
@@ -389,6 +431,14 @@ public class NurseChangeRecordServiceImpl implements ElderlyChangeRecordService
             e.setExpenseId(expenseDO.getId());
             return e;
         }).collect(Collectors.toList());
+        // 判断费用是否真的发生变化:比较原费用项实际金额与新费用项实际金额
+        BigDecimal originalActual = originalItem.getActualAmount() != null
+                ? originalItem.getActualAmount() : originalItem.getAmount();
+        BigDecimal newActual = actualAmount != null ? actualAmount : amount;
+        boolean amountChanged = originalActual == null
+                || originalActual.compareTo(newActual) != 0;
+
         expenseItemMapper.insertBatch(newExpenseItemList);
+        return amountChanged;
     }
 }

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

@@ -60,6 +60,27 @@
         eeb.created_time DESC
     </select>
 
+    <select id="getUnpaidExpenseOrderPage"
+            resultType="cn.iocoder.yudao.module.system.dal.dataobject.biz.ExpenseOrderDO">
+        SELECT
+        eeb.*,
+        ei.elder_name
+        FROM elderly_expense_order eeb
+        LEFT JOIN elderly_info ei ON ei.id = eeb.elder_id
+        WHERE eeb.is_show = 1
+          AND eeb.pay_status = 0
+          <if test="params.billStartMonth != null and params.billStartMonth != ''">
+              AND eeb.billing_month <![CDATA[ >= ]]> #{params.billStartMonth}
+          </if>
+          <if test="params.tenantId != null">
+              AND eeb.tenant_id = #{params.tenantId}
+          </if>
+        ORDER BY
+        eeb.billing_month ASC,
+        ei.elder_name ASC
+    </select>
+
+
     <select id="selectListByMonth" resultType="cn.iocoder.yudao.module.system.api.order.DTO.OrderItemRespDTO">
         SELECT
             ei.id,

+ 14 - 0
yudao-module-system/yudao-module-system-biz/src/main/resources/mapper/RefundSettlementOrderMapper.xml

@@ -36,4 +36,18 @@
         </if>
         order by erso.created_time desc
     </select>
+
+    <select id="getUnsettledRefundSettlementPage"
+            resultType="cn.iocoder.yudao.module.system.dal.dataobject.biz.RefundSettlementOrderDO">
+        select erso.*,
+        ei.elder_name
+        from elderly_refund_settlement_order erso
+        left join elderly_info ei on ei.id = erso.elder_id
+        where erso.status = 0
+        <if test="tenantId != null">
+            and erso.tenant_id = #{tenantId}
+        </if>
+        order by erso.created_time desc
+    </select>
+
 </mapper>