Bläddra i källkod

新增
1、新增费用项目变更模块
BUGFIX
1、解决审批流推送企微,抄送人未推送的问题
2、解决审批流推送企微,审批出错也推送的问题

liangwenxuan 2 veckor sedan
förälder
incheckning
a0a7efe5ca
28 ändrade filer med 753 tillägg och 10 borttagningar
  1. 4 0
      yudao-module-bpm/yudao-module-bpm-api/src/main/java/cn/iocoder/yudao/module/bpm/enums/message/BpmMessageEnum.java
  2. 8 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/controller/admin/business/BpmElderlyController.java
  3. 120 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/controller/admin/business/vo/FeeItemChangeRecordCreateReqVO.java
  4. 25 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/controller/admin/task/BpmProcessInstanceController.java
  5. 66 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/controller/admin/task/vo/excel/FeeItemChangeRecordExcelVO.java
  6. 2 1
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/enums/BpmBusinessTypeEnum.java
  7. 2 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/business/BpmBusinessFormService.java
  8. 18 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/business/BpmBusinessFormServiceImpl.java
  9. 2 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/business/BpmElderlApplyService.java
  10. 29 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/business/BpmElderlApplyServiceImpl.java
  11. 52 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/business/listener/BpmFeeItemChangeStatusListener.java
  12. 51 3
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/message/BpmMessageServiceImpl.java
  13. 98 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmProcessInstanceCopyServiceImpl.java
  14. 2 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmProcessInstanceService.java
  15. 24 0
      yudao-module-bpm/yudao-module-bpm-biz/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmProcessInstanceServiceImpl.java
  16. 2 0
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/bpm/BpmElderlyChangeApi.java
  17. 61 0
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/bpm/vo/FeeItemChangeRecordRespVO.java
  18. 5 0
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/LogRecordConstants.java
  19. 2 1
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/change/BusinessTypeEnum.java
  20. 14 0
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/change/ChangeConstants.java
  21. 2 1
      yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/change/ChangeTypeEnum.java
  22. 35 1
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/bpm/BpmElderlyChangeApiImpl.java
  23. 1 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/wechat/CorpWechatApiImpl.java
  24. 3 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/ElderlyChangeRecordMapper.java
  25. 13 0
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/biz/ExpenseMapper.java
  26. 26 3
      yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/notify/NotifySendServiceImpl.java
  27. 65 0
      yudao-module-system/yudao-module-system-biz/src/main/resources/mapper/ElderlyChangeRecordMapper.xml
  28. 21 0
      yudao-module-system/yudao-module-system-biz/src/main/resources/mapper/ExpenseMapper.xml

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

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

@@ -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());
@@ -478,13 +490,17 @@ 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();
+            // 抄送人可能属于其他租户,必须忽略租户过滤查询:
+            // 若用 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()),
-                    promoter.getId() == null ? "" : String.valueOf(promoter.getId())));
+                    taskCopyUserDO.getId() == null ? "" : String.valueOf(taskCopyUserDO.getId())));
             corpSendMessage.setTouser(corpUserId);
             corpSendMessage.setMiniProgramNotice(corpNotice);
             wechatMessageSaveReq.setTitle(CorpMsgUtil.TITLE_COPY);
@@ -590,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());
@@ -783,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());

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

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

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

@@ -165,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());
@@ -282,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;
 
@@ -334,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 = "";
         }
@@ -1095,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)

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

@@ -403,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);

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

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

@@ -24,6 +24,8 @@ 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;
@@ -220,10 +222,31 @@ public class NotifySendServiceImpl implements NotifySendService {
             contentItems.add(new CorpWechatContentItem("消息内容", content));
         }
         notice.setContentItem(contentItems);
-        corpWechatApi.sendMessage(corpSendMessage);
+        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;
@@ -275,7 +298,7 @@ public class NotifySendServiceImpl implements NotifySendService {
             contentItems.add(new CorpWechatContentItem("抄送内容", content));
             notice.setContentItem(contentItems);
             req.setContent(content);
-            corpWechatApi.sendMessage(corpSendMessage);
+            sendCorpMessageAfterCommit(corpSendMessage);
         });
         return null;
     }
@@ -302,7 +325,7 @@ public class NotifySendServiceImpl implements NotifySendService {
         notice.setContentItem(contentItems);
         req.setTitle(TITLE_CANCEL);
         req.setContent(content);
-        corpWechatApi.sendMessage(corpSendMessage);
+        sendCorpMessageAfterCommit(corpSendMessage);
         return null;
     }
 

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