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.framework.mybatis.core.query.LambdaQueryWrapperX; 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.controller.admin.application.vo.member.ResearchApplicationMemberSaveReqVO; import cn.iocoder.yudao.module.research.controller.admin.application.vo.member.ResearchApplicationMemberStatisticsReqVO; import cn.iocoder.yudao.module.research.controller.admin.application.vo.member.ResearchApplicationMemberStatisticsRespVO; import cn.iocoder.yudao.module.research.dal.dataobject.application.ResearchApplicationDO; import cn.iocoder.yudao.module.research.dal.dataobject.application.ResearchApplicationMemberDO; 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.mysql.application.ResearchApplicationMemberMapper; 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 ResearchApplicationMemberMapper memberMapper; @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); saveMembers(application.getId(), createReqVO.getMembers()); 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); // 成员子表:先删后增,全量覆盖 memberMapper.deleteByApplicationId(updateReqVO.getId()); saveMembers(updateReqVO.getId(), updateReqVO.getMembers()); 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())); } memberMapper.deleteByApplicationId(id); applicationMapper.deleteById(id); storageAttachmentApi.deleteAttachmentsByRecord(APPLICATION_RECORD_TYPE, id); } @Override public ResearchApplicationDO getApplication(Long id) { return applicationMapper.selectById(id); } @Override public List getMemberListByApplicationId(Long applicationId) { return memberMapper.selectListByApplicationId(applicationId); } @Override public List getMemberStatistics(ResearchApplicationMemberStatisticsReqVO reqVO) { // 1. 按状态过滤申报单 List applications = applicationMapper.selectList( new LambdaQueryWrapperX() .eqIfPresent(ResearchApplicationDO::getStatus, reqVO.getStatus())); if (CollUtil.isEmpty(applications)) { return Collections.emptyList(); } Map applicationMap = applications.stream() .collect(Collectors.toMap(ResearchApplicationDO::getId, application -> application, (a, b) -> a)); // 2. 成员子表聚合:姓名 + 电话 + 单位 相同视为同一成员 String keyword = StrUtil.trimToNull(reqVO.getKeyword()); Map resultMap = new LinkedHashMap<>(); Map> roleMap = new HashMap<>(); Map> projectIdMap = new HashMap<>(); Map> projectTitleMap = new HashMap<>(); for (ResearchApplicationMemberDO member : memberMapper.selectList()) { ResearchApplicationDO application = applicationMap.get(member.getApplicationId()); if (application == null || !matchesMemberKeyword(member, keyword)) { continue; } String key = buildMemberKey(member); ResearchApplicationMemberStatisticsRespVO vo = resultMap.computeIfAbsent(key, k -> { ResearchApplicationMemberStatisticsRespVO item = new ResearchApplicationMemberStatisticsRespVO(); item.setName(StrUtil.trim(member.getName())); item.setContactPhone(StrUtil.trim(member.getContactPhone())); item.setWorkUnit(StrUtil.trim(member.getWorkUnit())); item.setJobTitle(StrUtil.trim(member.getJobTitle())); return item; }); if (StrUtil.isBlank(vo.getJobTitle()) && StrUtil.isNotBlank(member.getJobTitle())) { vo.setJobTitle(StrUtil.trim(member.getJobTitle())); } if (StrUtil.isNotBlank(member.getMemberRole())) { roleMap.computeIfAbsent(key, k -> new LinkedHashSet<>()).add(StrUtil.trim(member.getMemberRole())); } projectIdMap.computeIfAbsent(key, k -> new LinkedHashSet<>()).add(member.getApplicationId()); if (StrUtil.isNotBlank(application.getTitle())) { projectTitleMap.computeIfAbsent(key, k -> new LinkedHashSet<>()).add(application.getTitle()); } } // 3. 汇总成员维度结果 List result = new ArrayList<>(resultMap.size()); for (Map.Entry entry : resultMap.entrySet()) { String key = entry.getKey(); ResearchApplicationMemberStatisticsRespVO vo = entry.getValue(); vo.setMemberRoles(String.join("、", roleMap.getOrDefault(key, Collections.emptySet()))); vo.setProjectCount(projectIdMap.getOrDefault(key, Collections.emptySet()).size()); vo.setProjectTitles(String.join(";", projectTitleMap.getOrDefault(key, Collections.emptySet()))); result.add(vo); } result.sort((a, b) -> { int compare = Integer.compare( b.getProjectCount() == null ? 0 : b.getProjectCount(), a.getProjectCount() == null ? 0 : a.getProjectCount()); return compare != 0 ? compare : StrUtil.compare(a.getName(), b.getName(), true); }); return result; } /** 保存申报书成员子表(调用方需自行保证 update 场景已先删除旧数据) */ private void saveMembers(Long applicationId, List members) { if (CollUtil.isEmpty(members)) { return; } List list = new ArrayList<>(members.size()); int index = 1; for (ResearchApplicationMemberSaveReqVO member : members) { if (member == null || StrUtil.isBlank(member.getName())) { continue; } list.add(ResearchApplicationMemberDO.builder() .applicationId(applicationId) .name(StrUtil.trim(member.getName())) .contactPhone(StrUtil.trim(member.getContactPhone())) .workUnit(StrUtil.trim(member.getWorkUnit())) .jobTitle(StrUtil.trim(member.getJobTitle())) .memberRole(StrUtil.trim(member.getMemberRole())) .sortNo(member.getSortNo() != null ? member.getSortNo() : index) .build()); index++; } if (CollUtil.isNotEmpty(list)) { memberMapper.insertBatch(list); } } private String buildMemberKey(ResearchApplicationMemberDO member) { return StrUtil.join("|", StrUtil.nullToEmpty(member.getName()).trim(), StrUtil.nullToEmpty(member.getContactPhone()).trim(), StrUtil.nullToEmpty(member.getWorkUnit()).trim()); } private boolean matchesMemberKeyword(ResearchApplicationMemberDO member, String keyword) { if (StrUtil.isBlank(keyword)) { return true; } return StrUtil.containsIgnoreCase(member.getName(), keyword) || StrUtil.containsIgnoreCase(member.getContactPhone(), keyword) || StrUtil.containsIgnoreCase(member.getWorkUnit(), keyword); } @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); } }