liu
15 小时以前 5d34bdd3c426bb2ca5f6627424ae67984f745603
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/application/ResearchApplicationServiceImpl.java
@@ -7,6 +7,10 @@
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;
@@ -15,15 +19,21 @@
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;
@@ -40,6 +50,7 @@
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;
@@ -54,6 +65,15 @@
    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;
@@ -62,6 +82,12 @@
    @Resource
    private ResearchApplicationMapper applicationMapper;
    @Resource
    private ResearchApplicationMemberMapper memberMapper;
    @Resource
    private ArchiveDocumentApi archiveDocumentApi;
    @Resource
    private ResearchNoRedisDAO noRedisDAO;
@@ -108,6 +134,7 @@
                .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());
@@ -130,6 +157,10 @@
        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());
    }
@@ -143,6 +174,7 @@
                    application.getNo(), ResearchApplicationStatusEnum.nameOf(application.getStatus()));
        }
        memberMapper.deleteByApplicationId(id);
        applicationMapper.deleteById(id);
        storageAttachmentApi.deleteAttachmentsByRecord(APPLICATION_RECORD_TYPE, id);
    }
@@ -150,6 +182,116 @@
    @Override
    public ResearchApplicationDO getApplication(Long id) {
        return applicationMapper.selectById(id);
    }
    @Override
    public List<ResearchApplicationMemberDO> getMemberListByApplicationId(Long applicationId) {
        return memberMapper.selectListByApplicationId(applicationId);
    }
    @Override
    public List<ResearchApplicationMemberStatisticsRespVO> getMemberStatistics(ResearchApplicationMemberStatisticsReqVO reqVO) {
        // 1. 按状态过滤申报单
        List<ResearchApplicationDO> applications = applicationMapper.selectList(
                new LambdaQueryWrapperX<ResearchApplicationDO>()
                        .eqIfPresent(ResearchApplicationDO::getStatus, reqVO.getStatus()));
        if (CollUtil.isEmpty(applications)) {
            return Collections.emptyList();
        }
        Map<Long, ResearchApplicationDO> applicationMap = applications.stream()
                .collect(Collectors.toMap(ResearchApplicationDO::getId, application -> application, (a, b) -> a));
        // 2. 成员子表聚合:姓名 + 电话 + 单位 相同视为同一成员
        String keyword = StrUtil.trimToNull(reqVO.getKeyword());
        Map<String, ResearchApplicationMemberStatisticsRespVO> resultMap = new LinkedHashMap<>();
        Map<String, Set<String>> roleMap = new HashMap<>();
        Map<String, Set<Long>> projectIdMap = new HashMap<>();
        Map<String, Set<String>> 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<ResearchApplicationMemberStatisticsRespVO> result = new ArrayList<>(resultMap.size());
        for (Map.Entry<String, ResearchApplicationMemberStatisticsRespVO> 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<ResearchApplicationMemberSaveReqVO> members) {
        if (CollUtil.isEmpty(members)) {
            return;
        }
        List<ResearchApplicationMemberDO> 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
@@ -275,6 +417,42 @@
    @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<Long> 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<ResearchApplicationDO>()
@@ -293,6 +471,13 @@
        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);