package cn.iocoder.yudao.module.research.service.application; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.http.HtmlUtil; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.archive.api.document.ArchiveDocumentApi; import cn.iocoder.yudao.module.archive.api.document.dto.ArchiveDocumentCreateReqDTO; import cn.iocoder.yudao.module.archive.api.document.dto.ArchiveDocumentRespDTO; import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi; import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO; import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmCategoryDO; import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessDefinitionInfoDO; import cn.iocoder.yudao.module.bpm.service.definition.BpmCategoryService; import cn.iocoder.yudao.module.bpm.service.definition.BpmProcessDefinitionService; import cn.iocoder.yudao.module.research.controller.admin.application.vo.ResearchApplicationPageReqVO; import cn.iocoder.yudao.module.research.controller.admin.application.vo.ResearchApplicationSaveReqVO; import cn.iocoder.yudao.module.research.dal.dataobject.application.ResearchApplicationDO; import cn.iocoder.yudao.module.research.dal.dataobject.guideline.ResearchGuidelineDO; import cn.iocoder.yudao.module.research.dal.mysql.application.ResearchApplicationMapper; import cn.iocoder.yudao.module.research.dal.redis.ResearchNoRedisDAO; import cn.iocoder.yudao.module.research.enums.ResearchApplicationStatusEnum; import cn.iocoder.yudao.module.research.enums.ResearchGuidelineCategoryEnum; import cn.iocoder.yudao.module.research.enums.ResearchGuidelineStatusEnum; import cn.iocoder.yudao.module.research.service.guideline.ResearchGuidelineService; import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi; import cn.iocoder.yudao.module.system.api.storage.dto.StorageBlobRespDTO; import cn.iocoder.yudao.module.system.controller.admin.notice.vo.NoticeSaveReqVO; import cn.iocoder.yudao.module.system.dal.dataobject.notice.NoticeDO; import cn.iocoder.yudao.module.system.service.notice.NoticeService; import cn.iocoder.yudao.module.ums.dal.dataobject.member.UmsMemberDO; import cn.iocoder.yudao.module.ums.service.member.UmsMemberService; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.flowable.engine.repository.ProcessDefinition; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; import java.util.stream.Collectors; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; import static cn.iocoder.yudao.module.research.enums.ErrorCodeConstants.*; @Service @Validated @Slf4j public class ResearchApplicationServiceImpl implements ResearchApplicationService { private static final String APPLICATION_APPROVE_CATEGORY_CODE = "research_topic_approve"; public static final String APPLICATION_RECORD_TYPE = "research_application"; /** 归档来源类型:2-课题(对应 archive 模块 source_type) */ private static final Integer ARCHIVE_SOURCE_TYPE_RESEARCH = 2; /** 归档密级:2-内部 */ private static final Integer ARCHIVE_SECRET_LEVEL_INTERNAL = 2; /** 课题档案分类编码(对应 archive_category.code) */ private static final String ARCHIVE_CATEGORY_CODE_RESEARCH = "research"; /** 立项通知使用「公告」类型(system_notice_type: 1-通知 2-公告) */ private static final Integer NOTICE_TYPE_ANNOUNCEMENT = 2; private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern(FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND); @Resource private ResearchApplicationMapper applicationMapper; @Resource private ArchiveDocumentApi archiveDocumentApi; @Resource private ResearchNoRedisDAO noRedisDAO; @Resource private StorageAttachmentApi storageAttachmentApi; @Resource private BpmProcessInstanceApi processInstanceApi; @Resource private BpmCategoryService bpmCategoryService; @Resource private BpmProcessDefinitionService bpmProcessDefinitionService; @Resource private ResearchGuidelineService guidelineService; @Resource private UmsMemberService umsMemberService; @Resource private NoticeService noticeService; @Override @Transactional(rollbackFor = Exception.class) public Long createApplication(ResearchApplicationSaveReqVO createReqVO) { // 1. 校验指南与会员 ResearchGuidelineDO guideline = validateGuidelineForSave(createReqVO.getGuidelineId()); validateMemberExists(createReqVO.getMemberId()); // 2. 生成编号(SB 前缀) String no = noRedisDAO.generate(ResearchNoRedisDAO.APPLICATION_NO_PREFIX); if (applicationMapper.selectByNo(no) != null) { throw exception(APPLICATION_NO_EXISTS); } // 3. 保存。类别冗余自指南,不信任前端传值 ResearchApplicationDO application = BeanUtils.toBean(createReqVO, ResearchApplicationDO.class, in -> in .setNo(no) .setCategory(guideline.getCategory()) .setApplicantUserId(getLoginUserId()) .setStatus(ResearchApplicationStatusEnum.DRAFT.getStatus()) .setBudgetAmount(in.getBudgetAmount() == null ? BigDecimal.ZERO : in.getBudgetAmount())); applicationMapper.insert(application); storageAttachmentApi.bindAttachments("file", APPLICATION_RECORD_TYPE, application.getId(), createReqVO.getBlobIds()); return application.getId(); } @Override @Transactional(rollbackFor = Exception.class) public void updateApplication(ResearchApplicationSaveReqVO updateReqVO) { ResearchApplicationDO application = validateApplicationExists(updateReqVO.getId()); if (!ResearchApplicationStatusEnum.DRAFT.getStatus().equals(application.getStatus()) && !ResearchApplicationStatusEnum.REJECTED.getStatus().equals(application.getStatus())) { throw exception(APPLICATION_UPDATE_FAIL_NOT_DRAFT, application.getNo(), ResearchApplicationStatusEnum.nameOf(application.getStatus())); } ResearchGuidelineDO guideline = validateGuidelineForSave(updateReqVO.getGuidelineId()); validateMemberExists(updateReqVO.getMemberId()); ResearchApplicationDO updateObj = BeanUtils.toBean(updateReqVO, ResearchApplicationDO.class); updateObj.setCategory(guideline.getCategory()); applicationMapper.updateById(updateObj); storageAttachmentApi.updateAttachments("file", APPLICATION_RECORD_TYPE, updateReqVO.getId(), updateReqVO.getBlobIds()); } @Override @Transactional(rollbackFor = Exception.class) public void deleteApplication(Long id) { ResearchApplicationDO application = validateApplicationExists(id); if (!ResearchApplicationStatusEnum.DRAFT.getStatus().equals(application.getStatus())) { throw exception(APPLICATION_DELETE_FAIL_NOT_DRAFT, application.getNo(), ResearchApplicationStatusEnum.nameOf(application.getStatus())); } applicationMapper.deleteById(id); storageAttachmentApi.deleteAttachmentsByRecord(APPLICATION_RECORD_TYPE, id); } @Override public ResearchApplicationDO getApplication(Long id) { return applicationMapper.selectById(id); } @Override public PageResult getApplicationPage(ResearchApplicationPageReqVO pageReqVO) { return applicationMapper.selectPage(pageReqVO); } @Override @Transactional(rollbackFor = Exception.class) public void submitApplication(Long id, String processDefinitionKey) { ResearchApplicationDO application = validateApplicationExists(id); Integer status = application.getStatus(); if (!ResearchApplicationStatusEnum.DRAFT.getStatus().equals(status) && !ResearchApplicationStatusEnum.REJECTED.getStatus().equals(status)) { throw exception(APPLICATION_SUBMIT_FAIL_STATUS, application.getNo(), ResearchApplicationStatusEnum.nameOf(status)); } // 提交时严格校验指南「已发布 + 在申报期内」 guidelineService.validateGuidelineSubmittable(application.getGuidelineId()); String processInstanceId = processInstanceApi.createProcessInstance(getLoginUserId(), new BpmProcessInstanceCreateReqDTO() .setProcessDefinitionKey(processDefinitionKey) .setBusinessKey(String.valueOf(id))); applicationMapper.updateById(new ResearchApplicationDO() .setId(id) .setStatus(ResearchApplicationStatusEnum.APPROVING.getStatus()) .setProcessInstanceId(processInstanceId)); } @Override public List> getApproveProcessDefinitionList() { validateApplicationApproveCategory(); List definitionInfoList = bpmProcessDefinitionService.getProcessDefinitionInfoListByCategory(APPLICATION_APPROVE_CATEGORY_CODE); if (CollUtil.isEmpty(definitionInfoList)) { return Collections.emptyList(); } Map latestVersionMap = new HashMap<>(); for (BpmProcessDefinitionInfoDO info : definitionInfoList) { ProcessDefinition pd = bpmProcessDefinitionService.getProcessDefinition(info.getProcessDefinitionId()); if (pd == null || pd.isSuspended()) { continue; } ProcessDefinition existing = latestVersionMap.get(pd.getKey()); if (existing == null || pd.getVersion() > existing.getVersion()) { latestVersionMap.put(pd.getKey(), pd); } } List> result = new ArrayList<>(); for (ProcessDefinition pd : latestVersionMap.values()) { Map item = new HashMap<>(); item.put("id", pd.getId()); item.put("key", pd.getKey()); item.put("name", pd.getName()); result.add(item); } return result; } @Override @Transactional(rollbackFor = Exception.class) public void updateApplicationAuditStatus(Long id, Integer bpmResult) { ResearchApplicationDO application = validateApplicationExists(id); if (!ResearchApplicationStatusEnum.APPROVING.getStatus().equals(application.getStatus())) { log.warn("[updateApplicationAuditStatus] 课题申报书({}) 不处于审批中状态", id); return; } Integer newStatus = convertBpmResultToStatus(bpmResult); if (newStatus != null) { applicationMapper.updateById(new ResearchApplicationDO().setId(id).setStatus(newStatus)); } } @Override @Transactional(rollbackFor = Exception.class) public void approveApplication(Long id) { ResearchApplicationDO application = validateApplicationExists(id); if (!ResearchApplicationStatusEnum.APPROVED.getStatus().equals(application.getStatus())) { log.warn("[approveApplication] 课题申报书({}) 不是已立项状态,跳过生成立项编号", id); return; } // 幂等:已生成立项编号则直接返回 if (StrUtil.isNotBlank(application.getResearchNo())) { return; } String researchNo = noRedisDAO.generate(ResearchNoRedisDAO.PROJECT_NO_PREFIX); applicationMapper.updateById(new ResearchApplicationDO() .setId(id) .setResearchNo(researchNo) .setApproveTime(LocalDateTime.now())); } @Override @Transactional(rollbackFor = Exception.class) public void publishNotice(Long id) { ResearchApplicationDO application = validateApplicationExists(id); if (!ResearchApplicationStatusEnum.APPROVED.getStatus().equals(application.getStatus())) { throw exception(APPLICATION_PUBLISH_NOTICE_FAIL_STATUS, application.getNo(), ResearchApplicationStatusEnum.nameOf(application.getStatus())); } if (application.getNoticeId() != null) { NoticeDO existNotice = noticeService.getNotice(application.getNoticeId()); throw exception(APPLICATION_NOTICE_EXISTS, application.getNo(), existNotice != null ? format(existNotice.getCreateTime()) : "此前"); } ResearchGuidelineDO guideline = guidelineService.getGuideline(application.getGuidelineId()); NoticeSaveReqVO noticeReqVO = new NoticeSaveReqVO(); noticeReqVO.setTitle(buildNoticeTitle(guideline)); noticeReqVO.setType(NOTICE_TYPE_ANNOUNCEMENT); noticeReqVO.setContent(buildNoticeContent(application, guideline)); noticeReqVO.setStatus(CommonStatusEnum.ENABLE.getStatus()); Long noticeId = noticeService.createNotice(noticeReqVO); applicationMapper.updateById(new ResearchApplicationDO().setId(id).setNoticeId(noticeId)); } @Override @Transactional(rollbackFor = Exception.class) public String archiveApplication(Long id) { // 1.1 校验存在 ResearchApplicationDO application = validateApplicationExists(id); // 1.2 只有「已立项」的课题才能归档 if (!ResearchApplicationStatusEnum.APPROVED.getStatus().equals(application.getStatus())) { throw exception(APPLICATION_ARCHIVE_FAIL_STATUS, application.getNo(), ResearchApplicationStatusEnum.nameOf(application.getStatus())); } // 1.3 重复归档校验 if (application.getArchiveDocumentId() != null) { throw exception(APPLICATION_ALREADY_ARCHIVED, application.getNo(), StrUtil.blankToDefault(archiveNo(id), "未知")); } // 2. 带入申报书附件,按来源单据归档(来源单号取立项编号,未生成时回退申报编号) String sourceNo = StrUtil.blankToDefault(application.getResearchNo(), application.getNo()); List blobIds = storageAttachmentApi.listAttachments(APPLICATION_RECORD_TYPE, id).stream() .map(StorageBlobRespDTO::getId).collect(Collectors.toList()); Long archiveDocumentId = archiveDocumentApi.archiveBySource(new ArchiveDocumentCreateReqDTO() .setSourceType(ARCHIVE_SOURCE_TYPE_RESEARCH) .setSourceId(id) .setSourceNo(sourceNo) .setSourceTitle(application.getTitle()) .setCategoryCode(ARCHIVE_CATEGORY_CODE_RESEARCH) .setSecretLevel(ARCHIVE_SECRET_LEVEL_INTERNAL) .setSummary("课题申报书归档") .setBlobIds(blobIds)).getCheckedData(); // 3. 回写归档档案 ID(作为已归档硬标记) applicationMapper.updateById(new ResearchApplicationDO() .setId(id).setArchiveDocumentId(archiveDocumentId)); return StrUtil.blankToDefault(archiveNo(id), "未知"); } @Override @Transactional(rollbackFor = Exception.class) public void updateReviewSummary(Long id, BigDecimal reviewAvgScore, Integer reviewPass) { // 用 UpdateWrapper 显式 set,保证「评审明细被删空」时能把汇总值清成 null applicationMapper.update(null, new LambdaUpdateWrapper() .eq(ResearchApplicationDO::getId, id) .set(ResearchApplicationDO::getReviewAvgScore, reviewAvgScore) .set(ResearchApplicationDO::getReviewPass, reviewPass)); } // =================== 内部方法 =================== private ResearchApplicationDO validateApplicationExists(Long id) { ResearchApplicationDO application = applicationMapper.selectById(id); if (application == null) { throw exception(APPLICATION_NOT_EXISTS); } return application; } /** 查询来源单据已生成的档案编号(未归档时返回 null) */ private String archiveNo(Long applicationId) { ArchiveDocumentRespDTO archive = archiveDocumentApi .getBySource(ARCHIVE_SOURCE_TYPE_RESEARCH, applicationId).getCheckedData(); return archive != null ? archive.getNo() : null; } /** 保存(草稿)阶段校验指南:必须存在且已发布 */ private ResearchGuidelineDO validateGuidelineForSave(Long guidelineId) { ResearchGuidelineDO guideline = guidelineService.getGuideline(guidelineId); if (guideline == null) { throw exception(GUIDELINE_NOT_EXISTS); } if (!ResearchGuidelineStatusEnum.PUBLISHED.getStatus().equals(guideline.getPublishStatus())) { throw exception(APPLICATION_CREATE_FAIL_GUIDELINE_STATUS, guideline.getTitle(), ResearchGuidelineStatusEnum.nameOf(guideline.getPublishStatus())); } return guideline; } private void validateMemberExists(Long memberId) { if (memberId == null) { return; } UmsMemberDO member = umsMemberService.getMember(memberId); if (member == null) { throw exception(APPLICATION_MEMBER_NOT_EXISTS, memberId); } } private void validateApplicationApproveCategory() { List categories = bpmCategoryService.getCategoryListByCode( Collections.singletonList(APPLICATION_APPROVE_CATEGORY_CODE)); if (CollUtil.isEmpty(categories)) { throw exception(APPLICATION_BPM_CATEGORY_NOT_EXISTS); } } private Integer convertBpmResultToStatus(Integer bpmResult) { if (cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum.APPROVE.getStatus().equals(bpmResult)) { return ResearchApplicationStatusEnum.APPROVED.getStatus(); } else if (cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum.REJECT.getStatus().equals(bpmResult)) { return ResearchApplicationStatusEnum.REJECTED.getStatus(); } else if (cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum.CANCEL.getStatus().equals(bpmResult)) { return ResearchApplicationStatusEnum.DRAFT.getStatus(); } return null; } private String buildNoticeTitle(ResearchGuidelineDO guideline) { String title = "关于公布立项课题的通知"; if (guideline != null && StrUtil.isNotBlank(guideline.getTitle())) { title = "关于公布" + guideline.getTitle() + "立项课题的通知"; } // system_notice.title 上限 50 字符 return StrUtil.maxLength(title, 50); } private String buildNoticeContent(ResearchApplicationDO application, ResearchGuidelineDO guideline) { StringBuilder sb = new StringBuilder(); sb.append("

经学会立项审批通过,现将立项课题公布如下:

    "); sb.append("
  1. ").append(HtmlUtil.escape(application.getTitle())).append("") .append("(立项编号:").append(HtmlUtil.escape(application.getResearchNo())).append(")
    ") .append("课题类别:").append(HtmlUtil.escape(ResearchGuidelineCategoryEnum.nameOf(application.getCategory()))) .append(";申报人:").append(HtmlUtil.escape(application.getApplicantName())) .append(";工作单位:").append(HtmlUtil.escape(StrUtil.nullToDefault(application.getWorkUnit(), "-"))) .append(";资助金额:").append(application.getBudgetAmount()).append(" 元"); if (guideline != null && StrUtil.isNotBlank(guideline.getTitle())) { sb.append("
    所属指南:").append(HtmlUtil.escape(guideline.getTitle())); } sb.append("
"); sb.append("

请课题负责人按申报书约定推进研究工作,经费拨付事宜另行通知。

"); return sb.toString(); } private static String format(LocalDateTime time) { return time == null ? "未记录" : time.format(DATE_TIME_FORMATTER); } }