package cn.iocoder.yudao.module.research.service.fund; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum; import cn.iocoder.yudao.module.finance.api.reimbursement.FinanceReimbursementApi; import cn.iocoder.yudao.module.finance.api.reimbursement.dto.FinanceReimbursementCreateReqDTO; import cn.iocoder.yudao.module.finance.api.reimbursement.dto.FinanceReimbursementRespDTO; import cn.iocoder.yudao.module.finance.api.reimbursement.dto.FinanceReimbursementUpdateReqDTO; import cn.iocoder.yudao.module.research.controller.admin.fund.vo.ResearchFundRecordSaveReqVO; import cn.iocoder.yudao.module.research.dal.dataobject.application.ResearchApplicationDO; import cn.iocoder.yudao.module.research.dal.dataobject.fund.ResearchFundRecordDO; import cn.iocoder.yudao.module.research.dal.mysql.application.ResearchApplicationMapper; import cn.iocoder.yudao.module.research.dal.mysql.fund.ResearchFundRecordMapper; import cn.iocoder.yudao.module.research.enums.ResearchApplicationStatusEnum; import cn.iocoder.yudao.module.research.enums.ResearchFundStatusEnum; import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; import java.util.Objects; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; import static cn.iocoder.yudao.module.research.enums.ErrorCodeConstants.*; /** * 经费拨付记录 Service 实现类 * * 每条拨款记录都会自动生成并提交一张对应的财务报销单(1:1,金额一致), * 报销单审批结果由 {@code ResearchFundStatusListener} 回写本台账状态: * 待拨付(0) → 报销审批中(15) → 已拨付(20) / 已驳回(30);财务端撤回则退回待拨付(0)。 * 累计拨付金额以申报单 budget_amount(申请预算)为上限。 */ @Service @Validated @Slf4j public class ResearchFundRecordServiceImpl implements ResearchFundRecordService { public static final String FUND_RECORD_TYPE = "research_fund_record"; /** 财务报销单状态:草稿 */ private static final Integer FINANCE_STATUS_DRAFT = 0; @Resource private ResearchFundRecordMapper fundRecordMapper; @Resource private ResearchApplicationMapper applicationMapper; @Resource private StorageAttachmentApi storageAttachmentApi; @Resource private FinanceReimbursementApi financeReimbursementApi; @Override @Transactional(rollbackFor = Exception.class) public Long createFundRecord(ResearchFundRecordSaveReqVO createReqVO) { ResearchApplicationDO application = validateApplicationApproved(createReqVO.getApplicationId()); validateVoucherRequired(createReqVO.getBlobIds(), createReqVO.getBatchName()); // 累计校验:新登记金额 + 该申报单已有记录金额,不得超过申请预算 List existList = fundRecordMapper.selectListByApplicationId(application.getId()); BigDecimal registered = sumAmount(existList, null); validateAmountNotExceeded(createReqVO.getAmount(), registered, application.getBudgetAmount()); // 1. 落课题侧台账(状态先置待拨付,报销提交成功后改为报销审批中) ResearchFundRecordDO fundRecord = BeanUtils.toBean(createReqVO, ResearchFundRecordDO.class, in -> in.setId(null)); fundRecord.setStatus(ResearchFundStatusEnum.PENDING.getStatus()); fundRecordMapper.insert(fundRecord); // 2. 凭证附件同时绑定到课题台账(财务侧报销单由 finance 模块自行绑定同一批 blob) storageAttachmentApi.bindAttachments("file", FUND_RECORD_TYPE, fundRecord.getId(), createReqVO.getBlobIds()); // 3. 自动生成并提交财务报销单 String title = buildReimbursementTitle(application, createReqVO, existList.size() + 1); FinanceReimbursementCreateReqDTO reqDTO = new FinanceReimbursementCreateReqDTO(); reqDTO.setTitle(title); reqDTO.setTotalAmount(createReqVO.getAmount()); reqDTO.setApplyDate(createReqVO.getPayDate() != null ? createReqVO.getPayDate() : LocalDate.now()); reqDTO.setRemark(createReqVO.getRemark()); reqDTO.setBlobIds(createReqVO.getBlobIds()); reqDTO.setExpenseDescription(title); reqDTO.setApplicantId(getLoginUserId()); Long reimbursementId = financeReimbursementApi.createReimbursementAndSubmit(reqDTO).getCheckedData(); FinanceReimbursementRespDTO reimbursement = financeReimbursementApi.getReimbursement(reimbursementId).getCheckedData(); // 4. 回写财务报销单信息并进入「报销审批中」 fundRecordMapper.updateById(new ResearchFundRecordDO() .setId(fundRecord.getId()) .setStatus(ResearchFundStatusEnum.APPROVING.getStatus()) .setReimbursementId(reimbursementId) .setReimbursementNo(reimbursement != null ? reimbursement.getNo() : null)); return fundRecord.getId(); } @Override @Transactional(rollbackFor = Exception.class) public void updateFundRecord(ResearchFundRecordSaveReqVO updateReqVO) { ResearchFundRecordDO exist = validateFundRecordExists(updateReqVO.getId()); if (!isEditable(exist.getStatus())) { throw exception(FUND_UPDATE_FAIL_STATUS, exist.getId(), ResearchFundStatusEnum.nameOf(exist.getStatus())); } // 记录归属申报单不可变更,防止把拨款挪到别的课题下 ResearchApplicationDO application = validateApplicationApproved(exist.getApplicationId()); validateVoucherRequired(updateReqVO.getBlobIds(), updateReqVO.getBatchName()); BigDecimal registered = sumAmount( fundRecordMapper.selectListByApplicationId(exist.getApplicationId()), exist.getId()); validateAmountNotExceeded(updateReqVO.getAmount(), registered, application.getBudgetAmount()); // 1. 财务侧修改报销单并重新提交审批(先做,失败则课题侧不落任何变更) Long reimbursementId = exist.getReimbursementId(); if (reimbursementId == null) { throw exception(FUND_REIMBURSEMENT_NOT_EXISTS, exist.getId(), "空"); } FinanceReimbursementRespDTO reimbursement = financeReimbursementApi.getReimbursement(reimbursementId).getCheckedData(); if (reimbursement == null) { throw exception(FUND_REIMBURSEMENT_NOT_EXISTS, exist.getId(), reimbursementId); } String title = buildReimbursementTitle(application, updateReqVO, null); FinanceReimbursementUpdateReqDTO reqDTO = new FinanceReimbursementUpdateReqDTO(); reqDTO.setReimbursementId(reimbursementId); reqDTO.setTitle(title); reqDTO.setTotalAmount(updateReqVO.getAmount()); reqDTO.setApplyDate(updateReqVO.getPayDate() != null ? updateReqVO.getPayDate() : LocalDate.now()); reqDTO.setRemark(updateReqVO.getRemark()); reqDTO.setBlobIds(updateReqVO.getBlobIds()); reqDTO.setExpenseDescription(title); financeReimbursementApi.updateReimbursementAndResubmit(reqDTO).getCheckedData(); // 2. 回写课题侧台账:清空驳回信息,回到「报销审批中」 fundRecordMapper.update(null, new LambdaUpdateWrapper() .eq(ResearchFundRecordDO::getId, exist.getId()) .set(ResearchFundRecordDO::getBatchName, updateReqVO.getBatchName()) .set(ResearchFundRecordDO::getAmount, updateReqVO.getAmount()) .set(ResearchFundRecordDO::getPayDate, updateReqVO.getPayDate()) .set(ResearchFundRecordDO::getPayee, updateReqVO.getPayee()) .set(ResearchFundRecordDO::getRemark, updateReqVO.getRemark()) .set(ResearchFundRecordDO::getStatus, ResearchFundStatusEnum.APPROVING.getStatus()) .set(ResearchFundRecordDO::getReimbursementNo, reimbursement.getNo()) .set(ResearchFundRecordDO::getRejectReason, "") .set(ResearchFundRecordDO::getRejectTime, null)); storageAttachmentApi.updateAttachments("file", FUND_RECORD_TYPE, updateReqVO.getId(), updateReqVO.getBlobIds()); } @Override @Transactional(rollbackFor = Exception.class) public void deleteFundRecord(Long id) { ResearchFundRecordDO exist = validateFundRecordExists(id); if (!ResearchFundStatusEnum.PENDING.getStatus().equals(exist.getStatus())) { throw exception(FUND_DELETE_FAIL_STATUS, exist.getId(), ResearchFundStatusEnum.nameOf(exist.getStatus())); } // 联动清理财务侧草稿报销单(已被财务侧删除时直接跳过) Long reimbursementId = exist.getReimbursementId(); if (reimbursementId != null) { FinanceReimbursementRespDTO reimbursement = financeReimbursementApi.getReimbursement(reimbursementId).getCheckedData(); if (reimbursement != null && reimbursement.getStatus() != null) { if (!FINANCE_STATUS_DRAFT.equals(reimbursement.getStatus())) { throw exception(FUND_REIMBURSEMENT_STATUS_CHANGED, reimbursement.getNo(), financeStatusName(reimbursement.getStatus())); } financeReimbursementApi.deleteReimbursement(reimbursementId); } } fundRecordMapper.deleteById(id); storageAttachmentApi.deleteAttachmentsByRecord(FUND_RECORD_TYPE, id); } @Override public List getFundRecordListByApplicationId(Long applicationId) { return fundRecordMapper.selectListByApplicationId(applicationId); } @Override @Transactional(rollbackFor = Exception.class) public void updateFundStatusByReimbursement(Long reimbursementId, Integer bpmStatus, String reason) { ResearchFundRecordDO fundRecord = fundRecordMapper.selectByReimbursementId(reimbursementId); if (fundRecord == null) { log.warn("[updateFundStatusByReimbursement] 未找到关联报销单({})的经费拨付记录", reimbursementId); return; } boolean rejected = BpmTaskStatusEnum.REJECT.getStatus().equals(bpmStatus); LambdaUpdateWrapper update = new LambdaUpdateWrapper() .eq(ResearchFundRecordDO::getId, fundRecord.getId()); if (BpmTaskStatusEnum.APPROVE.getStatus().equals(bpmStatus)) { update.set(ResearchFundRecordDO::getStatus, ResearchFundStatusEnum.PAID.getStatus()); } else if (rejected) { update.set(ResearchFundRecordDO::getStatus, ResearchFundStatusEnum.REJECTED.getStatus()); } else { // 撤回/取消:退回「待拨付」,供课题侧修改后重新发起报销 update.set(ResearchFundRecordDO::getStatus, ResearchFundStatusEnum.PENDING.getStatus()); } update.set(ResearchFundRecordDO::getRejectReason, rejected ? StrUtil.blankToDefault(reason, "财务未填写驳回原因") : "") .set(ResearchFundRecordDO::getRejectTime, rejected ? LocalDateTime.now() : null); fundRecordMapper.update(null, update); } // =================== 内部方法 =================== /** 只有「待拨付」或「已驳回」的台账才能修改并重新发起报销 */ private boolean isEditable(Integer status) { return ResearchFundStatusEnum.PENDING.getStatus().equals(status) || ResearchFundStatusEnum.REJECTED.getStatus().equals(status); } private ResearchFundRecordDO validateFundRecordExists(Long id) { ResearchFundRecordDO fundRecord = fundRecordMapper.selectById(id); if (fundRecord == null) { throw exception(FUND_NOT_EXISTS); } return fundRecord; } /** 只有已立项的课题才能登记经费拨付 */ private ResearchApplicationDO validateApplicationApproved(Long applicationId) { ResearchApplicationDO application = applicationMapper.selectById(applicationId); if (application == null) { throw exception(APPLICATION_NOT_EXISTS); } if (!ResearchApplicationStatusEnum.APPROVED.getStatus().equals(application.getStatus())) { throw exception(FUND_RECORD_FAIL_STATUS, application.getNo(), ResearchApplicationStatusEnum.nameOf(application.getStatus())); } return application; } /** 登记与修改拨款都必须上传凭证:凭证会随自动生成的财务报销单送交财务审批 */ private void validateVoucherRequired(List blobIds, String batchName) { if (CollUtil.isEmpty(blobIds)) { throw exception(FUND_VOUCHER_REQUIRED, StrUtil.isBlank(batchName) ? "本次" : batchName); } } /** 报销单标题:课题〔课题名〕第一期经费拨款 / 课题〔课题名〕第2次经费拨款 */ private String buildReimbursementTitle(ResearchApplicationDO application, ResearchFundRecordSaveReqVO reqVO, Integer seq) { String batch = reqVO.getBatchName(); if (StrUtil.isBlank(batch)) { int no = seq != null ? seq : fundRecordMapper.selectListByApplicationId(application.getId()).size() + 1; batch = "第" + no + "次"; } return "课题〔" + application.getTitle() + "〕" + batch + "经费拨款"; } /** 累计浮出校验:本次 + 已登记(排除 excludeId 自身)不得超过申请预算 */ private void validateAmountNotExceeded(BigDecimal amount, BigDecimal registered, BigDecimal budget) { BigDecimal current = amount == null ? BigDecimal.ZERO : amount; BigDecimal total = registered.add(current); BigDecimal limit = budget == null ? BigDecimal.ZERO : budget; if (total.compareTo(limit) > 0) { throw exception(FUND_AMOUNT_EXCEEDED, current.toPlainString(), registered.toPlainString(), total.toPlainString(), limit.toPlainString()); } } private BigDecimal sumAmount(List list, Long excludeId) { if (CollUtil.isEmpty(list)) { return BigDecimal.ZERO; } return list.stream() .filter(item -> !Objects.equals(item.getId(), excludeId)) .map(item -> item.getAmount() == null ? BigDecimal.ZERO : item.getAmount()) .reduce(BigDecimal.ZERO, BigDecimal::add); } /** 财务报销单状态中文名(finance 模块未对外暴露状态枚举,此处按状态值转可读文案) */ private String financeStatusName(Integer status) { if (status == null) { return "未知"; } return switch (status) { case 0 -> "草稿"; case 15 -> "审批中"; case 20 -> "已通过"; case 35 -> "已驳回"; case 40 -> "已归档"; default -> String.valueOf(status); }; } }