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<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();
|
}
|
|
@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<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());
|
}
|
|
@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<ResearchFundRecordDO> 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<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);
|
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<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 自身)不得超过申请预算 */
|
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<ResearchFundRecordDO> 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);
|
};
|
}
|
|
}
|