yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/fund/ResearchFundRecordServiceImpl.java
@@ -1,7 +1,13 @@
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;
@@ -10,6 +16,7 @@
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;
@@ -17,16 +24,21 @@
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
@@ -35,6 +47,9 @@
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;
@@ -45,22 +60,50 @@
    @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);
        validateVoucherRequired(createReqVO.getBlobIds(), createReqVO.getBatchName());
        // 累计校验:新登记金额 + 该申报单已有记录金额,不得超过申请预算
        BigDecimal registered = sumAmount(fundRecordMapper.selectListByApplicationId(application.getId()), null);
        List<ResearchFundRecordDO> 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();
    }
@@ -68,21 +111,51 @@
    @Transactional(rollbackFor = Exception.class)
    public void updateFundRecord(ResearchFundRecordSaveReqVO updateReqVO) {
        ResearchFundRecordDO exist = validateFundRecordExists(updateReqVO.getId());
        if (!ResearchFundStatusEnum.PENDING.getStatus().equals(exist.getStatus())) {
        if (!isEditable(exist.getStatus())) {
            throw exception(FUND_UPDATE_FAIL_STATUS,
                    exist.getId(), ResearchFundStatusEnum.nameOf(exist.getStatus()));
        }
        // 记录归属申报单不可变更,防止把拨款挪到别的课题下
        ResearchApplicationDO application = validateApplicationApproved(exist.getApplicationId());
        validateVoucherRequired(updateReqVO);
        validateVoucherRequired(updateReqVO.getBlobIds(), updateReqVO.getBatchName());
        BigDecimal registered = sumAmount(fundRecordMapper.selectListByApplicationId(exist.getApplicationId()),
                exist.getId());
        BigDecimal registered = sumAmount(
                fundRecordMapper.selectListByApplicationId(exist.getApplicationId()), exist.getId());
        validateAmountNotExceeded(updateReqVO.getAmount(), registered, application.getBudgetAmount());
        ResearchFundRecordDO updateObj = BeanUtils.toBean(updateReqVO, ResearchFundRecordDO.class);
        updateObj.setApplicationId(exist.getApplicationId());
        fundRecordMapper.updateById(updateObj);
        // 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<ResearchFundRecordDO>()
                .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());
@@ -97,6 +170,20 @@
                    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);
    }
@@ -106,7 +193,39 @@
        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<ResearchFundRecordDO> update = new LambdaUpdateWrapper<ResearchFundRecordDO>()
                .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);
@@ -129,12 +248,22 @@
        return application;
    }
    /** 状态为「已拨付」时必须上传凭证附件 */
    private void validateVoucherRequired(ResearchFundRecordSaveReqVO reqVO) {
        if (ResearchFundStatusEnum.PAID.getStatus().equals(reqVO.getStatus())
                && CollUtil.isEmpty(reqVO.getBlobIds())) {
            throw exception(FUND_VOUCHER_REQUIRED);
    /** 登记与修改拨款都必须上传凭证:凭证会随自动生成的财务报销单送交财务审批 */
    private void validateVoucherRequired(List<Long> 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 自身)不得超过申请预算 */
@@ -159,4 +288,19 @@
                .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);
        };
    }
}