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