package cn.iocoder.yudao.module.research.service.review; import cn.hutool.core.collection.CollUtil; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.research.controller.admin.review.vo.ResearchTopicReviewSaveReqVO; import cn.iocoder.yudao.module.research.dal.dataobject.application.ResearchApplicationDO; import cn.iocoder.yudao.module.research.dal.dataobject.review.ResearchTopicReviewDO; import cn.iocoder.yudao.module.research.dal.mysql.application.ResearchApplicationMapper; import cn.iocoder.yudao.module.research.dal.mysql.review.ResearchTopicReviewMapper; import cn.iocoder.yudao.module.research.enums.ResearchApplicationStatusEnum; import cn.iocoder.yudao.module.research.enums.ResearchReviewConclusionEnum; import cn.iocoder.yudao.module.research.service.application.ResearchApplicationService; 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.math.RoundingMode; import java.time.LocalDateTime; import java.util.Comparator; import java.util.List; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.module.research.enums.ErrorCodeConstants.*; /** * 专家评审明细 Service 实现类 * * 评分权重与「均分 + 多数结论」通过规则参考 SRM 评标 * (srm_bid_evaluation:price*0.4 + tech*0.3 + delivery*0.2 + service*0.1 的加权汇总 + calculateRanking) */ @Service @Validated @Slf4j public class ResearchTopicReviewServiceImpl implements ResearchTopicReviewService { // ===== 评分权重(占位规则,需学会最终确认,改动只需改这里)===== private static final BigDecimal TOPIC_WEIGHT = new BigDecimal("0.3"); private static final BigDecimal PLAN_WEIGHT = new BigDecimal("0.3"); private static final BigDecimal TEAM_WEIGHT = new BigDecimal("0.2"); private static final BigDecimal RESULT_WEIGHT = new BigDecimal("0.2"); /** 推荐立项的均分阈值 */ private static final BigDecimal PASS_SCORE = new BigDecimal("60"); private static final BigDecimal SCORE_MAX = new BigDecimal("100"); @Resource private ResearchTopicReviewMapper reviewMapper; @Resource private ResearchApplicationMapper applicationMapper; @Resource private ResearchApplicationService applicationService; @Override @Transactional(rollbackFor = Exception.class) public Long createReview(ResearchTopicReviewSaveReqVO createReqVO) { validateApplicationReviewable(createReqVO.getApplicationId(), REVIEW_CREATE_FAIL_STATUS); validateScores(createReqVO); ResearchTopicReviewDO review = BeanUtils.toBean(createReqVO, ResearchTopicReviewDO.class, in -> in .setId(null) .setReviewTime(LocalDateTime.now())); reviewMapper.insert(review); recalculate(createReqVO.getApplicationId()); return review.getId(); } @Override @Transactional(rollbackFor = Exception.class) public void updateReview(ResearchTopicReviewSaveReqVO updateReqVO) { ResearchTopicReviewDO exist = reviewMapper.selectById(updateReqVO.getId()); if (exist == null) { throw exception(REVIEW_NOT_EXISTS); } validateApplicationReviewable(exist.getApplicationId(), REVIEW_UPDATE_FAIL_STATUS); validateScores(updateReqVO); ResearchTopicReviewDO updateObj = BeanUtils.toBean(updateReqVO, ResearchTopicReviewDO.class, in -> in.setReviewTime(LocalDateTime.now())); reviewMapper.updateById(updateObj); recalculate(exist.getApplicationId()); } @Override @Transactional(rollbackFor = Exception.class) public void deleteReview(Long id) { ResearchTopicReviewDO exist = reviewMapper.selectById(id); if (exist == null) { throw exception(REVIEW_NOT_EXISTS); } validateApplicationReviewable(exist.getApplicationId(), REVIEW_DELETE_FAIL_STATUS); reviewMapper.deleteById(id); recalculate(exist.getApplicationId()); } @Override public List getReviewListByApplicationId(Long applicationId) { return reviewMapper.selectListByApplicationId(applicationId); } // =================== 内部方法 =================== /** * 重算某申报书的全部评审明细:加权合计 → 排名 → 主单汇总均分与结论 */ private void recalculate(Long applicationId) { List list = reviewMapper.selectListByApplicationId(applicationId); // 1. 逐行重算加权合计 for (ResearchTopicReviewDO review : list) { review.setCompositeScore(weightedScore(review)); } // 2. 按加权合计降序排名,名次回写 List ranked = list.stream() .sorted(Comparator.comparing(ResearchTopicReviewDO::getCompositeScore, Comparator.reverseOrder())) .toList(); for (int i = 0; i < ranked.size(); i++) { ranked.get(i).setRanking(i + 1); } for (ResearchTopicReviewDO review : list) { reviewMapper.updateById(new ResearchTopicReviewDO() .setId(review.getId()) .setCompositeScore(review.getCompositeScore()) .setRanking(review.getRanking())); } // 3. 回写主单:均分 + 是否推荐立项(无明细时清空) if (CollUtil.isEmpty(list)) { applicationService.updateReviewSummary(applicationId, null, null); return; } BigDecimal sum = list.stream().map(ResearchTopicReviewDO::getCompositeScore) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal avg = sum.divide(BigDecimal.valueOf(list.size()), 2, RoundingMode.HALF_UP); long recommendCount = list.stream() .filter(r -> ResearchReviewConclusionEnum.RECOMMEND.getConclusion().equals(r.getConclusion())) .count(); boolean pass = avg.compareTo(PASS_SCORE) >= 0 && recommendCount * 2 > list.size(); applicationService.updateReviewSummary(applicationId, avg, pass ? 1 : 0); } private BigDecimal weightedScore(ResearchTopicReviewDO review) { return nvl(review.getTopicScore()).multiply(TOPIC_WEIGHT) .add(nvl(review.getPlanScore()).multiply(PLAN_WEIGHT)) .add(nvl(review.getTeamScore()).multiply(TEAM_WEIGHT)) .add(nvl(review.getResultScore()).multiply(RESULT_WEIGHT)) .setScale(2, RoundingMode.HALF_UP); } private void validateApplicationReviewable(Long applicationId, cn.iocoder.yudao.framework.common.exception.ErrorCode errorCode) { ResearchApplicationDO application = applicationMapper.selectById(applicationId); if (application == null) { throw exception(APPLICATION_NOT_EXISTS); } if (!ResearchApplicationStatusEnum.APPROVING.getStatus().equals(application.getStatus())) { throw exception(errorCode, application.getNo(), ResearchApplicationStatusEnum.nameOf(application.getStatus())); } } /** 逐项校验分值范围,提示里指出具体是哪个维度、实际值多少 */ private void validateScores(ResearchTopicReviewSaveReqVO reqVO) { checkScore("选题价值与意义", reqVO.getTopicScore()); checkScore("研究方案可行性", reqVO.getPlanScore()); checkScore("团队与研究基础", reqVO.getTeamScore()); checkScore("预期成果与效益", reqVO.getResultScore()); } private void checkScore(String label, BigDecimal score) { if (score == null) { return; } if (score.compareTo(BigDecimal.ZERO) < 0 || score.compareTo(SCORE_MAX) > 0) { throw exception(REVIEW_SCORE_OUT_OF_RANGE, label, score); } } private static BigDecimal nvl(BigDecimal value) { return value != null ? value : BigDecimal.ZERO; } }