docs(project-business): 更新项目业务文档增加财务报销会员管理和课题管理模块

- 在业务思维导图中添加财务报销、会员管理和课题管理三个独立模块及其功能点
- 更新模块关系图,添加新模块与BPM、附件中心等系统的关联关系
- 在数据流图中添加财务报销和课题管理的子图及详细数据流向
- 扩展业务对象关系图,添加新模块间的数据关联关系
- 新增财务报销流程图,详细描述报销单填报、审批、归档全流程
- 更新业务JSON数据,添加新模块信息、统计数据和待解决问题
- 扩展流程JSON,添加财务报销、会员入会、课题管理三个新业务流程
- 更新首页样式,为新模块添加颜色主题和导航链接
- 在系统概览中添加新子系统标识和数据库表统计信息
- 完善模块详情,为财务、会员、课题模块添加功能特性描述
已添加89个文件
已修改15个文件
6384 ■■■■■ 文件已修改
_e2e_attachment.txt 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
_login.json 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/README.md 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/business.json 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/flows.json 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/modules.json 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/objects.json 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/roles.json 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/index.html 297 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/01-business-mindmap.mmd 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/08-module-relation.mmd 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/09-data-flow.mmd 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/10-business-object.mmd 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/12-finance-flow.mmd 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pom.xml 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/pom.xml 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/FinanceReimbursementController.java 193 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/vo/FinanceReimbursementPageReqVO.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/vo/FinanceReimbursementRespVO.java 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/vo/FinanceReimbursementSaveReqVO.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/vo/item/FinanceReimbursementItemRespVO.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/vo/item/FinanceReimbursementItemSaveReqVO.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/dal/dataobject/reimbursement/FinanceReimbursementDO.java 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/dal/dataobject/reimbursement/FinanceReimbursementItemDO.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/dal/mysql/reimbursement/FinanceReimbursementItemMapper.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/dal/mysql/reimbursement/FinanceReimbursementMapper.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/dal/redis/FinanceNoRedisDAO.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/dal/redis/RedisKeyConstants.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/enums/DictTypeConstants.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/enums/ErrorCodeConstants.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/enums/FinanceReimbursementStatusEnum.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/framework/web/config/FinanceWebConfiguration.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/service/reimbursement/FinanceReimbursementService.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/service/reimbursement/FinanceReimbursementServiceImpl.java 348 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/service/reimbursement/listener/FinanceReimbursementStatusListener.java 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/pom.xml 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/application/ResearchApplicationController.java 162 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/application/vo/ResearchApplicationPageReqVO.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/application/vo/ResearchApplicationRespVO.java 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/application/vo/ResearchApplicationSaveReqVO.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/fund/ResearchFundRecordController.java 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/fund/vo/ResearchFundRecordRespVO.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/fund/vo/ResearchFundRecordSaveReqVO.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/guideline/ResearchGuidelineController.java 153 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/guideline/vo/ResearchGuidelinePageReqVO.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/guideline/vo/ResearchGuidelineRespVO.java 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/guideline/vo/ResearchGuidelineSaveReqVO.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/review/ResearchTopicReviewController.java 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/review/vo/ResearchTopicReviewRespVO.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/review/vo/ResearchTopicReviewSaveReqVO.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/dataobject/application/ResearchApplicationDO.java 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/dataobject/fund/ResearchFundRecordDO.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/dataobject/guideline/ResearchGuidelineDO.java 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/dataobject/review/ResearchTopicReviewDO.java 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/mysql/application/ResearchApplicationMapper.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/mysql/fund/ResearchFundRecordMapper.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/mysql/guideline/ResearchGuidelineMapper.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/mysql/review/ResearchTopicReviewMapper.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/redis/RedisKeyConstants.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/redis/ResearchNoRedisDAO.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/enums/DictTypeConstants.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/enums/ErrorCodeConstants.java 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/enums/ResearchApplicationStatusEnum.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/enums/ResearchFundStatusEnum.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/enums/ResearchGuidelineCategoryEnum.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/enums/ResearchGuidelineStatusEnum.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/enums/ResearchReviewConclusionEnum.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/framework/web/config/ResearchWebConfiguration.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/application/ResearchApplicationService.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/application/ResearchApplicationServiceImpl.java 368 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/application/listener/ResearchApplicationStatusListener.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/fund/ResearchFundRecordService.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/fund/ResearchFundRecordServiceImpl.java 162 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/guideline/ResearchGuidelineService.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/guideline/ResearchGuidelineServiceImpl.java 192 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/review/ResearchTopicReviewService.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/review/ResearchTopicReviewServiceImpl.java 190 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/util/storage/StorageFileUtil.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/pom.xml 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/application/UmsApplicationController.java 146 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/application/vo/UmsApplicationPageReqVO.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/application/vo/UmsApplicationRespVO.java 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/application/vo/UmsApplicationSaveReqVO.java 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/member/UmsMemberController.java 136 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/member/vo/UmsMemberPageReqVO.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/member/vo/UmsMemberRespVO.java 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/member/vo/UmsMemberSaveReqVO.java 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/dal/dataobject/application/UmsApplicationDO.java 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/dal/dataobject/member/UmsMemberDO.java 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/dal/mysql/application/UmsApplicationMapper.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/dal/mysql/member/UmsMemberMapper.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/dal/redis/RedisKeyConstants.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/dal/redis/UmsNoRedisDAO.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/enums/DictTypeConstants.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/enums/ErrorCodeConstants.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/enums/UmsApplicationStatusEnum.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/framework/web/config/UmsWebConfiguration.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/service/application/UmsApplicationService.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/service/application/UmsApplicationServiceImpl.java 233 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/service/application/listener/UmsApplicationStatusListener.java 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/service/member/UmsMemberService.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/service/member/UmsMemberServiceImpl.java 196 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/pom.xml 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
_e2e_attachment.txt
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,6 @@
课题申报书附件(E2E æµ‹è¯•)
课题名称:面向智能工厂的数字孪生关键技术研究
申报人:张伟
本文件用于端到端验证附件上传与绑定流程。
_login.json
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1 @@
{"code":0,"data":{"userId":1,"accessToken":"ad2605f793b2406cb951c18105d4c834","refreshToken":"8c2f2683b9cf43c6842cbd72a98b4841","expiresTime":1790590394166},"msg":""}
docs/project-business/README.md
@@ -8,18 +8,19 @@
- **业务定位**:离散制造工厂产供销人财物一体化:覆盖『销售接单 â†’ é‡‡è´­/生产 â†’ è´¨æ£€ â†’ ä»“储 â†’ å‘è´§ â†’ å›žæ¬¾ â†’ å”®åŽã€å…¨é“¾è·¯é—­çޝ
- **技术底座**:yudao(ruoyi-vue-pro)二开,Spring Boot + MyBatis + Flowable + Redis + MySQL 8.4 + Milvus + é€šä¹‰åƒé—®ï¼›å‰ç«¯ mom-pro2-before(Vue3)
- **核心主线**:CRM åˆåŒ â†’ ERP é”€å”®è®¢å• â†’ MES ä¸»è®¡åˆ’/工单/报工 â†’ è´¨æ£€ä¸Žä»“储 â†’ é”€å”®å‡ºåº“ â†’ å¼€ç¥¨å›žæ¬¾ â†’ å”®åŽé—­çޝ
- **独立模块**:财务报销(`yudao-module-finance`,报销单/明细/发票附件/审批/归档)与会员管理(`yudao-module-ums`,入会申请/秘书处与理事会两级审批/会员/通讯录)各自成一条链路,均不与其他业务模块产生单据关联(UMS ä¾‹å¤–:审批通过会经 AdminUserApi åˆ›å»ºç³»ç»Ÿç™»å½•账号);课题管理(`yudao-module-research`,课题指南/在线申报/专家评审/三级立项审批/立项通知/经费拨付)为另一条独立科研主线,仅向外取 `ums_member` ä½œä¸ºç”³æŠ¥äººã€å‘内写 `system_notice` ç«‹é¡¹å…¬å‘Š
## 2. ç›®å½•结构
```text
docs/project-business/
├── index.html      # å¯è§†åŒ–页面:双击直接打开(file:// å³å¯ï¼Œæ— éœ€ä»»ä½•服务)
├── mermaid/        # 11 å¼  Mermaid ä¸šåŠ¡å›¾ï¼ˆæƒå¨æºï¼Œæ”¹è¿™é‡Œï¼‰
├── mermaid/        # 14 å¼  Mermaid ä¸šåŠ¡å›¾ï¼ˆæƒå¨æºï¼Œæ”¹è¿™é‡Œï¼‰
├── data/           # ä¸šåŠ¡æ•°æ®ï¼ˆæƒå¨æºï¼Œæ”¹è¿™é‡Œï¼‰
│   â”œâ”€â”€ business.json  # é¡¹ç›®å…ƒä¿¡æ¯/业务十问/AI能力/待确认事项
│   â”œâ”€â”€ modules.json   # 12 ä¸ªä¸šåŠ¡æ¨¡å—å®šä¹‰
│   â”œâ”€â”€ modules.json   # 16 ä¸ªä¸šåŠ¡æ¨¡å—å®šä¹‰
│   â”œâ”€â”€ objects.json   # æ ¸å¿ƒä¸šåŠ¡å¯¹è±¡ï¼ˆä½œç”¨/上游/下游/表名)
│   â”œâ”€â”€ flows.json     # 7 æ¡æµç¨‹ï¼ˆæ‘˜è¦+步骤+角色)
│   â”œâ”€â”€ flows.json     # 9 æ¡æµç¨‹ï¼ˆæ‘˜è¦+步骤+角色)
│   â””── roles.json     # è§’色权限
└── README.md
```
@@ -45,6 +46,9 @@
| 09-data-flow | è·¨æ¨¡å—数据流(单据生成、字段带入、数量回写、事件) |
| 10-business-object | æ ¸å¿ƒä¸šåŠ¡å¯¹è±¡ ER(只含业务对象,非全库表) |
| 11-ai-business-flow | AI ä¸šåŠ¡é“¾è·¯ï¼ˆOCR/质检判定/四类预测/RAG,含 API ä¾èµ–) |
| 12-finance-flow | è´¢åŠ¡æŠ¥é”€ï¼ˆç‹¬ç«‹æ¨¡å—ï¼‰ï¼šæŠ¥é”€å•+明细+发票附件 â†’ æäº¤ BPM å®¡æ‰¹ â†’ é€šè¿‡/驳回 â†’ å½’档终态 |
| 13-ums-membership-flow | ä¼šå‘˜å…¥ä¼šï¼ˆç‹¬ç«‹æ¨¡å—):入会申请 + ææ–™é™„ä»¶ â†’ æäº¤ BPM ä¸¤çº§å®¡æ‰¹ï¼ˆç§˜ä¹¦å¤„初审→理事会审议)→ é€šè¿‡è‡ªåŠ¨å»ºä¼šå‘˜ä¸Žç™»å½•è´¦å· â†’ ä¼šå‘˜åˆ—表/通讯录检索 |
| 14-research-flow | è¯¾é¢˜ç®¡ç†ï¼ˆç‹¬ç«‹æ¨¡å—):指南发布(重点/一般)→在线申报(选会员带出申报人)→专家评分明细加权排名→BPM ä¸‰çº§å®¡æ‰¹(秘书处形式审查→专家评审→学会立项审批)→立项编号与公告→经费拨付记录 |
图中状态数字均为源码枚举真实值(如工单 0草稿/1已确认/2完成/3取消;ERP å®¡æ‰¹ 15审批中/20已审核)。
@@ -65,8 +69,8 @@
- æœ¬ç›®å½•由项目技能 **project-business-analysis** ç®¡ç†ï¼ˆ`.claude/skills/project-business-analysis/`),并有规则 `.claude/rules/business-visualization-maintenance.md` å¼ºåˆ¶ï¼šä¸šåŠ¡æ¨¡å—/流程/状态/对象/角色/AI å˜æ›´åŽéœ€åŒæ­¥ç»´æŠ¤æœ¬ç›®å½•。
- å¢žé‡ç»´æŠ¤ï¼šæ”¹å¯¹åº”çš„ `mermaid/*.mmd` + `data/*.json` â†’ è¿è¡Œä¸Šé¢çš„ sync è„šæœ¬ â†’ æµè§ˆå™¨éªŒè¯å—影响图。
- å…¨é‡é‡åšï¼ˆåå·®è¿‡å¤§æ—¶ï¼‰ï¼šå¯¹ Claude è¯´ã€Œé‡æ–°ç”Ÿæˆä¸šåŠ¡å…¨æ™¯ã€ï¼ŒæŠ€èƒ½ä¼šé‡æ–°èµ°è¯»æºç å¹¶æŒ‰ç”Ÿæˆæ¨¡å¼é‡å»ºã€‚
- ç”ŸæˆåŸºçº¿ï¼šdev_business @ 87bf4a99,2026-09-10。
- ç”ŸæˆåŸºçº¿ï¼šdev_business @ 87bf4a99,2026-09-10;2026-09-28 å¢žé‡è¡¥å……财务报销模块(`yudao-module-finance`)、会员管理模块(`yudao-module-ums`)与课题管理模块(`yudao-module-research`,一期项 1~5;二期结项验收与成果归档尚未做,图中已标注未覆盖范围)。
## 7. å·²çŸ¥å¾…确认业务
见 `data/business.json` çš„ `pendingConfirm`(页面「待确认事项」栏),要点:仓库类型/合格仓改造未落地、HRM äº¤æŽ¥ expandUserIds å°šæœªè¢«ä¸šåŠ¡æŸ¥è¯¢æŽ¥å…¥ã€å›žæ¬¾ä¸Žæ”¶æ¬¾å•æœªèµ° BPM、财务勾稽回写方法为空、NCR å¤„置不回写源单等。
见 `data/business.json` çš„ `pendingConfirm`(页面「待确认事项」栏),要点:仓库类型/合格仓改造未落地、HRM äº¤æŽ¥ expandUserIds å°šæœªè¢«ä¸šåŠ¡æŸ¥è¯¢æŽ¥å…¥ã€å›žæ¬¾ä¸Žæ”¶æ¬¾å•æœªèµ° BPM、财务勾稽回写方法为空、NCR å¤„置不回写源单、财务报销的 BPM æµç¨‹å®šä¹‰æœªå‘布与普通角色未授菜单、会员管理的 BPM æµç¨‹æœªå‘布且缺审批自定义表单页、自动建账号用手机号作初始密码存在弱口令风险、课题管理的评分权重与通过阈值为占位规则待学会确认、一期经费拨付与财务模块无数据同步、申报单与会员为软关联可能悬空等。
docs/project-business/data/business.json
@@ -6,6 +6,9 @@
    "subSystems": [
      "MES(生产·仓储·质量·设备)",
      "ERP(进销存·财务)",
      "FIN(发票报销,独立模块)",
      "UMS(学会会员,独立模块)",
      "RES(科研课题,独立模块)",
      "CRM(客户关系)",
      "SRM(供应商关系)",
      "HRM(人力资源)",
@@ -17,14 +20,14 @@
    "businessPositioning": "让工厂的订单、物料、生产、库存、质量、人员、资金在一个系统内闭环流转,管理层可通过 BI å¤§å±æŽŒæ¡å…¨å±€ã€‚",
    "coreChain": "CRM å•†æœº/合同 â†’ ERP é”€å”®è®¢å• â†’ MES ä¸»ç”Ÿäº§è®¡åˆ’/工单/报工 â†’ MES è´¨æ£€ä¸Žä»“储 â†’ é”€å”®å‡ºåº“发货 â†’ å¼€ç¥¨å›žæ¬¾ â†’ å”®åŽé—­çޝ",
    "techBase": "Spring Boot + MyBatis + Flowable + Redis + Quartz/RocketMQ/Kafka + Milvus å‘量库 + é€šä¹‰åƒé—®",
    "database": "MySQL 8.4,413 å¼ è¡¨ï¼ˆå« system_*、bpm_*、act_* åº•座表与 mes_*/erp_*/crm_*/srm_*/hrm_*/mdm_*/ai_* ä¸šåŠ¡è¡¨ï¼‰ï¼Œå¤šç§Ÿæˆ·å·²å…³é—­",
    "database": "MySQL 8.4,运行库 mom-jssm å…± 427 å¼ è¡¨ï¼ˆå« system_*、bpm_*、act_* åº•座表与 mes_*/erp_*/crm_*/srm_*/hrm_*/mdm_*/ai_*/finance_*/ums_*/research_* ä¸šåŠ¡è¡¨ï¼‰ï¼Œå¤šç§Ÿæˆ·å·²å…³é—­",
    "frontend": "mom-pro2-before(Vue3 ç®¡ç†åŽå°ï¼ŒåŒä»“库存放,只读参考)"
  },
  "stats": {
    "businessModules": 13,
    "coreObjects": 48,
    "documentFlows": 6,
    "approvalListeners": 13,
    "businessModules": 16,
    "coreObjects": 56,
    "documentFlows": 9,
    "approvalListeners": 14,
    "aiCapabilities": 8,
    "wmBillTypes": 15
  },
@@ -143,8 +146,19 @@
    "MesWmSnDO æ–°å¢žçš„ uuid å­—段生成/消费逻辑未在核心 Service ä¸­ç¡®è®¤",
    "转正申请(hrm_regularize_application)、培训证书等仅有 SQL ä¸Žèœå•初始化,Java ä¾§ Service/Controller æœªå®žçް",
    "NCR å¤„置结果(返工/退货)未回写触发源单据,仅台账记录",
    "MPS çŠ¶æ€æžšä¸¾ç‹¬ç«‹ç¼–å·(0/1/2/3),与通用单据常量(0/1/4/5)不一致"
    "MPS çŠ¶æ€æžšä¸¾ç‹¬ç«‹ç¼–å·(0/1/2/3),与通用单据常量(0/1/4/5)不一致",
    "财务报销:BPM åˆ†ç±» finance_reimbursement_approve ä¸‹å°šæ— å·²å‘布的流程定义(act_re_cprocdef ä¸ºç©ºï¼‰ï¼Œä¸”流程需把「自定义表单路径」配为 /finance/reimbursement/detail/approve/index.vue,否则前端「提交审批」被禁用、审批中心看不到报销单内容【需 BPM è®¾è®¡å™¨ä¾§é…ç½®ã€‘",
    "财务报销:菜单仅授予 super_admin(超管由代码直返全部菜单),普通角色未配 system_role_menu,非超管登录看不到「报销单」菜单【待确认是否补授权】",
    "会员管理:会员管理菜单(1075490~1075503)仅 super_admin å¯è§ï¼Œæ™®é€šè§’色未配 system_role_menu,非超管登录看不到会员管理目录【待确认是否补授权】",
    "会员管理:审批通过自动建系统账号用「联系电话」同时作用户名与初始密码,若联系电话为空会用字面量 unknown å…œåº•,存在弱口令与重名风险【待确认是否改造】",
    "会员管理:自动建档不搬运 contactUser/unitIntro/expireDate(ums_application æ— å¯¹åº”字段),单位会员的「联系人/单位简介/有效期」只能在会员列表手工补录【待确认是否需要申请单增字段】",
    "会员管理:单位会员的等级字典 ums_unit_grade ä»…到「会长单位」(4),但校验逻辑额外硬编码禁止单位选「理事长」(grade=5)——该值本不在单位字典内,属防御性冗余校验【待确认是否精简】",
    "课题管理:专家评分权重(选题0.3/方案0.3/团队0.2/成果0.2)与「加权均分≥60 ä¸” å¤šæ•°ç»“论为推荐立项」的通过阈值是占位规则,需学会最终确认;已集中为常量便于调整【待确认,改动只需改常量】",
    "课题管理:一期经费拨付只在本模块落拨款记录,与财务模块无任何数据同步,同一笔钱在两处记录存在不一致风险;二期对接时需明确以哪侧为准【待确认】",
    "课题管理:申报单与 ums_member æ˜¯è½¯å…³è”(无外键),会员被删除后申报单会留下悬空 memberId;成员列表需容错展示「会员已删除」【待确认是否加外键或删除保护】",
    "课题管理:BPM æµç¨‹ research_topic_approve ä¸‰èŠ‚ç‚¹é»˜è®¤å®¡æ‰¹äººä¸ºã€Œè§’è‰²=超级管理员(1) + ä¾æ¬¡å®¡æ‰¹ã€ï¼Œè¯¥ dev åº“超级管理员角色下有 4 ä¸ªç”¨æˆ·ï¼ˆadmin/张娟/马芳/马贞),每个节点需 4 äººä¼šç­¾æ‰æŽ¨è¿›ï¼›å…¨æ–°å®‰è£…库该角色仅 admin ä¸€äººã€æµç¨‹å¯å¼€ç®±å³ç”¨ã€‚dev åº“需减少超级管理员用户或改在 BPM è®¾è®¡å™¨ä¸­ä¸ºæ¯èŠ‚ç‚¹æŒ‡å®šå®¡æ‰¹äººã€çŽ¯å¢ƒé…ç½®é¡¹ï¼Œéžä»£ç ç¼ºé™·ã€‘",
    "课题管理:立项通知复用 system_notice ç³»ç»Ÿå…¬å‘Šï¼Œä¸æ–°å¢ž system_notify_template、不发站内信;如需短信/站内信定向通知需另行配置【待确认通知渠道】"
  ],
  "generatedAt": "2026-09-10",
  "analysisBasis": "基于 dev_business åˆ†æ”¯ 87bf4a99 æºç ï¼Œ6 ç»„并行源码走读(MES ç”Ÿäº§è´¨é‡ / MES ä»“储 / ERP / CRM+SRM / å”®åŽ+HRM / AI+BI+MDM+BPM+底座)"
  "generatedAt": "2026-09-28",
  "analysisBasis": "基于 dev_business åˆ†æ”¯ 87bf4a99 æºç ï¼Œ6 ç»„并行源码走读(MES ç”Ÿäº§è´¨é‡ / MES ä»“储 / ERP / CRM+SRM / å”®åŽ+HRM / AI+BI+MDM+BPM+底座);2026-09-28 å¢žé‡è¡¥å…… yudao-module-finance ç‹¬ç«‹è´¢åŠ¡æŠ¥é”€æ¨¡å—ï¼ˆæŠ¥é”€å• + æ˜Žç»† + å‘票附件 + BPM å®¡æ‰¹ + å½’档)与 yudao-module-ums ç‹¬ç«‹ä¼šå‘˜ç®¡ç†æ¨¡å—(入会申请 + BPM ä¸¤çº§å®¡æ‰¹ + å®¡æ‰¹é€šè¿‡è‡ªåŠ¨å»ºä¼šå‘˜ä¸Žç™»å½•è´¦å· + ä¼šå‘˜åˆ—表/通讯录);2026-09-28 å¢žé‡ï¼šBPM æµç¨‹å®šä¹‰ ums_membership_approve(入会审批)已在 mom-jssm éƒ¨ç½²å¹¶å¹¶å…¥ config_export_all é˜¶æ®µä¸€ï¼ˆåˆ†ç±» ums_membership_approve,秘书处初审 â†’ ç†äº‹ä¼šå®¡è®®ï¼Œä¸¤èŠ‚ç‚¹å®¡æ‰¹äººå‡ä¸ºã€Œè§’è‰²ã€ç­–ç•¥ã€è§’è‰²=超级管理员(1) å…œåº•,表单类型=自定义表单,查看路径 /ums/application/detail/approve/index.vue);同时修复 mom-jssm ç”± CREATE TABLE ... LIKE å…‹éš†å¯¼è‡´çš„ 47 ä¸ªæŒ‡å‘ ruoyi-vue-pro çš„ act_*/flw_* è·¨åº“外键(重建为指向 mom-jssm è‡ªèº«ï¼Œå­¤å„¿è¡Œé¢„检为 0);2026-09-28 å¢žé‡ï¼šæ–°å¢ž yudao-module-research ç‹¬ç«‹ç§‘研课题模块一期(课题指南发布 + åœ¨çº¿ç”³æŠ¥ + ä¸“家评审明细 + BPM ä¸‰çº§ç«‹é¡¹å®¡æ‰¹ + ç«‹é¡¹é€šçŸ¥å…¬å‘Š + ç»è´¹æ‹¨ä»˜è®°å½•,4 å¼  research_* è¡¨ / 5 ä¸ªå­—å…¸ / bpm_category id=8002 / æµç¨‹ research_topic_approve / èœå• 1075510~1075553),并并入 config_export_all_20260928 é˜¶æ®µä¸€ï¼›ç”³æŠ¥äººå– ums_member(软关联)、立项通知复用 system_notice å…¬å‘Šã€æ‹¨æ¬¾å‡­è¯èµ° system é™„件中心 recordType=research_fund_record"
}
docs/project-business/data/flows.json
@@ -53,6 +53,36 @@
       {"stage": "判定", "action": "问题判定: ä¸€èˆ¬/ç»´ä¿®/退货,严重度 0-3", "roles": ["售后工程师"]},
       {"stage": "ç»´ä¿®", "action": "现场/返厂/远程维修,费用与结果记录,完成联动工单完结", "roles": ["维修工程师"]},
       {"stage": "退货", "action": "退货申请审核→自动建 MES é”€å”®é€€è´§å•(RQC)→质检/入库回调→退款回调→关闭", "roles": ["售后", "质检", "财务"]}]},
    {"id": "finance", "name": "财务报销流程", "mermaid": "mermaid/12-finance-flow.mmd", "color": "#3ec9a7",
     "summary": "独立财务模块(yudao-module-finance):报销单(主表 + 1..n æ˜Žç»†è¡Œ + å‘票附件) ä¸€æ¬¡æ€§ä¿å­˜ â†’ æäº¤ BPM å®¡æ‰¹ â†’ é€šè¿‡åŽå½’档终态;驳回可改发票重新提交。附件走 system é™„件中心按 recordType=finance_reimbursement ç»‘定,模块不与其他业务模块产生任何单据关联或字段带入。",
     "steps": [
       {"stage": "填报", "action": "主表(报销标题/报销总金额/申请日期/备注) + è‡³å°‘ 1 è¡Œæ˜Žç»†(费用类型/发票号码/发票金额/发票日期/开票方/费用说明);总金额由明细求和回填,用户可手改,后端不校验一致性", "roles": ["报销人"]},
       {"stage": "附件", "action": "发票先调 /system/storage-blob/upload å– blobId;保存时把该单当前全部 blobId æ”¾è¿› blobIds ä¸€èµ·æäº¤ï¼ˆå…¨é‡æ›¿æ¢è¯­ä¹‰ï¼Œä¼ ç©ºæ•°ç»„=清空,不传=不动)", "roles": ["报销人"]},
       {"stage": "保存", "action": "create ç”± Redis ç”Ÿæˆå•号 BX+yyyyMMdd+6位流水(每日重置);明细先删后增全量覆盖且提交剥掉行 id;status=0 è‰ç¨¿", "roles": ["报销人"]},
       {"stage": "修改", "action": "update ä»…放行 status=0 è‰ç¨¿ ä¸Ž status=35 å·²é©³å›žï¼›delete ä»…放行草稿(连带清理明细与发票附件)", "roles": ["报销人"]},
       {"stage": "审批", "action": "submit ä¼  id ä¸Ž processDefinitionKey(query å‚数,请求体为空)→ status=15 å®¡æ‰¹ä¸­ å¹¶å›žå†™ processInstanceId;BPM ç›‘听器按结果回写:通过→20 å·²é€šè¿‡ã€é©³å›žâ†’35 å·²é©³å›žã€å–消→退回 0 è‰ç¨¿ï¼›æµç¨‹ key å– approve-process-list å®žæ—¶åˆ—表不写死", "roles": ["报销人", "审批人"]},
       {"stage": "归档", "action": "archive ä»…放行 status=20 å·²é€šè¿‡ â†’ status=40 å·²å½’档(终态)并写入 archiveTime", "roles": ["财务"]}]},
    {"id": "ums", "name": "会员入会流程", "mermaid": "mermaid/13-ums-membership-flow.mmd", "color": "#d6955c",
     "summary": "独立会员模块(yudao-module-ums):入会申请单(个人/单位二型 + ç”³è¯·ç­‰çº§ + è¯æ˜Žææ–™é™„ä»¶) è‰ç¨¿ â†’ æäº¤ BPM ä¸¤çº§å®¡æ‰¹ï¼ˆç§˜ä¹¦å¤„初审 â†’ ç†äº‹ä¼šå®¡è®®ï¼‰â†’ é€šè¿‡åŽè‡ªåŠ¨åˆ›å»ºç³»ç»Ÿç™»å½•è´¦å·ä¸Žä¼šå‘˜è®°å½• â†’ ä¼šå‘˜è¿›å…¥åˆ—表与通讯录(按地区/行业检索)。驳回可改后重提,取消退回草稿。模块不与其他业务模块产生单据关联,仅复用 BPM ä¸Ž system é™„件中心。",
     "steps": [
       {"stage": "填报", "action": "选会员类型 1个人 / 2单位(**非租户多类型,而是两套字段**):个人填 å§“名/工作单位/职称;单位填 å•位名称/法定代表人/统一社会信用代码;共有 ç ”究方向/联系电话/邮箱/行业/地区编码/备注", "roles": ["入会申请人", "会员管理员"]},
       {"stage": "定级", "action": "选申请等级;等级字典随会员类型切换(个人 ums_personal_grade / å•位 ums_unit_grade),前端切换类型时清空已选等级", "roles": ["入会申请人"]},
       {"stage": "附件", "action": "证明材料先调 /system/storage-blob/upload å– blobId,随保存请求的 blobIds ä¸€èµ·æäº¤ï¼›ç»‘定 recordType=ums_application(会员侧为 ums_member)", "roles": ["入会申请人"]},
       {"stage": "保存", "action": "create ç”± Redis ç”Ÿæˆç”³è¯·ç¼–号 SQ+yyyyMMdd+6位流水(每日重置),后端生成不可自定义;status=0 è‰ç¨¿ï¼Œè¡¨å•不填状态", "roles": ["入会申请人"]},
       {"stage": "修改", "action": "update ä»…放行 status=0 è‰ç¨¿ ä¸Ž status=35 å·²é©³å›žï¼ˆé©³å›žæ—¶æç¤ºå¸¦ç”³è¯·ç¼–号);delete ä»…放行草稿,且连带清理该单附件", "roles": ["入会申请人", "会员管理员"]},
       {"stage": "审批", "action": "submit ä¼  id ä¸Ž processDefinitionKey(query å‚数)→ status=15 å®¡æ‰¹ä¸­å¹¶å›žå†™ processInstanceId;BPM ç›‘听器 UmsApplicationStatusListener æŒ‰ç»“果回写:通过→20、驳回→35、取消→退回 0;流程列表由 approve-process-list å®žæ—¶å–(先校验 BPM åˆ†ç±» ums_membership_approve å­˜åœ¨ï¼Œå†æŒ‰ key å–未挂起的最高版本),不写死 key", "roles": ["入会申请人", "秘书处(初审)", "理事会(审议)"]},
       {"stage": "建档", "action": "审批通过钩子自动建会员:①生成会员编号 HY+yyyyMMdd+6位流水 â‘¡AdminUserApi.createUser å»ºç³»ç»Ÿè´¦å·ï¼ˆç”¨æˆ·åä¸Žåˆå§‹å¯†ç =联系电话、昵称=申请人姓名、部门=学会会员)③会员落库(入会日期=当天、状态 1 æ­£å¸¸ã€applicationId å›žå¡«æ¥æºç”³è¯·å•);手机号重复会明确报错", "roles": ["系统自动"]},
       {"stage": "管理与检索", "action": "会员列表支持手工新增/编辑/删除(等级与类型校验:单位会员不可选『理事长』);会员通讯录为只读页,按地区编码与行业检索", "roles": ["会员管理员", "全体可查通讯录"]}]},
    {"id": "research", "name": "课题管理流程", "mermaid": "mermaid/14-research-flow.mmd", "color": "#e070a8",
     "summary": "独立科研模块(yudao-module-research):学会发布课题指南(重点/一般分类)→ ä¼šå‘˜åœ¨çº¿æäº¤è¯¾é¢˜ç”³æŠ¥ä¹¦ â†’ æäº¤ BPM ä¸‰çº§å®¡æ‰¹ï¼ˆç§˜ä¹¦å¤„形式审查 â†’ ä¸“家评审 â†’ å­¦ä¼šç«‹é¡¹å®¡æ‰¹ï¼‰ï¼Œä¸“家评分以结构化明细子表记录并由后端自动汇总加权均分、排名与推荐结论 â†’ å®¡æ‰¹é€šè¿‡ç”Ÿæˆç«‹é¡¹ç¼–号并发布系统公告 â†’ æŒ‰æ‰¹æ¬¡ç™»è®°ç»è´¹æ‹¨ä»˜è®°å½•(含凭证附件)。院级二期再补结项验收与成果归档。",
     "steps": [
       {"stage": "发布指南", "action": "新建指南选类别 1重点课题 / 2一般课题,填申报起止时间与指南正文;编号 GL+yyyyMMdd+6位流水(Redis æ¯æ—¥é‡ç½®ï¼‰ï¼›status: 0草稿/20已发布/30已截止。仅草稿可改可删,发布后即冻结;已产生申报单的指南禁止删除(提示该指南下已有 N å¼ ç”³æŠ¥å•)", "roles": ["学会秘书处", "课题管理员"]},
       {"stage": "在线申报", "action": "从已发布且在申报期内的指南新建申报书;选会员(ums_member)自动带出姓名/工作单位/职称/联系电话/邮箱,可改;填课题名称、课题组成员、申请经费、申报书正文;编号 SB+yyyyMMdd+6位流水;类别冗余自指南,不信任前端传值;status: 0草稿", "roles": ["课题申报人(会员)", "课题管理员"]},
       {"stage": "提交审批", "action": "submit ä¼  id ä¸Ž processDefinitionKey(query å‚数)→ status=15 å®¡æ‰¹ä¸­å¹¶å›žå†™ processInstanceId。提交前逐条校验:① æŒ‡å—必须已发布 â‘¡ å½“前时间必须在申报起止期内——任一不满足即拒绝,提示带指南名与实际状态或起止时间;仅草稿与已驳回可改可重提,仅草稿可删(连带清附件)", "roles": ["课题申报人"]},
       {"stage": "专家评审", "action": "申报单处于审批中(15)时录入专家评分明细(一位专家一行:姓名/单位/职称 + é€‰é¢˜ä»·å€¼0.3/研究方案0.3/团队基础0.2/预期成果0.2 å››é¡¹åˆ†å€¼ + ç»“论 + æ„è§ï¼‰ï¼›åŽç«¯æ¯æ¬¡å¢žåˆ æ”¹åŽé‡ç®— compositeScore åŠ æƒåˆè®¡ã€æŒ‰åŠ æƒåˆ†é™åºé‡æŽ’ ranking,并回写主单 reviewAvgScore(全部专家均分)与 reviewPass(均分达标且多数结论为推荐立项);明细全部删除时汇总值清空为未汇总;四项分值均在 0~100 ä¹‹å¤–时提示指出具体维度与实际值", "roles": ["评审专家", "学会秘书处", "课题管理员"]},
       {"stage": "立项审批", "action": "BPM æµç¨‹ research_topic_approve é¡ºåºèµ°ä¸‰èŠ‚ç‚¹ï¼šç§˜ä¹¦å¤„å½¢å¼å®¡æŸ¥ â†’ ä¸“家评审 â†’ å­¦ä¼šç«‹é¡¹å®¡æ‰¹ã€‚审批方法与审批人在流程定义内配置,部署后由管理员在 BPM æµç¨‹è®¾è®¡å™¨æŒ‰å­¦ä¼šå®žé™…组织调整;审批中心内的自定义表单只读回显申报单 + è¯„审明细 + æ‹¨ä»˜è®°å½•。通过→20 å·²ç«‹é¡¹ã€é©³å›žâ†’35 å·²é©³å›žã€å–消→退回 0 è‰ç¨¿", "roles": ["学会秘书处(形式审查)", "评审专家(评审节点)", "学会理事会(立项审批)"]},
       {"stage": "立项与通知", "action": "监听器 ResearchApplicationStatusListener å…ˆå›žå†™å®¡æ‰¹çŠ¶æ€ï¼Œå®¡æ‰¹é€šè¿‡åŽå†è°ƒ approveApplication:生成立项编号 LX+yyyyMMdd+6位流水 ä¸Ž approveTime(已有编号则幂等跳过)。点「发布立项通知」生成 system_notice å…¬å‘Šï¼ˆæ ‡é¢˜ã€Žå…³äºŽå…¬å¸ƒç«‹é¡¹è¯¾é¢˜çš„通知』,正文含课题名/立项编号/类别/申报人/单位/资助金额),写回 noticeId é˜²é‡å¤â€”—重复点击按已发布拦截并告知上次发布时间", "roles": ["课题管理员", "学会秘书处"]},
       {"stage": "经费拨付", "action": "仅「已立项(20)」的申报单可登记拨款记录(批次名称/拨付金额/拨付日期/收款单位/状态/备注/凭证附件);金额校验:已登记金额 + æœ¬æ¬¡é‡‘额 ä¸å¾—超出该单申请经费,超出时提示报出本次、已登记、合计、预算四项数值;status: 0待拨付/20已拨付,凭证明细走 system é™„件中心 recordType=research_fund_record,状态改为已拨付时凭证必填;仅待拨付状态可改可删,编辑时会把本行金额加回余额显示", "roles": ["学会财务", "课题管理员"]}]},
    {"id": "ai", "name": "AI ä¸šåŠ¡æµç¨‹", "mermaid": "mermaid/11-ai-business-flow.mmd", "color": "#56c8d8",
     "summary": "统一模式:业务侧收集上下文(必要时调 MDM/SRM/CRM API å–名称)→组装 Prompt→chat/chatWithImage→解析 JSON→前端预填或建议;无 Function Calling;RAG çŸ¥è¯†åº“已建成但暂无业务消费。",
     "steps": [
docs/project-business/data/modules.json
@@ -32,6 +32,14 @@
     "roles": ["采购员", "销售员", "出纳", "会计"],
     "upstream": ["CRM(合同)", "SRM(定标)", "主数据"], "downstream": ["生产(MPS)", "仓储(收发)"],
     "coreTables": ["erp_purchase_request", "erp_purchase_order", "erp_purchase_invoice", "erp_finance_payment", "erp_sale_order", "erp_finance_receipt", "erp_account"]},
    {"key": "fin", "name": "财务报销(独立)", "color": "#3ec9a7", "tag": "FIN", "flow": "finance",
     "positioning": "独立的发票报销单据与归档",
     "problem": "纸质发票难归集、报销审批无留痕、归档后无法追溯",
     "features": ["报销单(主表 + 1..n æ˜Žç»†è¡Œ)", "发票附件多文件上传(附件中心多态关联 storage_attachment)", "单号 Redis ç”Ÿæˆ BX+yyyyMMdd+6位流水·每日重置", "明细随主表一次性提交(先删后增全量覆盖)", "BPM å®¡æ‰¹(通过20/驳回35/取消退回草稿0)", "已驳回可改发票后重新提交", "归档终态 40 å¹¶è®°å½•归档时间", "费用类型/报销状态双字典 + Excel å¯¼å‡º"],
     "objects": ["报销单", "报销明细"],
     "roles": ["报销人", "财务", "审批人"],
     "upstream": [], "downstream": [],
     "coreTables": ["finance_reimbursement", "finance_reimbursement_item"]},
    {"key": "pro", "name": "生产管理", "color": "#e0b34f", "tag": "MES-PRO", "flow": "production",
     "positioning": "生产执行核心:计划-工单-任务-报工",
     "problem": "订单到车间执行的逐层拆解与过程可控",
@@ -88,10 +96,27 @@
     "roles": ["HR", "部门主管", "员工本人"],
     "upstream": [], "downstream": ["各模块(数据可见性扩展)", "system(账号)"],
     "coreTables": ["hrm_employee", "hrm_leave_application", "hrm_resignation_application", "hrm_user_handover", "hrm_attendance_record", "hrm_salary_calculation", "hrm_salary_payment"]},
    {"key": "ums", "name": "会员管理(独立)", "color": "#d6955c", "tag": "UMS", "flow": "ums",
     "positioning": "学会/协会型会员全生命周期:入会申请审批 â†’ è‡ªåŠ¨å»ºæ¡£ â†’ ä¼šå‘˜åˆ—表与通讯录",
     "problem": "会员入会靠线下表格与人工建档,会员等级口径混乱,会员信息无法按地区/行业检索",
     "features": ["入会申请单(个人/单位二型·字段分叉)", "申请等级两级口径(个人 ums_personal_grade / å•位 ums_unit_grade)", "申请编号 SQ+yyyyMMdd+6位流水·会员编号 HY+同日流水(Redis æ¯æ—¥é‡ç½®)", "BPM ä¸¤çº§å®¡æ‰¹(秘书处初审→理事会审议)·状态 0草稿/15审批中/20已通过/35已驳回", "审批通过自动建会员 + è‡ªåŠ¨å»ºç³»ç»Ÿç™»å½•è´¦å·(AdminUserApi.createUser·用户名与初始密码=联系电话)", "会员等级与类型匹配校验(单位会员不可选『理事长』)", "会员通讯录只读检索(按地区编码/行业)", "证明材料走 system é™„件中心(recordType=ums_application / ums_member)"],
     "objects": ["入会申请单", "会员"],
     "roles": ["入会申请人", "会员管理员", "秘书处(初审)", "理事会(审议)"],
     "upstream": [], "downstream": ["system(自动创建登录账号)"],
     "coreTables": ["ums_application", "ums_member"]},
    {"key": "res", "name": "课题管理(独立)", "color": "#e070a8", "tag": "RES", "flow": "research",
     "positioning": "学会/协会型科研课题全生命周期:指南发布 â†’ åœ¨çº¿ç”³æŠ¥ â†’ ä¸“家评审 â†’ ç«‹é¡¹å®¡æ‰¹ â†’ ç»è´¹æ‹¨ä»˜ï¼ˆç«‹é¡¹é€šçŸ¥èµ°ç³»ç»Ÿå…¬å‘Šï¼‰",
     "problem": "课题指南靠线下发布、申报书靠纸质或邮件收集、专家评分散落在表格里无法自动汇总排名、立项与拨款无留痕",
     "features": ["课题指南(重点1/一般2 åˆ†ç±»æ ‡æ³¨)", "指南状态 0草稿/20已发布/30已截止·发布/截止两个动作", "指南编号 GL+yyyyMMdd+6位流水(Redis æ¯æ—¥é‡ç½®)", "在线申报书(选指南+选会员带出申报人信息+课题组成员+申请经费+申报书正文)", "申报编号 SB+同日流水·类别冗余自指南不信任前端", "申报状态 0草稿/15审批中/20已立项/35已驳回", "提交前校验指南已发布且在申报期内(逐条报具体维度与时间)", "专家评审明细子表(一位专家一行·四项加权 0.3/0.3/0.2/0.2)", "后端自动重算加权合计与排名·回写主单均分与推荐结论", "BPM ä¸‰çº§å®¡æ‰¹ research_topic_approve(秘书处形式审查→专家评审→学会立项审批)", "审批通过生成立项编号 LX+同日流水 ä¸Žç«‹é¡¹æ—¶é—´(幂等)", "发布立项通知(生成 system_notice å…¬å‘ŠÂ·å†™å›ž noticeId é˜²é‡å¤)", "经费拨付记录(批次/金额/日期/收款单位/状态/凭证附件)·金额不得超申请经费", "附件走 system é™„件中心(recordType=research_guideline / research_application / research_fund_record)"],
     "objects": ["课题指南", "课题申报书", "专家评审明细", "经费拨付记录"],
     "roles": ["课题申报人(会员)", "课题管理员", "评审专家", "学会秘书处", "学会理事会"],
     "upstream": ["会员管理(ums_member å–申报人姓名/单位/职称/电话)", "BPM(流程定义 research_topic_approve)"],
     "downstream": ["system(system_notice ç«‹é¡¹é€šçŸ¥å…¬å‘Š)", "system(附件中心)", "财务(二期预期待对接·一期不接)"],
     "coreTables": ["research_guideline", "research_application", "research_topic_review", "research_fund_record"]},
    {"key": "bpm", "name": "协同审批", "color": "#e6a23c", "tag": "BPM", "flow": null,
     "positioning": "全系统单据审批中枢(Flowable)",
     "problem": "多模块单据需要灵活可配的多级审批",
     "features": ["BPMN+钉钉风格双设计器", "流程定义部署", "审批事件统一回调(BpmProcessInstanceStatusEvent)", "13个业务监听器", "表单·用户组·抄送·加签"],
     "features": ["BPMN+钉钉风格双设计器", "流程定义部署", "审批事件统一回调(BpmProcessInstanceStatusEvent)", "14个业务监听器", "表单·用户组·抄送·加签"],
     "objects": ["流程模型", "流程实例", "审批任务"],
     "roles": ["审批人(按流程配置)", "流程管理员"],
     "upstream": ["各业务提交审批"], "downstream": ["回调更新各业务单据状态"],
@@ -108,6 +133,6 @@
  "platform": {
    "name": "系统底座",
    "capabilities": ["JWT+OAuth2 è®¤è¯Â·æ»‘块验证码", "RBAC è§’色-菜单-数据范围(全部/部门/本部门及下级/仅本人/自定义)", "字典", "附件中心(storage-blob æ–‡ä»¶ + storage-attachment æŒ‰ recordType+recordId å¤šæ€å…³è”,全模块强制使用)", "编码规则(各业务自实现 Redis å‰ç¼€+日期+流水)", "Quartz å®šæ—¶ä»»åŠ¡Â·RocketMQ/Kafka/Redis Stream MQ·WebSocket", "多租户已关闭", "实际 system_role ä»… 4 ä¸ªï¼šè¶…级管理员/普通角色/CRM管理员/租户管理员,业务分工靠数据权限而非多角色"],
    "unused": ["im æ¨¡å—(IM/RTC,业务未消费)", "member æ¨¡å—(C端会员,遗留)"]
    "unused": ["im æ¨¡å—(IM/RTC,业务未消费)", "member æ¨¡å—(C端商城会员,遗留;与 UMS å­¦ä¼šä¼šå‘˜æ— å…³ï¼Œå‹¿æ··ç”¨)"]
  }
}
docs/project-business/data/objects.json
@@ -47,6 +47,14 @@
    {"name": "离职交接", "module": "人力", "table": "hrm_user_handover", "doc": "from→to æ˜ å°„,已完成20 åŽ expandUserIds æ‰©å±•交接人可见数据(当前各业务模块尚未实际接入调用)", "upstream": ["离职申请"], "downstream": ["各模块我的数据查询"], "keywords": "交接 ç¦»èŒ handover expandUserIds"},
    {"name": "排班计划", "module": "设备", "table": "mes_cal_plan", "doc": "班组×班次×日期排班,供生产与考勤引用", "upstream": [], "downstream": ["生产任务", "考勤"], "keywords": "排班 ç­ç»„ ç­æ¬¡ æ—¥åކ"},
    {"name": "报告模板", "module": "质检报告", "table": "qc_report_template", "doc": "智能质检报告模板,启用0/停用1;画布内容存在 qc_report_template_version(草稿0/已发布1/已停用2,已发布不可覆盖),currentVersion æŒ‡å‘当前已发布版本;源文件经 AI å¯¼å…¥è¯†åˆ«æˆè‰ç¨¿åŽç”±äººå·¥ç¡®è®¤æ‰æˆä¸ºç‰ˆæœ¬ï¼›åŒä¸€æ¨¡æ¿é‡å¤å¯¼å…¥æŒ‰ã€æ›¿æ¢ã€‘处理——先删旧附件(连带 blob è¡Œä¸Žç£ç›˜æ–‡ä»¶ï¼‰å†ç»‘新的,不留孤儿文件;删除模板会连带清附件", "upstream": ["人工设计(GrapesJS è®¾è®¡å™¨)", "AI å¯¼å…¥è‰ç¨¿(qc-report/ai-import/draft)"], "downstream": ["模板版本", "报告实例"], "keywords": "报告模板 è´¨æ£€æŠ¥å‘Š template è®¾è®¡å™¨ ç‰ˆæœ¬ AI导入 è¯†åˆ« è‰ç¨¿ æºæ–‡ä»¶æ›¿æ¢"},
    {"name": "报告实例", "module": "质检报告", "table": "qc_report_instance", "doc": "出件产物,编号 QR+yyyyMMdd+4位流水按天独立且业务唯一,可显式指定覆盖(注意 uk_report_no ä¸åŒºåˆ†è½¯åˆ é™¤ï¼Œåˆ æŽ‰çš„号不回收);两条出件入口:通用 generate(调用方自带 context)与 generate-from-qc(数据从 MES è´¨æ£€å•取,权限码 qc-report:instance:generate å·²å…¥ system_menu id=1075416,五道校验按业务优先级只报第一处:105_000 ç±»åž‹éžæ³• / 105_001 å•据不存在 / 105_002 æœªå®Œæˆ / 105_003 æœªåˆ¤å®š / 105_005 æ¨¡æ¿ç±»åž‹ä¸ç¬¦ / 105_006 æ— å¯ç”¨æ£€éªŒé¡¹ï¼›å››å¼ è´¨æ£€å•列表的行操作「出报告」仅在 status=已完成时可见,弹窗只列 reportType ä¸€è‡´ã€æ¨¡æ¿å¯ç”¨ã€ä¸” currentVersion æŒ‡å‘的版本已发布的模板(后端 /qc-report/template/selectable åˆ¤å®šâ€”—停用版本不清空 currentVersion,前端拿分页结果自己推导会漏),结果就地展示并跳本页列表);冻结 data_snapshot(判定后数据,含后端算好的 PASS/FAIL ä¸Žåˆæ ¼çŽ‡ï¼‰ä¸Ž render_html;regenerate åªé‡æ¸²æŽ’版、数据与编号不变;business_id/business_type å­˜ MES è¡¨åï¼ˆmes_qc_iqc ç­‰ï¼‰ä¸Žå•据 ID,可反查、一单可出多份;报告头另带单据原判定 qcResult(数)/qcResultText(中文,合格/特采/不合格退货/不合格报废),与引擎三态判定并存互不覆盖;响应带 undecidableCount=快照中 result ä¸ºç©ºä¸²çš„项数与 warnings(分组标题跳过/指标已删/未录实测值/样品超3),两者都不落库、事后看不到", "upstream": ["报告模板版本", "质检单上下文(通用 generate ç”±è°ƒç”¨æ–¹ä¼ å…¥)", "MES è´¨æ£€å• IQC/IPQC/OQC/RQC(generate-from-qc ç» MesQcReportApi å–æ•°)"], "downstream": ["前端实例页 /qc/report-instance(列表·iframe预览·快照页签·导出PDF)", "PDF归档(system_storage_attachment recordType=qc_report_instance)"], "keywords": "报告实例 å‡ºä»¶ QR编号 å¿«ç…§ å¤çް regenerate PDF归档 æŠ¥å‘Šå®žä¾‹é¡µ å¯¼å‡ºPDF generate-from-qc è´¨æ£€å•出报告 å‡ºæŠ¥å‘Šå¼¹çª— qcResult undecidableCount warnings å¾…判定 åæŸ¥ ä¸šåŠ¡å•æ®ç­›é€‰"}
    {"name": "报告实例", "module": "质检报告", "table": "qc_report_instance", "doc": "出件产物,编号 QR+yyyyMMdd+4位流水按天独立且业务唯一,可显式指定覆盖(注意 uk_report_no ä¸åŒºåˆ†è½¯åˆ é™¤ï¼Œåˆ æŽ‰çš„号不回收);两条出件入口:通用 generate(调用方自带 context)与 generate-from-qc(数据从 MES è´¨æ£€å•取,权限码 qc-report:instance:generate å·²å…¥ system_menu id=1075416,五道校验按业务优先级只报第一处:105_000 ç±»åž‹éžæ³• / 105_001 å•据不存在 / 105_002 æœªå®Œæˆ / 105_003 æœªåˆ¤å®š / 105_005 æ¨¡æ¿ç±»åž‹ä¸ç¬¦ / 105_006 æ— å¯ç”¨æ£€éªŒé¡¹ï¼›å››å¼ è´¨æ£€å•列表的行操作「出报告」仅在 status=已完成时可见,弹窗只列 reportType ä¸€è‡´ã€æ¨¡æ¿å¯ç”¨ã€ä¸” currentVersion æŒ‡å‘的版本已发布的模板(后端 /qc-report/template/selectable åˆ¤å®šâ€”—停用版本不清空 currentVersion,前端拿分页结果自己推导会漏),结果就地展示并跳本页列表);冻结 data_snapshot(判定后数据,含后端算好的 PASS/FAIL ä¸Žåˆæ ¼çŽ‡ï¼‰ä¸Ž render_html;regenerate åªé‡æ¸²æŽ’版、数据与编号不变;business_id/business_type å­˜ MES è¡¨åï¼ˆmes_qc_iqc ç­‰ï¼‰ä¸Žå•据 ID,可反查、一单可出多份;报告头另带单据原判定 qcResult(数)/qcResultText(中文,合格/特采/不合格退货/不合格报废),与引擎三态判定并存互不覆盖;响应带 undecidableCount=快照中 result ä¸ºç©ºä¸²çš„项数与 warnings(分组标题跳过/指标已删/未录实测值/样品超3),两者都不落库、事后看不到", "upstream": ["报告模板版本", "质检单上下文(通用 generate ç”±è°ƒç”¨æ–¹ä¼ å…¥)", "MES è´¨æ£€å• IQC/IPQC/OQC/RQC(generate-from-qc ç» MesQcReportApi å–æ•°)"], "downstream": ["前端实例页 /qc/report-instance(列表·iframe预览·快照页签·导出PDF)", "PDF归档(system_storage_attachment recordType=qc_report_instance)"], "keywords": "报告实例 å‡ºä»¶ QR编号 å¿«ç…§ å¤çް regenerate PDF归档 æŠ¥å‘Šå®žä¾‹é¡µ å¯¼å‡ºPDF generate-from-qc è´¨æ£€å•出报告 å‡ºæŠ¥å‘Šå¼¹çª— qcResult undecidableCount warnings å¾…判定 åæŸ¥ ä¸šåŠ¡å•æ®ç­›é€‰"},
    {"name": "报销单", "module": "财务报销", "table": "finance_reimbursement", "doc": "单号可自定义(中间不允许空格,与已有单号重复则报错);新增时留空或纯空格由系统生成 BX+yyyyMMdd+6位流水(Redis æ¯æ—¥é‡ç½®),修改时留空表示保持不变。status: 0草稿/15审批中/20已通过/35已驳回/40已归档;仅草稿可删、草稿与已驳回可改、仅已通过可归档;提交后由 BPM ç›‘听器按结果回写状态(取消退回草稿),审批中 processInstanceId è¢«å›žå†™ï¼›ç‹¬ç«‹æ¨¡å—,不与其它业务模块产生单据关联", "upstream": [], "downstream": ["报销明细", "发票附件(storage_attachment·recordType=finance_reimbursement)"], "keywords": "报销单 æŠ¥é”€ å‘票 reimbursement BX å½’æ¡£ è´¢åŠ¡ è´¹ç”¨ è‡ªå®šä¹‰å•号"},
    {"name": "报销明细", "module": "财务报销", "table": "finance_reimbursement_item", "doc": "报销单的费用行:费用类型(1差旅/2办公/3招待/4交通/5其他)、发票号码、发票金额、发票日期、开票方、费用说明;随主表一次性提交(先删后增全量覆盖),提交时必须剥掉行 id ä¸Ž reimbursementId â€”— æ˜Žç»†åˆ é™¤æ˜¯è½¯åˆ ï¼Œå›žä¼ åŽŸ id ä¼šä¸Žå·²è½¯åˆ è¡Œä¸»é”®å†²çª", "upstream": ["报销单"], "downstream": [], "keywords": "报销明细 è´¹ç”¨æ˜Žç»† è´¹ç”¨ç±»åž‹ å‘票金额 item æ˜Žç»†è¡Œ å·®æ—…"},
    {"name": "入会申请单", "module": "会员管理", "table": "ums_application", "doc": "单号 SQ+yyyyMMdd+6位流水(Redis æ¯æ—¥é‡ç½®),后端生成不可自定义;memberType 1个人/2单位决定字段分叉(个人用 workUnit/title,单位用 legalPerson/organizationCode);memberGrade æŒ‰ç±»åž‹å–不同字典;status: 0草稿/15审批中/20已通过/35已驳回。仅草稿可删(连带清附件)、草稿与已驳回可改可重提;提交后由 BPM ç›‘听器按结果回写(取消退回草稿),审批中回写 processInstanceId;审批通过触发自动建会员与系统账号。独立模块,不与其它业务模块产生单据关联", "upstream": ["人工填报(申请人)", "证明材料附件(storage_attachment·recordType=ums_application)"], "downstream": ["会员(审批通过自动创建)", "BPM æµç¨‹å®žä¾‹(ums_membership_approve åˆ†ç±»)"], "keywords": "入会申请 å…¥ä¼š ä¼šå‘˜ç”³è¯· application SQ ç§˜ä¹¦å¤„ ç†äº‹ä¼š å®¡æ‰¹ ä¼šå‘˜ç±»åž‹ ä¸ªäºº å•位 æ³•定代表人 ç ”究方向 ç”³è¯·ç­‰çº§"},
    {"name": "会员", "module": "会员管理", "table": "ums_member", "doc": "单号 HY+yyyyMMdd+6位流水(Redis æ¯æ—¥é‡ç½®);两个来源:①入会申请审批通过自动创建(回填 applicationId ä¸Ž userName/系统账号) â‘¡ä¼šå‘˜åˆ—表手工新增。status: 1正常/0暂停;memberGrade æŒ‰ memberType å–对应字典,校验规则是单位会员(type=2)不可选『理事长』(grade=5)。自动建档时入会日期=当天、不搬运 contactUser/unitIntro/expireDate(申请单无对应字段);自动建的系统账号用户名为联系电话且初始密码同为联系电话,部门固定为「学会会员」;手机号重复会明确报错且整单事务回滚", "upstream": ["入会申请单(审批通过)", "人工新增", "附件(storage_attachment·recordType=ums_member)"], "downstream": ["会员通讯录(按 regionCode/industry æ£€ç´¢)", "system_users(登录账号)"], "keywords": "会员 member HY ä¼šå‘˜ç¼–号 ä¼šå‘˜ç­‰çº§ ä¼šå‘˜ç±»åž‹ é€šè®¯å½• åœ°åŒº è¡Œä¸š å…¥ä¼šæ—¥æœŸ æœ‰æ•ˆæœŸ"},
    {"name": "课题指南", "module": "课题管理", "table": "research_guideline", "doc": "指南编号 GL+yyyyMMdd+6位流水(Redis æ¯æ—¥é‡ç½®);category 1重点课题/2一般课题;publishStatus 0草稿/20已发布/30已截止,仅草稿可改可删,publish å‘布回写 publishTime,close æˆªæ­¢ç½® 30;已产生申报单(applicationMapper.selectCountByGuidelineId å¤§äºŽ 0)的指南禁止删除并在提示中报出该指南下已有多少张申报单;正文 content ä¸ºé•¿æ–‡æœ¬ï¼›é™„ä»¶ recordType=research_guideline", "upstream": ["人工维护(学会秘书处)", "指南附件(storage_attachment·recordType=research_guideline)"], "downstream": ["课题申报书(申报时引用且必须已发布并在申报期内)"], "keywords": "课题指南 æŒ‡å— å‘布 æˆªæ­¢ é‡ç‚¹è¯¾é¢˜ ä¸€èˆ¬è¯¾é¢˜ GL æŒ‡å—编号 ç”³æŠ¥æœŸ ç”³æŠ¥èµ·æ­¢"},
    {"name": "课题申报书", "module": "课题管理", "table": "research_application", "doc": "申报编号 SB+yyyyMMdd+6位流水(Redis æ¯æ—¥é‡ç½®);guidelineId å…³è”指南、memberId å…³è” ums_member(软关联无外键,会员删除后会留下悬空 memberId éœ€å®¹é”™æ˜¾ç¤º);applicantName/workUnit/jobTitle/contactPhone/contactEmail ç”±é€‰ä¼šå‘˜è‡ªåŠ¨å¸¦å‡ºå¯æ‰‹æ”¹ï¼›category å†—余自指南且不信任前端传值;budgetAmount ç”³è¯·ç»è´¹ä¸ºæ‹¨ä»˜ä¸Šé™ï¼›status: 0草稿/15审批中/20已立项/35已驳回。仅草稿可删(连带清附件)、草稿与已驳回可改可重提;submit å‰é€æ¡æ ¡éªŒã€ŒæŒ‡å—已发布」与「现在在申报期内」,任一不满足即拒绝并按维度给出指南名与实际值;审批通过由监听器生成立项编号 researchNo(LX+同日流水)与 approveTime(已有编号幂等跳过);reviewAvgScore/reviewPass ä¸ºè¯„审汇总回写值,明细删空时清成未汇总;noticeId è®°å½•已发布的立项通知公告,重复发布被拦截", "upstream": ["课题指南(必须已发布且在申报期内)", "会员(ums_member·带出申报人信息)", "申报附件(storage_attachment·recordType=research_application)"], "downstream": ["专家评审明细", "经费拨付记录", "立项通知(system_notice å…¬å‘Š)", "BPM æµç¨‹å®žä¾‹(research_topic_approve åˆ†ç±»)", "附件中心"], "keywords": "课题申报 ç”³æŠ¥ä¹¦ ç”³æŠ¥å• SB ç”³æŠ¥ç¼–号 ç«‹é¡¹ ç«‹é¡¹ç¼–号 LX ç”³è¯·ç»è´¹ ç”³æŠ¥ä¹¦æ­£æ–‡ è¯¾é¢˜ç»„成员 ä¸“家评审 è¯„审汇总 ç«‹é¡¹é€šçŸ¥ å‘布通知 æäº¤å®¡æ‰¹ é©³å›ž é‡æ"},
    {"name": "专家评审明细", "module": "课题管理", "table": "research_topic_review", "doc": "一位专家一行的一主多行子表:evaluatorName/evaluatorUnit/evaluatorTitle + topicScore é€‰é¢˜ä»·å€¼ä¸Žæ„ä¹‰(权重0.3) + planScore ç ”究方案可行性(0.3) + teamScore å›¢é˜Ÿä¸Žç ”究基础(0.2) + resultScore é¢„期成果与效益(0.2) + conclusion 1推荐立项/2不推荐立项 + opinion è¯„审意见 + reviewTime。compositeScore ç”±åŽç«¯ weightedScore è®¡ç®—、ranking ç”±åŽç«¯æŒ‰åŠ æƒåˆ†é™åºé‡æŽ’ï¼Œå‡ä¸æŽ¥å—å‰ç«¯ä¼ å€¼ï¼›æ¯æ¬¡å¢žåˆ æ”¹åŽè°ƒ recalculate å›žå†™ä¸»å• reviewAvgScore(全部专家加权分均值·保留2位)与 reviewPass(均分达标 ä¸” å¤šæ•°ç»“论为推荐立项时为 1);四项分值超出 0~100 æ—¶æç¤ºæŒ‡å‡ºå…·ä½“维度与实际值;仅在申报单 status=15 å®¡æ‰¹ä¸­å¯å¢žåˆ æ”¹", "upstream": ["课题申报书(仅审批中可写)"], "downstream": ["课题申报书(回写 reviewAvgScore/reviewPass)"], "keywords": "专家评审 è¯„审明细 è¯„分 åŠ æƒ æƒé‡ æŽ’名 ranking compositeScore æŽ¨èç«‹é¡¹ è¯„审意见 å‡åˆ†"},
    {"name": "经费拨付记录", "module": "课题管理", "table": "research_fund_record", "doc": "一主多行的拨款记录:batchName æ‰¹æ¬¡åç§°/amount æ‹¨ä»˜é‡‘额/payDate æ‹¨ä»˜æ—¥æœŸ/payee æ”¶æ¬¾å•位/status 0待拨付 20已拨付/remark,凭证附件走 system é™„件中心 recordType=research_fund_record(表内不存文件地址)。仅申报单 status=20 å·²ç«‹é¡¹å¯ç™»è®°ï¼›é‡‘额校验:已登记金额(该单全部记录求和) + æœ¬æ¬¡é‡‘额 ä¸å¾—超出申报单 budgetAmount,超出时提示报出本次、已登记、合计、预算四项数值;status=20 å·²æ‹¨ä»˜ æ—¶å‡­è¯é™„ä»¶å¿…å¡«(前端先行拦截,后端 validateVoucherRequired å…œåº•);仅待拨付状态可改可删,编辑时把本行金额加回可用余额展示避免误解", "upstream": ["课题申报书(必须已立项)"], "downstream": ["拨付凭证(storage_attachment·recordType=research_fund_record)", "财务(二期预期待对接·一期不接)"], "keywords": "经费拨付 æ‹¨æ¬¾ æ‹¨ä»˜è®°å½• æ‰¹æ¬¡ æ‹¨ä»˜é‡‘额 å‡­è¯ å¾…拨付 å·²æ‹¨ä»˜ æ”¶æ¬¾å•位 èµ„助金额 é¢„ç®— è¶…额"}
  ]
}
docs/project-business/data/roles.json
@@ -19,6 +19,10 @@
    {"role": "客服/售后工程师", "modules": ["售后"], "operations": ["工单受理派工", "ç»´ä¿®", "退货申请"], "data": ["after_sale_*"], "scope": "行级数据权限四视图:我负责/我参与/下属负责/全部(aftersales:view-all)"},
    {"role": "财务", "modules": ["ERP", "CRM"], "operations": ["收款单", "付款单", "开票审核", "结算账户"], "data": ["erp_finance_*", "crm_invoice", "crm_receivable"], "scope": "financeUserId å½’属"},
    {"role": "HR", "modules": ["HRM"], "operations": ["员工档案入转调离", "考勤规则排班", "薪酬核算发放", "离职交接"], "data": ["hrm_*"], "scope": "部门范围 dataScope"},
    {"role": "管理层", "modules": ["BI"], "operations": ["查看采购销售/仓库/生产设备/质量/人力五大主题大屏"], "data": ["bi_dashboard"], "scope": "只读"}
    {"role": "管理层", "modules": ["BI"], "operations": ["查看采购销售/仓库/生产设备/质量/人力五大主题大屏"], "data": ["bi_dashboard"], "scope": "只读"},
    {"role": "入会申请人/会员管理员", "modules": ["UMS"], "operations": ["填报与修改入会申请(个人/单位)", "上传证明材料附件", "提交 BPM å®¡æ‰¹", "会员列表维护与会员通讯录检索"], "data": ["ums_application", "ums_member"], "scope": "权限码 ums:application:create/update/delete/submit Â· ums:member:create/update/delete/query Â· ums:member:directory;无数据权限隔离(全员可查通讯录,维护类按钮另凭权限码)"},
    {"role": "秘书处/理事会(审批人)", "modules": ["UMS", "BPM"], "operations": ["入会申请两级审批:秘书处初审 â†’ ç†äº‹ä¼šå®¡è®®"], "data": ["ums_application(状态回写)", "bpm_task"], "scope": "审批人由 BPM æµç¨‹å®šä¹‰é…ç½®ï¼ŒåŽç«¯ä¸å†™æ­»ï¼›é€šè¿‡åŽç³»ç»Ÿè‡ªåŠ¨å»ºä¼šå‘˜ä¸Žç™»å½•è´¦å·"},
    {"role": "课题申报人/课题管理员", "modules": ["RES"], "operations": ["课题指南发布与截止(课题管理员)", "新建与修改课题申报书(选会员带出申报人信息、上传申报附件)", "提交 BPM å®¡æ‰¹ã€é©³å›žåŽä¿®æ”¹é‡æ", "每月/每批次登记经费拨付记录与凭证", "发布立项通知"], "data": ["research_guideline", "research_application", "research_topic_review", "research_fund_record"], "scope": "权限码 research:guideline:create/update/delete/publish Â· research:application:create/update/delete/submit/notice Â· research:review:create/update/delete Â· research:fund:create/update/delete;无数据权限隔离(列表不按申请人过滤),按钮凭权限码控制"},
    {"role": "评审专家/学会秘书处·理事会(课题审批人)", "modules": ["RES", "BPM"], "operations": ["在审批中(15)录入本人的专家评分明细(四项分值 + ç»“论 + æ„è§)", "秘书处形式审查 â†’ ä¸“家评审 â†’ å­¦ä¼šç«‹é¡¹å®¡æ‰¹ ä¸‰èŠ‚ç‚¹é¡ºåºå®¡æ‰¹", "在审批中心通过自定义表单只读查看申报书正文、评审明细与拨付记录"], "data": ["research_topic_review", "research_application(状态回写)", "bpm_task"], "scope": "评分录入要求申报单处于审批中(15)·由课题管理员或专家依据 research:review:* æƒé™ç æ‰§è¡Œï¼›å®¡æ‰¹äººä¸Žå®¡æ‰¹æ–¹æ³•在 BPM æµç¨‹å®šä¹‰ research_topic_approve å†…配置(默认依次审批·角色兜底),后端不写死审批人,部署后由管理员在流程设计器按学会实际组织调整"}
  ]
}
docs/project-business/index.html
@@ -10,7 +10,7 @@
  --line:#24344f; --text:#dbe4f0; --muted:#8fa1b8;
  --c-base:#8a94a6; --c-sale:#4f9cff; --c-sup:#c9a86a; --c-erp:#5fc79e;
  --c-prod:#e0b34f; --c-wm:#7e6bd6; --c-qc:#e06c6c; --c-dv:#59a8b8;
  --c-as:#c78bd6; --c-hrm:#9db85a; --c-bpm:#e6a23c; --c-ai:#56c8d8; --c-fin:#3ec9a7;
  --c-as:#c78bd6; --c-hrm:#9db85a; --c-bpm:#e6a23c; --c-ai:#56c8d8; --c-fin:#3ec9a7; --c-ums:#d6955c; --c-res:#e070a8;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
@@ -111,6 +111,9 @@
  <a data-t="flow-quality"><span class="dot" style="background:var(--c-qc)"></span>质量流程</a>
  <a data-t="flow-warehouse"><span class="dot" style="background:var(--c-wm)"></span>仓储流程</a>
  <a data-t="flow-aftersales"><span class="dot" style="background:var(--c-as)"></span>售后流程</a>
  <a data-t="flow-finance"><span class="dot" style="background:var(--c-fin)"></span>财务报销流程</a>
  <a data-t="flow-ums"><span class="dot" style="background:var(--c-ums)"></span>会员入会流程</a>
  <a data-t="flow-research"><span class="dot" style="background:var(--c-res)"></span>课题管理流程</a>
  <div class="group">结构与数据</div>
  <a data-t="relation"><span class="dot" style="background:var(--c-bpm)"></span>系统关系</a>
  <a data-t="dataflow"><span class="dot" style="background:var(--c-fin)"></span>数据流</a>
@@ -137,6 +140,9 @@
<section class="page" id="flow-quality"></section>
<section class="page" id="flow-warehouse"></section>
<section class="page" id="flow-aftersales"></section>
<section class="page" id="flow-finance"></section>
<section class="page" id="flow-ums"></section>
<section class="page" id="flow-research"></section>
<section class="page" id="relation">
  <h2 class="pt">系统关系</h2>
  <p class="desc">模块之间的调用与数据依赖:实线=单据/数据流向,虚线=审批回调与辅助能力。</p>
@@ -186,6 +192,9 @@
    "subSystems": [
      "MES(生产·仓储·质量·设备)",
      "ERP(进销存·财务)",
      "FIN(发票报销,独立模块)",
      "UMS(学会会员,独立模块)",
      "RES(科研课题,独立模块)",
      "CRM(客户关系)",
      "SRM(供应商关系)",
      "HRM(人力资源)",
@@ -197,14 +206,14 @@
    "businessPositioning": "让工厂的订单、物料、生产、库存、质量、人员、资金在一个系统内闭环流转,管理层可通过 BI å¤§å±æŽŒæ¡å…¨å±€ã€‚",
    "coreChain": "CRM å•†æœº/合同 â†’ ERP é”€å”®è®¢å• â†’ MES ä¸»ç”Ÿäº§è®¡åˆ’/工单/报工 â†’ MES è´¨æ£€ä¸Žä»“储 â†’ é”€å”®å‡ºåº“发货 â†’ å¼€ç¥¨å›žæ¬¾ â†’ å”®åŽé—­çޝ",
    "techBase": "Spring Boot + MyBatis + Flowable + Redis + Quartz/RocketMQ/Kafka + Milvus å‘量库 + é€šä¹‰åƒé—®",
    "database": "MySQL 8.4,413 å¼ è¡¨ï¼ˆå« system_*、bpm_*、act_* åº•座表与 mes_*/erp_*/crm_*/srm_*/hrm_*/mdm_*/ai_* ä¸šåŠ¡è¡¨ï¼‰ï¼Œå¤šç§Ÿæˆ·å·²å…³é—­",
    "database": "MySQL 8.4,运行库 mom-jssm å…± 427 å¼ è¡¨ï¼ˆå« system_*、bpm_*、act_* åº•座表与 mes_*/erp_*/crm_*/srm_*/hrm_*/mdm_*/ai_*/finance_*/ums_*/research_* ä¸šåŠ¡è¡¨ï¼‰ï¼Œå¤šç§Ÿæˆ·å·²å…³é—­",
    "frontend": "mom-pro2-before(Vue3 ç®¡ç†åŽå°ï¼ŒåŒä»“库存放,只读参考)"
  },
  "stats": {
    "businessModules": 13,
    "coreObjects": 48,
    "documentFlows": 6,
    "approvalListeners": 13,
    "businessModules": 16,
    "coreObjects": 56,
    "documentFlows": 9,
    "approvalListeners": 14,
    "aiCapabilities": 8,
    "wmBillTypes": 15
  },
@@ -323,10 +332,21 @@
    "MesWmSnDO æ–°å¢žçš„ uuid å­—段生成/消费逻辑未在核心 Service ä¸­ç¡®è®¤",
    "转正申请(hrm_regularize_application)、培训证书等仅有 SQL ä¸Žèœå•初始化,Java ä¾§ Service/Controller æœªå®žçް",
    "NCR å¤„置结果(返工/退货)未回写触发源单据,仅台账记录",
    "MPS çŠ¶æ€æžšä¸¾ç‹¬ç«‹ç¼–å·(0/1/2/3),与通用单据常量(0/1/4/5)不一致"
    "MPS çŠ¶æ€æžšä¸¾ç‹¬ç«‹ç¼–å·(0/1/2/3),与通用单据常量(0/1/4/5)不一致",
    "财务报销:BPM åˆ†ç±» finance_reimbursement_approve ä¸‹å°šæ— å·²å‘布的流程定义(act_re_cprocdef ä¸ºç©ºï¼‰ï¼Œä¸”流程需把「自定义表单路径」配为 /finance/reimbursement/detail/approve/index.vue,否则前端「提交审批」被禁用、审批中心看不到报销单内容【需 BPM è®¾è®¡å™¨ä¾§é…ç½®ã€‘",
    "财务报销:菜单仅授予 super_admin(超管由代码直返全部菜单),普通角色未配 system_role_menu,非超管登录看不到「报销单」菜单【待确认是否补授权】",
    "会员管理:会员管理菜单(1075490~1075503)仅 super_admin å¯è§ï¼Œæ™®é€šè§’色未配 system_role_menu,非超管登录看不到会员管理目录【待确认是否补授权】",
    "会员管理:审批通过自动建系统账号用「联系电话」同时作用户名与初始密码,若联系电话为空会用字面量 unknown å…œåº•,存在弱口令与重名风险【待确认是否改造】",
    "会员管理:自动建档不搬运 contactUser/unitIntro/expireDate(ums_application æ— å¯¹åº”字段),单位会员的「联系人/单位简介/有效期」只能在会员列表手工补录【待确认是否需要申请单增字段】",
    "会员管理:单位会员的等级字典 ums_unit_grade ä»…到「会长单位」(4),但校验逻辑额外硬编码禁止单位选「理事长」(grade=5)——该值本不在单位字典内,属防御性冗余校验【待确认是否精简】",
    "课题管理:专家评分权重(选题0.3/方案0.3/团队0.2/成果0.2)与「加权均分≥60 ä¸” å¤šæ•°ç»“论为推荐立项」的通过阈值是占位规则,需学会最终确认;已集中为常量便于调整【待确认,改动只需改常量】",
    "课题管理:一期经费拨付只在本模块落拨款记录,与财务模块无任何数据同步,同一笔钱在两处记录存在不一致风险;二期对接时需明确以哪侧为准【待确认】",
    "课题管理:申报单与 ums_member æ˜¯è½¯å…³è”(无外键),会员被删除后申报单会留下悬空 memberId;成员列表需容错展示「会员已删除」【待确认是否加外键或删除保护】",
    "课题管理:BPM æµç¨‹ research_topic_approve ä¸‰èŠ‚ç‚¹é»˜è®¤å®¡æ‰¹äººä¸ºã€Œè§’è‰²=超级管理员(1) + ä¾æ¬¡å®¡æ‰¹ã€ï¼Œè¯¥ dev åº“超级管理员角色下有 4 ä¸ªç”¨æˆ·ï¼ˆadmin/张娟/马芳/马贞),每个节点需 4 äººä¼šç­¾æ‰æŽ¨è¿›ï¼›å…¨æ–°å®‰è£…库该角色仅 admin ä¸€äººã€æµç¨‹å¯å¼€ç®±å³ç”¨ã€‚dev åº“需减少超级管理员用户或改在 BPM è®¾è®¡å™¨ä¸­ä¸ºæ¯èŠ‚ç‚¹æŒ‡å®šå®¡æ‰¹äººã€çŽ¯å¢ƒé…ç½®é¡¹ï¼Œéžä»£ç ç¼ºé™·ã€‘",
    "课题管理:立项通知复用 system_notice ç³»ç»Ÿå…¬å‘Šï¼Œä¸æ–°å¢ž system_notify_template、不发站内信;如需短信/站内信定向通知需另行配置【待确认通知渠道】"
  ],
  "generatedAt": "2026-09-10",
  "analysisBasis": "基于 dev_business åˆ†æ”¯ 87bf4a99 æºç ï¼Œ6 ç»„并行源码走读(MES ç”Ÿäº§è´¨é‡ / MES ä»“储 / ERP / CRM+SRM / å”®åŽ+HRM / AI+BI+MDM+BPM+底座)"
  "generatedAt": "2026-09-28",
  "analysisBasis": "基于 dev_business åˆ†æ”¯ 87bf4a99 æºç ï¼Œ6 ç»„并行源码走读(MES ç”Ÿäº§è´¨é‡ / MES ä»“储 / ERP / CRM+SRM / å”®åŽ+HRM / AI+BI+MDM+BPM+底座);2026-09-28 å¢žé‡è¡¥å…… yudao-module-finance ç‹¬ç«‹è´¢åŠ¡æŠ¥é”€æ¨¡å—ï¼ˆæŠ¥é”€å• + æ˜Žç»† + å‘票附件 + BPM å®¡æ‰¹ + å½’档)与 yudao-module-ums ç‹¬ç«‹ä¼šå‘˜ç®¡ç†æ¨¡å—(入会申请 + BPM ä¸¤çº§å®¡æ‰¹ + å®¡æ‰¹é€šè¿‡è‡ªåŠ¨å»ºä¼šå‘˜ä¸Žç™»å½•è´¦å· + ä¼šå‘˜åˆ—表/通讯录);2026-09-28 å¢žé‡ï¼šBPM æµç¨‹å®šä¹‰ ums_membership_approve(入会审批)已在 mom-jssm éƒ¨ç½²å¹¶å¹¶å…¥ config_export_all é˜¶æ®µä¸€ï¼ˆåˆ†ç±» ums_membership_approve,秘书处初审 â†’ ç†äº‹ä¼šå®¡è®®ï¼Œä¸¤èŠ‚ç‚¹å®¡æ‰¹äººå‡ä¸ºã€Œè§’è‰²ã€ç­–ç•¥ã€è§’è‰²=超级管理员(1) å…œåº•,表单类型=自定义表单,查看路径 /ums/application/detail/approve/index.vue);同时修复 mom-jssm ç”± CREATE TABLE ... LIKE å…‹éš†å¯¼è‡´çš„ 47 ä¸ªæŒ‡å‘ ruoyi-vue-pro çš„ act_*/flw_* è·¨åº“外键(重建为指向 mom-jssm è‡ªèº«ï¼Œå­¤å„¿è¡Œé¢„检为 0);2026-09-28 å¢žé‡ï¼šæ–°å¢ž yudao-module-research ç‹¬ç«‹ç§‘研课题模块一期(课题指南发布 + åœ¨çº¿ç”³æŠ¥ + ä¸“家评审明细 + BPM ä¸‰çº§ç«‹é¡¹å®¡æ‰¹ + ç«‹é¡¹é€šçŸ¥å…¬å‘Š + ç»è´¹æ‹¨ä»˜è®°å½•,4 å¼  research_* è¡¨ / 5 ä¸ªå­—å…¸ / bpm_category id=8002 / æµç¨‹ research_topic_approve / èœå• 1075510~1075553),并并入 config_export_all_20260928 é˜¶æ®µä¸€ï¼›ç”³æŠ¥äººå– ums_member(软关联)、立项通知复用 system_notice å…¬å‘Šã€æ‹¨æ¬¾å‡­è¯èµ° system é™„件中心 recordType=research_fund_record"
}</script>
<script type="application/json" id="data-modules">{
  "modules": [
@@ -362,6 +382,14 @@
     "roles": ["采购员", "销售员", "出纳", "会计"],
     "upstream": ["CRM(合同)", "SRM(定标)", "主数据"], "downstream": ["生产(MPS)", "仓储(收发)"],
     "coreTables": ["erp_purchase_request", "erp_purchase_order", "erp_purchase_invoice", "erp_finance_payment", "erp_sale_order", "erp_finance_receipt", "erp_account"]},
    {"key": "fin", "name": "财务报销(独立)", "color": "#3ec9a7", "tag": "FIN", "flow": "finance",
     "positioning": "独立的发票报销单据与归档",
     "problem": "纸质发票难归集、报销审批无留痕、归档后无法追溯",
     "features": ["报销单(主表 + 1..n æ˜Žç»†è¡Œ)", "发票附件多文件上传(附件中心多态关联 storage_attachment)", "单号 Redis ç”Ÿæˆ BX+yyyyMMdd+6位流水·每日重置", "明细随主表一次性提交(先删后增全量覆盖)", "BPM å®¡æ‰¹(通过20/驳回35/取消退回草稿0)", "已驳回可改发票后重新提交", "归档终态 40 å¹¶è®°å½•归档时间", "费用类型/报销状态双字典 + Excel å¯¼å‡º"],
     "objects": ["报销单", "报销明细"],
     "roles": ["报销人", "财务", "审批人"],
     "upstream": [], "downstream": [],
     "coreTables": ["finance_reimbursement", "finance_reimbursement_item"]},
    {"key": "pro", "name": "生产管理", "color": "#e0b34f", "tag": "MES-PRO", "flow": "production",
     "positioning": "生产执行核心:计划-工单-任务-报工",
     "problem": "订单到车间执行的逐层拆解与过程可控",
@@ -418,10 +446,27 @@
     "roles": ["HR", "部门主管", "员工本人"],
     "upstream": [], "downstream": ["各模块(数据可见性扩展)", "system(账号)"],
     "coreTables": ["hrm_employee", "hrm_leave_application", "hrm_resignation_application", "hrm_user_handover", "hrm_attendance_record", "hrm_salary_calculation", "hrm_salary_payment"]},
    {"key": "ums", "name": "会员管理(独立)", "color": "#d6955c", "tag": "UMS", "flow": "ums",
     "positioning": "学会/协会型会员全生命周期:入会申请审批 â†’ è‡ªåŠ¨å»ºæ¡£ â†’ ä¼šå‘˜åˆ—表与通讯录",
     "problem": "会员入会靠线下表格与人工建档,会员等级口径混乱,会员信息无法按地区/行业检索",
     "features": ["入会申请单(个人/单位二型·字段分叉)", "申请等级两级口径(个人 ums_personal_grade / å•位 ums_unit_grade)", "申请编号 SQ+yyyyMMdd+6位流水·会员编号 HY+同日流水(Redis æ¯æ—¥é‡ç½®)", "BPM ä¸¤çº§å®¡æ‰¹(秘书处初审→理事会审议)·状态 0草稿/15审批中/20已通过/35已驳回", "审批通过自动建会员 + è‡ªåŠ¨å»ºç³»ç»Ÿç™»å½•è´¦å·(AdminUserApi.createUser·用户名与初始密码=联系电话)", "会员等级与类型匹配校验(单位会员不可选『理事长』)", "会员通讯录只读检索(按地区编码/行业)", "证明材料走 system é™„件中心(recordType=ums_application / ums_member)"],
     "objects": ["入会申请单", "会员"],
     "roles": ["入会申请人", "会员管理员", "秘书处(初审)", "理事会(审议)"],
     "upstream": [], "downstream": ["system(自动创建登录账号)"],
     "coreTables": ["ums_application", "ums_member"]},
    {"key": "res", "name": "课题管理(独立)", "color": "#e070a8", "tag": "RES", "flow": "research",
     "positioning": "学会/协会型科研课题全生命周期:指南发布 â†’ åœ¨çº¿ç”³æŠ¥ â†’ ä¸“家评审 â†’ ç«‹é¡¹å®¡æ‰¹ â†’ ç»è´¹æ‹¨ä»˜ï¼ˆç«‹é¡¹é€šçŸ¥èµ°ç³»ç»Ÿå…¬å‘Šï¼‰",
     "problem": "课题指南靠线下发布、申报书靠纸质或邮件收集、专家评分散落在表格里无法自动汇总排名、立项与拨款无留痕",
     "features": ["课题指南(重点1/一般2 åˆ†ç±»æ ‡æ³¨)", "指南状态 0草稿/20已发布/30已截止·发布/截止两个动作", "指南编号 GL+yyyyMMdd+6位流水(Redis æ¯æ—¥é‡ç½®)", "在线申报书(选指南+选会员带出申报人信息+课题组成员+申请经费+申报书正文)", "申报编号 SB+同日流水·类别冗余自指南不信任前端", "申报状态 0草稿/15审批中/20已立项/35已驳回", "提交前校验指南已发布且在申报期内(逐条报具体维度与时间)", "专家评审明细子表(一位专家一行·四项加权 0.3/0.3/0.2/0.2)", "后端自动重算加权合计与排名·回写主单均分与推荐结论", "BPM ä¸‰çº§å®¡æ‰¹ research_topic_approve(秘书处形式审查→专家评审→学会立项审批)", "审批通过生成立项编号 LX+同日流水 ä¸Žç«‹é¡¹æ—¶é—´(幂等)", "发布立项通知(生成 system_notice å…¬å‘ŠÂ·å†™å›ž noticeId é˜²é‡å¤)", "经费拨付记录(批次/金额/日期/收款单位/状态/凭证附件)·金额不得超申请经费", "附件走 system é™„件中心(recordType=research_guideline / research_application / research_fund_record)"],
     "objects": ["课题指南", "课题申报书", "专家评审明细", "经费拨付记录"],
     "roles": ["课题申报人(会员)", "课题管理员", "评审专家", "学会秘书处", "学会理事会"],
     "upstream": ["会员管理(ums_member å–申报人姓名/单位/职称/电话)", "BPM(流程定义 research_topic_approve)"],
     "downstream": ["system(system_notice ç«‹é¡¹é€šçŸ¥å…¬å‘Š)", "system(附件中心)", "财务(二期预期待对接·一期不接)"],
     "coreTables": ["research_guideline", "research_application", "research_topic_review", "research_fund_record"]},
    {"key": "bpm", "name": "协同审批", "color": "#e6a23c", "tag": "BPM", "flow": null,
     "positioning": "全系统单据审批中枢(Flowable)",
     "problem": "多模块单据需要灵活可配的多级审批",
     "features": ["BPMN+钉钉风格双设计器", "流程定义部署", "审批事件统一回调(BpmProcessInstanceStatusEvent)", "13个业务监听器", "表单·用户组·抄送·加签"],
     "features": ["BPMN+钉钉风格双设计器", "流程定义部署", "审批事件统一回调(BpmProcessInstanceStatusEvent)", "14个业务监听器", "表单·用户组·抄送·加签"],
     "objects": ["流程模型", "流程实例", "审批任务"],
     "roles": ["审批人(按流程配置)", "流程管理员"],
     "upstream": ["各业务提交审批"], "downstream": ["回调更新各业务单据状态"],
@@ -438,7 +483,7 @@
  "platform": {
    "name": "系统底座",
    "capabilities": ["JWT+OAuth2 è®¤è¯Â·æ»‘块验证码", "RBAC è§’色-菜单-数据范围(全部/部门/本部门及下级/仅本人/自定义)", "字典", "附件中心(storage-blob æ–‡ä»¶ + storage-attachment æŒ‰ recordType+recordId å¤šæ€å…³è”,全模块强制使用)", "编码规则(各业务自实现 Redis å‰ç¼€+日期+流水)", "Quartz å®šæ—¶ä»»åŠ¡Â·RocketMQ/Kafka/Redis Stream MQ·WebSocket", "多租户已关闭", "实际 system_role ä»… 4 ä¸ªï¼šè¶…级管理员/普通角色/CRM管理员/租户管理员,业务分工靠数据权限而非多角色"],
    "unused": ["im æ¨¡å—(IM/RTC,业务未消费)", "member æ¨¡å—(C端会员,遗留)"]
    "unused": ["im æ¨¡å—(IM/RTC,业务未消费)", "member æ¨¡å—(C端商城会员,遗留;与 UMS å­¦ä¼šä¼šå‘˜æ— å…³ï¼Œå‹¿æ··ç”¨)"]
  }
}</script>
<script type="application/json" id="data-objects">{
@@ -490,7 +535,15 @@
    {"name": "离职交接", "module": "人力", "table": "hrm_user_handover", "doc": "from→to æ˜ å°„,已完成20 åŽ expandUserIds æ‰©å±•交接人可见数据(当前各业务模块尚未实际接入调用)", "upstream": ["离职申请"], "downstream": ["各模块我的数据查询"], "keywords": "交接 ç¦»èŒ handover expandUserIds"},
    {"name": "排班计划", "module": "设备", "table": "mes_cal_plan", "doc": "班组×班次×日期排班,供生产与考勤引用", "upstream": [], "downstream": ["生产任务", "考勤"], "keywords": "排班 ç­ç»„ ç­æ¬¡ æ—¥åކ"},
    {"name": "报告模板", "module": "质检报告", "table": "qc_report_template", "doc": "智能质检报告模板,启用0/停用1;画布内容存在 qc_report_template_version(草稿0/已发布1/已停用2,已发布不可覆盖),currentVersion æŒ‡å‘当前已发布版本;源文件经 AI å¯¼å…¥è¯†åˆ«æˆè‰ç¨¿åŽç”±äººå·¥ç¡®è®¤æ‰æˆä¸ºç‰ˆæœ¬ï¼›åŒä¸€æ¨¡æ¿é‡å¤å¯¼å…¥æŒ‰ã€æ›¿æ¢ã€‘处理——先删旧附件(连带 blob è¡Œä¸Žç£ç›˜æ–‡ä»¶ï¼‰å†ç»‘新的,不留孤儿文件;删除模板会连带清附件", "upstream": ["人工设计(GrapesJS è®¾è®¡å™¨)", "AI å¯¼å…¥è‰ç¨¿(qc-report/ai-import/draft)"], "downstream": ["模板版本", "报告实例"], "keywords": "报告模板 è´¨æ£€æŠ¥å‘Š template è®¾è®¡å™¨ ç‰ˆæœ¬ AI导入 è¯†åˆ« è‰ç¨¿ æºæ–‡ä»¶æ›¿æ¢"},
    {"name": "报告实例", "module": "质检报告", "table": "qc_report_instance", "doc": "出件产物,编号 QR+yyyyMMdd+4位流水按天独立且业务唯一,可显式指定覆盖(注意 uk_report_no ä¸åŒºåˆ†è½¯åˆ é™¤ï¼Œåˆ æŽ‰çš„号不回收);两条出件入口:通用 generate(调用方自带 context)与 generate-from-qc(数据从 MES è´¨æ£€å•取,权限码 qc-report:instance:generate å·²å…¥ system_menu id=1075416,五道校验按业务优先级只报第一处:105_000 ç±»åž‹éžæ³• / 105_001 å•据不存在 / 105_002 æœªå®Œæˆ / 105_003 æœªåˆ¤å®š / 105_005 æ¨¡æ¿ç±»åž‹ä¸ç¬¦ / 105_006 æ— å¯ç”¨æ£€éªŒé¡¹ï¼›å››å¼ è´¨æ£€å•列表的行操作「出报告」仅在 status=已完成时可见,弹窗只列 reportType ä¸€è‡´ã€æ¨¡æ¿å¯ç”¨ã€ä¸” currentVersion æŒ‡å‘的版本已发布的模板(后端 /qc-report/template/selectable åˆ¤å®šâ€”—停用版本不清空 currentVersion,前端拿分页结果自己推导会漏),结果就地展示并跳本页列表);冻结 data_snapshot(判定后数据,含后端算好的 PASS/FAIL ä¸Žåˆæ ¼çŽ‡ï¼‰ä¸Ž render_html;regenerate åªé‡æ¸²æŽ’版、数据与编号不变;business_id/business_type å­˜ MES è¡¨åï¼ˆmes_qc_iqc ç­‰ï¼‰ä¸Žå•据 ID,可反查、一单可出多份;报告头另带单据原判定 qcResult(数)/qcResultText(中文,合格/特采/不合格退货/不合格报废),与引擎三态判定并存互不覆盖;响应带 undecidableCount=快照中 result ä¸ºç©ºä¸²çš„项数与 warnings(分组标题跳过/指标已删/未录实测值/样品超3),两者都不落库、事后看不到", "upstream": ["报告模板版本", "质检单上下文(通用 generate ç”±è°ƒç”¨æ–¹ä¼ å…¥)", "MES è´¨æ£€å• IQC/IPQC/OQC/RQC(generate-from-qc ç» MesQcReportApi å–æ•°)"], "downstream": ["前端实例页 /qc/report-instance(列表·iframe预览·快照页签·导出PDF)", "PDF归档(system_storage_attachment recordType=qc_report_instance)"], "keywords": "报告实例 å‡ºä»¶ QR编号 å¿«ç…§ å¤çް regenerate PDF归档 æŠ¥å‘Šå®žä¾‹é¡µ å¯¼å‡ºPDF generate-from-qc è´¨æ£€å•出报告 å‡ºæŠ¥å‘Šå¼¹çª— qcResult undecidableCount warnings å¾…判定 åæŸ¥ ä¸šåŠ¡å•æ®ç­›é€‰"}
    {"name": "报告实例", "module": "质检报告", "table": "qc_report_instance", "doc": "出件产物,编号 QR+yyyyMMdd+4位流水按天独立且业务唯一,可显式指定覆盖(注意 uk_report_no ä¸åŒºåˆ†è½¯åˆ é™¤ï¼Œåˆ æŽ‰çš„号不回收);两条出件入口:通用 generate(调用方自带 context)与 generate-from-qc(数据从 MES è´¨æ£€å•取,权限码 qc-report:instance:generate å·²å…¥ system_menu id=1075416,五道校验按业务优先级只报第一处:105_000 ç±»åž‹éžæ³• / 105_001 å•据不存在 / 105_002 æœªå®Œæˆ / 105_003 æœªåˆ¤å®š / 105_005 æ¨¡æ¿ç±»åž‹ä¸ç¬¦ / 105_006 æ— å¯ç”¨æ£€éªŒé¡¹ï¼›å››å¼ è´¨æ£€å•列表的行操作「出报告」仅在 status=已完成时可见,弹窗只列 reportType ä¸€è‡´ã€æ¨¡æ¿å¯ç”¨ã€ä¸” currentVersion æŒ‡å‘的版本已发布的模板(后端 /qc-report/template/selectable åˆ¤å®šâ€”—停用版本不清空 currentVersion,前端拿分页结果自己推导会漏),结果就地展示并跳本页列表);冻结 data_snapshot(判定后数据,含后端算好的 PASS/FAIL ä¸Žåˆæ ¼çŽ‡ï¼‰ä¸Ž render_html;regenerate åªé‡æ¸²æŽ’版、数据与编号不变;business_id/business_type å­˜ MES è¡¨åï¼ˆmes_qc_iqc ç­‰ï¼‰ä¸Žå•据 ID,可反查、一单可出多份;报告头另带单据原判定 qcResult(数)/qcResultText(中文,合格/特采/不合格退货/不合格报废),与引擎三态判定并存互不覆盖;响应带 undecidableCount=快照中 result ä¸ºç©ºä¸²çš„项数与 warnings(分组标题跳过/指标已删/未录实测值/样品超3),两者都不落库、事后看不到", "upstream": ["报告模板版本", "质检单上下文(通用 generate ç”±è°ƒç”¨æ–¹ä¼ å…¥)", "MES è´¨æ£€å• IQC/IPQC/OQC/RQC(generate-from-qc ç» MesQcReportApi å–æ•°)"], "downstream": ["前端实例页 /qc/report-instance(列表·iframe预览·快照页签·导出PDF)", "PDF归档(system_storage_attachment recordType=qc_report_instance)"], "keywords": "报告实例 å‡ºä»¶ QR编号 å¿«ç…§ å¤çް regenerate PDF归档 æŠ¥å‘Šå®žä¾‹é¡µ å¯¼å‡ºPDF generate-from-qc è´¨æ£€å•出报告 å‡ºæŠ¥å‘Šå¼¹çª— qcResult undecidableCount warnings å¾…判定 åæŸ¥ ä¸šåŠ¡å•æ®ç­›é€‰"},
    {"name": "报销单", "module": "财务报销", "table": "finance_reimbursement", "doc": "单号可自定义(中间不允许空格,与已有单号重复则报错);新增时留空或纯空格由系统生成 BX+yyyyMMdd+6位流水(Redis æ¯æ—¥é‡ç½®),修改时留空表示保持不变。status: 0草稿/15审批中/20已通过/35已驳回/40已归档;仅草稿可删、草稿与已驳回可改、仅已通过可归档;提交后由 BPM ç›‘听器按结果回写状态(取消退回草稿),审批中 processInstanceId è¢«å›žå†™ï¼›ç‹¬ç«‹æ¨¡å—,不与其它业务模块产生单据关联", "upstream": [], "downstream": ["报销明细", "发票附件(storage_attachment·recordType=finance_reimbursement)"], "keywords": "报销单 æŠ¥é”€ å‘票 reimbursement BX å½’æ¡£ è´¢åŠ¡ è´¹ç”¨ è‡ªå®šä¹‰å•号"},
    {"name": "报销明细", "module": "财务报销", "table": "finance_reimbursement_item", "doc": "报销单的费用行:费用类型(1差旅/2办公/3招待/4交通/5其他)、发票号码、发票金额、发票日期、开票方、费用说明;随主表一次性提交(先删后增全量覆盖),提交时必须剥掉行 id ä¸Ž reimbursementId â€”— æ˜Žç»†åˆ é™¤æ˜¯è½¯åˆ ï¼Œå›žä¼ åŽŸ id ä¼šä¸Žå·²è½¯åˆ è¡Œä¸»é”®å†²çª", "upstream": ["报销单"], "downstream": [], "keywords": "报销明细 è´¹ç”¨æ˜Žç»† è´¹ç”¨ç±»åž‹ å‘票金额 item æ˜Žç»†è¡Œ å·®æ—…"},
    {"name": "入会申请单", "module": "会员管理", "table": "ums_application", "doc": "单号 SQ+yyyyMMdd+6位流水(Redis æ¯æ—¥é‡ç½®),后端生成不可自定义;memberType 1个人/2单位决定字段分叉(个人用 workUnit/title,单位用 legalPerson/organizationCode);memberGrade æŒ‰ç±»åž‹å–不同字典;status: 0草稿/15审批中/20已通过/35已驳回。仅草稿可删(连带清附件)、草稿与已驳回可改可重提;提交后由 BPM ç›‘听器按结果回写(取消退回草稿),审批中回写 processInstanceId;审批通过触发自动建会员与系统账号。独立模块,不与其它业务模块产生单据关联", "upstream": ["人工填报(申请人)", "证明材料附件(storage_attachment·recordType=ums_application)"], "downstream": ["会员(审批通过自动创建)", "BPM æµç¨‹å®žä¾‹(ums_membership_approve åˆ†ç±»)"], "keywords": "入会申请 å…¥ä¼š ä¼šå‘˜ç”³è¯· application SQ ç§˜ä¹¦å¤„ ç†äº‹ä¼š å®¡æ‰¹ ä¼šå‘˜ç±»åž‹ ä¸ªäºº å•位 æ³•定代表人 ç ”究方向 ç”³è¯·ç­‰çº§"},
    {"name": "会员", "module": "会员管理", "table": "ums_member", "doc": "单号 HY+yyyyMMdd+6位流水(Redis æ¯æ—¥é‡ç½®);两个来源:①入会申请审批通过自动创建(回填 applicationId ä¸Ž userName/系统账号) â‘¡ä¼šå‘˜åˆ—表手工新增。status: 1正常/0暂停;memberGrade æŒ‰ memberType å–对应字典,校验规则是单位会员(type=2)不可选『理事长』(grade=5)。自动建档时入会日期=当天、不搬运 contactUser/unitIntro/expireDate(申请单无对应字段);自动建的系统账号用户名为联系电话且初始密码同为联系电话,部门固定为「学会会员」;手机号重复会明确报错且整单事务回滚", "upstream": ["入会申请单(审批通过)", "人工新增", "附件(storage_attachment·recordType=ums_member)"], "downstream": ["会员通讯录(按 regionCode/industry æ£€ç´¢)", "system_users(登录账号)"], "keywords": "会员 member HY ä¼šå‘˜ç¼–号 ä¼šå‘˜ç­‰çº§ ä¼šå‘˜ç±»åž‹ é€šè®¯å½• åœ°åŒº è¡Œä¸š å…¥ä¼šæ—¥æœŸ æœ‰æ•ˆæœŸ"},
    {"name": "课题指南", "module": "课题管理", "table": "research_guideline", "doc": "指南编号 GL+yyyyMMdd+6位流水(Redis æ¯æ—¥é‡ç½®);category 1重点课题/2一般课题;publishStatus 0草稿/20已发布/30已截止,仅草稿可改可删,publish å‘布回写 publishTime,close æˆªæ­¢ç½® 30;已产生申报单(applicationMapper.selectCountByGuidelineId å¤§äºŽ 0)的指南禁止删除并在提示中报出该指南下已有多少张申报单;正文 content ä¸ºé•¿æ–‡æœ¬ï¼›é™„ä»¶ recordType=research_guideline", "upstream": ["人工维护(学会秘书处)", "指南附件(storage_attachment·recordType=research_guideline)"], "downstream": ["课题申报书(申报时引用且必须已发布并在申报期内)"], "keywords": "课题指南 æŒ‡å— å‘布 æˆªæ­¢ é‡ç‚¹è¯¾é¢˜ ä¸€èˆ¬è¯¾é¢˜ GL æŒ‡å—编号 ç”³æŠ¥æœŸ ç”³æŠ¥èµ·æ­¢"},
    {"name": "课题申报书", "module": "课题管理", "table": "research_application", "doc": "申报编号 SB+yyyyMMdd+6位流水(Redis æ¯æ—¥é‡ç½®);guidelineId å…³è”指南、memberId å…³è” ums_member(软关联无外键,会员删除后会留下悬空 memberId éœ€å®¹é”™æ˜¾ç¤º);applicantName/workUnit/jobTitle/contactPhone/contactEmail ç”±é€‰ä¼šå‘˜è‡ªåŠ¨å¸¦å‡ºå¯æ‰‹æ”¹ï¼›category å†—余自指南且不信任前端传值;budgetAmount ç”³è¯·ç»è´¹ä¸ºæ‹¨ä»˜ä¸Šé™ï¼›status: 0草稿/15审批中/20已立项/35已驳回。仅草稿可删(连带清附件)、草稿与已驳回可改可重提;submit å‰é€æ¡æ ¡éªŒã€ŒæŒ‡å—已发布」与「现在在申报期内」,任一不满足即拒绝并按维度给出指南名与实际值;审批通过由监听器生成立项编号 researchNo(LX+同日流水)与 approveTime(已有编号幂等跳过);reviewAvgScore/reviewPass ä¸ºè¯„审汇总回写值,明细删空时清成未汇总;noticeId è®°å½•已发布的立项通知公告,重复发布被拦截", "upstream": ["课题指南(必须已发布且在申报期内)", "会员(ums_member·带出申报人信息)", "申报附件(storage_attachment·recordType=research_application)"], "downstream": ["专家评审明细", "经费拨付记录", "立项通知(system_notice å…¬å‘Š)", "BPM æµç¨‹å®žä¾‹(research_topic_approve åˆ†ç±»)", "附件中心"], "keywords": "课题申报 ç”³æŠ¥ä¹¦ ç”³æŠ¥å• SB ç”³æŠ¥ç¼–号 ç«‹é¡¹ ç«‹é¡¹ç¼–号 LX ç”³è¯·ç»è´¹ ç”³æŠ¥ä¹¦æ­£æ–‡ è¯¾é¢˜ç»„成员 ä¸“家评审 è¯„审汇总 ç«‹é¡¹é€šçŸ¥ å‘布通知 æäº¤å®¡æ‰¹ é©³å›ž é‡æ"},
    {"name": "专家评审明细", "module": "课题管理", "table": "research_topic_review", "doc": "一位专家一行的一主多行子表:evaluatorName/evaluatorUnit/evaluatorTitle + topicScore é€‰é¢˜ä»·å€¼ä¸Žæ„ä¹‰(权重0.3) + planScore ç ”究方案可行性(0.3) + teamScore å›¢é˜Ÿä¸Žç ”究基础(0.2) + resultScore é¢„期成果与效益(0.2) + conclusion 1推荐立项/2不推荐立项 + opinion è¯„审意见 + reviewTime。compositeScore ç”±åŽç«¯ weightedScore è®¡ç®—、ranking ç”±åŽç«¯æŒ‰åŠ æƒåˆ†é™åºé‡æŽ’ï¼Œå‡ä¸æŽ¥å—å‰ç«¯ä¼ å€¼ï¼›æ¯æ¬¡å¢žåˆ æ”¹åŽè°ƒ recalculate å›žå†™ä¸»å• reviewAvgScore(全部专家加权分均值·保留2位)与 reviewPass(均分达标 ä¸” å¤šæ•°ç»“论为推荐立项时为 1);四项分值超出 0~100 æ—¶æç¤ºæŒ‡å‡ºå…·ä½“维度与实际值;仅在申报单 status=15 å®¡æ‰¹ä¸­å¯å¢žåˆ æ”¹", "upstream": ["课题申报书(仅审批中可写)"], "downstream": ["课题申报书(回写 reviewAvgScore/reviewPass)"], "keywords": "专家评审 è¯„审明细 è¯„分 åŠ æƒ æƒé‡ æŽ’名 ranking compositeScore æŽ¨èç«‹é¡¹ è¯„审意见 å‡åˆ†"},
    {"name": "经费拨付记录", "module": "课题管理", "table": "research_fund_record", "doc": "一主多行的拨款记录:batchName æ‰¹æ¬¡åç§°/amount æ‹¨ä»˜é‡‘额/payDate æ‹¨ä»˜æ—¥æœŸ/payee æ”¶æ¬¾å•位/status 0待拨付 20已拨付/remark,凭证附件走 system é™„件中心 recordType=research_fund_record(表内不存文件地址)。仅申报单 status=20 å·²ç«‹é¡¹å¯ç™»è®°ï¼›é‡‘额校验:已登记金额(该单全部记录求和) + æœ¬æ¬¡é‡‘额 ä¸å¾—超出申报单 budgetAmount,超出时提示报出本次、已登记、合计、预算四项数值;status=20 å·²æ‹¨ä»˜ æ—¶å‡­è¯é™„ä»¶å¿…å¡«(前端先行拦截,后端 validateVoucherRequired å…œåº•);仅待拨付状态可改可删,编辑时把本行金额加回可用余额展示避免误解", "upstream": ["课题申报书(必须已立项)"], "downstream": ["拨付凭证(storage_attachment·recordType=research_fund_record)", "财务(二期预期待对接·一期不接)"], "keywords": "经费拨付 æ‹¨æ¬¾ æ‹¨ä»˜è®°å½• æ‰¹æ¬¡ æ‹¨ä»˜é‡‘额 å‡­è¯ å¾…拨付 å·²æ‹¨ä»˜ æ”¶æ¬¾å•位 èµ„助金额 é¢„ç®— è¶…额"}
  ]
}</script>
<script type="application/json" id="data-flows">{
@@ -548,6 +601,36 @@
       {"stage": "判定", "action": "问题判定: ä¸€èˆ¬/ç»´ä¿®/退货,严重度 0-3", "roles": ["售后工程师"]},
       {"stage": "ç»´ä¿®", "action": "现场/返厂/远程维修,费用与结果记录,完成联动工单完结", "roles": ["维修工程师"]},
       {"stage": "退货", "action": "退货申请审核→自动建 MES é”€å”®é€€è´§å•(RQC)→质检/入库回调→退款回调→关闭", "roles": ["售后", "质检", "财务"]}]},
    {"id": "finance", "name": "财务报销流程", "mermaid": "mermaid/12-finance-flow.mmd", "color": "#3ec9a7",
     "summary": "独立财务模块(yudao-module-finance):报销单(主表 + 1..n æ˜Žç»†è¡Œ + å‘票附件) ä¸€æ¬¡æ€§ä¿å­˜ â†’ æäº¤ BPM å®¡æ‰¹ â†’ é€šè¿‡åŽå½’档终态;驳回可改发票重新提交。附件走 system é™„件中心按 recordType=finance_reimbursement ç»‘定,模块不与其他业务模块产生任何单据关联或字段带入。",
     "steps": [
       {"stage": "填报", "action": "主表(报销标题/报销总金额/申请日期/备注) + è‡³å°‘ 1 è¡Œæ˜Žç»†(费用类型/发票号码/发票金额/发票日期/开票方/费用说明);总金额由明细求和回填,用户可手改,后端不校验一致性", "roles": ["报销人"]},
       {"stage": "附件", "action": "发票先调 /system/storage-blob/upload å– blobId;保存时把该单当前全部 blobId æ”¾è¿› blobIds ä¸€èµ·æäº¤ï¼ˆå…¨é‡æ›¿æ¢è¯­ä¹‰ï¼Œä¼ ç©ºæ•°ç»„=清空,不传=不动)", "roles": ["报销人"]},
       {"stage": "保存", "action": "create ç”± Redis ç”Ÿæˆå•号 BX+yyyyMMdd+6位流水(每日重置);明细先删后增全量覆盖且提交剥掉行 id;status=0 è‰ç¨¿", "roles": ["报销人"]},
       {"stage": "修改", "action": "update ä»…放行 status=0 è‰ç¨¿ ä¸Ž status=35 å·²é©³å›žï¼›delete ä»…放行草稿(连带清理明细与发票附件)", "roles": ["报销人"]},
       {"stage": "审批", "action": "submit ä¼  id ä¸Ž processDefinitionKey(query å‚数,请求体为空)→ status=15 å®¡æ‰¹ä¸­ å¹¶å›žå†™ processInstanceId;BPM ç›‘听器按结果回写:通过→20 å·²é€šè¿‡ã€é©³å›žâ†’35 å·²é©³å›žã€å–消→退回 0 è‰ç¨¿ï¼›æµç¨‹ key å– approve-process-list å®žæ—¶åˆ—表不写死", "roles": ["报销人", "审批人"]},
       {"stage": "归档", "action": "archive ä»…放行 status=20 å·²é€šè¿‡ â†’ status=40 å·²å½’档(终态)并写入 archiveTime", "roles": ["财务"]}]},
    {"id": "ums", "name": "会员入会流程", "mermaid": "mermaid/13-ums-membership-flow.mmd", "color": "#d6955c",
     "summary": "独立会员模块(yudao-module-ums):入会申请单(个人/单位二型 + ç”³è¯·ç­‰çº§ + è¯æ˜Žææ–™é™„ä»¶) è‰ç¨¿ â†’ æäº¤ BPM ä¸¤çº§å®¡æ‰¹ï¼ˆç§˜ä¹¦å¤„初审 â†’ ç†äº‹ä¼šå®¡è®®ï¼‰â†’ é€šè¿‡åŽè‡ªåŠ¨åˆ›å»ºç³»ç»Ÿç™»å½•è´¦å·ä¸Žä¼šå‘˜è®°å½• â†’ ä¼šå‘˜è¿›å…¥åˆ—表与通讯录(按地区/行业检索)。驳回可改后重提,取消退回草稿。模块不与其他业务模块产生单据关联,仅复用 BPM ä¸Ž system é™„件中心。",
     "steps": [
       {"stage": "填报", "action": "选会员类型 1个人 / 2单位(**非租户多类型,而是两套字段**):个人填 å§“名/工作单位/职称;单位填 å•位名称/法定代表人/统一社会信用代码;共有 ç ”究方向/联系电话/邮箱/行业/地区编码/备注", "roles": ["入会申请人", "会员管理员"]},
       {"stage": "定级", "action": "选申请等级;等级字典随会员类型切换(个人 ums_personal_grade / å•位 ums_unit_grade),前端切换类型时清空已选等级", "roles": ["入会申请人"]},
       {"stage": "附件", "action": "证明材料先调 /system/storage-blob/upload å– blobId,随保存请求的 blobIds ä¸€èµ·æäº¤ï¼›ç»‘定 recordType=ums_application(会员侧为 ums_member)", "roles": ["入会申请人"]},
       {"stage": "保存", "action": "create ç”± Redis ç”Ÿæˆç”³è¯·ç¼–号 SQ+yyyyMMdd+6位流水(每日重置),后端生成不可自定义;status=0 è‰ç¨¿ï¼Œè¡¨å•不填状态", "roles": ["入会申请人"]},
       {"stage": "修改", "action": "update ä»…放行 status=0 è‰ç¨¿ ä¸Ž status=35 å·²é©³å›žï¼ˆé©³å›žæ—¶æç¤ºå¸¦ç”³è¯·ç¼–号);delete ä»…放行草稿,且连带清理该单附件", "roles": ["入会申请人", "会员管理员"]},
       {"stage": "审批", "action": "submit ä¼  id ä¸Ž processDefinitionKey(query å‚数)→ status=15 å®¡æ‰¹ä¸­å¹¶å›žå†™ processInstanceId;BPM ç›‘听器 UmsApplicationStatusListener æŒ‰ç»“果回写:通过→20、驳回→35、取消→退回 0;流程列表由 approve-process-list å®žæ—¶å–(先校验 BPM åˆ†ç±» ums_membership_approve å­˜åœ¨ï¼Œå†æŒ‰ key å–未挂起的最高版本),不写死 key", "roles": ["入会申请人", "秘书处(初审)", "理事会(审议)"]},
       {"stage": "建档", "action": "审批通过钩子自动建会员:①生成会员编号 HY+yyyyMMdd+6位流水 â‘¡AdminUserApi.createUser å»ºç³»ç»Ÿè´¦å·ï¼ˆç”¨æˆ·åä¸Žåˆå§‹å¯†ç =联系电话、昵称=申请人姓名、部门=学会会员)③会员落库(入会日期=当天、状态 1 æ­£å¸¸ã€applicationId å›žå¡«æ¥æºç”³è¯·å•);手机号重复会明确报错", "roles": ["系统自动"]},
       {"stage": "管理与检索", "action": "会员列表支持手工新增/编辑/删除(等级与类型校验:单位会员不可选『理事长』);会员通讯录为只读页,按地区编码与行业检索", "roles": ["会员管理员", "全体可查通讯录"]}]},
    {"id": "research", "name": "课题管理流程", "mermaid": "mermaid/14-research-flow.mmd", "color": "#e070a8",
     "summary": "独立科研模块(yudao-module-research):学会发布课题指南(重点/一般分类)→ ä¼šå‘˜åœ¨çº¿æäº¤è¯¾é¢˜ç”³æŠ¥ä¹¦ â†’ æäº¤ BPM ä¸‰çº§å®¡æ‰¹ï¼ˆç§˜ä¹¦å¤„形式审查 â†’ ä¸“家评审 â†’ å­¦ä¼šç«‹é¡¹å®¡æ‰¹ï¼‰ï¼Œä¸“家评分以结构化明细子表记录并由后端自动汇总加权均分、排名与推荐结论 â†’ å®¡æ‰¹é€šè¿‡ç”Ÿæˆç«‹é¡¹ç¼–号并发布系统公告 â†’ æŒ‰æ‰¹æ¬¡ç™»è®°ç»è´¹æ‹¨ä»˜è®°å½•(含凭证附件)。院级二期再补结项验收与成果归档。",
     "steps": [
       {"stage": "发布指南", "action": "新建指南选类别 1重点课题 / 2一般课题,填申报起止时间与指南正文;编号 GL+yyyyMMdd+6位流水(Redis æ¯æ—¥é‡ç½®ï¼‰ï¼›status: 0草稿/20已发布/30已截止。仅草稿可改可删,发布后即冻结;已产生申报单的指南禁止删除(提示该指南下已有 N å¼ ç”³æŠ¥å•)", "roles": ["学会秘书处", "课题管理员"]},
       {"stage": "在线申报", "action": "从已发布且在申报期内的指南新建申报书;选会员(ums_member)自动带出姓名/工作单位/职称/联系电话/邮箱,可改;填课题名称、课题组成员、申请经费、申报书正文;编号 SB+yyyyMMdd+6位流水;类别冗余自指南,不信任前端传值;status: 0草稿", "roles": ["课题申报人(会员)", "课题管理员"]},
       {"stage": "提交审批", "action": "submit ä¼  id ä¸Ž processDefinitionKey(query å‚数)→ status=15 å®¡æ‰¹ä¸­å¹¶å›žå†™ processInstanceId。提交前逐条校验:① æŒ‡å—必须已发布 â‘¡ å½“前时间必须在申报起止期内——任一不满足即拒绝,提示带指南名与实际状态或起止时间;仅草稿与已驳回可改可重提,仅草稿可删(连带清附件)", "roles": ["课题申报人"]},
       {"stage": "专家评审", "action": "申报单处于审批中(15)时录入专家评分明细(一位专家一行:姓名/单位/职称 + é€‰é¢˜ä»·å€¼0.3/研究方案0.3/团队基础0.2/预期成果0.2 å››é¡¹åˆ†å€¼ + ç»“论 + æ„è§ï¼‰ï¼›åŽç«¯æ¯æ¬¡å¢žåˆ æ”¹åŽé‡ç®— compositeScore åŠ æƒåˆè®¡ã€æŒ‰åŠ æƒåˆ†é™åºé‡æŽ’ ranking,并回写主单 reviewAvgScore(全部专家均分)与 reviewPass(均分达标且多数结论为推荐立项);明细全部删除时汇总值清空为未汇总;四项分值均在 0~100 ä¹‹å¤–时提示指出具体维度与实际值", "roles": ["评审专家", "学会秘书处", "课题管理员"]},
       {"stage": "立项审批", "action": "BPM æµç¨‹ research_topic_approve é¡ºåºèµ°ä¸‰èŠ‚ç‚¹ï¼šç§˜ä¹¦å¤„å½¢å¼å®¡æŸ¥ â†’ ä¸“家评审 â†’ å­¦ä¼šç«‹é¡¹å®¡æ‰¹ã€‚审批方法与审批人在流程定义内配置,部署后由管理员在 BPM æµç¨‹è®¾è®¡å™¨æŒ‰å­¦ä¼šå®žé™…组织调整;审批中心内的自定义表单只读回显申报单 + è¯„审明细 + æ‹¨ä»˜è®°å½•。通过→20 å·²ç«‹é¡¹ã€é©³å›žâ†’35 å·²é©³å›žã€å–消→退回 0 è‰ç¨¿", "roles": ["学会秘书处(形式审查)", "评审专家(评审节点)", "学会理事会(立项审批)"]},
       {"stage": "立项与通知", "action": "监听器 ResearchApplicationStatusListener å…ˆå›žå†™å®¡æ‰¹çŠ¶æ€ï¼Œå®¡æ‰¹é€šè¿‡åŽå†è°ƒ approveApplication:生成立项编号 LX+yyyyMMdd+6位流水 ä¸Ž approveTime(已有编号则幂等跳过)。点「发布立项通知」生成 system_notice å…¬å‘Šï¼ˆæ ‡é¢˜ã€Žå…³äºŽå…¬å¸ƒç«‹é¡¹è¯¾é¢˜çš„通知』,正文含课题名/立项编号/类别/申报人/单位/资助金额),写回 noticeId é˜²é‡å¤â€”—重复点击按已发布拦截并告知上次发布时间", "roles": ["课题管理员", "学会秘书处"]},
       {"stage": "经费拨付", "action": "仅「已立项(20)」的申报单可登记拨款记录(批次名称/拨付金额/拨付日期/收款单位/状态/备注/凭证附件);金额校验:已登记金额 + æœ¬æ¬¡é‡‘额 ä¸å¾—超出该单申请经费,超出时提示报出本次、已登记、合计、预算四项数值;status: 0待拨付/20已拨付,凭证明细走 system é™„件中心 recordType=research_fund_record,状态改为已拨付时凭证必填;仅待拨付状态可改可删,编辑时会把本行金额加回余额显示", "roles": ["学会财务", "课题管理员"]}]},
    {"id": "ai", "name": "AI ä¸šåŠ¡æµç¨‹", "mermaid": "mermaid/11-ai-business-flow.mmd", "color": "#56c8d8",
     "summary": "统一模式:业务侧收集上下文(必要时调 MDM/SRM/CRM API å–名称)→组装 Prompt→chat/chatWithImage→解析 JSON→前端预填或建议;无 Function Calling;RAG çŸ¥è¯†åº“已建成但暂无业务消费。",
     "steps": [
@@ -579,7 +662,11 @@
    {"role": "客服/售后工程师", "modules": ["售后"], "operations": ["工单受理派工", "ç»´ä¿®", "退货申请"], "data": ["after_sale_*"], "scope": "行级数据权限四视图:我负责/我参与/下属负责/全部(aftersales:view-all)"},
    {"role": "财务", "modules": ["ERP", "CRM"], "operations": ["收款单", "付款单", "开票审核", "结算账户"], "data": ["erp_finance_*", "crm_invoice", "crm_receivable"], "scope": "financeUserId å½’属"},
    {"role": "HR", "modules": ["HRM"], "operations": ["员工档案入转调离", "考勤规则排班", "薪酬核算发放", "离职交接"], "data": ["hrm_*"], "scope": "部门范围 dataScope"},
    {"role": "管理层", "modules": ["BI"], "operations": ["查看采购销售/仓库/生产设备/质量/人力五大主题大屏"], "data": ["bi_dashboard"], "scope": "只读"}
    {"role": "管理层", "modules": ["BI"], "operations": ["查看采购销售/仓库/生产设备/质量/人力五大主题大屏"], "data": ["bi_dashboard"], "scope": "只读"},
    {"role": "入会申请人/会员管理员", "modules": ["UMS"], "operations": ["填报与修改入会申请(个人/单位)", "上传证明材料附件", "提交 BPM å®¡æ‰¹", "会员列表维护与会员通讯录检索"], "data": ["ums_application", "ums_member"], "scope": "权限码 ums:application:create/update/delete/submit Â· ums:member:create/update/delete/query Â· ums:member:directory;无数据权限隔离(全员可查通讯录,维护类按钮另凭权限码)"},
    {"role": "秘书处/理事会(审批人)", "modules": ["UMS", "BPM"], "operations": ["入会申请两级审批:秘书处初审 â†’ ç†äº‹ä¼šå®¡è®®"], "data": ["ums_application(状态回写)", "bpm_task"], "scope": "审批人由 BPM æµç¨‹å®šä¹‰é…ç½®ï¼ŒåŽç«¯ä¸å†™æ­»ï¼›é€šè¿‡åŽç³»ç»Ÿè‡ªåŠ¨å»ºä¼šå‘˜ä¸Žç™»å½•è´¦å·"},
    {"role": "课题申报人/课题管理员", "modules": ["RES"], "operations": ["课题指南发布与截止(课题管理员)", "新建与修改课题申报书(选会员带出申报人信息、上传申报附件)", "提交 BPM å®¡æ‰¹ã€é©³å›žåŽä¿®æ”¹é‡æ", "每月/每批次登记经费拨付记录与凭证", "发布立项通知"], "data": ["research_guideline", "research_application", "research_topic_review", "research_fund_record"], "scope": "权限码 research:guideline:create/update/delete/publish Â· research:application:create/update/delete/submit/notice Â· research:review:create/update/delete Â· research:fund:create/update/delete;无数据权限隔离(列表不按申请人过滤),按钮凭权限码控制"},
    {"role": "评审专家/学会秘书处·理事会(课题审批人)", "modules": ["RES", "BPM"], "operations": ["在审批中(15)录入本人的专家评分明细(四项分值 + ç»“论 + æ„è§)", "秘书处形式审查 â†’ ä¸“家评审 â†’ å­¦ä¼šç«‹é¡¹å®¡æ‰¹ ä¸‰èŠ‚ç‚¹é¡ºåºå®¡æ‰¹", "在审批中心通过自定义表单只读查看申报书正文、评审明细与拨付记录"], "data": ["research_topic_review", "research_application(状态回写)", "bpm_task"], "scope": "评分录入要求申报单处于审批中(15)·由课题管理员或专家依据 research:review:* æƒé™ç æ‰§è¡Œï¼›å®¡æ‰¹äººä¸Žå®¡æ‰¹æ–¹æ³•在 BPM æµç¨‹å®šä¹‰ research_topic_approve å†…配置(默认依次审批·角色兜底),后端不写死审批人,部署后由管理员在流程设计器按学会实际组织调整"}
  ]
}</script>
<pre style="display:none" class="mmd-src" data-mmd="01-business-mindmap">
@@ -613,6 +700,30 @@
      ä»˜æ¬¾å•
      æ”¶æ¬¾å•
      ç»“算账户
    è´¢åŠ¡æŠ¥é”€ FIN ç‹¬ç«‹
      æŠ¥é”€å•主表
      æŠ¥é”€æ˜Žç»†è¡Œ
      å‘票附件上传
      æäº¤å®¡æ‰¹ä¸Žé©³å›žé‡æ
      å½’档终态
    ä¼šå‘˜ç®¡ç† UMS ç‹¬ç«‹
      å…¥ä¼šç”³è¯·å•
      ä¸ªäººä¸Žå•位二型字段
      ä¼šå‘˜ç­‰çº§ä¸ªäººä¸Žå•位两套
      ç§˜ä¹¦å¤„理事会两级审批
      å®¡æ‰¹é€šè¿‡è‡ªåŠ¨å»ºä¼šå‘˜
      è‡ªåŠ¨åˆ›å»ºç³»ç»Ÿç™»å½•è´¦å·
      ä¼šå‘˜åˆ—表与通讯录检索
    è¯¾é¢˜ç®¡ç† RES ç‹¬ç«‹
      è¯¾é¢˜æŒ‡å—发布
      é‡ç‚¹ä¸Žä¸€èˆ¬è¯¾é¢˜åˆ†ç±»
      æŒ‡å¯¼ç”³æŠ¥æœŸä¸Žæˆªæ­¢
      åœ¨çº¿è¯¾é¢˜ç”³æŠ¥ä¹¦
      ä»Žä¼šå‘˜å¸¦å‡ºç”³æŠ¥äººä¿¡æ¯
      ä¸“家评审明细与加权排名
      ç§˜ä¹¦å¤„理事会三级审批
      ç«‹é¡¹ç¼–号与立项通知公告
      ç»è´¹æ‹¨ä»˜è®°å½•与凭证
    ç”Ÿäº§ç®¡ç† MES
      ä¸»ç”Ÿäº§è®¡åˆ’ MPS
      ç”Ÿäº§å·¥å•
@@ -808,7 +919,7 @@
  RCTX --> RCITEM[检验项=样品×指标交叉展开·全指标进不过滤·多样品时样品号折进 remark]
  RCTX --> RCQC[携带单据原判定 qcResult æ•°Â·qcResultText ä¸­æ–‡Â·ä¸Žå¼•擎判定并存互不覆盖]
  MESMAP -->|分组项 item_type=2 ä¸Žå®ƒçš„子项按 parent_id è¿˜åŽŸä¸¤å±‚Â·å½’å±žä¸çœ‹ type ä¹Ÿä¸çœ‹è¡Œåº| RCTXG
  RCTXG["分组还原·组头行背组名与 formula_text å…¬å¼Â·ç»„内第一行背 span=该组总行数·同组其余行 span=1 ä¸” hidden è®©ä½Â·åªåˆå¹¶æ£€éªŒé¡¹ç›®ä¸€åˆ—"] --- RCTX
  RCTXG["分组还原·组头行背组名与 formula_text å…¬å¼Â·ç»„内第一行背 span=该组总行数·同组其余行 span=1 ä¸” hidden è®©ä½Â·åˆå¹¶çš„æ˜¯æ¨¡æ¿ columns é‡Œå¸¦ # çš„那一列(默认列即检验项目)·合并行数是本单该组实际行数而非主数据子项数·该组本单无子项行则不合并"] --- RCTX
  MESMAP -.->|分组项本单没有属于它的子项 / ç»„头本单没有行由后端补一行| RCWARN
  MESMAP -.->|指标已删 / æœªå½•实测值(组头锚点行按定义无值·不报) / æ ·å“è¶… 3 ä¸ªÂ·è¿› warnings| RCWARN
  RCWARN[warnings è½¯æç¤ºÂ·ä¸è½åº“·事后看不到需自行留痕] --- RCTX
@@ -899,7 +1010,7 @@
  MES -->|退货质检入库回调| AS[售后工单维修退货]
  CRM -->|客户主数据| AS
  HRM[HRM å‘˜å·¥å…¥è½¬è°ƒç¦»äº¤æŽ¥è€ƒå‹¤è–ªé…¬] -.->|expandUserIds æ•°æ®å¯è§æ€§æ‰©å±•·接口已就绪待各模块接入| CRM
  BPM[BPM Flowable å®¡æ‰¹å¼•擎] -.->|状态事件回调·13个监听器| ERP
  BPM[BPM Flowable å®¡æ‰¹å¼•擎] -.->|状态事件回调·14个监听器| ERP
  BPM -.-> CRM
  BPM -.-> SRM
  BPM -.-> MES
@@ -916,6 +1027,17 @@
  SYS[系统底座 è®¤è¯Â·RBAC·字典·附件中心·定时任务] --- MDM
  SYS --- MES
  STG[附件中心 storage-blob+attachment å…¨æ¨¡å—统一挂附件] --- ERP
  FIN[财务报销 ç‹¬ç«‹æ¨¡å— yudao-module-finance æŠ¥é”€å• æ˜Žç»† å‘票附件] -.->|复用 BPM å®¡æ‰¹ä¸ŽçŠ¶æ€å›žè°ƒ| BPM
  FIN -.->|发票走附件中心 recordType=finance_reimbursement| STG
  FIN -.->|独立模块·不与其它业务模块产生单据或数据关联| ERP
  UMS[会员管理 ç‹¬ç«‹æ¨¡å— yudao-module-ums å…¥ä¼šç”³è¯· ä¼šå‘˜ é€šè®¯å½•] -.->|复用 BPM ä¸¤çº§å®¡æ‰¹ä¸ŽçŠ¶æ€å›žè°ƒ| BPM
  UMS -.->|证明材料走附件中心 recordType=ums_application / ums_member| STG
  UMS -.->|审批通过经 AdminUserApi.createUser å»ºç³»ç»Ÿç™»å½•账号| SYS
  UMS -.->|独立模块·不与其它业务模块产生单据或数据关联| ERP
  RES[课题管理 ç‹¬ç«‹æ¨¡å— yudao-module-research æŒ‡å— ç”³æŠ¥ä¹¦ ä¸“家评审 ç»è´¹æ‹¨ä»˜] -.->|复用 BPM ä¸‰çº§å®¡æ‰¹ä¸ŽçŠ¶æ€å›žè°ƒÂ·æµç¨‹ key=research_topic_approve| BPM
  RES -.->|附件走附件中心 recordType=research_guideline / research_application / research_fund_record| STG
  RES -->|选会员取申报人姓名 å·¥ä½œå•位 èŒç§° è”系电话| UMS
  RES -->|立项通知写 system_notice å…¬å‘Š| SYS
</pre>
<pre style="display:none" class="mmd-src" data-mmd="09-data-flow">
flowchart LR
@@ -990,6 +1112,37 @@
  UIREP -.->|结果就地展示 reportNo itemCount ä¸Ž undecidableCount å‘Šè­¦| QCFE
  QCFE -.->|进入时带 businessType+businessId è·¯ç”± query·预填搜索表单的「业务单据」字段| UIREP
  RPT -.->|权限码 qc-report:template:ai-import å·²å…¥ system_menu| SYS1
  subgraph S5 [独立财务报销 finance]
    RBM[报销单 finance_reimbursement] --> RBI[报销明细 finance_reimbursement_item]
    RBM --> RBA[发票附件 recordType=finance_reimbursement]
    RBM --> RBS[状态 è‰ç¨¿0 å®¡æ‰¹ä¸­15 å·²é€šè¿‡20 å·²é©³å›ž35 å·²å½’æ¡£40]
  end
  RBM -->|create å¸¦ä¸»è¡¨ä¸Ž items æ•°ç»„与 blobIds ä¸€æ¬¡æ€§æäº¤| RBI
  RBI -.->|invoiceAmount æ±‚和回填 totalAmount·用户可手改·后端不校验一致性| RBM
  RBM -->|submit å¸¦ id ä¸Ž processDefinitionKey èµ° query| RBS
  RBS -.->|BPM ç›‘听器结果回写 é€šè¿‡20 é©³å›ž35 å–消退回0| RBM
  RBA -.->|上传与保存分离·禁在业务表存文件地址| PDFARCH
  FINNOTE[独立模块·销售采购链条任意单据均不生成报销单·报销单亦不回写任何外部单据] -.-> RBM
  subgraph S6 [独立课题管理 research]
    RGL[课题指南 research_guideline] --> RAP[课题申报书 research_application]
    RAP --> RRV[专家评审明细 research_topic_review]
    RAP --> RFR[经费拨付记录 research_fund_record]
    RAP --> RST[状态 è‰ç¨¿0 å®¡æ‰¹ä¸­15 å·²ç«‹é¡¹20 å·²é©³å›ž35]
  end
  UMSM[会员 ums_member] -->|"选会员带出 applicantName workUnit jobTitle contactPhone contactEmail·软关联无外键"| RAP
  RGL -->|"申报时引用·要求 publishStatus=已发布20 ä¸” çŽ°åœ¨åœ¨ applyStartTime è‡³ applyEndTime å†…"| RAP
  RAP -->|"category å†—余自指南·不信任前端传值"| RAP
  RAP -->|"submit å¸¦ id ä¸Ž processDefinitionKey èµ° query·回写 processInstanceId"| RST
  RST -.->|"BPM ç›‘听器 ResearchApplicationStatusListener å›žå†™ é€šè¿‡20 é©³å›ž35 å–消退回0"| RAP
  RAP -->|"审批通过后 approveApplication ç”Ÿæˆ researchNo LX ä¸Ž approveTime·已有编号幂等跳过"| RAP
  RRV -->|"recalculate: compositeScore åŠ æƒ 0.3/0.3/0.2/0.2 â†’ ranking é™åºé‡æŽ’ â†’ ä¸»å• reviewAvgScore ä¸Ž reviewPass"| RAP
  RRV -.->|"明细删空时主单汇总清为 null"| RAP
  RAP -->|"publishNotice ç”Ÿæˆ system_notice å…¬å‘Šå¹¶å›žå†™ noticeId·重复发布拦截"| SYS2[system_notice ç³»ç»Ÿå…¬å‘Š]
  RFR -->|"金额校验 å·²ç™»è®° + æœ¬æ¬¡ ä¸å¾—超出 budgetAmount ç”³è¯·ç»è´¹"| RAP
  RFR -.->|"凭证走附件中心 recordType=research_fund_record·表内不存文件地址"| PDFARCH
  RAP -.->|"申报附件 recordType=research_application"| PDFARCH
  RGL -.->|"指南附件 recordType=research_guideline"| PDFARCH
  RESNOTE[独立模块·一期经费拨付只在本模块落记录·与财务模块无数据同步·二期再对接] -.-> RFR
</pre>
<pre style="display:none" class="mmd-src" data-mmd="10-business-object">
erDiagram
@@ -1027,9 +1180,15 @@
  srm_supplier ||--o{ mes_wm_arrival_notice : "到货来源"
  erp_purchase_order ||--o{ erp_purchase_invoice : "来票"
  erp_purchase_invoice ||--o{ erp_finance_payment : "付款"
  ums_application ||--o| ums_member : "审批通过自动建档"
  hrm_employee ||--o| hrm_user_handover : "离职交接映射"
  qc_report_template ||--o{ qc_report_template_version : "多版本"
  qc_report_template_version ||--o{ qc_report_instance : "冻结出件版本"
  finance_reimbursement ||--o{ finance_reimbursement_item : "费用明细"
  research_guideline ||--o{ research_application : "被申报引用"
  research_application ||--o{ research_topic_review : "专家评分明细"
  research_application ||--o{ research_fund_record : "经费拨付记录"
  ums_member ||--o{ research_application : "申报人(软关联无外键)"
</pre>
<pre style="display:none" class="mmd-src" data-mmd="11-ai-business-flow">
flowchart TD
@@ -1074,6 +1233,108 @@
  MODE[模型层:通义千问为主·预留DeepSeek/OpenAI/Ollama未实现] --- O2
  NOTE[全项目无Function Calling·AI只输出结构化JSON由业务侧解析] --- J2
</pre>
<pre style="display:none" class="mmd-src" data-mmd="12-finance-flow">
flowchart TD
  U1[报销人填报报销单·主表: æŠ¥é”€å•号(选填) æŠ¥é”€æ ‡é¢˜ æŠ¥é”€æ€»é‡‘额 ç”³è¯·æ—¥æœŸ å¤‡æ³¨] --> U2[录入报销明细 1..n è¡Œ: è´¹ç”¨ç±»åž‹ å‘票号码 å‘票金额 å‘票日期 å¼€ç¥¨æ–¹ è´¹ç”¨è¯´æ˜Ž]
  U2 --> U3[发票附件: å›¾ç‰‡æˆ– PDF å…ˆä¼  system storage-blob upload å– blobId]
  U3 --> S1[保存 create·主表与明细与全部 blobIds ä¸€æ¬¡æ€§æäº¤]
  S1 --> G1["报销单号:可自定义;留空(含纯空格)则由后端生成 BX + yyyyMMdd + 6位流水(Redis·每日重置)"]
  G1 --> B1["明细先删后增全量覆盖·提交剥掉行 id(软删后重插避免主键冲突)"]
  B1 --> A1["附件绑定 recordType = finance_reimbursement·按该单全部 blobId å…¨é‡æ›¿æ¢"]
  A1 --> K1[草稿 0]
  K1 -->|update ä¿®æ”¹ä¸»è¡¨ æ˜Žç»† é™„ä»¶| K1
  K1 -->|submit é€‰å®¡æ‰¹æµç¨‹ processDefinitionKey| K2[审批中 15·回写 processInstanceId]
  K2 -->|BPM å®¡æ‰¹é€šè¿‡| K3[已通过 20]
  K2 -->|BPM å®¡æ‰¹é©³å›ž| K4[已驳回 35]
  K2 -->|BPM å®¡æ‰¹å–消| K1
  K4 -->|改发票后重新 submit| K2
  K3 -->|archive å½’æ¡£| K5[已归档 40·终态·写入 archiveTime]
  D1["仅草稿 0 å¯ delete·连带清理明细与发票附件"] -.-> K1
  E1["仅草稿 0 ä¸Žå·²é©³å›ž 35 å¯ update·其余状态只读"] -.-> K4
  N1["独立模块·不与其他业务模块产生单据或字段带入"] --- K1
</pre>
<pre style="display:none" class="mmd-src" data-mmd="13-ums-membership-flow">
flowchart TD
  A1[填报入会申请·选会员类型 1个人 / 2单位] --> A2[个人分支: å§“名 è”系电话 é‚®ç®± å·¥ä½œå•位 èŒç§° ç ”究方向 è¡Œä¸š åœ°åŒº]
  A1 --> A3[单位分支: å•位名称 æ³•定代表人 ç»Ÿä¸€ç¤¾ä¼šä¿¡ç”¨ä»£ç  ç ”究方向 è¡Œä¸š åœ°åŒº]
  A2 --> A4[选申请等级·按会员类型切换字典 ums_personal_grade / ums_unit_grade]
  A3 --> A4
  A4 --> A5[证明材料附件: å…ˆä¼  system storage-blob upload å– blobId]
  A5 --> S1[保存 create·附件按该单全部 blobIds ç»‘定]
  S1 --> G1["申请编号 SQ + yyyyMMdd + 6位流水(Redis·每日重置)·后端生成不可改"]
  G1 --> K1[草稿 0]
  K1 -->|update ä¿®æ”¹ç”³è¯·| K1
  K1 -->|submit é€‰å®¡æ‰¹æµç¨‹ processDefinitionKey| K2[审批中 15·回写 processInstanceId]
  K2 -->|BPM å®¡æ‰¹é€šè¿‡| K3[已通过 20]
  K2 -->|BPM å®¡æ‰¹é©³å›ž| K4[已驳回 35]
  K2 -->|BPM å®¡æ‰¹å–消| K1
  K4 -->|修改后重新 submit| K2
  K3 --> M1["审批通过钩子: è‡ªåŠ¨åˆ›å»ºä¼šå‘˜ ums_member (内部先查状态确为已通过才执行)"]
  M1 --> M2["会员编号 HY + yyyyMMdd + 6位流水(Redis)"]
  M2 --> M3["创建系统登录账号 AdminUserApi.createUser·用户名与初始密码=联系电话·昵称=申请人姓名·部门=学会会员"]
  M3 --> M4[会员落库·入会日期=当天·状态 1正常·来源申请单 applicationId å›žå¡«]
  M4 --> D2[会员列表·可手工 create / update / delete]
  D2 --> V1["等级类型校验: å•位会员(memberType=2) ä¸å¯é€‰ã€Žç†äº‹é•¿ã€(memberGrade=5) ç›´æŽ¥æ‹’绝"]
  M4 --> D1[会员通讯录·只读·按地区 regionCode ä¸Žè¡Œä¸š industry æ£€ç´¢]
  ATT1["附件 recordType = ums_application"] -.-> K1
  ATT2["附件 recordType = ums_member"] -.-> M4
  RES1["仅草稿 0 å¯ delete·连带清理附件"] -.-> K1
  RES2["仅草稿 0 ä¸Žå·²é©³å›ž 35 å¯ update·其余状态只读"] -.-> K4
  ROL["审批链: BPM æµç¨‹å®šä¹‰ ums_membership_approve(归属分类 ums_membership_approve) ç§˜ä¹¦å¤„初审 â†’ ç†äº‹ä¼šå®¡è®®Â·ä¸¤èŠ‚ç‚¹å®¡æ‰¹äºº=角色策略(超管角色兜底)·后端按分类实时取流程、不写死 key"] -.-> K2
  F1["审批中心看单: æµç¨‹å®šä¹‰è¡¨å•类型=自定义表单·查看路径 /ums/application/detail/approve/index.vue·按 businessKey å›žæ˜¾ç”³è¯·å•"] -.-> K2
  N1["独立模块·不与其他业务模块产生单据关联·仅复用 BPM å®¡æ‰¹ä¸Ž system é™„件中心"] --- K1
</pre>
<pre style="display:none" class="mmd-src" data-mmd="14-research-flow">
flowchart TD
  subgraph P1 [课题指南发布]
    G0[新建指南·选类别 1重点课题 / 2一般课题] --> G1[填指南名称 ç”³æŠ¥èµ·æ­¢æ—¶é—´ æŒ‡å—正文 é™„ä»¶]
    G1 --> G2["指南编号 GL + yyyyMMdd + 6位流水(Redis·每日重置)"]
    G2 --> G3[草稿 0·仅草稿可改可删]
    G3 -->|已产生申报单则禁止删除| G3B["提示该指南下已有 N å¼ ç”³æŠ¥å•·需先处理申报单"]
    G3 -->|publish å‘布| G4["已发布 20·回写 publishTime"]
    G4 -->|close æˆªæ­¢| G5[已截止 30]
  end
  G4 -->|"指南已发布才可作为申报前提"| A1
  subgraph P2 [在线申报]
    A1[选指南·要求已发布 ä¸”在申报期内] --> A2[选会员 ums_member è‡ªåŠ¨å¸¦å‡º å§“名 å·¥ä½œå•位 èŒç§° ç”µè¯ é‚®ç®±]
    A2 --> A3[填课题名称 è¯¾é¢˜ç»„成员 ç”³è¯·ç»è´¹ ç”³æŠ¥ä¹¦æ­£æ–‡ ç”³æŠ¥é™„ä»¶]
    A3 --> A4["申报编号 SB + yyyyMMdd + 6位流水·类别冗余自指南不信任前端传值"]
    A4 --> K1[草稿 0]
  end
  K1 -->|update ä¿®æ”¹Â·ä»…草稿 0 ä¸Žå·²é©³å›ž 35 æ”¾è¡Œ| K1
  K1 -->|delete åˆ é™¤Â·ä»…草稿 0 æ”¾è¡ŒÂ·è¿žå¸¦æ¸…理附件| K1
  K1 -->|submit é€‰å®¡æ‰¹æµç¨‹ processDefinitionKey| K2["审批中 15·回写 processInstanceId"]
  K1 -->|"submit å‰é€æ¡æ ¡éªŒ: â‘  æŒ‡å—必须已发布 â‘¡ å½“前时间必须在申报起止期内"| KERR["任一不满足即拒绝提交·提示带指南名与实际状态或起止时间"]
  K2 -->|"仅审批中可增删改专家评分"| RK[专家评审明细 research_topic_review·一位专家一行]
  RK -->|"后端逐行重算 compositeScore = é€‰é¢˜0.3 + æ–¹æ¡ˆ0.3 + å›¢é˜Ÿ0.2 + æˆæžœ0.2"| RC[按加权分降序重排名次 ranking]
  RC -->|"回写主单: reviewAvgScore=全部专家均分 Â· reviewPass=均分达标 ä¸” å¤šæ•°ç»“论为推荐立项"| SUM[申报单评审汇总·删除最后一条明细时清空汇总]
  SUM -->|"秘书处汇总申报书与评审结果"| K3
  K2 --> K3
  subgraph P3 [立项审批 BPM research_topic_approve]
    K3["三节点顺序·审批方法与审批人在流程定义内配置"] --> KA[Activity_secretariat_review ç§˜ä¹¦å¤„形式审查]
    KA -->|通过| KB[Activity_expert_review ä¸“家评审]
    KB -->|通过| KC[Activity_council_approve å­¦ä¼šç«‹é¡¹å®¡æ‰¹]
  end
  KC -->|BPM å®¡æ‰¹é€šè¿‡| K4["已立项 20·监听器生成 ç«‹é¡¹ç¼–号 LX + yyyyMMdd + 6位流水 ä¸Ž approveTime·已有编号则幂等跳过"]
  KC -->|BPM å®¡æ‰¹é©³å›ž| K5[已驳回 35]
  KC -->|BPM å®¡æ‰¹å–消| K1
  KA -->|驳回| K5
  K5 -->|修改后重新 submit| K2
  K4 -->|publishNotice å‘布立项通知| N1["system_notice å…¬å‘ŠÂ·æ ‡é¢˜ã€Žå…³äºŽå…¬å¸ƒç«‹é¡¹è¯¾é¢˜çš„通知』·正文含课题名 ç«‹é¡¹ç¼–号 ç±»åˆ« ç”³æŠ¥äºº å•位 èµ„助金额·回写 noticeId·重复发布被拦截"]
  K4 -->|登记拨款记录 research_fund_record| F1[填批次名称 æ‹¨ä»˜é‡‘额 æ‹¨ä»˜æ—¥æœŸ æ”¶æ¬¾å•位 æ‹¨ä»˜çŠ¶æ€ å‡­è¯é™„ä»¶]
  F1 -->|"金额校验: å·²ç™»è®°é‡‘额 + æœ¬æ¬¡é‡‘额 ä¸å¾—超出申报单申请经费·超出时报出本次 å·²ç™»è®° åˆè®¡ é¢„算四项数值"| F2[待拨付 0]
  F2 -->|"仅待拨付 0 å¯æ”¹å¯åˆ "| F2
  F2 -->|"状态改为已拨付时必须已有凭证附件"| F3[已拨付 20]
  ATT1["附件 recordType = research_guideline"] -.-> G3
  ATT2["附件 recordType = research_application"] -.-> K1
  ATT3["附件 recordType = research_fund_record"] -.-> F1
  ROL["审批中心看单: æµç¨‹å®šä¹‰è¡¨å•类型=自定义表单·创建路径 /research/application/index Â· æŸ¥çœ‹è·¯å¾„ /research/application/detail/approve/index.vue·按 businessKey å›žæ˜¾ç”³æŠ¥å•"] -.-> K3
  ROL2["审批人取值: ä¸‰èŠ‚ç‚¹åœ¨æµç¨‹å®šä¹‰å†…é…å¥½å®¡æ‰¹æ–¹æ³•(依次审批)与审批人策略·部署后由管理员在 BPM æµç¨‹è®¾è®¡å™¨æŒ‰å­¦ä¼šå®žé™…组织调整·提交时前端只让选流程不选审批人"] -.-> K3
  RES1["一期边界: ç»è´¹æ‹¨ä»˜åªåœ¨æœ¬æ¨¡å—落拨款记录·与财务模块无数据同步(不做跨模块对接)"] -.-> F1
  RES2["二期范围(本图未覆盖): ç»“项验收 ç»“项证书 æˆæžœå½’档知识库 é‡‡çº³æƒ…况与社会效益标注"] -.-> K4
  BOUND["独立模块 yudao-module-research·对 ums_member ä¸ºè½¯å…³è”(无外键)·仅复用 BPM å®¡æ‰¹ã€system å…¬å‘Šä¸Žé™„件中心"] --- K1
</pre>
<script type="module">
const DATA = {};
@@ -1093,7 +1354,7 @@
const mmdSrc = {};
document.querySelectorAll('pre.mmd-src').forEach(p=>{ mmdSrc[p.dataset.mmd]=p.textContent.trim(); });
const flowColor = {sales:'var(--c-sale)',purchase:'var(--c-erp)',production:'var(--c-prod)',quality:'var(--c-qc)',warehouse:'var(--c-wm)',aftersales:'var(--c-as)',ai:'var(--c-ai)'};
const flowColor = {sales:'var(--c-sale)',purchase:'var(--c-erp)',production:'var(--c-prod)',quality:'var(--c-qc)',warehouse:'var(--c-wm)',aftersales:'var(--c-as)',finance:'var(--c-fin)',ums:'var(--c-ums)',research:'var(--c-res)',ai:'var(--c-ai)'};
async function boot(){
  DATA.business = await loadJson('business');
@@ -1130,8 +1391,8 @@
  </div>
  <h3 class="sec">业务模块</h3>
  <div class="grid cards">`;
  const targets = {crm:'flow-sales',erp:'flow-purchase',pro:'flow-production',wm:'flow-warehouse',qc:'flow-quality',aftersales:'flow-aftersales',ai:'ai',srm:'flow-purchase',mdm:'objects',dv:'objects',hrm:'objects',bpm:'relation'};
  const accs = {mdm:'var(--c-base)',crm:'var(--c-sale)',srm:'var(--c-sup)',erp:'var(--c-erp)',pro:'var(--c-prod)',wm:'var(--c-wm)',qc:'var(--c-qc)',dv:'var(--c-dv)',aftersales:'var(--c-as)',hrm:'var(--c-hrm)',bpm:'var(--c-bpm)',ai:'var(--c-ai)'};
  const targets = {crm:'flow-sales',erp:'flow-purchase',pro:'flow-production',wm:'flow-warehouse',qc:'flow-quality',aftersales:'flow-aftersales',fin:'flow-finance',ums:'flow-ums',res:'flow-research',ai:'ai',srm:'flow-purchase',mdm:'objects',dv:'objects',hrm:'objects',bpm:'relation'};
  const accs = {mdm:'var(--c-base)',crm:'var(--c-sale)',srm:'var(--c-sup)',erp:'var(--c-erp)',fin:'var(--c-fin)',ums:'var(--c-ums)',res:'var(--c-res)',pro:'var(--c-prod)',wm:'var(--c-wm)',qc:'var(--c-qc)',dv:'var(--c-dv)',aftersales:'var(--c-as)',hrm:'var(--c-hrm)',bpm:'var(--c-bpm)',ai:'var(--c-ai)'};
  DATA.modules.modules.forEach(m=>{
    html += `<div class="card" style="--acc:${accs[m.key]||'#2c5cff'}" data-goto="${targets[m.key]||'panorama'}">
      <h3>${esc(m.name)}<span class="tag">${esc(m.tag)}</span></h3>
docs/project-business/mermaid/01-business-mindmap.mmd
@@ -28,6 +28,30 @@
      ä»˜æ¬¾å•
      æ”¶æ¬¾å•
      ç»“算账户
    è´¢åŠ¡æŠ¥é”€ FIN ç‹¬ç«‹
      æŠ¥é”€å•主表
      æŠ¥é”€æ˜Žç»†è¡Œ
      å‘票附件上传
      æäº¤å®¡æ‰¹ä¸Žé©³å›žé‡æ
      å½’档终态
    ä¼šå‘˜ç®¡ç† UMS ç‹¬ç«‹
      å…¥ä¼šç”³è¯·å•
      ä¸ªäººä¸Žå•位二型字段
      ä¼šå‘˜ç­‰çº§ä¸ªäººä¸Žå•位两套
      ç§˜ä¹¦å¤„理事会两级审批
      å®¡æ‰¹é€šè¿‡è‡ªåŠ¨å»ºä¼šå‘˜
      è‡ªåŠ¨åˆ›å»ºç³»ç»Ÿç™»å½•è´¦å·
      ä¼šå‘˜åˆ—表与通讯录检索
    è¯¾é¢˜ç®¡ç† RES ç‹¬ç«‹
      è¯¾é¢˜æŒ‡å—发布
      é‡ç‚¹ä¸Žä¸€èˆ¬è¯¾é¢˜åˆ†ç±»
      æŒ‡å¯¼ç”³æŠ¥æœŸä¸Žæˆªæ­¢
      åœ¨çº¿è¯¾é¢˜ç”³æŠ¥ä¹¦
      ä»Žä¼šå‘˜å¸¦å‡ºç”³æŠ¥äººä¿¡æ¯
      ä¸“家评审明细与加权排名
      ç§˜ä¹¦å¤„理事会三级审批
      ç«‹é¡¹ç¼–号与立项通知公告
      ç»è´¹æ‹¨ä»˜è®°å½•与凭证
    ç”Ÿäº§ç®¡ç† MES
      ä¸»ç”Ÿäº§è®¡åˆ’ MPS
      ç”Ÿäº§å·¥å•
docs/project-business/mermaid/08-module-relation.mmd
@@ -11,7 +11,7 @@
  MES -->|退货质检入库回调| AS[售后工单维修退货]
  CRM -->|客户主数据| AS
  HRM[HRM å‘˜å·¥å…¥è½¬è°ƒç¦»äº¤æŽ¥è€ƒå‹¤è–ªé…¬] -.->|expandUserIds æ•°æ®å¯è§æ€§æ‰©å±•·接口已就绪待各模块接入| CRM
  BPM[BPM Flowable å®¡æ‰¹å¼•擎] -.->|状态事件回调·13个监听器| ERP
  BPM[BPM Flowable å®¡æ‰¹å¼•擎] -.->|状态事件回调·14个监听器| ERP
  BPM -.-> CRM
  BPM -.-> SRM
  BPM -.-> MES
@@ -28,3 +28,14 @@
  SYS[系统底座 è®¤è¯Â·RBAC·字典·附件中心·定时任务] --- MDM
  SYS --- MES
  STG[附件中心 storage-blob+attachment å…¨æ¨¡å—统一挂附件] --- ERP
  FIN[财务报销 ç‹¬ç«‹æ¨¡å— yudao-module-finance æŠ¥é”€å• æ˜Žç»† å‘票附件] -.->|复用 BPM å®¡æ‰¹ä¸ŽçŠ¶æ€å›žè°ƒ| BPM
  FIN -.->|发票走附件中心 recordType=finance_reimbursement| STG
  FIN -.->|独立模块·不与其它业务模块产生单据或数据关联| ERP
  UMS[会员管理 ç‹¬ç«‹æ¨¡å— yudao-module-ums å…¥ä¼šç”³è¯· ä¼šå‘˜ é€šè®¯å½•] -.->|复用 BPM ä¸¤çº§å®¡æ‰¹ä¸ŽçŠ¶æ€å›žè°ƒ| BPM
  UMS -.->|证明材料走附件中心 recordType=ums_application / ums_member| STG
  UMS -.->|审批通过经 AdminUserApi.createUser å»ºç³»ç»Ÿç™»å½•账号| SYS
  UMS -.->|独立模块·不与其它业务模块产生单据或数据关联| ERP
  RES[课题管理 ç‹¬ç«‹æ¨¡å— yudao-module-research æŒ‡å— ç”³æŠ¥ä¹¦ ä¸“家评审 ç»è´¹æ‹¨ä»˜] -.->|复用 BPM ä¸‰çº§å®¡æ‰¹ä¸ŽçŠ¶æ€å›žè°ƒÂ·æµç¨‹ key=research_topic_approve| BPM
  RES -.->|附件走附件中心 recordType=research_guideline / research_application / research_fund_record| STG
  RES -->|选会员取申报人姓名 å·¥ä½œå•位 èŒç§° è”系电话| UMS
  RES -->|立项通知写 system_notice å…¬å‘Š| SYS
docs/project-business/mermaid/09-data-flow.mmd
@@ -70,3 +70,34 @@
  UIREP -.->|结果就地展示 reportNo itemCount ä¸Ž undecidableCount å‘Šè­¦| QCFE
  QCFE -.->|进入时带 businessType+businessId è·¯ç”± query·预填搜索表单的「业务单据」字段| UIREP
  RPT -.->|权限码 qc-report:template:ai-import å·²å…¥ system_menu| SYS1
  subgraph S5 [独立财务报销 finance]
    RBM[报销单 finance_reimbursement] --> RBI[报销明细 finance_reimbursement_item]
    RBM --> RBA[发票附件 recordType=finance_reimbursement]
    RBM --> RBS[状态 è‰ç¨¿0 å®¡æ‰¹ä¸­15 å·²é€šè¿‡20 å·²é©³å›ž35 å·²å½’æ¡£40]
  end
  RBM -->|create å¸¦ä¸»è¡¨ä¸Ž items æ•°ç»„与 blobIds ä¸€æ¬¡æ€§æäº¤| RBI
  RBI -.->|invoiceAmount æ±‚和回填 totalAmount·用户可手改·后端不校验一致性| RBM
  RBM -->|submit å¸¦ id ä¸Ž processDefinitionKey èµ° query| RBS
  RBS -.->|BPM ç›‘听器结果回写 é€šè¿‡20 é©³å›ž35 å–消退回0| RBM
  RBA -.->|上传与保存分离·禁在业务表存文件地址| PDFARCH
  FINNOTE[独立模块·销售采购链条任意单据均不生成报销单·报销单亦不回写任何外部单据] -.-> RBM
  subgraph S6 [独立课题管理 research]
    RGL[课题指南 research_guideline] --> RAP[课题申报书 research_application]
    RAP --> RRV[专家评审明细 research_topic_review]
    RAP --> RFR[经费拨付记录 research_fund_record]
    RAP --> RST[状态 è‰ç¨¿0 å®¡æ‰¹ä¸­15 å·²ç«‹é¡¹20 å·²é©³å›ž35]
  end
  UMSM[会员 ums_member] -->|"选会员带出 applicantName workUnit jobTitle contactPhone contactEmail·软关联无外键"| RAP
  RGL -->|"申报时引用·要求 publishStatus=已发布20 ä¸” çŽ°åœ¨åœ¨ applyStartTime è‡³ applyEndTime å†…"| RAP
  RAP -->|"category å†—余自指南·不信任前端传值"| RAP
  RAP -->|"submit å¸¦ id ä¸Ž processDefinitionKey èµ° query·回写 processInstanceId"| RST
  RST -.->|"BPM ç›‘听器 ResearchApplicationStatusListener å›žå†™ é€šè¿‡20 é©³å›ž35 å–消退回0"| RAP
  RAP -->|"审批通过后 approveApplication ç”Ÿæˆ researchNo LX ä¸Ž approveTime·已有编号幂等跳过"| RAP
  RRV -->|"recalculate: compositeScore åŠ æƒ 0.3/0.3/0.2/0.2 â†’ ranking é™åºé‡æŽ’ â†’ ä¸»å• reviewAvgScore ä¸Ž reviewPass"| RAP
  RRV -.->|"明细删空时主单汇总清为 null"| RAP
  RAP -->|"publishNotice ç”Ÿæˆ system_notice å…¬å‘Šå¹¶å›žå†™ noticeId·重复发布拦截"| SYS2[system_notice ç³»ç»Ÿå…¬å‘Š]
  RFR -->|"金额校验 å·²ç™»è®° + æœ¬æ¬¡ ä¸å¾—超出 budgetAmount ç”³è¯·ç»è´¹"| RAP
  RFR -.->|"凭证走附件中心 recordType=research_fund_record·表内不存文件地址"| PDFARCH
  RAP -.->|"申报附件 recordType=research_application"| PDFARCH
  RGL -.->|"指南附件 recordType=research_guideline"| PDFARCH
  RESNOTE[独立模块·一期经费拨付只在本模块落记录·与财务模块无数据同步·二期再对接] -.-> RFR
docs/project-business/mermaid/10-business-object.mmd
@@ -33,6 +33,12 @@
  srm_supplier ||--o{ mes_wm_arrival_notice : "到货来源"
  erp_purchase_order ||--o{ erp_purchase_invoice : "来票"
  erp_purchase_invoice ||--o{ erp_finance_payment : "付款"
  ums_application ||--o| ums_member : "审批通过自动建档"
  hrm_employee ||--o| hrm_user_handover : "离职交接映射"
  qc_report_template ||--o{ qc_report_template_version : "多版本"
  qc_report_template_version ||--o{ qc_report_instance : "冻结出件版本"
  finance_reimbursement ||--o{ finance_reimbursement_item : "费用明细"
  research_guideline ||--o{ research_application : "被申报引用"
  research_application ||--o{ research_topic_review : "专家评分明细"
  research_application ||--o{ research_fund_record : "经费拨付记录"
  ums_member ||--o{ research_application : "申报人(软关联无外键)"
docs/project-business/mermaid/12-finance-flow.mmd
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
flowchart TD
  U1[报销人填报报销单·主表: æŠ¥é”€å•号(选填) æŠ¥é”€æ ‡é¢˜ æŠ¥é”€æ€»é‡‘额 ç”³è¯·æ—¥æœŸ å¤‡æ³¨] --> U2[录入报销明细 1..n è¡Œ: è´¹ç”¨ç±»åž‹ å‘票号码 å‘票金额 å‘票日期 å¼€ç¥¨æ–¹ è´¹ç”¨è¯´æ˜Ž]
  U2 --> U3[发票附件: å›¾ç‰‡æˆ– PDF å…ˆä¼  system storage-blob upload å– blobId]
  U3 --> S1[保存 create·主表与明细与全部 blobIds ä¸€æ¬¡æ€§æäº¤]
  S1 --> G1["报销单号:可自定义;留空(含纯空格)则由后端生成 BX + yyyyMMdd + 6位流水(Redis·每日重置)"]
  G1 --> B1["明细先删后增全量覆盖·提交剥掉行 id(软删后重插避免主键冲突)"]
  B1 --> A1["附件绑定 recordType = finance_reimbursement·按该单全部 blobId å…¨é‡æ›¿æ¢"]
  A1 --> K1[草稿 0]
  K1 -->|update ä¿®æ”¹ä¸»è¡¨ æ˜Žç»† é™„ä»¶| K1
  K1 -->|submit é€‰å®¡æ‰¹æµç¨‹ processDefinitionKey| K2[审批中 15·回写 processInstanceId]
  K2 -->|BPM å®¡æ‰¹é€šè¿‡| K3[已通过 20]
  K2 -->|BPM å®¡æ‰¹é©³å›ž| K4[已驳回 35]
  K2 -->|BPM å®¡æ‰¹å–消| K1
  K4 -->|改发票后重新 submit| K2
  K3 -->|archive å½’æ¡£| K5[已归档 40·终态·写入 archiveTime]
  D1["仅草稿 0 å¯ delete·连带清理明细与发票附件"] -.-> K1
  E1["仅草稿 0 ä¸Žå·²é©³å›ž 35 å¯ update·其余状态只读"] -.-> K4
  N1["独立模块·不与其他业务模块产生单据或字段带入"] --- K1
pom.xml
@@ -49,6 +49,12 @@
        <module>yudao-module-bi</module>
        <!-- æ™ºèƒ½è´¨æ£€æŠ¥å‘Šè®¾è®¡å¹³å°æ¨¡å— -->
        <module>yudao-module-qcreport</module>
        <!-- è´¢åŠ¡ç®¡ç†æ¨¡å— -->
        <module>yudao-module-finance</module>
        <!-- ä¼šå‘˜ç®¡ç†æ¨¡å— -->
        <module>yudao-module-ums</module>
        <!-- è¯¾é¢˜ç®¡ç†æ¨¡å— -->
        <module>yudao-module-research</module>
    </modules>
    <name>${project.artifactId}</name>
yudao-module-finance/pom.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <groupId>cn.iocoder.boot</groupId>
        <artifactId>yudao</artifactId>
        <version>${revision}</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>
    <artifactId>yudao-module-finance</artifactId>
    <packaging>jar</packaging>
    <name>${project.artifactId}</name>
    <description>
        finance åŒ…下,财务管理模块(发票报销)。
    </description>
    <dependencies>
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-system</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- BPM å·¥ä½œæµ -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-bpm</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- Web ç›¸å…³ -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-security</artifactId>
        </dependency>
        <!-- DB ç›¸å…³ -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-mybatis</artifactId>
        </dependency>
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-redis</artifactId>
        </dependency>
        <!-- å·¥å…·ç±»ç›¸å…³ -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-excel</artifactId>
        </dependency>
        <!-- Test æµ‹è¯•相关 -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-test</artifactId>
        </dependency>
    </dependencies>
</project>
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/FinanceReimbursementController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,193 @@
package cn.iocoder.yudao.module.finance.controller.admin.reimbursement;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.number.NumberUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.finance.controller.admin.reimbursement.vo.FinanceReimbursementPageReqVO;
import cn.iocoder.yudao.module.finance.controller.admin.reimbursement.vo.FinanceReimbursementRespVO;
import cn.iocoder.yudao.module.finance.controller.admin.reimbursement.vo.FinanceReimbursementSaveReqVO;
import cn.iocoder.yudao.module.finance.dal.dataobject.reimbursement.FinanceReimbursementDO;
import cn.iocoder.yudao.module.finance.dal.dataobject.reimbursement.FinanceReimbursementItemDO;
import cn.iocoder.yudao.module.finance.service.reimbursement.FinanceReimbursementService;
import cn.iocoder.yudao.module.finance.service.reimbursement.FinanceReimbursementServiceImpl;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.common.pojo.PageParam.PAGE_SIZE_NONE;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen;
@Tag(name = "管理后台 - è´¢åŠ¡æŠ¥é”€å•")
@RestController
@RequestMapping("/finance/reimbursement")
@Validated
public class FinanceReimbursementController {
    public static final String REIMBURSEMENT_RECORD_TYPE =
            FinanceReimbursementServiceImpl.REIMBURSEMENT_RECORD_TYPE;
    @Resource
    private FinanceReimbursementService reimbursementService;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @PostMapping("/create")
    @Operation(summary = "创建报销单")
    @PreAuthorize("@ss.hasPermission('finance:reimbursement:create')")
    public CommonResult<Long> createReimbursement(@Valid @RequestBody FinanceReimbursementSaveReqVO createReqVO) {
        return success(reimbursementService.createReimbursement(createReqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "更新报销单")
    @PreAuthorize("@ss.hasPermission('finance:reimbursement:update')")
    public CommonResult<Boolean> updateReimbursement(@Valid @RequestBody FinanceReimbursementSaveReqVO updateReqVO) {
        reimbursementService.updateReimbursement(updateReqVO);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除报销单")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('finance:reimbursement:delete')")
    public CommonResult<Boolean> deleteReimbursement(@RequestParam("id") Long id) {
        reimbursementService.deleteReimbursement(id);
        return success(true);
    }
    @GetMapping("/get")
    @Operation(summary = "获得报销单")
    @Parameter(name = "id", description = "编号", required = true, example = "1024")
    @PreAuthorize("@ss.hasPermission('finance:reimbursement:query')")
    public CommonResult<FinanceReimbursementRespVO> getReimbursement(@RequestParam("id") Long id) {
        FinanceReimbursementDO reimbursement = reimbursementService.getReimbursement(id);
        return success(buildReimbursementDetail(reimbursement));
    }
    @GetMapping("/page")
    @Operation(summary = "获得报销单分页")
    @PreAuthorize("@ss.hasPermission('finance:reimbursement:query')")
    public CommonResult<PageResult<FinanceReimbursementRespVO>> getReimbursementPage(
            @Valid FinanceReimbursementPageReqVO pageReqVO) {
        PageResult<FinanceReimbursementDO> pageResult = reimbursementService.getReimbursementPage(pageReqVO);
        return success(new PageResult<>(buildReimbursementDetailList(pageResult.getList()), pageResult.getTotal()));
    }
    @GetMapping("/export-excel")
    @Operation(summary = "导出报销单 Excel")
    @PreAuthorize("@ss.hasPermission('finance:reimbursement:export')")
    @ApiAccessLog(operateType = EXPORT)
    public void exportReimbursementExcel(@Valid FinanceReimbursementPageReqVO pageReqVO,
                                          HttpServletResponse response) throws IOException {
        pageReqVO.setPageSize(PAGE_SIZE_NONE);
        List<FinanceReimbursementDO> list = reimbursementService.getReimbursementPage(pageReqVO).getList();
        ExcelUtils.write(response, "报销单.xls", "数据", FinanceReimbursementRespVO.class,
                buildReimbursementDetailList(list));
    }
    @PostMapping("/submit")
    @Operation(summary = "提交报销单审批")
    @PreAuthorize("@ss.hasPermission('finance:reimbursement:update-status')")
    public CommonResult<Boolean> submitReimbursement(@RequestParam("id") Long id,
                                                      @RequestParam("processDefinitionKey") String processDefinitionKey) {
        reimbursementService.submitReimbursement(id, processDefinitionKey, getLoginUserId());
        return success(true);
    }
    @PostMapping("/archive")
    @Operation(summary = "归档报销单")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('finance:reimbursement:archive')")
    public CommonResult<Boolean> archiveReimbursement(@RequestParam("id") Long id) {
        reimbursementService.archiveReimbursement(id);
        return success(true);
    }
    @GetMapping("/approve-process-list")
    @Operation(summary = "获取报销单审批流程列表")
    @PreAuthorize("@ss.hasPermission('finance:reimbursement:query')")
    public CommonResult<List<Map<String, Object>>> getReimbursementApproveProcessDefinitionList() {
        return success(reimbursementService.getReimbursementApproveProcessDefinitionList());
    }
    // ==================== å†…部方法 ====================
    private FinanceReimbursementRespVO buildReimbursementDetail(FinanceReimbursementDO reimbursement) {
        if (reimbursement == null) {
            return null;
        }
        return buildReimbursementDetailList(Collections.singletonList(reimbursement)).get(0);
    }
    private List<FinanceReimbursementRespVO> buildReimbursementDetailList(List<FinanceReimbursementDO> list) {
        if (CollUtil.isEmpty(list)) {
            return Collections.emptyList();
        }
        // 1.1 èŽ·å–ç”³è¯·äºº
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
                convertSet(list, FinanceReimbursementDO::getApplicantId));
        // 1.2 èŽ·å–åˆ›å»ºäºº
        Map<Long, AdminUserRespDTO> creatorMap = adminUserApi.getUserMap(
                convertSet(list, invoice -> NumberUtils.parseLong(invoice.getCreator())));
        // 1.3 èŽ·å–æ˜Žç»†
        Map<Long, List<FinanceReimbursementItemDO>> itemMap = list.stream()
                .collect(Collectors.toMap(
                        FinanceReimbursementDO::getId,
                        r -> reimbursementService.getReimbursementItemListByReimbursementId(r.getId())));
        // 1.4 èŽ·å–é™„ä»¶
        Map<Long, List<cn.iocoder.yudao.module.system.api.storage.dto.StorageBlobRespDTO>> attachmentMap = list.stream()
                .collect(Collectors.toMap(
                        FinanceReimbursementDO::getId,
                        r -> storageAttachmentApi.listAttachments(REIMBURSEMENT_RECORD_TYPE, r.getId())));
        // 2. æ‹¼æŽ¥ç»“æžœ
        return BeanUtils.toBean(list, FinanceReimbursementRespVO.class, (vo) -> {
            // 2.1 ç”³è¯·äººå§“名
            findAndThen(userMap, vo.getApplicantId(), user -> vo.setApplicantName(user.getNickname()));
            // 2.2 åˆ›å»ºäººåç§°
            findAndThen(creatorMap, NumberUtils.parseLong(vo.getCreator()),
                    user -> vo.setCreatorName(user.getNickname()));
            // 2.3 æ˜Žç»†åˆ—表
            vo.setItems(BeanUtils.toBean(itemMap.getOrDefault(vo.getId(), Collections.emptyList()),
                    cn.iocoder.yudao.module.finance.controller.admin.reimbursement.vo.item.FinanceReimbursementItemRespVO.class));
            // 2.4 é™„件列表
            vo.setAttachmentList(attachmentMap.getOrDefault(vo.getId(), Collections.emptyList()));
            vo.setHasAttachment(CollUtil.isNotEmpty(vo.getAttachmentList()));
        });
    }
    /**
     * èŽ·å–å½“å‰ç™»å½•ç”¨æˆ·ID
     */
    private static Long getLoginUserId() {
        return cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId();
    }
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/vo/FinanceReimbursementPageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
package cn.iocoder.yudao.module.finance.controller.admin.reimbursement.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY;
@Schema(description = "管理后台 - æŠ¥é”€å•分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class FinanceReimbursementPageReqVO extends PageParam {
    @Schema(description = "报销单号", example = "BX20260928000001")
    private String no;
    @Schema(description = "报销标题", example = "9月差旅报销")
    private String title;
    @Schema(description = "状态", example = "0")
    private Integer status;
    @Schema(description = "申请人ID", example = "1")
    private Long applicantId;
    @Schema(description = "申请日期")
    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY)
    private LocalDate[] applyDate;
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/vo/FinanceReimbursementRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,91 @@
package cn.iocoder.yudao.module.finance.controller.admin.reimbursement.vo;
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty;
import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat;
import cn.iocoder.yudao.framework.excel.core.convert.DictConvert;
import cn.iocoder.yudao.module.finance.controller.admin.reimbursement.vo.item.FinanceReimbursementItemRespVO;
import cn.iocoder.yudao.module.finance.enums.DictTypeConstants;
import cn.iocoder.yudao.module.system.api.storage.dto.StorageBlobRespDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - æŠ¥é”€å• Response VO")
@Data
@ExcelIgnoreUnannotated
public class FinanceReimbursementRespVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "报销单号", example = "BX20260928000001")
    @ExcelProperty("报销单号")
    private String no;
    @Schema(description = "报销标题", example = "9月差旅报销")
    @ExcelProperty("报销标题")
    private String title;
    @Schema(description = "报销总金额", example = "1000.00")
    @ExcelProperty("报销总金额")
    private BigDecimal totalAmount;
    @Schema(description = "状态", example = "0")
    @ExcelProperty(value = "状态", converter = DictConvert.class)
    @DictFormat(DictTypeConstants.REIMBURSEMENT_STATUS)
    private Integer status;
    @Schema(description = "申请人ID", example = "1")
    private Long applicantId;
    @Schema(description = "申请人姓名", example = "张三")
    @ExcelProperty("申请人")
    private String applicantName;
    @Schema(description = "申请日期")
    @ExcelProperty("申请日期")
    private LocalDate applyDate;
    @Schema(description = "BPM流程实例ID")
    private String processInstanceId;
    @Schema(description = "归档时间")
    @ExcelProperty("归档时间")
    private LocalDateTime archiveTime;
    @Schema(description = "备注", example = "出差北京")
    @ExcelProperty("备注")
    private String remark;
    @Schema(description = "创建时间")
    @ExcelProperty("创建时间")
    private LocalDateTime createTime;
    @Schema(description = "更新时间")
    private LocalDateTime updateTime;
    @Schema(description = "创建人名称")
    private String creatorName;
    @Schema(description = "创建者")
    private String creator;
    /** æŠ¥é”€æ˜Žç»†åˆ—表 */
    @Schema(description = "报销明细列表")
    private List<FinanceReimbursementItemRespVO> items;
    /** å‘票附件列表 */
    @Schema(description = "发票附件列表")
    private List<StorageBlobRespDTO> attachmentList;
    /** æ˜¯å¦æœ‰é™„ä»¶ */
    @Schema(description = "是否有附件")
    @ExcelProperty("是否上传发票")
    private Boolean hasAttachment;
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/vo/FinanceReimbursementSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
package cn.iocoder.yudao.module.finance.controller.admin.reimbursement.vo;
import cn.iocoder.yudao.module.finance.controller.admin.reimbursement.vo.item.FinanceReimbursementItemSaveReqVO;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
@Schema(description = "管理后台 - æŠ¥é”€å•新增/修改 Request VO")
@Data
public class FinanceReimbursementSaveReqVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "报销单号。留空(含只填空格)则由系统按 BX+日期+6位流水自动生成;"
            + "修改时不传或留空表示保持原单号不变", example = "BX20260928000001")
    @Size(max = 36, message = "报销单号长度不能超过 36 ä¸ªå­—符")
    private String no;
    @Schema(description = "报销标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "9月差旅报销")
    @NotEmpty(message = "报销标题不能为空")
    private String title;
    @Schema(description = "报销总金额", requiredMode = Schema.RequiredMode.REQUIRED, example = "1000.00")
    @NotNull(message = "报销总金额不能为空")
    private BigDecimal totalAmount;
    @Schema(description = "申请日期", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotNull(message = "申请日期不能为空")
    private LocalDate applyDate;
    @Schema(description = "备注", example = "出差北京")
    private String remark;
    @Schema(description = "发票附件BlobId列表")
    private List<Long> blobIds;
    @Schema(description = "报销明细列表")
    @NotEmpty(message = "报销明细不能为空")
    private List<FinanceReimbursementItemSaveReqVO> items;
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/vo/item/FinanceReimbursementItemRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,37 @@
package cn.iocoder.yudao.module.finance.controller.admin.reimbursement.vo.item;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
@Schema(description = "管理后台 - æŠ¥é”€æ˜Žç»† Response VO")
@Data
public class FinanceReimbursementItemRespVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "报销单ID", example = "1")
    private Long reimbursementId;
    @Schema(description = "费用类型", example = "1")
    private Integer expenseType;
    @Schema(description = "发票号码", example = "12345678")
    private String invoiceNo;
    @Schema(description = "发票金额", example = "500.00")
    private BigDecimal invoiceAmount;
    @Schema(description = "发票日期")
    private LocalDate invoiceDate;
    @Schema(description = "开票方", example = "XX公司")
    private String drawer;
    @Schema(description = "费用说明", example = "出差火车票")
    private String description;
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/vo/item/FinanceReimbursementItemSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,37 @@
package cn.iocoder.yudao.module.finance.controller.admin.reimbursement.vo.item;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
@Schema(description = "管理后台 - æŠ¥é”€æ˜Žç»†æ–°å¢ž/修改 Request VO")
@Data
public class FinanceReimbursementItemSaveReqVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "费用类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "费用类型不能为空")
    private Integer expenseType;
    @Schema(description = "发票号码", example = "12345678")
    private String invoiceNo;
    @Schema(description = "发票金额", requiredMode = Schema.RequiredMode.REQUIRED, example = "500.00")
    @NotNull(message = "发票金额不能为空")
    private BigDecimal invoiceAmount;
    @Schema(description = "发票日期")
    private LocalDate invoiceDate;
    @Schema(description = "开票方", example = "XX公司")
    private String drawer;
    @Schema(description = "费用说明", example = "出差火车票")
    private String description;
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/dal/dataobject/reimbursement/FinanceReimbursementDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,58 @@
package cn.iocoder.yudao.module.finance.dal.dataobject.reimbursement;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
 * è´¢åŠ¡-报销单 DO
 *
 * @author è¶…级管理员
 */
@TableName("finance_reimbursement")
@KeySequence("finance_reimbursement_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class FinanceReimbursementDO extends BaseDO {
    @TableId
    private Long id;
    /** æŠ¥é”€å•号 */
    private String no;
    /** æŠ¥é”€æ ‡é¢˜ */
    private String title;
    /** æŠ¥é”€æ€»é‡‘额 */
    private BigDecimal totalAmount;
    /** çŠ¶æ€ï¼š0-草稿 15-审批中 20-已通过 35-已驳回 40-已归档 */
    private Integer status;
    /** ç”³è¯·äººID */
    private Long applicantId;
    /** ç”³è¯·æ—¥æœŸ */
    private LocalDate applyDate;
    /** BPM流程实例ID */
    private String processInstanceId;
    /** å½’档时间 */
    private LocalDateTime archiveTime;
    /** å¤‡æ³¨ */
    private String remark;
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/dal/dataobject/reimbursement/FinanceReimbursementItemDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.finance.dal.dataobject.reimbursement;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
 * è´¢åŠ¡-报销明细 DO
 *
 * @author è¶…级管理员
 */
@TableName("finance_reimbursement_item")
@KeySequence("finance_reimbursement_item_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class FinanceReimbursementItemDO extends BaseDO {
    @TableId
    private Long id;
    /** æŠ¥é”€å•ID */
    private Long reimbursementId;
    /** è´¹ç”¨ç±»åž‹ï¼š1-差旅 2-办公 3-招待 4-交通 5-其他 */
    private Integer expenseType;
    /** å‘票号码 */
    private String invoiceNo;
    /** å‘票金额 */
    private BigDecimal invoiceAmount;
    /** å‘票日期 */
    private LocalDate invoiceDate;
    /** å¼€ç¥¨æ–¹ */
    private String drawer;
    /** è´¹ç”¨è¯´æ˜Ž */
    private String description;
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/dal/mysql/reimbursement/FinanceReimbursementItemMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
package cn.iocoder.yudao.module.finance.dal.mysql.reimbursement;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.finance.dal.dataobject.reimbursement.FinanceReimbursementItemDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface FinanceReimbursementItemMapper extends BaseMapperX<FinanceReimbursementItemDO> {
    default List<FinanceReimbursementItemDO> selectListByReimbursementId(Long reimbursementId) {
        return selectList(FinanceReimbursementItemDO::getReimbursementId, reimbursementId);
    }
    default int deleteByReimbursementId(Long reimbursementId) {
        return delete(FinanceReimbursementItemDO::getReimbursementId, reimbursementId);
    }
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/dal/mysql/reimbursement/FinanceReimbursementMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,27 @@
package cn.iocoder.yudao.module.finance.dal.mysql.reimbursement;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.finance.controller.admin.reimbursement.vo.FinanceReimbursementPageReqVO;
import cn.iocoder.yudao.module.finance.dal.dataobject.reimbursement.FinanceReimbursementDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface FinanceReimbursementMapper extends BaseMapperX<FinanceReimbursementDO> {
    default FinanceReimbursementDO selectByNo(String no) {
        return selectOne(FinanceReimbursementDO::getNo, no);
    }
    default PageResult<FinanceReimbursementDO> selectPage(FinanceReimbursementPageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<FinanceReimbursementDO>()
                .likeIfPresent(FinanceReimbursementDO::getNo, reqVO.getNo())
                .likeIfPresent(FinanceReimbursementDO::getTitle, reqVO.getTitle())
                .eqIfPresent(FinanceReimbursementDO::getStatus, reqVO.getStatus())
                .eqIfPresent(FinanceReimbursementDO::getApplicantId, reqVO.getApplicantId())
                .betweenIfPresent(FinanceReimbursementDO::getApplyDate, reqVO.getApplyDate())
                .orderByDesc(FinanceReimbursementDO::getId));
    }
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/dal/redis/FinanceNoRedisDAO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
package cn.iocoder.yudao.module.finance.dal.redis;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.DateUtil;
import jakarta.annotation.Resource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;
import java.time.Duration;
import java.time.LocalDateTime;
/**
 * Finance è®¢å•序号的 Redis DAO
 *
 * @author è¶…级管理员
 */
@Repository
public class FinanceNoRedisDAO {
    /**
     * æŠ¥é”€å• {@link cn.iocoder.yudao.module.finance.dal.dataobject.reimbursement.FinanceReimbursementDO}
     */
    public static final String REIMBURSEMENT_NO_PREFIX = "BX";
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    /**
     * ç”Ÿæˆåºå·ï¼Œä½¿ç”¨å½“前日期,格式为 {PREFIX} + yyyyMMdd + 6 ä½è‡ªå¢ž
     *
     * @param prefix å‰ç¼€
     * @return åºå·
     */
    public String generate(String prefix) {
        String noPrefix = prefix + DateUtil.format(LocalDateTime.now(), DatePattern.PURE_DATE_PATTERN);
        String key = RedisKeyConstants.NO + noPrefix;
        Long no = stringRedisTemplate.opsForValue().increment(key);
        stringRedisTemplate.expire(key, Duration.ofDays(1L));
        return noPrefix + String.format("%06d", no);
    }
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/dal/redis/RedisKeyConstants.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
package cn.iocoder.yudao.module.finance.dal.redis;
/**
 * Finance Redis Key æžšä¸¾ç±»
 *
 * @author è¶…级管理员
 */
public interface RedisKeyConstants {
    /**
     * åºå·çš„缓存
     *
     * KEY æ ¼å¼ï¼štrade_no:{prefix}
     * VALUE æ•°æ®æ ¼å¼ï¼šç¼–号自增
     */
    String NO = "finance:seq_no:";
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/enums/DictTypeConstants.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,13 @@
package cn.iocoder.yudao.module.finance.enums;
/**
 * Finance å­—典类型的枚举类
 *
 * @author è¶…级管理员
 */
public interface DictTypeConstants {
    String REIMBURSEMENT_STATUS = "finance_reimbursement_status"; // æŠ¥é”€çŠ¶æ€
    String EXPENSE_TYPE = "finance_expense_type"; // è´¹ç”¨ç±»åž‹
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/enums/ErrorCodeConstants.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package cn.iocoder.yudao.module.finance.enums;
import cn.iocoder.yudao.framework.common.exception.ErrorCode;
/**
 * Finance é”™è¯¯ç æžšä¸¾ç±»
 * <p>
 * finance ç³»ç»Ÿï¼Œä½¿ç”¨ 1-030-107-000 æ®µï¼ˆæŽ¥åœ¨ ERP 1-030-106 ä¹‹åŽï¼‰
 */
public interface ErrorCodeConstants {
    // ========== æŠ¥é”€å•(1-030-107-000) ==========
    ErrorCode REIMBURSEMENT_NOT_EXISTS = new ErrorCode(1_030_107_000, "报销单不存在");
    ErrorCode REIMBURSEMENT_NO_EXISTS = new ErrorCode(1_030_107_001, "生成报销单号失败,请重新提交");
    ErrorCode REIMBURSEMENT_DELETE_FAIL_NOT_DRAFT = new ErrorCode(1_030_107_002, "报销单({})不是草稿状态,无法删除");
    ErrorCode REIMBURSEMENT_UPDATE_FAIL_NOT_DRAFT = new ErrorCode(1_030_107_003, "报销单({})不是草稿或已驳回状态,无法修改,只有草稿和已驳回的报销单可以编辑");
    ErrorCode REIMBURSEMENT_SUBMIT_FAIL_STATUS = new ErrorCode(1_030_107_004, "提交审批失败,只有草稿或已驳回状态的报销单才能提交");
    ErrorCode REIMBURSEMENT_SUBMIT_FAIL_ITEM_EMPTY = new ErrorCode(1_030_107_005, "提交审批失败,报销单没有明细行,无法提交");
    ErrorCode REIMBURSEMENT_ARCHIVE_FAIL_STATUS = new ErrorCode(1_030_107_006, "归档失败,只有已通过状态的报销单才能归档");
    ErrorCode REIMBURSEMENT_UPDATE_AUDIT_STATUS_FAIL_NOT_PROCESS = new ErrorCode(1_030_107_007, "更新审批状态失败,只有审批中的报销单才能更新");
    ErrorCode REIMBURSEMENT_BPM_CATEGORY_NOT_EXISTS = new ErrorCode(1_030_107_008, "报销审批分类不存在,请先在BPM模块创建报销审批流程分类");
    ErrorCode REIMBURSEMENT_BPM_PROCESS_DEFINITION_NOT_EXISTS = new ErrorCode(1_030_107_009, "报销审批分类下没有可用的流程模型,请先创建并发布流程");
    ErrorCode REIMBURSEMENT_CUSTOM_NO_EXISTS = new ErrorCode(1_030_107_010, "报销单号({})已被其他报销单使用,请更换其他单号,或清空该字段由系统自动生成");
    ErrorCode REIMBURSEMENT_CUSTOM_NO_INVALID = new ErrorCode(1_030_107_011, "报销单号({})中间不能包含空格,请去掉空格后重新输入,或清空该字段由系统自动生成");
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/enums/FinanceReimbursementStatusEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.finance.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
 * æŠ¥é”€å•状态枚举
 *
 * @author è¶…级管理员
 */
@Getter
@AllArgsConstructor
public enum FinanceReimbursementStatusEnum {
    DRAFT(0, "草稿"),
    APPROVING(15, "审批中"),
    APPROVED(20, "已通过"),
    REJECTED(35, "已驳回"),
    ARCHIVED(40, "已归档");
    private final Integer status;
    private final String name;
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/framework/web/config/FinanceWebConfiguration.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,16 @@
package cn.iocoder.yudao.module.finance.framework.web.config;
import cn.iocoder.yudao.framework.swagger.config.YudaoSwaggerAutoConfiguration;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class FinanceWebConfiguration {
    @Bean
    public GroupedOpenApi financeGroupedOpenApi() {
        return YudaoSwaggerAutoConfiguration.buildGroupedOpenApi("finance");
    }
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/service/reimbursement/FinanceReimbursementService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,39 @@
package cn.iocoder.yudao.module.finance.service.reimbursement;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.finance.controller.admin.reimbursement.vo.FinanceReimbursementPageReqVO;
import cn.iocoder.yudao.module.finance.controller.admin.reimbursement.vo.FinanceReimbursementSaveReqVO;
import cn.iocoder.yudao.module.finance.dal.dataobject.reimbursement.FinanceReimbursementDO;
import cn.iocoder.yudao.module.finance.dal.dataobject.reimbursement.FinanceReimbursementItemDO;
import java.util.List;
import java.util.Map;
/**
 * æŠ¥é”€å• Service æŽ¥å£
 *
 * @author è¶…级管理员
 */
public interface FinanceReimbursementService {
    Long createReimbursement(FinanceReimbursementSaveReqVO createReqVO);
    void updateReimbursement(FinanceReimbursementSaveReqVO updateReqVO);
    void deleteReimbursement(Long id);
    FinanceReimbursementDO getReimbursement(Long id);
    PageResult<FinanceReimbursementDO> getReimbursementPage(FinanceReimbursementPageReqVO pageReqVO);
    List<FinanceReimbursementItemDO> getReimbursementItemListByReimbursementId(Long reimbursementId);
    void submitReimbursement(Long id, String processDefinitionKey, Long userId);
    void archiveReimbursement(Long id);
    List<Map<String, Object>> getReimbursementApproveProcessDefinitionList();
    void updateReimbursementAuditStatus(Long id, Integer bpmResult);
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/service/reimbursement/FinanceReimbursementServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,348 @@
package cn.iocoder.yudao.module.finance.service.reimbursement;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
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.finance.controller.admin.reimbursement.vo.FinanceReimbursementPageReqVO;
import cn.iocoder.yudao.module.finance.controller.admin.reimbursement.vo.FinanceReimbursementSaveReqVO;
import cn.iocoder.yudao.module.finance.controller.admin.reimbursement.vo.item.FinanceReimbursementItemSaveReqVO;
import cn.iocoder.yudao.module.finance.dal.dataobject.reimbursement.FinanceReimbursementDO;
import cn.iocoder.yudao.module.finance.dal.dataobject.reimbursement.FinanceReimbursementItemDO;
import cn.iocoder.yudao.module.finance.dal.mysql.reimbursement.FinanceReimbursementItemMapper;
import cn.iocoder.yudao.module.finance.dal.mysql.reimbursement.FinanceReimbursementMapper;
import cn.iocoder.yudao.module.finance.dal.redis.FinanceNoRedisDAO;
import cn.iocoder.yudao.module.finance.enums.FinanceReimbursementStatusEnum;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
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.time.LocalDate;
import java.time.LocalDateTime;
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.collection.CollectionUtils.convertList;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
import static cn.iocoder.yudao.module.finance.enums.ErrorCodeConstants.*;
/**
 * æŠ¥é”€å• Service å®žçŽ°ç±»
 *
 * @author è¶…级管理员
 */
@Service
@Validated
@Slf4j
public class FinanceReimbursementServiceImpl implements FinanceReimbursementService {
    /**
     * BPM æŠ¥é”€å®¡æ‰¹åˆ†ç±»ç¼–码
     */
    private static final String REIMBURSEMENT_APPROVE_CATEGORY_CODE = "finance_reimbursement_approve";
    /**
     * é™„件关联的业务类型
     */
    public static final String REIMBURSEMENT_RECORD_TYPE = "finance_reimbursement";
    @Resource
    private FinanceReimbursementMapper reimbursementMapper;
    @Resource
    private FinanceReimbursementItemMapper reimbursementItemMapper;
    @Resource
    private FinanceNoRedisDAO noRedisDAO;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @Resource
    private BpmProcessInstanceApi processInstanceApi;
    @Resource
    private BpmCategoryService bpmCategoryService;
    @Resource
    private BpmProcessDefinitionService bpmProcessDefinitionService;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createReimbursement(FinanceReimbursementSaveReqVO createReqVO) {
        // 1. æŠ¥é”€å•号:允许用户自定义,未填时由系统生成
        String no = resolveCreateNo(createReqVO.getNo());
        // 2.1 æ’入报销单
        FinanceReimbursementDO reimbursement = BeanUtils.toBean(createReqVO, FinanceReimbursementDO.class, in -> in
                .setNo(no)
                .setStatus(FinanceReimbursementStatusEnum.DRAFT.getStatus())
                .setApplicantId(getLoginUserId())
                .setApplyDate(createReqVO.getApplyDate() != null ? createReqVO.getApplyDate() : LocalDate.now()));
        reimbursementMapper.insert(reimbursement);
        // 2.2 æ’入报销明细
        List<FinanceReimbursementItemDO> items = buildItems(reimbursement.getId(), createReqVO.getItems());
        if (CollUtil.isNotEmpty(items)) {
            reimbursementItemMapper.insertBatch(items);
        }
        // 2.3 ç»‘定发票附件
        storageAttachmentApi.bindAttachments("file", REIMBURSEMENT_RECORD_TYPE,
                reimbursement.getId(), createReqVO.getBlobIds());
        return reimbursement.getId();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateReimbursement(FinanceReimbursementSaveReqVO updateReqVO) {
        // 1.1 æ ¡éªŒå­˜åœ¨
        FinanceReimbursementDO reimbursement = validateReimbursementExists(updateReqVO.getId());
        // 1.2 åªæœ‰è‰ç¨¿ã€å·²é©³å›žçŠ¶æ€æ‰èƒ½ä¿®æ”¹
        Integer status = reimbursement.getStatus();
        if (!FinanceReimbursementStatusEnum.DRAFT.getStatus().equals(status)
                && !FinanceReimbursementStatusEnum.REJECTED.getStatus().equals(status)) {
            throw exception(REIMBURSEMENT_UPDATE_FAIL_NOT_DRAFT, reimbursement.getNo());
        }
        // 2.1 æ›´æ–°æŠ¥é”€å•
        FinanceReimbursementDO updateObj = BeanUtils.toBean(updateReqVO, FinanceReimbursementDO.class);
        // æŠ¥é”€å•号:未填表示不变更,填了则校验后覆盖
        String customNo = StrUtil.trimToNull(updateReqVO.getNo());
        if (customNo == null || customNo.equals(reimbursement.getNo())) {
            updateObj.setNo(reimbursement.getNo());
        } else {
            validateCustomNoFormat(customNo);
            if (reimbursementMapper.selectByNo(customNo) != null) {
                throw exception(REIMBURSEMENT_CUSTOM_NO_EXISTS, customNo);
            }
            updateObj.setNo(customNo);
        }
        reimbursementMapper.updateById(updateObj);
        // 2.2 æ›´æ–°æ˜Žç»†ï¼šå…ˆåˆ åŽå¢ž
        reimbursementItemMapper.deleteByReimbursementId(updateReqVO.getId());
        List<FinanceReimbursementItemDO> items = buildItems(updateReqVO.getId(), updateReqVO.getItems());
        if (CollUtil.isNotEmpty(items)) {
            reimbursementItemMapper.insertBatch(items);
        }
        // 2.3 æ›´æ–°å‘票附件
        storageAttachmentApi.updateAttachments("file", REIMBURSEMENT_RECORD_TYPE,
                updateReqVO.getId(), updateReqVO.getBlobIds());
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteReimbursement(Long id) {
        // 1. æ ¡éªŒå­˜åœ¨ + è‰ç¨¿çŠ¶æ€
        FinanceReimbursementDO reimbursement = validateReimbursementExists(id);
        if (!FinanceReimbursementStatusEnum.DRAFT.getStatus().equals(reimbursement.getStatus())) {
            throw exception(REIMBURSEMENT_DELETE_FAIL_NOT_DRAFT, reimbursement.getNo());
        }
        // 2. åˆ é™¤æ˜Žç»†
        reimbursementItemMapper.deleteByReimbursementId(id);
        // 3. åˆ é™¤ä¸»è¡¨
        reimbursementMapper.deleteById(id);
        // 4. åˆ é™¤å‘票附件
        storageAttachmentApi.deleteAttachmentsByRecord(REIMBURSEMENT_RECORD_TYPE, id);
    }
    @Override
    public FinanceReimbursementDO getReimbursement(Long id) {
        return reimbursementMapper.selectById(id);
    }
    @Override
    public PageResult<FinanceReimbursementDO> getReimbursementPage(FinanceReimbursementPageReqVO pageReqVO) {
        return reimbursementMapper.selectPage(pageReqVO);
    }
    @Override
    public List<FinanceReimbursementItemDO> getReimbursementItemListByReimbursementId(Long reimbursementId) {
        return reimbursementItemMapper.selectListByReimbursementId(reimbursementId);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void submitReimbursement(Long id, String processDefinitionKey, Long userId) {
        // 1. æ ¡éªŒå­˜åœ¨ + å¯æäº¤çŠ¶æ€ï¼ˆè‰ç¨¿æˆ–å·²é©³å›žï¼‰
        FinanceReimbursementDO reimbursement = validateReimbursementExists(id);
        Integer status = reimbursement.getStatus();
        if (!FinanceReimbursementStatusEnum.DRAFT.getStatus().equals(status)
                && !FinanceReimbursementStatusEnum.REJECTED.getStatus().equals(status)) {
            throw exception(REIMBURSEMENT_SUBMIT_FAIL_STATUS);
        }
        // 1.2 æ ¡éªŒå¿…须有明细
        List<FinanceReimbursementItemDO> items = reimbursementItemMapper.selectListByReimbursementId(id);
        if (CollUtil.isEmpty(items)) {
            throw exception(REIMBURSEMENT_SUBMIT_FAIL_ITEM_EMPTY);
        }
        // 2. åˆ›å»º BPM æµç¨‹å®žä¾‹
        String processInstanceId = processInstanceApi.createProcessInstance(userId,
                new BpmProcessInstanceCreateReqDTO()
                        .setProcessDefinitionKey(processDefinitionKey)
                        .setBusinessKey(String.valueOf(id)));
        // 3. æ›´æ–°çŠ¶æ€ä¸ºå®¡æ‰¹ä¸­
        reimbursementMapper.updateById(new FinanceReimbursementDO()
                .setId(id)
                .setStatus(FinanceReimbursementStatusEnum.APPROVING.getStatus())
                .setProcessInstanceId(processInstanceId));
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void archiveReimbursement(Long id) {
        // 1. æ ¡éªŒå­˜åœ¨ + å·²é€šè¿‡çŠ¶æ€
        FinanceReimbursementDO reimbursement = validateReimbursementExists(id);
        if (!FinanceReimbursementStatusEnum.APPROVED.getStatus().equals(reimbursement.getStatus())) {
            throw exception(REIMBURSEMENT_ARCHIVE_FAIL_STATUS);
        }
        // 2. æ›´æ–°ä¸ºå·²å½’æ¡£
        reimbursementMapper.updateById(new FinanceReimbursementDO()
                .setId(id)
                .setStatus(FinanceReimbursementStatusEnum.ARCHIVED.getStatus())
                .setArchiveTime(LocalDateTime.now()));
    }
    @Override
    public List<Map<String, Object>> getReimbursementApproveProcessDefinitionList() {
        // 1. æ ¡éªŒåˆ†ç±»å’Œæµç¨‹å®šä¹‰æ˜¯å¦å­˜åœ¨
        validateReimbursementApproveCategoryAndProcessDefinition();
        // 2. èŽ·å–åˆ†ç±»ä¸‹çš„æµç¨‹å®šä¹‰ä¿¡æ¯
        List<BpmProcessDefinitionInfoDO> definitionInfoList = bpmProcessDefinitionService
                .getProcessDefinitionInfoListByCategory(REIMBURSEMENT_APPROVE_CATEGORY_CODE);
        if (CollUtil.isEmpty(definitionInfoList)) {
            return Collections.emptyList();
        }
        // 3. éåŽ†èŽ·å–æµç¨‹å®šä¹‰è¯¦æƒ…ï¼Œè¿‡æ»¤æ¿€æ´»çŠ¶æ€ï¼Œä¿ç•™æœ€æ–°ç‰ˆæœ¬
        Map<String, ProcessDefinition> 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);
            }
        }
        // 4. è¿”回流程定义列表
        List<Map<String, Object>> result = new ArrayList<>();
        for (ProcessDefinition pd : latestVersionMap.values()) {
            Map<String, Object> 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 updateReimbursementAuditStatus(Long id, Integer bpmResult) {
        // 1. æ ¡éªŒå­˜åœ¨
        FinanceReimbursementDO reimbursement = validateReimbursementExists(id);
        if (!FinanceReimbursementStatusEnum.APPROVING.getStatus().equals(reimbursement.getStatus())) {
            log.warn("[updateReimbursementAuditStatus] æŠ¥é”€å•({}) ä¸å¤„于审批中状态", id);
            return;
        }
        // 2. æ ¹æ®å®¡æ‰¹ç»“果更新状态
        Integer newStatus = convertBpmResultToStatus(bpmResult);
        if (newStatus != null) {
            reimbursementMapper.updateById(new FinanceReimbursementDO()
                    .setId(id).setStatus(newStatus));
        }
    }
    // ==================== å†…部方法 ====================
    private FinanceReimbursementDO validateReimbursementExists(Long id) {
        FinanceReimbursementDO reimbursement = reimbursementMapper.selectById(id);
        if (reimbursement == null) {
            throw exception(REIMBURSEMENT_NOT_EXISTS);
        }
        return reimbursement;
    }
    /**
     * è§£æžæ–°å¢žæ—¶çš„æŠ¥é”€å•号:去掉首尾空格后若为空,则由系统按 BX + æ—¥æœŸ + 6 ä½æµæ°´ç”Ÿæˆï¼Œ
     * å¦åˆ™ä½¿ç”¨ç”¨æˆ·è‡ªå®šä¹‰å•号。
     * <p>
     * æ³¨æ„ï¼šåªç”±ç©ºæ ¼ï¼ˆå«å…¨è§’空格)组成的输入等同于未填,不会作为单号落库。
     */
    private String resolveCreateNo(String customNo) {
        String no = StrUtil.trimToNull(customNo);
        if (no == null) {
            no = noRedisDAO.generate(FinanceNoRedisDAO.REIMBURSEMENT_NO_PREFIX);
            if (reimbursementMapper.selectByNo(no) != null) {
                throw exception(REIMBURSEMENT_NO_EXISTS);
            }
            return no;
        }
        validateCustomNoFormat(no);
        if (reimbursementMapper.selectByNo(no) != null) {
            throw exception(REIMBURSEMENT_CUSTOM_NO_EXISTS, no);
        }
        return no;
    }
    /** è‡ªå®šä¹‰å•号中间不允许出现空格(首尾空格已在解析时去掉) */
    private void validateCustomNoFormat(String no) {
        if (StrUtil.containsAny(no, ' ', '\t', ' ')) {
            throw exception(REIMBURSEMENT_CUSTOM_NO_INVALID, no);
        }
    }
    private List<FinanceReimbursementItemDO> buildItems(Long reimbursementId,
                                                         List<FinanceReimbursementItemSaveReqVO> itemVOs) {
        if (CollUtil.isEmpty(itemVOs)) {
            return Collections.emptyList();
        }
        return itemVOs.stream()
                .map(item -> BeanUtils.toBean(item, FinanceReimbursementItemDO.class,
                        in -> in.setReimbursementId(reimbursementId)))
                .collect(Collectors.toList());
    }
    private void validateReimbursementApproveCategoryAndProcessDefinition() {
        List<BpmCategoryDO> categories = bpmCategoryService.getCategoryListByCode(
                Collections.singletonList(REIMBURSEMENT_APPROVE_CATEGORY_CODE));
        if (CollUtil.isEmpty(categories)) {
            throw exception(REIMBURSEMENT_BPM_CATEGORY_NOT_EXISTS);
        }
    }
    /**
     * è½¬æ¢ BPM å®¡æ‰¹ç»“果为报销单状态
     */
    private Integer convertBpmResultToStatus(Integer bpmResult) {
        if (cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum.APPROVE.getStatus().equals(bpmResult)) {
            return FinanceReimbursementStatusEnum.APPROVED.getStatus();
        } else if (cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum.REJECT.getStatus().equals(bpmResult)) {
            return FinanceReimbursementStatusEnum.REJECTED.getStatus();
        } else if (cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum.CANCEL.getStatus().equals(bpmResult)) {
            return FinanceReimbursementStatusEnum.DRAFT.getStatus();
        }
        return null;
    }
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/service/reimbursement/listener/FinanceReimbursementStatusListener.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,54 @@
package cn.iocoder.yudao.module.finance.service.reimbursement.listener;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener;
import cn.iocoder.yudao.module.finance.service.reimbursement.FinanceReimbursementService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
 * æŠ¥é”€å•审批状态监听器
 *
 * ç›‘听 BPM æµç¨‹å®žä¾‹çŠ¶æ€å˜æ›´äº‹ä»¶ï¼Œæ›´æ–°æŠ¥é”€å•çš„å®¡æ‰¹çŠ¶æ€ï¼ˆååŒåŠžå…¬å®¡æ‰¹åŽå›žå†™ï¼‰
 *
 * @author è¶…级管理员
 */
@Component
@Slf4j
public class FinanceReimbursementStatusListener extends BpmProcessInstanceStatusEventListener {
    /**
     * æŠ¥é”€å•审批流程定义 Key
     */
    private static final String PROCESS_DEFINITION_KEY = "finance_reimbursement_approve";
    @Resource
    private FinanceReimbursementService reimbursementService;
    @Override
    protected String getProcessDefinitionKey() {
        return PROCESS_DEFINITION_KEY;
    }
    @Override
    protected void onEvent(BpmProcessInstanceStatusEvent event) {
        Long reimbursementId;
        try {
            reimbursementId = Long.parseLong(event.getBusinessKey());
        } catch (NumberFormatException e) {
            log.warn("[onEvent] businessKey({}) ä¸æ˜¯æœ‰æ•ˆçš„æŠ¥é”€å•编号", event.getBusinessKey());
            return;
        }
        log.info("[onEvent] æ”¶åˆ°BPM审批事件,reimbursementId={}, status={}", reimbursementId, event.getStatus());
        try {
            reimbursementService.updateReimbursementAuditStatus(reimbursementId, event.getStatus());
            log.info("[onEvent] æŠ¥é”€å•({}) å®¡æ‰¹çŠ¶æ€æ›´æ–°å®Œæˆ", reimbursementId);
        } catch (Exception e) {
            log.error("[onEvent] æ›´æ–°æŠ¥é”€å•审批状态失败,reimbursementId={}", reimbursementId, e);
        }
    }
}
yudao-module-research/pom.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <groupId>cn.iocoder.boot</groupId>
        <artifactId>yudao</artifactId>
        <version>${revision}</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>
    <artifactId>yudao-module-research</artifactId>
    <packaging>jar</packaging>
    <name>${project.artifactId}</name>
    <description>
        research åŒ…下,课题管理模块(学会/协会型科研课题:课题指南发布、在线申报、专家评审、立项审批、经费拨付)。
    </description>
    <dependencies>
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-system</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- BPM å·¥ä½œæµï¼ˆç§˜ä¹¦å¤„形式审查 â†’ ä¸“家评审 â†’ å­¦ä¼šç«‹é¡¹å®¡æ‰¹ ä¸‰èŠ‚ç‚¹å®¡æ‰¹ï¼‰ -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-bpm</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- UMS ä¼šå‘˜æ¨¡å—(申报人关联会员,读取会员信息自动带入) -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-ums</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- Web ç›¸å…³ -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-security</artifactId>
        </dependency>
        <!-- DB ç›¸å…³ -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-mybatis</artifactId>
        </dependency>
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-redis</artifactId>
        </dependency>
        <!-- å·¥å…·ç±»ç›¸å…³ -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-excel</artifactId>
        </dependency>
        <!-- Test æµ‹è¯•相关 -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-test</artifactId>
        </dependency>
    </dependencies>
</project>
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/application/ResearchApplicationController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,162 @@
package cn.iocoder.yudao.module.research.controller.admin.application;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.number.NumberUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.research.controller.admin.application.vo.ResearchApplicationPageReqVO;
import cn.iocoder.yudao.module.research.controller.admin.application.vo.ResearchApplicationRespVO;
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.service.application.ResearchApplicationService;
import cn.iocoder.yudao.module.research.service.application.ResearchApplicationServiceImpl;
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.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.*;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen;
@Tag(name = "管理后台 - è¯¾é¢˜ç”³æŠ¥")
@RestController
@RequestMapping("/research/application")
@Validated
public class ResearchApplicationController {
    public static final String APPLICATION_RECORD_TYPE =
            ResearchApplicationServiceImpl.APPLICATION_RECORD_TYPE;
    @Resource
    private ResearchApplicationService applicationService;
    @Resource
    private ResearchGuidelineService guidelineService;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @PostMapping("/create")
    @Operation(summary = "创建课题申报书")
    @PreAuthorize("@ss.hasPermission('research:application:create')")
    public CommonResult<Long> createApplication(@Valid @RequestBody ResearchApplicationSaveReqVO createReqVO) {
        return success(applicationService.createApplication(createReqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "更新课题申报书")
    @PreAuthorize("@ss.hasPermission('research:application:update')")
    public CommonResult<Boolean> updateApplication(@Valid @RequestBody ResearchApplicationSaveReqVO updateReqVO) {
        applicationService.updateApplication(updateReqVO);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除课题申报书")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('research:application:delete')")
    public CommonResult<Boolean> deleteApplication(@RequestParam("id") Long id) {
        applicationService.deleteApplication(id);
        return success(true);
    }
    @GetMapping("/get")
    @Operation(summary = "获得课题申报书")
    @Parameter(name = "id", description = "编号", required = true, example = "1024")
    @PreAuthorize("@ss.hasPermission('research:application:query')")
    public CommonResult<ResearchApplicationRespVO> getApplication(@RequestParam("id") Long id) {
        return success(buildApplicationDetail(applicationService.getApplication(id)));
    }
    @GetMapping("/page")
    @Operation(summary = "获得课题申报书分页")
    @PreAuthorize("@ss.hasPermission('research:application:query')")
    public CommonResult<PageResult<ResearchApplicationRespVO>> getApplicationPage(
            @Valid ResearchApplicationPageReqVO pageReqVO) {
        PageResult<ResearchApplicationDO> pageResult = applicationService.getApplicationPage(pageReqVO);
        return success(new PageResult<>(buildApplicationDetailList(pageResult.getList()), pageResult.getTotal()));
    }
    @PostMapping("/submit")
    @Operation(summary = "提交课题申报书审批")
    @PreAuthorize("@ss.hasPermission('research:application:submit')")
    public CommonResult<Boolean> submitApplication(@RequestParam("id") Long id,
                                                   @RequestParam("processDefinitionKey") String processDefinitionKey) {
        applicationService.submitApplication(id, processDefinitionKey);
        return success(true);
    }
    @GetMapping("/approve-process-list")
    @Operation(summary = "获取课题立项审批流程列表")
    @PreAuthorize("@ss.hasPermission('research:application:query')")
    public CommonResult<List<Map<String, Object>>> getApproveProcessDefinitionList() {
        return success(applicationService.getApproveProcessDefinitionList());
    }
    @PostMapping("/publish-notice")
    @Operation(summary = "发布立项通知")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('research:application:notice')")
    public CommonResult<Boolean> publishNotice(@RequestParam("id") Long id) {
        applicationService.publishNotice(id);
        return success(true);
    }
    // ==================== å†…部方法 ====================
    private ResearchApplicationRespVO buildApplicationDetail(ResearchApplicationDO application) {
        if (application == null) {
            return null;
        }
        return buildApplicationDetailList(Collections.singletonList(application)).get(0);
    }
    private List<ResearchApplicationRespVO> buildApplicationDetailList(List<ResearchApplicationDO> list) {
        if (CollUtil.isEmpty(list)) {
            return Collections.emptyList();
        }
        // 1.1 èŽ·å–åˆ›å»ºäºº
        Set<Long> creatorIds = convertSet(list, application -> NumberUtils.parseLong(application.getCreator()));
        Map<Long, AdminUserRespDTO> creatorMap = adminUserApi.getUserMap(creatorIds);
        // 1.2 èŽ·å–é™„ä»¶
        Map<Long, List<StorageBlobRespDTO>> attachmentMap = list.stream()
                .collect(Collectors.toMap(ResearchApplicationDO::getId,
                        r -> storageAttachmentApi.listAttachments(APPLICATION_RECORD_TYPE, r.getId())));
        // 1.3 èŽ·å–æŒ‡å—åç§°
        Set<Long> guidelineIds = convertSet(list, ResearchApplicationDO::getGuidelineId);
        Map<Long, ResearchGuidelineDO> guidelineMap = guidelineService.getGuidelineList(guidelineIds).stream()
                .collect(Collectors.toMap(ResearchGuidelineDO::getId, g -> g));
        // 2. æ‹¼æŽ¥ç»“æžœ
        return BeanUtils.toBean(list, ResearchApplicationRespVO.class, (vo) -> {
            findAndThen(creatorMap, NumberUtils.parseLong(vo.getCreator()),
                    user -> vo.setCreatorName(user.getNickname()));
            List<StorageBlobRespDTO> attachments = attachmentMap.getOrDefault(vo.getId(), Collections.emptyList());
            vo.setAttachmentList(attachments);
            vo.setHasAttachment(CollUtil.isNotEmpty(attachments));
            if (vo.getGuidelineId() != null) {
                findAndThen(guidelineMap, vo.getGuidelineId(), g -> vo.setGuidelineTitle(g.getTitle()));
            }
        });
    }
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/application/vo/ResearchApplicationPageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,40 @@
package cn.iocoder.yudao.module.research.controller.admin.application.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
@Schema(description = "管理后台 - è¯¾é¢˜ç”³æŠ¥ä¹¦åˆ†é¡µ Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class ResearchApplicationPageReqVO extends PageParam {
    @Schema(description = "申报编号", example = "SB20260928000001")
    private String no;
    @Schema(description = "课题名称", example = "智能制造产线数字孪生关键技术研究")
    private String title;
    @Schema(description = "所属课题指南 ID", example = "1")
    private Long guidelineId;
    @Schema(description = "课题类别", example = "1")
    private Integer category;
    @Schema(description = "申报会员 ID", example = "1")
    private Long memberId;
    @Schema(description = "状态", example = "0")
    private Integer status;
    @Schema(description = "创建时间")
    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
    private LocalDateTime[] createTime;
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/application/vo/ResearchApplicationRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,102 @@
package cn.iocoder.yudao.module.research.controller.admin.application.vo;
import cn.iocoder.yudao.module.system.api.storage.dto.StorageBlobRespDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - è¯¾é¢˜ç”³æŠ¥ä¹¦ Response VO")
@Data
public class ResearchApplicationRespVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "申报编号", example = "SB20260928000001")
    private String no;
    @Schema(description = "所属课题指南 ID", example = "1")
    private Long guidelineId;
    @Schema(description = "所属课题指南名称")
    private String guidelineTitle;
    @Schema(description = "课题名称", example = "智能制造产线数字孪生关键技术研究")
    private String title;
    @Schema(description = "课题类别:1-重点课题 2-一般课题", example = "1")
    private Integer category;
    @Schema(description = "申报会员 ID", example = "1")
    private Long memberId;
    @Schema(description = "申报人姓名/单位名称", example = "张三")
    private String applicantName;
    @Schema(description = "申报登录用户 ID")
    private Long applicantUserId;
    @Schema(description = "工作单位", example = "XX大学")
    private String workUnit;
    @Schema(description = "职称", example = "教授")
    private String jobTitle;
    @Schema(description = "联系电话", example = "13800138000")
    private String contactPhone;
    @Schema(description = "联系邮箱", example = "zhangsan@example.com")
    private String contactEmail;
    @Schema(description = "课题组成员")
    private String teamMembers;
    @Schema(description = "申请经费(元)", example = "100000.00")
    private BigDecimal budgetAmount;
    @Schema(description = "申报书正文")
    private String planContent;
    @Schema(description = "状态:0-草稿 15-审批中 20-已立项 35-已驳回", example = "0")
    private Integer status;
    @Schema(description = "BPM æµç¨‹å®žä¾‹ ID", example = "1001")
    private String processInstanceId;
    @Schema(description = "专家评审加权均分", example = "86.50")
    private BigDecimal reviewAvgScore;
    @Schema(description = "评审结论:0-未通过 1-推荐立项", example = "1")
    private Integer reviewPass;
    @Schema(description = "立项编号", example = "LX20260928000001")
    private String researchNo;
    @Schema(description = "立项时间")
    private LocalDateTime approveTime;
    @Schema(description = "立项通知公告 ID")
    private Long noticeId;
    @Schema(description = "备注")
    private String remark;
    @Schema(description = "创建者")
    private String creator;
    @Schema(description = "创建者名称")
    private String creatorName;
    @Schema(description = "创建时间")
    private LocalDateTime createTime;
    @Schema(description = "附件列表")
    private List<StorageBlobRespDTO> attachmentList;
    @Schema(description = "是否有附件")
    private Boolean hasAttachment;
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/application/vo/ResearchApplicationSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,62 @@
package cn.iocoder.yudao.module.research.controller.admin.application.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
@Schema(description = "管理后台 - è¯¾é¢˜ç”³æŠ¥ä¹¦æ–°å¢ž/修改 Request VO")
@Data
public class ResearchApplicationSaveReqVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "所属课题指南 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "所属课题指南不能为空")
    private Long guidelineId;
    @Schema(description = "课题名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "智能制造产线数字孪生关键技术研究")
    @NotEmpty(message = "课题名称不能为空")
    private String title;
    @Schema(description = "申报会员 ID(关联 ums_member)", example = "1")
    private Long memberId;
    @Schema(description = "申报人姓名/单位名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "张三")
    @NotEmpty(message = "申报人姓名不能为空")
    private String applicantName;
    @Schema(description = "工作单位", example = "XX大学")
    private String workUnit;
    @Schema(description = "职称", example = "教授")
    private String jobTitle;
    @Schema(description = "联系电话", example = "13800138000")
    private String contactPhone;
    @Schema(description = "联系邮箱", example = "zhangsan@example.com")
    private String contactEmail;
    @Schema(description = "课题组成员(每行一条:姓名|单位|职称)")
    private String teamMembers;
    @Schema(description = "申请经费(元)", example = "100000.00")
    @PositiveOrZero(message = "申请经费不能为负数")
    private BigDecimal budgetAmount;
    @Schema(description = "申报书正文(研究背景/目标/方案/预期成果)")
    private String planContent;
    @Schema(description = "备注", example = "依托单位已同意申报")
    private String remark;
    @Schema(description = "附件BlobId列表")
    private List<Long> blobIds;
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/fund/ResearchFundRecordController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,103 @@
package cn.iocoder.yudao.module.research.controller.admin.fund;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.util.number.NumberUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.research.controller.admin.fund.vo.ResearchFundRecordRespVO;
import cn.iocoder.yudao.module.research.controller.admin.fund.vo.ResearchFundRecordSaveReqVO;
import cn.iocoder.yudao.module.research.dal.dataobject.fund.ResearchFundRecordDO;
import cn.iocoder.yudao.module.research.service.fund.ResearchFundRecordService;
import cn.iocoder.yudao.module.research.service.fund.ResearchFundRecordServiceImpl;
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.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen;
@Tag(name = "管理后台 - è¯¾é¢˜ç»è´¹æ‹¨ä»˜")
@RestController
@RequestMapping("/research/fund-record")
@Validated
public class ResearchFundRecordController {
    public static final String FUND_RECORD_TYPE =
            ResearchFundRecordServiceImpl.FUND_RECORD_TYPE;
    @Resource
    private ResearchFundRecordService fundRecordService;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @PostMapping("/create")
    @Operation(summary = "登记经费拨付")
    @PreAuthorize("@ss.hasPermission('research:fund:create')")
    public CommonResult<Long> createFundRecord(@Valid @RequestBody ResearchFundRecordSaveReqVO createReqVO) {
        return success(fundRecordService.createFundRecord(createReqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "更新经费拨付")
    @PreAuthorize("@ss.hasPermission('research:fund:update')")
    public CommonResult<Boolean> updateFundRecord(@Valid @RequestBody ResearchFundRecordSaveReqVO updateReqVO) {
        fundRecordService.updateFundRecord(updateReqVO);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除经费拨付")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('research:fund:delete')")
    public CommonResult<Boolean> deleteFundRecord(@RequestParam("id") Long id) {
        fundRecordService.deleteFundRecord(id);
        return success(true);
    }
    @GetMapping("/list")
    @Operation(summary = "获得某课题申报书的经费拨付记录列表")
    @Parameter(name = "applicationId", description = "课题申报书编号", required = true)
    @PreAuthorize("@ss.hasPermission('research:fund:query')")
    public CommonResult<List<ResearchFundRecordRespVO>> getFundRecordList(
            @RequestParam("applicationId") Long applicationId) {
        List<ResearchFundRecordDO> list = fundRecordService.getFundRecordListByApplicationId(applicationId);
        if (CollUtil.isEmpty(list)) {
            return success(Collections.emptyList());
        }
        Set<Long> creatorIds = convertSet(list, record -> NumberUtils.parseLong(record.getCreator()));
        Map<Long, AdminUserRespDTO> creatorMap = adminUserApi.getUserMap(creatorIds);
        Map<Long, List<StorageBlobRespDTO>> attachmentMap = list.stream()
                .collect(Collectors.toMap(ResearchFundRecordDO::getId,
                        r -> storageAttachmentApi.listAttachments(FUND_RECORD_TYPE, r.getId())));
        return success(BeanUtils.toBean(list, ResearchFundRecordRespVO.class, (vo) -> {
            findAndThen(creatorMap, NumberUtils.parseLong(vo.getCreator()),
                    user -> vo.setCreatorName(user.getNickname()));
            List<StorageBlobRespDTO> attachments = attachmentMap.getOrDefault(vo.getId(), Collections.emptyList());
            vo.setAttachmentList(attachments);
            vo.setHasAttachment(CollUtil.isNotEmpty(attachments));
        }));
    }
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/fund/vo/ResearchFundRecordRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,55 @@
package cn.iocoder.yudao.module.research.controller.admin.fund.vo;
import cn.iocoder.yudao.module.system.api.storage.dto.StorageBlobRespDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - ç»è´¹æ‹¨ä»˜è®°å½• Response VO")
@Data
public class ResearchFundRecordRespVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "课题申报书 ID", example = "1")
    private Long applicationId;
    @Schema(description = "拨付批次", example = "第一期")
    private String batchName;
    @Schema(description = "拨付金额(元)", example = "50000.00")
    private BigDecimal amount;
    @Schema(description = "拨付日期", example = "2026-12-01")
    private LocalDate payDate;
    @Schema(description = "收款单位/账户名", example = "XX大学")
    private String payee;
    @Schema(description = "状态:0-待拨付 20-已拨付", example = "20")
    private Integer status;
    @Schema(description = "备注")
    private String remark;
    @Schema(description = "创建者")
    private String creator;
    @Schema(description = "创建者名称")
    private String creatorName;
    @Schema(description = "创建时间")
    private LocalDateTime createTime;
    @Schema(description = "凭证附件列表")
    private List<StorageBlobRespDTO> attachmentList;
    @Schema(description = "是否有凭证附件")
    private Boolean hasAttachment;
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/fund/vo/ResearchFundRecordSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.research.controller.admin.fund.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY;
@Schema(description = "管理后台 - ç»è´¹æ‹¨ä»˜è®°å½•新增/修改 Request VO")
@Data
public class ResearchFundRecordSaveReqVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "课题申报书 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "课题申报书不能为空")
    private Long applicationId;
    @Schema(description = "拨付批次", example = "第一期")
    private String batchName;
    @Schema(description = "拨付金额(元)", requiredMode = Schema.RequiredMode.REQUIRED, example = "50000.00")
    @NotNull(message = "拨付金额不能为空")
    @PositiveOrZero(message = "拨付金额不能为负数")
    private BigDecimal amount;
    @Schema(description = "拨付日期", example = "2026-12-01")
    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY)
    private LocalDate payDate;
    @Schema(description = "收款单位/账户名", example = "XX大学")
    private String payee;
    @Schema(description = "状态:0-待拨付 20-已拨付", requiredMode = Schema.RequiredMode.REQUIRED, example = "20")
    @NotNull(message = "拨付状态不能为空")
    private Integer status;
    @Schema(description = "备注")
    private String remark;
    @Schema(description = "凭证附件BlobId列表")
    private List<Long> blobIds;
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/guideline/ResearchGuidelineController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,153 @@
package cn.iocoder.yudao.module.research.controller.admin.guideline;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.number.NumberUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.research.controller.admin.guideline.vo.ResearchGuidelinePageReqVO;
import cn.iocoder.yudao.module.research.controller.admin.guideline.vo.ResearchGuidelineRespVO;
import cn.iocoder.yudao.module.research.controller.admin.guideline.vo.ResearchGuidelineSaveReqVO;
import cn.iocoder.yudao.module.research.dal.dataobject.guideline.ResearchGuidelineDO;
import cn.iocoder.yudao.module.research.service.guideline.ResearchGuidelineService;
import cn.iocoder.yudao.module.research.service.guideline.ResearchGuidelineServiceImpl;
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.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen;
@Tag(name = "管理后台 - è¯¾é¢˜æŒ‡å—")
@RestController
@RequestMapping("/research/guideline")
@Validated
public class ResearchGuidelineController {
    public static final String GUIDELINE_RECORD_TYPE =
            ResearchGuidelineServiceImpl.GUIDELINE_RECORD_TYPE;
    @Resource
    private ResearchGuidelineService guidelineService;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @PostMapping("/create")
    @Operation(summary = "创建课题指南")
    @PreAuthorize("@ss.hasPermission('research:guideline:create')")
    public CommonResult<Long> createGuideline(@Valid @RequestBody ResearchGuidelineSaveReqVO createReqVO) {
        return success(guidelineService.createGuideline(createReqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "更新课题指南")
    @PreAuthorize("@ss.hasPermission('research:guideline:update')")
    public CommonResult<Boolean> updateGuideline(@Valid @RequestBody ResearchGuidelineSaveReqVO updateReqVO) {
        guidelineService.updateGuideline(updateReqVO);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除课题指南")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('research:guideline:delete')")
    public CommonResult<Boolean> deleteGuideline(@RequestParam("id") Long id) {
        guidelineService.deleteGuideline(id);
        return success(true);
    }
    @GetMapping("/get")
    @Operation(summary = "获得课题指南")
    @Parameter(name = "id", description = "编号", required = true, example = "1024")
    @PreAuthorize("@ss.hasPermission('research:guideline:query')")
    public CommonResult<ResearchGuidelineRespVO> getGuideline(@RequestParam("id") Long id) {
        return success(buildGuidelineDetail(guidelineService.getGuideline(id)));
    }
    @GetMapping("/page")
    @Operation(summary = "获得课题指南分页")
    @PreAuthorize("@ss.hasPermission('research:guideline:query')")
    public CommonResult<PageResult<ResearchGuidelineRespVO>> getGuidelinePage(
            @Valid ResearchGuidelinePageReqVO pageReqVO) {
        PageResult<ResearchGuidelineDO> pageResult = guidelineService.getGuidelinePage(pageReqVO);
        return success(new PageResult<>(buildGuidelineDetailList(pageResult.getList()), pageResult.getTotal()));
    }
    @GetMapping("/published-list")
    @Operation(summary = "获得已发布的课题指南列表(供申报选指南)")
    @PreAuthorize("@ss.hasPermission('research:guideline:query')")
    public CommonResult<List<ResearchGuidelineRespVO>> getPublishedGuidelineList() {
        return success(buildGuidelineDetailList(guidelineService.getPublishedGuidelineList()));
    }
    @PutMapping("/publish")
    @Operation(summary = "发布课题指南")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('research:guideline:publish')")
    public CommonResult<Boolean> publishGuideline(@RequestParam("id") Long id) {
        guidelineService.publishGuideline(id);
        return success(true);
    }
    @PutMapping("/close")
    @Operation(summary = "截止课题指南")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('research:guideline:publish')")
    public CommonResult<Boolean> closeGuideline(@RequestParam("id") Long id) {
        guidelineService.closeGuideline(id);
        return success(true);
    }
    // ==================== å†…部方法 ====================
    private ResearchGuidelineRespVO buildGuidelineDetail(ResearchGuidelineDO guideline) {
        if (guideline == null) {
            return null;
        }
        return buildGuidelineDetailList(Collections.singletonList(guideline)).get(0);
    }
    private List<ResearchGuidelineRespVO> buildGuidelineDetailList(List<ResearchGuidelineDO> list) {
        if (CollUtil.isEmpty(list)) {
            return Collections.emptyList();
        }
        // 1.1 èŽ·å–åˆ›å»ºäºº
        Set<Long> creatorIds = convertSet(list, guideline -> NumberUtils.parseLong(guideline.getCreator()));
        Map<Long, AdminUserRespDTO> creatorMap = adminUserApi.getUserMap(creatorIds);
        // 1.2 èŽ·å–é™„ä»¶
        Map<Long, List<StorageBlobRespDTO>> attachmentMap = list.stream()
                .collect(Collectors.toMap(ResearchGuidelineDO::getId,
                        r -> storageAttachmentApi.listAttachments(GUIDELINE_RECORD_TYPE, r.getId())));
        // 2. æ‹¼æŽ¥ç»“æžœ
        return BeanUtils.toBean(list, ResearchGuidelineRespVO.class, (vo) -> {
            findAndThen(creatorMap, NumberUtils.parseLong(vo.getCreator()),
                    user -> vo.setCreatorName(user.getNickname()));
            List<StorageBlobRespDTO> attachments = attachmentMap.getOrDefault(vo.getId(), Collections.emptyList());
            vo.setAttachmentList(attachments);
            vo.setHasAttachment(CollUtil.isNotEmpty(attachments));
        });
    }
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/guideline/vo/ResearchGuidelinePageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,25 @@
package cn.iocoder.yudao.module.research.controller.admin.guideline.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Schema(description = "管理后台 - è¯¾é¢˜æŒ‡å—分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class ResearchGuidelinePageReqVO extends PageParam {
    @Schema(description = "指南编号", example = "GL20260928000001")
    private String no;
    @Schema(description = "指南名称", example = "2026年度学会科研课题指南")
    private String title;
    @Schema(description = "课题类别", example = "1")
    private Integer category;
    @Schema(description = "发布状态", example = "20")
    private Integer publishStatus;
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/guideline/vo/ResearchGuidelineRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,59 @@
package cn.iocoder.yudao.module.research.controller.admin.guideline.vo;
import cn.iocoder.yudao.module.system.api.storage.dto.StorageBlobRespDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - è¯¾é¢˜æŒ‡å— Response VO")
@Data
public class ResearchGuidelineRespVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "指南编号", example = "GL20260928000001")
    private String no;
    @Schema(description = "指南名称", example = "2026年度学会科研课题指南")
    private String title;
    @Schema(description = "课题类别:1-重点课题 2-一般课题", example = "1")
    private Integer category;
    @Schema(description = "申报开始时间")
    private LocalDateTime applyStartTime;
    @Schema(description = "申报截止时间")
    private LocalDateTime applyEndTime;
    @Schema(description = "指南正文")
    private String content;
    @Schema(description = "发布状态:0-草稿 20-已发布 30-已截止", example = "0")
    private Integer publishStatus;
    @Schema(description = "发布时间")
    private LocalDateTime publishTime;
    @Schema(description = "备注")
    private String remark;
    @Schema(description = "创建者")
    private String creator;
    @Schema(description = "创建者名称")
    private String creatorName;
    @Schema(description = "创建时间")
    private LocalDateTime createTime;
    @Schema(description = "附件列表")
    private List<StorageBlobRespDTO> attachmentList;
    @Schema(description = "是否有附件")
    private Boolean hasAttachment;
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/guideline/vo/ResearchGuidelineSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,46 @@
package cn.iocoder.yudao.module.research.controller.admin.guideline.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import java.util.List;
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
@Schema(description = "管理后台 - è¯¾é¢˜æŒ‡å—新增/修改 Request VO")
@Data
public class ResearchGuidelineSaveReqVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "指南名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026年度学会科研课题指南")
    @NotEmpty(message = "指南名称不能为空")
    private String title;
    @Schema(description = "课题类别:1-重点课题 2-一般课题", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "课题类别不能为空")
    private Integer category;
    @Schema(description = "申报开始时间", example = "2026-10-01 00:00:00")
    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
    private LocalDateTime applyStartTime;
    @Schema(description = "申报截止时间", example = "2026-11-30 23:59:59")
    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
    private LocalDateTime applyEndTime;
    @Schema(description = "指南正文")
    private String content;
    @Schema(description = "备注")
    private String remark;
    @Schema(description = "附件BlobId列表")
    private List<Long> blobIds;
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/review/ResearchTopicReviewController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,83 @@
package cn.iocoder.yudao.module.research.controller.admin.review;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.util.number.NumberUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.research.controller.admin.review.vo.ResearchTopicReviewRespVO;
import cn.iocoder.yudao.module.research.controller.admin.review.vo.ResearchTopicReviewSaveReqVO;
import cn.iocoder.yudao.module.research.dal.dataobject.review.ResearchTopicReviewDO;
import cn.iocoder.yudao.module.research.service.review.ResearchTopicReviewService;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen;
@Tag(name = "管理后台 - ä¸“家评审明细")
@RestController
@RequestMapping("/research/topic-review")
@Validated
public class ResearchTopicReviewController {
    @Resource
    private ResearchTopicReviewService reviewService;
    @Resource
    private AdminUserApi adminUserApi;
    @PostMapping("/create")
    @Operation(summary = "录入专家评审明细")
    @PreAuthorize("@ss.hasPermission('research:review:create')")
    public CommonResult<Long> createReview(@Valid @RequestBody ResearchTopicReviewSaveReqVO createReqVO) {
        return success(reviewService.createReview(createReqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "更新专家评审明细")
    @PreAuthorize("@ss.hasPermission('research:review:update')")
    public CommonResult<Boolean> updateReview(@Valid @RequestBody ResearchTopicReviewSaveReqVO updateReqVO) {
        reviewService.updateReview(updateReqVO);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除专家评审明细")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('research:review:delete')")
    public CommonResult<Boolean> deleteReview(@RequestParam("id") Long id) {
        reviewService.deleteReview(id);
        return success(true);
    }
    @GetMapping("/list")
    @Operation(summary = "获得某课题申报书的专家评审明细列表")
    @Parameter(name = "applicationId", description = "课题申报书编号", required = true)
    @PreAuthorize("@ss.hasPermission('research:review:query')")
    public CommonResult<List<ResearchTopicReviewRespVO>> getReviewList(
            @RequestParam("applicationId") Long applicationId) {
        List<ResearchTopicReviewDO> list = reviewService.getReviewListByApplicationId(applicationId);
        if (CollUtil.isEmpty(list)) {
            return success(Collections.emptyList());
        }
        Map<Long, AdminUserRespDTO> creatorMap = adminUserApi.getUserMap(
                convertSet(list, review -> NumberUtils.parseLong(review.getCreator())));
        return success(BeanUtils.toBean(list, ResearchTopicReviewRespVO.class, (vo) ->
                findAndThen(creatorMap, NumberUtils.parseLong(vo.getCreator()),
                        user -> vo.setCreatorName(user.getNickname()))));
    }
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/review/vo/ResearchTopicReviewRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,64 @@
package cn.iocoder.yudao.module.research.controller.admin.review.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - ä¸“家评审明细 Response VO")
@Data
public class ResearchTopicReviewRespVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "课题申报书 ID", example = "1")
    private Long applicationId;
    @Schema(description = "专家姓名", example = "李四")
    private String evaluatorName;
    @Schema(description = "专家所在单位", example = "XX大学")
    private String evaluatorUnit;
    @Schema(description = "专家职称", example = "教授")
    private String evaluatorTitle;
    @Schema(description = "选题价值与意义得分", example = "85.00")
    private BigDecimal topicScore;
    @Schema(description = "研究方案可行性得分", example = "90.00")
    private BigDecimal planScore;
    @Schema(description = "团队与研究基础得分", example = "80.00")
    private BigDecimal teamScore;
    @Schema(description = "预期成果与效益得分", example = "75.00")
    private BigDecimal resultScore;
    @Schema(description = "加权合计得分(后端计算)", example = "84.00")
    private BigDecimal compositeScore;
    @Schema(description = "本申报单内评分排名(后端计算)", example = "1")
    private Integer ranking;
    @Schema(description = "评审结论:1-推荐立项 2-不推荐立项", example = "1")
    private Integer conclusion;
    @Schema(description = "评审意见", example = "建议立项支持")
    private String opinion;
    @Schema(description = "评审时间")
    private LocalDateTime reviewTime;
    @Schema(description = "创建者")
    private String creator;
    @Schema(description = "创建者名称")
    private String creatorName;
    @Schema(description = "创建时间")
    private LocalDateTime createTime;
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/review/vo/ResearchTopicReviewSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,49 @@
package cn.iocoder.yudao.module.research.controller.admin.review.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
@Schema(description = "管理后台 - ä¸“家评审明细新增/修改 Request VO")
@Data
public class ResearchTopicReviewSaveReqVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "课题申报书 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "课题申报书不能为空")
    private Long applicationId;
    @Schema(description = "专家姓名", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四")
    @NotEmpty(message = "专家姓名不能为空")
    private String evaluatorName;
    @Schema(description = "专家所在单位", example = "XX大学")
    private String evaluatorUnit;
    @Schema(description = "专家职称", example = "教授")
    private String evaluatorTitle;
    @Schema(description = "选题价值与意义得分(0~100,权重0.3)", example = "85.00")
    private BigDecimal topicScore;
    @Schema(description = "研究方案可行性得分(0~100,权重0.3)", example = "90.00")
    private BigDecimal planScore;
    @Schema(description = "团队与研究基础得分(0~100,权重0.2)", example = "80.00")
    private BigDecimal teamScore;
    @Schema(description = "预期成果与效益得分(0~100,权重0.2)", example = "75.00")
    private BigDecimal resultScore;
    @Schema(description = "评审结论:1-推荐立项 2-不推荐立项", example = "1")
    private Integer conclusion;
    @Schema(description = "评审意见", example = "选题符合学会年度方向,方案可行,建议立项支持")
    private String opinion;
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/dataobject/application/ResearchApplicationDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,96 @@
package cn.iocoder.yudao.module.research.dal.dataobject.application;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
 * è¯¾é¢˜-申报书 DO
 *
 * @author è¶…级管理员
 */
@TableName("research_application")
@KeySequence("research_application_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ResearchApplicationDO extends BaseDO {
    @TableId
    private Long id;
    /** ç”³æŠ¥ç¼–号 */
    private String no;
    /** æ‰€å±žè¯¾é¢˜æŒ‡å— ID */
    private Long guidelineId;
    /** è¯¾é¢˜åç§° */
    private String title;
    /** è¯¾é¢˜ç±»åˆ«ï¼š1-重点课题 2-一般课题(冗余自指南) */
    private Integer category;
    /** ç”³æŠ¥ä¼šå‘˜ ID(关联 ums_member) */
    private Long memberId;
    /** ç”³æŠ¥äººå§“名/单位名称 */
    private String applicantName;
    /** ç”³æŠ¥ç™»å½•用户 ID */
    private Long applicantUserId;
    /** å·¥ä½œå•位 */
    private String workUnit;
    /** èŒç§° */
    private String jobTitle;
    /** è”系电话 */
    private String contactPhone;
    /** è”系邮箱 */
    private String contactEmail;
    /** è¯¾é¢˜ç»„成员(每行一条:姓名|单位|职称) */
    private String teamMembers;
    /** ç”³è¯·ç»è´¹ï¼ˆå…ƒï¼‰ */
    private BigDecimal budgetAmount;
    /** ç”³æŠ¥ä¹¦æ­£æ–‡ */
    private String planContent;
    /** çŠ¶æ€ï¼š0-草稿 15-审批中 20-已立项 35-已驳回 */
    private Integer status;
    /** BPM æµç¨‹å®žä¾‹ ID */
    private String processInstanceId;
    /** ä¸“家评审加权均分(后端汇总) */
    private BigDecimal reviewAvgScore;
    /** è¯„审结论:0-未通过 1-推荐立项(后端汇总) */
    private Integer reviewPass;
    /** ç«‹é¡¹ç¼–号(审批通过后生成) */
    private String researchNo;
    /** ç«‹é¡¹æ—¶é—´ */
    private LocalDateTime approveTime;
    /** å·²å‘布的立项通知公告 ID */
    private Long noticeId;
    /** å¤‡æ³¨ */
    private String remark;
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/dataobject/fund/ResearchFundRecordDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.research.dal.dataobject.fund;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
 * è¯¾é¢˜-经费拨付记录 DO
 *
 * @author è¶…级管理员
 */
@TableName("research_fund_record")
@KeySequence("research_fund_record_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ResearchFundRecordDO extends BaseDO {
    @TableId
    private Long id;
    /** è¯¾é¢˜ç”³æŠ¥ä¹¦ ID */
    private Long applicationId;
    /** æ‹¨ä»˜æ‰¹æ¬¡ï¼ˆå¦‚:第一期) */
    private String batchName;
    /** æ‹¨ä»˜é‡‘额(元) */
    private BigDecimal amount;
    /** æ‹¨ä»˜æ—¥æœŸ */
    private LocalDate payDate;
    /** æ”¶æ¬¾å•位/账户名 */
    private String payee;
    /** çŠ¶æ€ï¼š0-待拨付 20-已拨付 */
    private Integer status;
    /** å¤‡æ³¨ */
    private String remark;
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/dataobject/guideline/ResearchGuidelineDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,56 @@
package cn.iocoder.yudao.module.research.dal.dataobject.guideline;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.time.LocalDateTime;
/**
 * è¯¾é¢˜-指南 DO
 *
 * @author è¶…级管理员
 */
@TableName("research_guideline")
@KeySequence("research_guideline_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ResearchGuidelineDO extends BaseDO {
    @TableId
    private Long id;
    /** æŒ‡å—编号 */
    private String no;
    /** æŒ‡å—名称 */
    private String title;
    /** è¯¾é¢˜ç±»åˆ«ï¼š1-重点课题 2-一般课题 */
    private Integer category;
    /** ç”³æŠ¥å¼€å§‹æ—¶é—´ */
    private LocalDateTime applyStartTime;
    /** ç”³æŠ¥æˆªæ­¢æ—¶é—´ */
    private LocalDateTime applyEndTime;
    /** æŒ‡å—正文 */
    private String content;
    /** å‘布状态:0-草稿 20-已发布 30-已截止 */
    private Integer publishStatus;
    /** å‘布时间 */
    private LocalDateTime publishTime;
    /** å¤‡æ³¨ */
    private String remark;
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/dataobject/review/ResearchTopicReviewDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,71 @@
package cn.iocoder.yudao.module.research.dal.dataobject.review;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
 * è¯¾é¢˜-专家评审明细 DO
 *
 * ä¸€ä¸»å•(课题申报书)挂多行,每行一位专家
 *
 * @author è¶…级管理员
 */
@TableName("research_topic_review")
@KeySequence("research_topic_review_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ResearchTopicReviewDO extends BaseDO {
    @TableId
    private Long id;
    /** è¯¾é¢˜ç”³æŠ¥ä¹¦ ID */
    private Long applicationId;
    /** ä¸“家姓名 */
    private String evaluatorName;
    /** ä¸“家所在单位 */
    private String evaluatorUnit;
    /** ä¸“家职称 */
    private String evaluatorTitle;
    /** é€‰é¢˜ä»·å€¼ä¸Žæ„ä¹‰å¾—分(权重 0.3) */
    private BigDecimal topicScore;
    /** ç ”究方案可行性得分(权重 0.3) */
    private BigDecimal planScore;
    /** å›¢é˜Ÿä¸Žç ”究基础得分(权重 0.2) */
    private BigDecimal teamScore;
    /** é¢„期成果与效益得分(权重 0.2) */
    private BigDecimal resultScore;
    /** åŠ æƒåˆè®¡å¾—åˆ†ï¼ˆåŽç«¯è®¡ç®—ï¼‰ */
    private BigDecimal compositeScore;
    /** æœ¬ç”³æŠ¥å•内评分排名(后端计算) */
    private Integer ranking;
    /** è¯„审结论:1-推荐立项 2-不推荐立项 */
    private Integer conclusion;
    /** è¯„审意见 */
    private String opinion;
    /** è¯„审时间 */
    private LocalDateTime reviewTime;
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/mysql/application/ResearchApplicationMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,34 @@
package cn.iocoder.yudao.module.research.dal.mysql.application;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.research.controller.admin.application.vo.ResearchApplicationPageReqVO;
import cn.iocoder.yudao.module.research.dal.dataobject.application.ResearchApplicationDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ResearchApplicationMapper extends BaseMapperX<ResearchApplicationDO> {
    default ResearchApplicationDO selectByNo(String no) {
        return selectOne(ResearchApplicationDO::getNo, no);
    }
    default PageResult<ResearchApplicationDO> selectPage(ResearchApplicationPageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<ResearchApplicationDO>()
                .likeIfPresent(ResearchApplicationDO::getNo, reqVO.getNo())
                .likeIfPresent(ResearchApplicationDO::getTitle, reqVO.getTitle())
                .eqIfPresent(ResearchApplicationDO::getGuidelineId, reqVO.getGuidelineId())
                .eqIfPresent(ResearchApplicationDO::getCategory, reqVO.getCategory())
                .eqIfPresent(ResearchApplicationDO::getMemberId, reqVO.getMemberId())
                .eqIfPresent(ResearchApplicationDO::getStatus, reqVO.getStatus())
                .betweenIfPresent(ResearchApplicationDO::getCreateTime, reqVO.getCreateTime())
                .orderByDesc(ResearchApplicationDO::getId));
    }
    /** æŸè¯¾é¢˜æŒ‡å—下的申报单数量(用于删除指南前的占用校验) */
    default Long selectCountByGuidelineId(Long guidelineId) {
        return selectCount(ResearchApplicationDO::getGuidelineId, guidelineId);
    }
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/mysql/fund/ResearchFundRecordMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,16 @@
package cn.iocoder.yudao.module.research.dal.mysql.fund;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.research.dal.dataobject.fund.ResearchFundRecordDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface ResearchFundRecordMapper extends BaseMapperX<ResearchFundRecordDO> {
    default List<ResearchFundRecordDO> selectListByApplicationId(Long applicationId) {
        return selectList(ResearchFundRecordDO::getApplicationId, applicationId);
    }
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/mysql/guideline/ResearchGuidelineMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,45 @@
package cn.iocoder.yudao.module.research.dal.mysql.guideline;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.research.controller.admin.guideline.vo.ResearchGuidelinePageReqVO;
import cn.iocoder.yudao.module.research.dal.dataobject.guideline.ResearchGuidelineDO;
import cn.iocoder.yudao.module.research.enums.ResearchGuidelineStatusEnum;
import org.apache.ibatis.annotations.Mapper;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
@Mapper
public interface ResearchGuidelineMapper extends BaseMapperX<ResearchGuidelineDO> {
    default ResearchGuidelineDO selectByNo(String no) {
        return selectOne(ResearchGuidelineDO::getNo, no);
    }
    default PageResult<ResearchGuidelineDO> selectPage(ResearchGuidelinePageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<ResearchGuidelineDO>()
                .likeIfPresent(ResearchGuidelineDO::getNo, reqVO.getNo())
                .likeIfPresent(ResearchGuidelineDO::getTitle, reqVO.getTitle())
                .eqIfPresent(ResearchGuidelineDO::getCategory, reqVO.getCategory())
                .eqIfPresent(ResearchGuidelineDO::getPublishStatus, reqVO.getPublishStatus())
                .orderByDesc(ResearchGuidelineDO::getId));
    }
    /** å·²å‘布(可申报)的指南列表,供申报单选择指南使用 */
    default List<ResearchGuidelineDO> selectPublishedList() {
        return selectList(new LambdaQueryWrapperX<ResearchGuidelineDO>()
                .eq(ResearchGuidelineDO::getPublishStatus, ResearchGuidelineStatusEnum.PUBLISHED.getStatus())
                .orderByDesc(ResearchGuidelineDO::getId));
    }
    /** æŒ‰ id é›†åˆæ‰¹é‡æŸ¥è¯¢ï¼ˆç”³æŠ¥å•列表回显指南名称用) */
    default List<ResearchGuidelineDO> selectListByIds(Collection<Long> ids) {
        return CollUtil.isEmpty(ids) ? Collections.emptyList()
                : selectList(ResearchGuidelineDO::getId, ids);
    }
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/mysql/review/ResearchTopicReviewMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,16 @@
package cn.iocoder.yudao.module.research.dal.mysql.review;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.research.dal.dataobject.review.ResearchTopicReviewDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface ResearchTopicReviewMapper extends BaseMapperX<ResearchTopicReviewDO> {
    default List<ResearchTopicReviewDO> selectListByApplicationId(Long applicationId) {
        return selectList(ResearchTopicReviewDO::getApplicationId, applicationId);
    }
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/redis/RedisKeyConstants.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
package cn.iocoder.yudao.module.research.dal.redis;
/**
 * Research Redis Key å¸¸é‡
 *
 * @author è¶…级管理员
 */
public interface RedisKeyConstants {
    String NO = "research:seq_no:";
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/redis/ResearchNoRedisDAO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,46 @@
package cn.iocoder.yudao.module.research.dal.redis;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.DateUtil;
import jakarta.annotation.Resource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;
import java.time.Duration;
import java.time.LocalDateTime;
/**
 * Research ç¼–号 Redis DAO
 *
 * @author è¶…级管理员
 */
@Repository
public class ResearchNoRedisDAO {
    /**
     * è¯¾é¢˜æŒ‡å— {@link cn.iocoder.yudao.module.research.dal.dataobject.guideline.ResearchGuidelineDO}
     */
    public static final String GUIDELINE_NO_PREFIX = "GL";
    /**
     * è¯¾é¢˜ç”³æŠ¥ä¹¦ {@link cn.iocoder.yudao.module.research.dal.dataobject.application.ResearchApplicationDO}
     */
    public static final String APPLICATION_NO_PREFIX = "SB";
    /**
     * ç«‹é¡¹ç¼–号(课题申报书审批通过后生成)
     */
    public static final String PROJECT_NO_PREFIX = "LX";
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    public String generate(String prefix) {
        String noPrefix = prefix + DateUtil.format(LocalDateTime.now(), DatePattern.PURE_DATE_PATTERN);
        String key = RedisKeyConstants.NO + noPrefix;
        Long no = stringRedisTemplate.opsForValue().increment(key);
        stringRedisTemplate.expire(key, Duration.ofDays(1L));
        return noPrefix + String.format("%06d", no);
    }
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/enums/DictTypeConstants.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,25 @@
package cn.iocoder.yudao.module.research.enums;
/**
 * Research å­—典类型常量
 *
 * @author è¶…级管理员
 */
public interface DictTypeConstants {
    /** è¯¾é¢˜æŒ‡å—类别:重点/一般 */
    String GUIDELINE_CATEGORY = "research_guideline_category";
    /** è¯¾é¢˜æŒ‡å—发布状态 */
    String GUIDELINE_STATUS = "research_guideline_status";
    /** è¯¾é¢˜ç”³æŠ¥ä¹¦çŠ¶æ€ */
    String APPLICATION_STATUS = "research_application_status";
    /** ä¸“家评审结论 */
    String REVIEW_CONCLUSION = "research_review_conclusion";
    /** ç»è´¹æ‹¨ä»˜çŠ¶æ€ */
    String FUND_STATUS = "research_fund_status";
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/enums/ErrorCodeConstants.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,53 @@
package cn.iocoder.yudao.module.research.enums;
import cn.iocoder.yudao.framework.common.exception.ErrorCode;
/**
 * Research é”™è¯¯ç æžšä¸¾ç±»
 *
 * research æ¨¡å—,使用 1-030-109-000 æ®µ
 *
 * @author è¶…级管理员
 */
public interface ErrorCodeConstants {
    // ========== è¯¾é¢˜æŒ‡å— 1_030_109_000 ==========
    ErrorCode GUIDELINE_NOT_EXISTS = new ErrorCode(1_030_109_000, "课题指南不存在");
    ErrorCode GUIDELINE_NO_EXISTS = new ErrorCode(1_030_109_001, "生成指南编号失败,请重新提交");
    ErrorCode GUIDELINE_UPDATE_FAIL_NOT_DRAFT = new ErrorCode(1_030_109_002, "课题指南({})不是草稿状态,无法修改");
    ErrorCode GUIDELINE_DELETE_FAIL_NOT_DRAFT = new ErrorCode(1_030_109_003, "课题指南({})不是草稿状态,无法删除");
    ErrorCode GUIDELINE_PUBLISH_FAIL_STATUS = new ErrorCode(1_030_109_004, "发布失败,只有草稿状态的课题指南才能发布");
    ErrorCode GUIDELINE_CLOSE_FAIL_STATUS = new ErrorCode(1_030_109_005, "截止失败,只有已发布状态的课题指南才能截止");
    ErrorCode GUIDELINE_APPLY_TIME_INVALID = new ErrorCode(1_030_109_006, "保存失败:申报截止时间({})不能早于申报开始时间({}),请重新选择申报起止时间");
    ErrorCode GUIDELINE_HAS_APPLICATION = new ErrorCode(1_030_109_007, "删除失败:课题指南({})下已存在 {} ä»½è¯¾é¢˜ç”³æŠ¥ä¹¦ï¼Œè¯·å…ˆå¤„理这些申报书后再删除指南");
    // ========== è¯¾é¢˜ç”³æŠ¥ä¹¦ 1_030_109_100 ==========
    ErrorCode APPLICATION_NOT_EXISTS = new ErrorCode(1_030_109_100, "课题申报书不存在");
    ErrorCode APPLICATION_NO_EXISTS = new ErrorCode(1_030_109_101, "生成申报编号失败,请重新提交");
    ErrorCode APPLICATION_UPDATE_FAIL_NOT_DRAFT = new ErrorCode(1_030_109_102, "课题申报书({})当前状态为〔{}〕,只有草稿或已驳回状态才能修改");
    ErrorCode APPLICATION_DELETE_FAIL_NOT_DRAFT = new ErrorCode(1_030_109_103, "删除失败:课题申报书({})当前状态为〔{}〕,只有草稿状态才能删除");
    ErrorCode APPLICATION_SUBMIT_FAIL_STATUS = new ErrorCode(1_030_109_104, "提交审批失败:课题申报书({})当前状态为〔{}〕,只有草稿或已驳回状态才能提交");
    ErrorCode APPLICATION_BPM_CATEGORY_NOT_EXISTS = new ErrorCode(1_030_109_105, "课题立项审批流程分类不存在,请先在BPM模块创建课题立项审批流程分类");
    ErrorCode APPLICATION_SUBMIT_FAIL_GUIDELINE_STATUS = new ErrorCode(1_030_109_106, "提交失败:所属课题指南〔{}〕当前状态为〔{}〕,只有已发布的指南才能提交申报,请联系秘书处确认指南发布情况");
    ErrorCode APPLICATION_SUBMIT_FAIL_NOT_IN_PERIOD = new ErrorCode(1_030_109_107, "提交失败:当前时间({})不在课题指南〔{}〕的申报期内({} è‡³ {}),请联系秘书处确认申报时间");
    ErrorCode APPLICATION_CREATE_FAIL_GUIDELINE_STATUS = new ErrorCode(1_030_109_108, "保存失败:所选课题指南〔{}〕当前状态为〔{}〕,只有已发布的指南才能申报,请重新选择指南");
    ErrorCode APPLICATION_PUBLISH_NOTICE_FAIL_STATUS = new ErrorCode(1_030_109_109, "发布立项通知失败:课题申报书({})当前状态为〔{}〕,只有已立项的课题才能发布立项通知");
    ErrorCode APPLICATION_NOTICE_EXISTS = new ErrorCode(1_030_109_110, "发布失败:该课题({})的立项通知已于 {} å‘布,请勿重复发布");
    ErrorCode APPLICATION_MEMBER_NOT_EXISTS = new ErrorCode(1_030_109_111, "保存失败:所选申报会员(ID={})已不存在,请重新从会员列表选择申报人");
    // ========== ä¸“家评审明细 1_030_109_200 ==========
    ErrorCode REVIEW_NOT_EXISTS = new ErrorCode(1_030_109_200, "专家评审记录不存在");
    ErrorCode REVIEW_CREATE_FAIL_STATUS = new ErrorCode(1_030_109_201, "录入失败:课题申报书({})当前状态为〔{}〕,只有审批中的申报书才能录入专家评审");
    ErrorCode REVIEW_UPDATE_FAIL_STATUS = new ErrorCode(1_030_109_202, "更新失败:课题申报书({})当前状态为〔{}〕,只有审批中的申报书才能修改专家评审");
    ErrorCode REVIEW_DELETE_FAIL_STATUS = new ErrorCode(1_030_109_203, "删除失败:课题申报书({})当前状态为〔{}〕,只有审批中的申报书才能删除专家评审");
    ErrorCode REVIEW_SCORE_OUT_OF_RANGE = new ErrorCode(1_030_109_204, "录入失败:{}得分为 {},超出 0~100 çš„允许范围,请修正后重新提交");
    // ========== ç»è´¹æ‹¨ä»˜è®°å½• 1_030_109_300 ==========
    ErrorCode FUND_NOT_EXISTS = new ErrorCode(1_030_109_300, "经费拨付记录不存在");
    ErrorCode FUND_RECORD_FAIL_STATUS = new ErrorCode(1_030_109_301, "登记失败:课题申报书({})当前状态为〔{}〕,只有已立项的课题才能登记经费拨付");
    ErrorCode FUND_AMOUNT_EXCEEDED = new ErrorCode(1_030_109_302, "登记失败:本次拨付 {} å…ƒï¼Œå·²ç™»è®° {} å…ƒï¼Œåˆè®¡ {} å…ƒï¼Œè¶…出该课题申请预算 {} å…ƒï¼Œè¯·è°ƒä½Žæœ¬æ¬¡æ‹¨ä»˜é‡‘额");
    ErrorCode FUND_VOUCHER_REQUIRED = new ErrorCode(1_030_109_303, "登记失败:状态为「已拨付」时必须上传凭证附件,请先上传凭证附件后再保存");
    ErrorCode FUND_UPDATE_FAIL_STATUS = new ErrorCode(1_030_109_304, "更新失败:经费拨付记录({})当前状态为〔{}〕,只有待拨付状态才能修改");
    ErrorCode FUND_DELETE_FAIL_STATUS = new ErrorCode(1_030_109_305, "删除失败:经费拨付记录({})当前状态为〔{}〕,只有待拨付状态才能删除");
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/enums/ResearchApplicationStatusEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,37 @@
package cn.iocoder.yudao.module.research.enums;
import cn.hutool.core.util.ArrayUtil;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
 * è¯¾é¢˜ç”³æŠ¥ä¹¦çŠ¶æ€æžšä¸¾
 *
 * ä¸Žä»“库既有单据(入会申请、报销单)保持一致的状态机取值
 *
 * @author è¶…级管理员
 */
@Getter
@AllArgsConstructor
public enum ResearchApplicationStatusEnum {
    DRAFT(0, "草稿"),
    APPROVING(15, "审批中"),
    APPROVED(20, "已立项"),
    REJECTED(35, "已驳回"),
    ;
    private final Integer status;
    private final String name;
    public static ResearchApplicationStatusEnum valueOf(Integer status) {
        return ArrayUtil.firstMatch(e -> e.getStatus().equals(status), values());
    }
    /** æŒ‰çŠ¶æ€å€¼å–ä¸­æ–‡åï¼ŒæœªåŒ¹é…æ—¶å›žé€€åŽŸå§‹å€¼ï¼Œä¾¿äºŽé”™è¯¯æç¤ºå¯è¯» */
    public static String nameOf(Integer status) {
        ResearchApplicationStatusEnum item = valueOf(status);
        return item != null ? item.getName() : String.valueOf(status);
    }
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/enums/ResearchFundStatusEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
package cn.iocoder.yudao.module.research.enums;
import cn.hutool.core.util.ArrayUtil;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
 * ç»è´¹æ‹¨ä»˜çŠ¶æ€æžšä¸¾
 *
 * @author è¶…级管理员
 */
@Getter
@AllArgsConstructor
public enum ResearchFundStatusEnum {
    PENDING(0, "待拨付"),
    PAID(20, "已拨付"),
    ;
    private final Integer status;
    private final String name;
    public static ResearchFundStatusEnum valueOf(Integer status) {
        return ArrayUtil.firstMatch(e -> e.getStatus().equals(status), values());
    }
    /** æŒ‰çŠ¶æ€å€¼å–ä¸­æ–‡åï¼ŒæœªåŒ¹é…æ—¶å›žé€€åŽŸå§‹å€¼ï¼Œä¾¿äºŽé”™è¯¯æç¤ºå¯è¯» */
    public static String nameOf(Integer status) {
        ResearchFundStatusEnum item = valueOf(status);
        return item != null ? item.getName() : String.valueOf(status);
    }
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/enums/ResearchGuidelineCategoryEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
package cn.iocoder.yudao.module.research.enums;
import cn.hutool.core.util.ArrayUtil;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
 * è¯¾é¢˜æŒ‡å—类别枚举(重点/一般分类标注)
 *
 * @author è¶…级管理员
 */
@Getter
@AllArgsConstructor
public enum ResearchGuidelineCategoryEnum {
    KEY(1, "重点课题"),
    GENERAL(2, "一般课题"),
    ;
    private final Integer category;
    private final String name;
    public static ResearchGuidelineCategoryEnum valueOf(Integer category) {
        return ArrayUtil.firstMatch(e -> e.getCategory().equals(category), values());
    }
    /** æŒ‰ç±»åˆ«å€¼å–中文名,未匹配时回退原始值,便于错误提示可读 */
    public static String nameOf(Integer category) {
        ResearchGuidelineCategoryEnum item = valueOf(category);
        return item != null ? item.getName() : String.valueOf(category);
    }
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/enums/ResearchGuidelineStatusEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,34 @@
package cn.iocoder.yudao.module.research.enums;
import cn.hutool.core.util.ArrayUtil;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
 * è¯¾é¢˜æŒ‡å—发布状态枚举
 *
 * @author è¶…级管理员
 */
@Getter
@AllArgsConstructor
public enum ResearchGuidelineStatusEnum {
    DRAFT(0, "草稿"),
    PUBLISHED(20, "已发布"),
    CLOSED(30, "已截止"),
    ;
    private final Integer status;
    private final String name;
    public static ResearchGuidelineStatusEnum valueOf(Integer status) {
        return ArrayUtil.firstMatch(e -> e.getStatus().equals(status), values());
    }
    /** æŒ‰çŠ¶æ€å€¼å–ä¸­æ–‡åï¼ŒæœªåŒ¹é…æ—¶å›žé€€åŽŸå§‹å€¼ï¼Œä¾¿äºŽé”™è¯¯æç¤ºå¯è¯» */
    public static String nameOf(Integer status) {
        ResearchGuidelineStatusEnum item = valueOf(status);
        return item != null ? item.getName() : String.valueOf(status);
    }
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/enums/ResearchReviewConclusionEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
package cn.iocoder.yudao.module.research.enums;
import cn.hutool.core.util.ArrayUtil;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
 * ä¸“家评审结论枚举
 *
 * @author è¶…级管理员
 */
@Getter
@AllArgsConstructor
public enum ResearchReviewConclusionEnum {
    RECOMMEND(1, "推荐立项"),
    REJECT(2, "不推荐立项"),
    ;
    private final Integer conclusion;
    private final String name;
    public static ResearchReviewConclusionEnum valueOf(Integer conclusion) {
        return ArrayUtil.firstMatch(e -> e.getConclusion().equals(conclusion), values());
    }
    /** æŒ‰ç»“论值取中文名,未匹配时回退原始值,便于错误提示可读 */
    public static String nameOf(Integer conclusion) {
        ResearchReviewConclusionEnum item = valueOf(conclusion);
        return item != null ? item.getName() : String.valueOf(conclusion);
    }
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/framework/web/config/ResearchWebConfiguration.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,16 @@
package cn.iocoder.yudao.module.research.framework.web.config;
import cn.iocoder.yudao.framework.swagger.config.YudaoSwaggerAutoConfiguration;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class ResearchWebConfiguration {
    @Bean
    public GroupedOpenApi researchGroupedOpenApi() {
        return YudaoSwaggerAutoConfiguration.buildGroupedOpenApi("research");
    }
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/application/ResearchApplicationService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,40 @@
package cn.iocoder.yudao.module.research.service.application;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
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 jakarta.validation.Valid;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
public interface ResearchApplicationService {
    Long createApplication(@Valid ResearchApplicationSaveReqVO createReqVO);
    void updateApplication(@Valid ResearchApplicationSaveReqVO updateReqVO);
    void deleteApplication(Long id);
    ResearchApplicationDO getApplication(Long id);
    PageResult<ResearchApplicationDO> getApplicationPage(ResearchApplicationPageReqVO pageReqVO);
    void submitApplication(Long id, String processDefinitionKey);
    List<Map<String, Object>> getApproveProcessDefinitionList();
    void updateApplicationAuditStatus(Long id, Integer bpmResult);
    /** å®¡æ‰¹é€šè¿‡åŽç”Ÿæˆç«‹é¡¹ç¼–号与立项时间(幂等) */
    void approveApplication(Long id);
    /** å‘布立项通知(生成系统公告,防重复) */
    void publishNotice(Long id);
    /** ä¸“家评审汇总结果回写主单(由评审服务调用) */
    void updateReviewSummary(Long id, BigDecimal reviewAvgScore, Integer reviewPass);
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/application/ResearchApplicationServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,368 @@
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.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.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 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";
    /** ç«‹é¡¹é€šçŸ¥ä½¿ç”¨ã€Œå…¬å‘Šã€ç±»åž‹ï¼ˆ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 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<ResearchApplicationDO> 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<Map<String, Object>> getApproveProcessDefinitionList() {
        validateApplicationApproveCategory();
        List<BpmProcessDefinitionInfoDO> definitionInfoList =
                bpmProcessDefinitionService.getProcessDefinitionInfoListByCategory(APPLICATION_APPROVE_CATEGORY_CODE);
        if (CollUtil.isEmpty(definitionInfoList)) {
            return Collections.emptyList();
        }
        Map<String, ProcessDefinition> 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<Map<String, Object>> result = new ArrayList<>();
        for (ProcessDefinition pd : latestVersionMap.values()) {
            Map<String, Object> 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 void updateReviewSummary(Long id, BigDecimal reviewAvgScore, Integer reviewPass) {
        // ç”¨ UpdateWrapper æ˜¾å¼ set,保证「评审明细被删空」时能把汇总值清成 null
        applicationMapper.update(null, new LambdaUpdateWrapper<ResearchApplicationDO>()
                .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;
    }
    /** ä¿å­˜ï¼ˆè‰ç¨¿ï¼‰é˜¶æ®µæ ¡éªŒæŒ‡å—:必须存在且已发布 */
    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<BpmCategoryDO> 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("<p>经学会立项审批通过,现将立项课题公布如下:</p><ol>");
        sb.append("<li><strong>").append(HtmlUtil.escape(application.getTitle())).append("</strong>")
                .append("(立项编号:").append(HtmlUtil.escape(application.getResearchNo())).append(")<br/>")
                .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("<br/>所属指南:").append(HtmlUtil.escape(guideline.getTitle()));
        }
        sb.append("</li></ol>");
        sb.append("<p>请课题负责人按申报书约定推进研究工作,经费拨付事宜另行通知。</p>");
        return sb.toString();
    }
    private static String format(LocalDateTime time) {
        return time == null ? "未记录" : time.format(DATE_TIME_FORMATTER);
    }
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/application/listener/ResearchApplicationStatusListener.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,62 @@
package cn.iocoder.yudao.module.research.service.application.listener;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener;
import cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum;
import cn.iocoder.yudao.module.research.service.application.ResearchApplicationService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
 * è¯¾é¢˜ç”³æŠ¥ä¹¦å®¡æ‰¹çŠ¶æ€ç›‘å¬å™¨
 *
 * ç›‘听 BPM æµç¨‹å®žä¾‹çŠ¶æ€å˜æ›´äº‹ä»¶ï¼Œæ›´æ–°ç”³æŠ¥ä¹¦çš„å®¡æ‰¹çŠ¶æ€ï¼Œå¹¶åœ¨å®¡æ‰¹é€šè¿‡åŽç”Ÿæˆç«‹é¡¹ç¼–å·
 */
@Component
@Slf4j
public class ResearchApplicationStatusListener extends BpmProcessInstanceStatusEventListener {
    private static final String PROCESS_DEFINITION_KEY = "research_topic_approve";
    @Resource
    private ResearchApplicationService applicationService;
    @Override
    protected String getProcessDefinitionKey() {
        return PROCESS_DEFINITION_KEY;
    }
    @Override
    protected void onEvent(BpmProcessInstanceStatusEvent event) {
        Long applicationId;
        try {
            applicationId = Long.parseLong(event.getBusinessKey());
        } catch (NumberFormatException e) {
            log.warn("[onEvent] businessKey({}) ä¸æ˜¯æœ‰æ•ˆçš„课题申报书编号", event.getBusinessKey());
            return;
        }
        log.info("[onEvent] æ”¶åˆ°BPM审批事件,applicationId={}, status={}", applicationId, event.getStatus());
        // 1. æ›´æ–°ç”³æŠ¥ä¹¦å®¡æ‰¹çŠ¶æ€
        try {
            applicationService.updateApplicationAuditStatus(applicationId, event.getStatus());
            log.info("[onEvent] è¯¾é¢˜ç”³æŠ¥ä¹¦({}) å®¡æ‰¹çŠ¶æ€æ›´æ–°å®Œæˆ", applicationId);
        } catch (Exception e) {
            log.error("[onEvent] æ›´æ–°è¯¾é¢˜ç”³æŠ¥ä¹¦å®¡æ‰¹çŠ¶æ€å¤±è´¥ï¼ŒapplicationId={}", applicationId, e);
            return;
        }
        // 2. å®¡æ‰¹é€šè¿‡åŽç”Ÿæˆç«‹é¡¹ç¼–号与立项时间
        if (BpmTaskStatusEnum.APPROVE.getStatus().equals(event.getStatus())) {
            try {
                applicationService.approveApplication(applicationId);
                log.info("[onEvent] è¯¾é¢˜ç”³æŠ¥ä¹¦({}) å®¡æ‰¹é€šè¿‡ï¼Œç«‹é¡¹ç¼–号生成完成", applicationId);
            } catch (Exception e) {
                log.error("[onEvent] è¯¾é¢˜ç”³æŠ¥ä¹¦({}) å®¡æ‰¹é€šè¿‡åŽç”Ÿæˆç«‹é¡¹ç¼–号失败", applicationId, e);
            }
        }
    }
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/fund/ResearchFundRecordService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,20 @@
package cn.iocoder.yudao.module.research.service.fund;
import cn.iocoder.yudao.module.research.controller.admin.fund.vo.ResearchFundRecordSaveReqVO;
import cn.iocoder.yudao.module.research.dal.dataobject.fund.ResearchFundRecordDO;
import jakarta.validation.Valid;
import java.util.List;
public interface ResearchFundRecordService {
    Long createFundRecord(@Valid ResearchFundRecordSaveReqVO createReqVO);
    void updateFundRecord(@Valid ResearchFundRecordSaveReqVO updateReqVO);
    void deleteFundRecord(Long id);
    /** æŸè¯¾é¢˜ç”³æŠ¥ä¹¦ä¸‹çš„全部经费拨付记录(按创建时间倒序) */
    List<ResearchFundRecordDO> getFundRecordListByApplicationId(Long applicationId);
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/fund/ResearchFundRecordServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,162 @@
package cn.iocoder.yudao.module.research.service.fund;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
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.dal.mysql.application.ResearchApplicationMapper;
import cn.iocoder.yudao.module.research.dal.mysql.fund.ResearchFundRecordMapper;
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 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.util.List;
import java.util.Objects;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.research.enums.ErrorCodeConstants.*;
/**
 * ç»è´¹æ‹¨ä»˜è®°å½• Service å®žçŽ°ç±»
 *
 * ä¸€æœŸåªåœ¨æœ¬æ¨¡å—内落地拨款记录(批次/金额/日期/收款方/凭证附件),不与财务模块同步;
 * ç´¯è®¡æ‹¨ä»˜é‡‘额以申报单 budget_amount(申请预算)为上限。
 */
@Service
@Validated
@Slf4j
public class ResearchFundRecordServiceImpl implements ResearchFundRecordService {
    public static final String FUND_RECORD_TYPE = "research_fund_record";
    @Resource
    private ResearchFundRecordMapper fundRecordMapper;
    @Resource
    private ResearchApplicationMapper applicationMapper;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createFundRecord(ResearchFundRecordSaveReqVO createReqVO) {
        ResearchApplicationDO application = validateApplicationApproved(createReqVO.getApplicationId());
        validateVoucherRequired(createReqVO);
        // ç´¯è®¡æ ¡éªŒï¼šæ–°ç™»è®°é‡‘额 + è¯¥ç”³æŠ¥å•已有记录金额,不得超过申请预算
        BigDecimal registered = sumAmount(fundRecordMapper.selectListByApplicationId(application.getId()), null);
        validateAmountNotExceeded(createReqVO.getAmount(), registered, application.getBudgetAmount());
        ResearchFundRecordDO fundRecord = BeanUtils.toBean(createReqVO, ResearchFundRecordDO.class,
                in -> in.setId(null));
        fundRecordMapper.insert(fundRecord);
        storageAttachmentApi.bindAttachments("file", FUND_RECORD_TYPE,
                fundRecord.getId(), createReqVO.getBlobIds());
        return fundRecord.getId();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateFundRecord(ResearchFundRecordSaveReqVO updateReqVO) {
        ResearchFundRecordDO exist = validateFundRecordExists(updateReqVO.getId());
        if (!ResearchFundStatusEnum.PENDING.getStatus().equals(exist.getStatus())) {
            throw exception(FUND_UPDATE_FAIL_STATUS,
                    exist.getId(), ResearchFundStatusEnum.nameOf(exist.getStatus()));
        }
        // è®°å½•归属申报单不可变更,防止把拨款挪到别的课题下
        ResearchApplicationDO application = validateApplicationApproved(exist.getApplicationId());
        validateVoucherRequired(updateReqVO);
        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);
        storageAttachmentApi.updateAttachments("file", FUND_RECORD_TYPE,
                updateReqVO.getId(), updateReqVO.getBlobIds());
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteFundRecord(Long id) {
        ResearchFundRecordDO exist = validateFundRecordExists(id);
        if (!ResearchFundStatusEnum.PENDING.getStatus().equals(exist.getStatus())) {
            throw exception(FUND_DELETE_FAIL_STATUS,
                    exist.getId(), ResearchFundStatusEnum.nameOf(exist.getStatus()));
        }
        fundRecordMapper.deleteById(id);
        storageAttachmentApi.deleteAttachmentsByRecord(FUND_RECORD_TYPE, id);
    }
    @Override
    public List<ResearchFundRecordDO> getFundRecordListByApplicationId(Long applicationId) {
        return fundRecordMapper.selectListByApplicationId(applicationId);
    }
    // =================== å†…部方法 ===================
    private ResearchFundRecordDO validateFundRecordExists(Long id) {
        ResearchFundRecordDO fundRecord = fundRecordMapper.selectById(id);
        if (fundRecord == null) {
            throw exception(FUND_NOT_EXISTS);
        }
        return fundRecord;
    }
    /** åªæœ‰å·²ç«‹é¡¹çš„课题才能登记经费拨付 */
    private ResearchApplicationDO validateApplicationApproved(Long applicationId) {
        ResearchApplicationDO application = applicationMapper.selectById(applicationId);
        if (application == null) {
            throw exception(APPLICATION_NOT_EXISTS);
        }
        if (!ResearchApplicationStatusEnum.APPROVED.getStatus().equals(application.getStatus())) {
            throw exception(FUND_RECORD_FAIL_STATUS, application.getNo(),
                    ResearchApplicationStatusEnum.nameOf(application.getStatus()));
        }
        return application;
    }
    /** çŠ¶æ€ä¸ºã€Œå·²æ‹¨ä»˜ã€æ—¶å¿…é¡»ä¸Šä¼ å‡­è¯é™„ä»¶ */
    private void validateVoucherRequired(ResearchFundRecordSaveReqVO reqVO) {
        if (ResearchFundStatusEnum.PAID.getStatus().equals(reqVO.getStatus())
                && CollUtil.isEmpty(reqVO.getBlobIds())) {
            throw exception(FUND_VOUCHER_REQUIRED);
        }
    }
    /** ç´¯è®¡æµ®å‡ºæ ¡éªŒï¼šæœ¬æ¬¡ + å·²ç™»è®°ï¼ˆæŽ’除 excludeId è‡ªèº«ï¼‰ä¸å¾—超过申请预算 */
    private void validateAmountNotExceeded(BigDecimal amount, BigDecimal registered, BigDecimal budget) {
        BigDecimal current = amount == null ? BigDecimal.ZERO : amount;
        BigDecimal total = registered.add(current);
        BigDecimal limit = budget == null ? BigDecimal.ZERO : budget;
        if (total.compareTo(limit) > 0) {
            throw exception(FUND_AMOUNT_EXCEEDED,
                    current.toPlainString(), registered.toPlainString(),
                    total.toPlainString(), limit.toPlainString());
        }
    }
    private BigDecimal sumAmount(List<ResearchFundRecordDO> list, Long excludeId) {
        if (CollUtil.isEmpty(list)) {
            return BigDecimal.ZERO;
        }
        return list.stream()
                .filter(item -> !Objects.equals(item.getId(), excludeId))
                .map(item -> item.getAmount() == null ? BigDecimal.ZERO : item.getAmount())
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/guideline/ResearchGuidelineService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,39 @@
package cn.iocoder.yudao.module.research.service.guideline;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.research.controller.admin.guideline.vo.ResearchGuidelinePageReqVO;
import cn.iocoder.yudao.module.research.controller.admin.guideline.vo.ResearchGuidelineSaveReqVO;
import cn.iocoder.yudao.module.research.dal.dataobject.guideline.ResearchGuidelineDO;
import jakarta.validation.Valid;
import java.util.Collection;
import java.util.List;
public interface ResearchGuidelineService {
    Long createGuideline(@Valid ResearchGuidelineSaveReqVO createReqVO);
    void updateGuideline(@Valid ResearchGuidelineSaveReqVO updateReqVO);
    void deleteGuideline(Long id);
    ResearchGuidelineDO getGuideline(Long id);
    PageResult<ResearchGuidelineDO> getGuidelinePage(ResearchGuidelinePageReqVO pageReqVO);
    /** å·²å‘布的课题指南列表(供申报单选择指南) */
    List<ResearchGuidelineDO> getPublishedGuidelineList();
    /** æŒ‰ id é›†åˆæ‰¹é‡æŸ¥è¯¢æŒ‡å—(申报单列表回显指南名称) */
    List<ResearchGuidelineDO> getGuidelineList(Collection<Long> ids);
    /** å‘布指南:草稿 â†’ å·²å‘布 */
    void publishGuideline(Long id);
    /** æˆªæ­¢æŒ‡å—:已发布 â†’ å·²æˆªæ­¢ */
    void closeGuideline(Long id);
    /** æ ¡éªŒæŒ‡å—是否可申报(已发布 + åœ¨ç”³æŠ¥æœŸå†…),返回该指南 */
    ResearchGuidelineDO validateGuidelineSubmittable(Long guidelineId);
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/guideline/ResearchGuidelineServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,192 @@
package cn.iocoder.yudao.module.research.service.guideline;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.research.controller.admin.guideline.vo.ResearchGuidelinePageReqVO;
import cn.iocoder.yudao.module.research.controller.admin.guideline.vo.ResearchGuidelineSaveReqVO;
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.guideline.ResearchGuidelineMapper;
import cn.iocoder.yudao.module.research.dal.redis.ResearchNoRedisDAO;
import cn.iocoder.yudao.module.research.enums.ResearchGuidelineStatusEnum;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
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.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collection;
import java.util.List;
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.module.research.enums.ErrorCodeConstants.*;
@Service
@Validated
@Slf4j
public class ResearchGuidelineServiceImpl implements ResearchGuidelineService {
    public static final String GUIDELINE_RECORD_TYPE = "research_guideline";
    private static final DateTimeFormatter DATE_TIME_FORMATTER =
            DateTimeFormatter.ofPattern(FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND);
    @Resource
    private ResearchGuidelineMapper guidelineMapper;
    @Resource
    private ResearchApplicationMapper applicationMapper;
    @Resource
    private ResearchNoRedisDAO noRedisDAO;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createGuideline(ResearchGuidelineSaveReqVO createReqVO) {
        validateApplyTime(createReqVO);
        String no = noRedisDAO.generate(ResearchNoRedisDAO.GUIDELINE_NO_PREFIX);
        if (guidelineMapper.selectByNo(no) != null) {
            throw exception(GUIDELINE_NO_EXISTS);
        }
        ResearchGuidelineDO guideline = BeanUtils.toBean(createReqVO, ResearchGuidelineDO.class, in -> in
                .setNo(no)
                .setPublishStatus(ResearchGuidelineStatusEnum.DRAFT.getStatus()));
        guidelineMapper.insert(guideline);
        storageAttachmentApi.bindAttachments("file", GUIDELINE_RECORD_TYPE,
                guideline.getId(), createReqVO.getBlobIds());
        return guideline.getId();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateGuideline(ResearchGuidelineSaveReqVO updateReqVO) {
        ResearchGuidelineDO guideline = validateGuidelineExists(updateReqVO.getId());
        if (!ResearchGuidelineStatusEnum.DRAFT.getStatus().equals(guideline.getPublishStatus())) {
            throw exception(GUIDELINE_UPDATE_FAIL_NOT_DRAFT, guideline.getNo());
        }
        validateApplyTime(updateReqVO);
        ResearchGuidelineDO updateObj = BeanUtils.toBean(updateReqVO, ResearchGuidelineDO.class);
        guidelineMapper.updateById(updateObj);
        storageAttachmentApi.updateAttachments("file", GUIDELINE_RECORD_TYPE,
                updateReqVO.getId(), updateReqVO.getBlobIds());
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteGuideline(Long id) {
        ResearchGuidelineDO guideline = validateGuidelineExists(id);
        if (!ResearchGuidelineStatusEnum.DRAFT.getStatus().equals(guideline.getPublishStatus())) {
            throw exception(GUIDELINE_DELETE_FAIL_NOT_DRAFT, guideline.getNo());
        }
        // å·²äº§ç”Ÿç”³æŠ¥å•时禁止删除,避免申报单悬挂在已删除指南上
        Long applicationCount = applicationMapper.selectCountByGuidelineId(id);
        if (applicationCount != null && applicationCount > 0) {
            throw exception(GUIDELINE_HAS_APPLICATION, guideline.getNo(), applicationCount);
        }
        guidelineMapper.deleteById(id);
        storageAttachmentApi.deleteAttachmentsByRecord(GUIDELINE_RECORD_TYPE, id);
    }
    @Override
    public ResearchGuidelineDO getGuideline(Long id) {
        return guidelineMapper.selectById(id);
    }
    @Override
    public PageResult<ResearchGuidelineDO> getGuidelinePage(ResearchGuidelinePageReqVO pageReqVO) {
        return guidelineMapper.selectPage(pageReqVO);
    }
    @Override
    public List<ResearchGuidelineDO> getPublishedGuidelineList() {
        return guidelineMapper.selectPublishedList();
    }
    @Override
    public List<ResearchGuidelineDO> getGuidelineList(Collection<Long> ids) {
        return guidelineMapper.selectListByIds(ids);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void publishGuideline(Long id) {
        ResearchGuidelineDO guideline = validateGuidelineExists(id);
        if (!ResearchGuidelineStatusEnum.DRAFT.getStatus().equals(guideline.getPublishStatus())) {
            throw exception(GUIDELINE_PUBLISH_FAIL_STATUS);
        }
        guidelineMapper.updateById(new ResearchGuidelineDO()
                .setId(id)
                .setPublishStatus(ResearchGuidelineStatusEnum.PUBLISHED.getStatus())
                .setPublishTime(LocalDateTime.now()));
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void closeGuideline(Long id) {
        ResearchGuidelineDO guideline = validateGuidelineExists(id);
        if (!ResearchGuidelineStatusEnum.PUBLISHED.getStatus().equals(guideline.getPublishStatus())) {
            throw exception(GUIDELINE_CLOSE_FAIL_STATUS);
        }
        guidelineMapper.updateById(new ResearchGuidelineDO()
                .setId(id)
                .setPublishStatus(ResearchGuidelineStatusEnum.CLOSED.getStatus()));
    }
    @Override
    public ResearchGuidelineDO validateGuidelineSubmittable(Long guidelineId) {
        ResearchGuidelineDO guideline = guidelineMapper.selectById(guidelineId);
        if (guideline == null) {
            throw exception(GUIDELINE_NOT_EXISTS);
        }
        // é€ç»´åº¦æ ¡éªŒï¼Œæç¤ºé‡Œç»™å‡ºã€ŒæŒ‡å—名 + å®žé™…状态/时间 + ä¸‹ä¸€æ­¥åŠ¨ä½œã€
        if (!ResearchGuidelineStatusEnum.PUBLISHED.getStatus().equals(guideline.getPublishStatus())) {
            throw exception(APPLICATION_SUBMIT_FAIL_GUIDELINE_STATUS,
                    guideline.getTitle(), ResearchGuidelineStatusEnum.nameOf(guideline.getPublishStatus()));
        }
        LocalDateTime now = LocalDateTime.now();
        boolean beforeStart = guideline.getApplyStartTime() != null && now.isBefore(guideline.getApplyStartTime());
        boolean afterEnd = guideline.getApplyEndTime() != null && now.isAfter(guideline.getApplyEndTime());
        if (beforeStart || afterEnd) {
            throw exception(APPLICATION_SUBMIT_FAIL_NOT_IN_PERIOD,
                    format(now), guideline.getTitle(),
                    format(guideline.getApplyStartTime()), format(guideline.getApplyEndTime()));
        }
        return guideline;
    }
    // =================== å†…部方法 ===================
    private ResearchGuidelineDO validateGuidelineExists(Long id) {
        ResearchGuidelineDO guideline = guidelineMapper.selectById(id);
        if (guideline == null) {
            throw exception(GUIDELINE_NOT_EXISTS);
        }
        return guideline;
    }
    private void validateApplyTime(ResearchGuidelineSaveReqVO reqVO) {
        if (reqVO.getApplyStartTime() != null && reqVO.getApplyEndTime() != null
                && reqVO.getApplyEndTime().isBefore(reqVO.getApplyStartTime())) {
            throw exception(GUIDELINE_APPLY_TIME_INVALID,
                    format(reqVO.getApplyEndTime()), format(reqVO.getApplyStartTime()));
        }
    }
    private static String format(LocalDateTime time) {
        return time == null ? "未设置" : time.format(DATE_TIME_FORMATTER);
    }
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/review/ResearchTopicReviewService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,20 @@
package cn.iocoder.yudao.module.research.service.review;
import cn.iocoder.yudao.module.research.controller.admin.review.vo.ResearchTopicReviewSaveReqVO;
import cn.iocoder.yudao.module.research.dal.dataobject.review.ResearchTopicReviewDO;
import jakarta.validation.Valid;
import java.util.List;
public interface ResearchTopicReviewService {
    Long createReview(@Valid ResearchTopicReviewSaveReqVO createReqVO);
    void updateReview(@Valid ResearchTopicReviewSaveReqVO updateReqVO);
    void deleteReview(Long id);
    /** æŸè¯¾é¢˜ç”³æŠ¥ä¹¦ä¸‹çš„全部专家评审明细(按排名升序) */
    List<ResearchTopicReviewDO> getReviewListByApplicationId(Long applicationId);
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/review/ResearchTopicReviewServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,190 @@
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<ResearchTopicReviewDO> getReviewListByApplicationId(Long applicationId) {
        return reviewMapper.selectListByApplicationId(applicationId);
    }
    // =================== å†…部方法 ===================
    /**
     * é‡ç®—某申报书的全部评审明细:加权合计 â†’ æŽ’名 â†’ ä¸»å•汇总均分与结论
     */
    private void recalculate(Long applicationId) {
        List<ResearchTopicReviewDO> list = reviewMapper.selectListByApplicationId(applicationId);
        // 1. é€è¡Œé‡ç®—加权合计
        for (ResearchTopicReviewDO review : list) {
            review.setCompositeScore(weightedScore(review));
        }
        // 2. æŒ‰åŠ æƒåˆè®¡é™åºæŽ’åï¼Œåæ¬¡å›žå†™
        List<ResearchTopicReviewDO> 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;
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java
@@ -44,6 +44,9 @@
    ERP_FINANCE_RECEIPT("erp_finance_receipt", "ERP æ”¶æ¬¾å•"),
    ERP_FINANCE_PAYMENT("erp_finance_payment", "ERP ä»˜æ¬¾å•"),
    // ===== è´¢åŠ¡æŠ¥é”€ =====
    FINANCE_REIMBURSEMENT("finance_reimbursement", "财务报销单"),
    // ===== CRM =====
    CRM_SALE_QUOTATION("crm_sale_quotation", "CRM é”€å”®æŠ¥ä»·å•"),
    CRM_FOLLOW_UP_RECORD("crm_follow_up_record", "CRM è·Ÿè¿›è®°å½•"),
@@ -71,7 +74,12 @@
    // ===== æ™ºèƒ½è´¨æ£€æŠ¥å‘Š =====
    QC_REPORT_INSTANCE("qc_report_instance", "质检报告实例"),
    QC_REPORT_TEMPLATE("qc_report_template", "质检报告模板");
    QC_REPORT_TEMPLATE("qc_report_template", "质检报告模板"),
    // ===== è¯¾é¢˜ç®¡ç† =====
    RESEARCH_GUIDELINE("research_guideline", "课题指南"),
    RESEARCH_APPLICATION("research_application", "课题申报书"),
    RESEARCH_FUND_RECORD("research_fund_record", "经费拨付凭证");
    private final String type;
    private final String description;
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/util/storage/StorageFileUtil.java
@@ -278,7 +278,8 @@
        List<SystemStorageAttachmentDO> attachments = getStorageAttachmentsByApplicationAndRecordTypeAndRecordId(
                application, recordType, recordId);
        if (CollUtil.isEmpty(attachments)) {
            return null;
            // è¿”回空集合而非 null:调用方常把它直接塞进 Collectors.toMap åšåˆ†ç»„(value ä¸º null ä¼šæŠ› NPE)
            return new ArrayList<>();
        }
        Map<Long, Long> blobIdToAttachmentIdMap = attachments.stream()
                .collect(Collectors.toMap(SystemStorageAttachmentDO::getStorageBlobId, SystemStorageAttachmentDO::getId));
@@ -302,7 +303,8 @@
    public List<SystemStorageBlobRespVO> getStorageBlobVOsByRecordTypeAndRecordId(String recordType, Long recordId) {
        List<SystemStorageAttachmentDO> attachments = getStorageAttachmentsByRecordTypeAndRecordId(recordType, recordId);
        if (CollUtil.isEmpty(attachments)) {
            return null;
            // è¿”回空集合而非 null,避免调用方直接使用返回值时抛 NPE
            return new ArrayList<>();
        }
        List<Long> storageBlobIds = attachments.stream()
                .map(SystemStorageAttachmentDO::getStorageBlobId)
yudao-module-ums/pom.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <groupId>cn.iocoder.boot</groupId>
        <artifactId>yudao</artifactId>
        <version>${revision}</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>
    <artifactId>yudao-module-ums</artifactId>
    <packaging>jar</packaging>
    <name>${project.artifactId}</name>
    <description>
        ums åŒ…下,会员管理模块(学会/协会型会员:入会申请审批、会员分级、通讯录检索)。
    </description>
    <dependencies>
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-system</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- BPM å·¥ä½œæµï¼ˆå…¥ä¼šå®¡æ‰¹ä¸¤çº§å®¡æ‰¹ï¼‰ -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-bpm</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- Web ç›¸å…³ -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-security</artifactId>
        </dependency>
        <!-- DB ç›¸å…³ -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-mybatis</artifactId>
        </dependency>
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-redis</artifactId>
        </dependency>
        <!-- å·¥å…·ç±»ç›¸å…³ -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-excel</artifactId>
        </dependency>
        <!-- Test æµ‹è¯•相关 -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-test</artifactId>
        </dependency>
    </dependencies>
</project>
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/application/UmsApplicationController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,146 @@
package cn.iocoder.yudao.module.ums.controller.admin.application;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.number.NumberUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
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.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import cn.iocoder.yudao.module.ums.controller.admin.application.vo.UmsApplicationPageReqVO;
import cn.iocoder.yudao.module.ums.controller.admin.application.vo.UmsApplicationRespVO;
import cn.iocoder.yudao.module.ums.controller.admin.application.vo.UmsApplicationSaveReqVO;
import cn.iocoder.yudao.module.ums.dal.dataobject.application.UmsApplicationDO;
import cn.iocoder.yudao.module.ums.service.application.UmsApplicationService;
import cn.iocoder.yudao.module.ums.service.application.UmsApplicationServiceImpl;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen;
@Tag(name = "管理后台 - å…¥ä¼šç”³è¯·")
@RestController
@RequestMapping("/ums/application")
@Validated
public class UmsApplicationController {
    public static final String APPLICATION_RECORD_TYPE =
            UmsApplicationServiceImpl.APPLICATION_RECORD_TYPE;
    @Resource
    private UmsApplicationService applicationService;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @PostMapping("/create")
    @Operation(summary = "创建入会申请")
    @PreAuthorize("@ss.hasPermission('ums:application:create')")
    public CommonResult<Long> createApplication(@Valid @RequestBody UmsApplicationSaveReqVO createReqVO) {
        return success(applicationService.createApplication(createReqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "更新入会申请")
    @PreAuthorize("@ss.hasPermission('ums:application:update')")
    public CommonResult<Boolean> updateApplication(@Valid @RequestBody UmsApplicationSaveReqVO updateReqVO) {
        applicationService.updateApplication(updateReqVO);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除入会申请")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('ums:application:delete')")
    public CommonResult<Boolean> deleteApplication(@RequestParam("id") Long id) {
        applicationService.deleteApplication(id);
        return success(true);
    }
    @GetMapping("/get")
    @Operation(summary = "获得入会申请")
    @Parameter(name = "id", description = "编号", required = true, example = "1024")
    @PreAuthorize("@ss.hasPermission('ums:application:query')")
    public CommonResult<UmsApplicationRespVO> getApplication(@RequestParam("id") Long id) {
        UmsApplicationDO application = applicationService.getApplication(id);
        return success(buildApplicationDetail(application));
    }
    @GetMapping("/page")
    @Operation(summary = "获得入会申请分页")
    @PreAuthorize("@ss.hasPermission('ums:application:query')")
    public CommonResult<PageResult<UmsApplicationRespVO>> getApplicationPage(
            @Valid UmsApplicationPageReqVO pageReqVO) {
        PageResult<UmsApplicationDO> pageResult = applicationService.getApplicationPage(pageReqVO);
        return success(new PageResult<>(buildApplicationDetailList(pageResult.getList()), pageResult.getTotal()));
    }
    @PostMapping("/submit")
    @Operation(summary = "提交入会申请审批")
    @PreAuthorize("@ss.hasPermission('ums:application:submit')")
    public CommonResult<Boolean> submitApplication(@RequestParam("id") Long id,
                                                    @RequestParam("processDefinitionKey") String processDefinitionKey) {
        applicationService.submitApplication(id, processDefinitionKey);
        return success(true);
    }
    @GetMapping("/approve-process-list")
    @Operation(summary = "获取入会申请审批流程列表")
    @PreAuthorize("@ss.hasPermission('ums:application:query')")
    public CommonResult<List<Map<String, Object>>> getApproveProcessDefinitionList() {
        return success(applicationService.getApproveProcessDefinitionList());
    }
    // ==================== å†…部方法 ====================
    private UmsApplicationRespVO buildApplicationDetail(UmsApplicationDO application) {
        if (application == null) {
            return null;
        }
        return buildApplicationDetailList(Collections.singletonList(application)).get(0);
    }
    private List<UmsApplicationRespVO> buildApplicationDetailList(List<UmsApplicationDO> list) {
        if (CollUtil.isEmpty(list)) {
            return Collections.emptyList();
        }
        // 1.1 èŽ·å–åˆ›å»ºäºº
        Set<Long> creatorIds = convertSet(list, application ->
                NumberUtils.parseLong(application.getCreator()));
        Map<Long, AdminUserRespDTO> creatorMap = adminUserApi.getUserMap(creatorIds);
        // 1.2 èŽ·å–é™„ä»¶
        Map<Long, List<StorageBlobRespDTO>> attachmentMap = list.stream()
                .collect(Collectors.toMap(UmsApplicationDO::getId,
                        r -> storageAttachmentApi.listAttachments(APPLICATION_RECORD_TYPE, r.getId())));
        // 2. æ‹¼æŽ¥ç»“æžœ
        return BeanUtils.toBean(list, UmsApplicationRespVO.class, (vo) -> {
            findAndThen(creatorMap, NumberUtils.parseLong(vo.getCreator()),
                    user -> vo.setCreatorName(user.getNickname()));
            List<StorageBlobRespDTO> attachments = attachmentMap.getOrDefault(vo.getId(), Collections.emptyList());
            vo.setAttachmentList(attachments);
            vo.setHasAttachment(CollUtil.isNotEmpty(attachments));
        });
    }
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/application/vo/UmsApplicationPageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,25 @@
package cn.iocoder.yudao.module.ums.controller.admin.application.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;
@Schema(description = "管理后台 - å…¥ä¼šç”³è¯·åˆ†é¡µ Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class UmsApplicationPageReqVO extends PageParam {
    @Schema(description = "申请编号", example = "SQ20260928000001")
    private String no;
    @Schema(description = "状态", example = "0")
    private Integer status;
    @Schema(description = "会员类型", example = "1")
    private Integer memberType;
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/application/vo/UmsApplicationRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,80 @@
package cn.iocoder.yudao.module.ums.controller.admin.application.vo;
import cn.iocoder.yudao.module.system.api.storage.dto.StorageBlobRespDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - å…¥ä¼šç”³è¯· Response VO")
@Data
public class UmsApplicationRespVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "申请编号", example = "SQ20260928000001")
    private String no;
    @Schema(description = "会员类型", example = "1")
    private Integer memberType;
    @Schema(description = "申请人姓名(个人)或单位名称(单位)", example = "张三")
    private String applicantName;
    @Schema(description = "联系电话", example = "13800138000")
    private String contactPhone;
    @Schema(description = "邮箱", example = "zhangsan@example.com")
    private String contactEmail;
    @Schema(description = "法定代表人(单位专有)", example = "李四")
    private String legalPerson;
    @Schema(description = "统一社会信用代码(单位专有)", example = "91440101MA5XXXXXX")
    private String organizationCode;
    @Schema(description = "研究方向", example = "人工智能与智能制造")
    private String researchDirection;
    @Schema(description = "工作单位(个人专有)", example = "XX大学")
    private String workUnit;
    @Schema(description = "职称(个人专有)", example = "教授")
    private String title;
    @Schema(description = "申请等级", example = "1")
    private String memberGrade;
    @Schema(description = "地区编码", example = "110000")
    private String regionCode;
    @Schema(description = "行业", example = "1")
    private String industry;
    @Schema(description = "状态", example = "0")
    private Integer status;
    @Schema(description = "BPM æµç¨‹å®žä¾‹ ID", example = "1001")
    private String processInstanceId;
    @Schema(description = "备注", example = "希望加入学会")
    private String remark;
    @Schema(description = "创建者")
    private String creator;
    @Schema(description = "创建者名称")
    private String creatorName;
    @Schema(description = "创建时间")
    private LocalDateTime createTime;
    @Schema(description = "附件列表")
    private List<StorageBlobRespDTO> attachmentList;
    @Schema(description = "是否有附件")
    private Boolean hasAttachment;
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/application/vo/UmsApplicationSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,61 @@
package cn.iocoder.yudao.module.ums.controller.admin.application.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
@Schema(description = "管理后台 - å…¥ä¼šç”³è¯·æ–°å¢ž/修改 Request VO")
@Data
public class UmsApplicationSaveReqVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "会员类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "会员类型不能为空")
    private Integer memberType;
    @Schema(description = "申请人姓名(个人)或单位名称(单位)", requiredMode = Schema.RequiredMode.REQUIRED, example = "张三")
    @NotEmpty(message = "申请人姓名不能为空")
    private String applicantName;
    @Schema(description = "联系电话", example = "13800138000")
    private String contactPhone;
    @Schema(description = "邮箱", example = "zhangsan@example.com")
    private String contactEmail;
    @Schema(description = "法定代表人(单位专有)", example = "李四")
    private String legalPerson;
    @Schema(description = "统一社会信用代码(单位专有)", example = "91440101MA5XXXXXX")
    private String organizationCode;
    @Schema(description = "研究方向", example = "人工智能与智能制造")
    private String researchDirection;
    @Schema(description = "工作单位(个人专有)", example = "XX大学")
    private String workUnit;
    @Schema(description = "职称(个人专有)", example = "教授")
    private String title;
    @Schema(description = "申请等级", example = "1")
    private String memberGrade;
    @Schema(description = "地区编码(Area æ ‘末级)", example = "110000")
    private String regionCode;
    @Schema(description = "行业", example = "1")
    private String industry;
    @Schema(description = "备注", example = "希望加入学会")
    private String remark;
    @Schema(description = "附件BlobId列表")
    private List<Long> blobIds;
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/member/UmsMemberController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,136 @@
package cn.iocoder.yudao.module.ums.controller.admin.member;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
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.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import cn.iocoder.yudao.module.ums.controller.admin.member.vo.UmsMemberPageReqVO;
import cn.iocoder.yudao.module.ums.controller.admin.member.vo.UmsMemberRespVO;
import cn.iocoder.yudao.module.ums.controller.admin.member.vo.UmsMemberSaveReqVO;
import cn.iocoder.yudao.module.ums.dal.dataobject.member.UmsMemberDO;
import cn.iocoder.yudao.module.ums.service.member.UmsMemberService;
import cn.iocoder.yudao.module.ums.service.member.UmsMemberServiceImpl;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen;
@Tag(name = "管理后台 - ä¼šå‘˜ç®¡ç†")
@RestController
@RequestMapping("/ums/member")
@Validated
public class UmsMemberController {
    public static final String MEMBER_RECORD_TYPE =
            UmsMemberServiceImpl.MEMBER_RECORD_TYPE;
    @Resource
    private UmsMemberService memberService;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @PostMapping("/create")
    @Operation(summary = "创建会员")
    @PreAuthorize("@ss.hasPermission('ums:member:create')")
    public CommonResult<Long> createMember(@Valid @RequestBody UmsMemberSaveReqVO createReqVO) {
        return success(memberService.createMember(createReqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "更新会员")
    @PreAuthorize("@ss.hasPermission('ums:member:update')")
    public CommonResult<Boolean> updateMember(@Valid @RequestBody UmsMemberSaveReqVO updateReqVO) {
        memberService.updateMember(updateReqVO);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除会员")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('ums:member:delete')")
    public CommonResult<Boolean> deleteMember(@RequestParam("id") Long id) {
        memberService.deleteMember(id);
        return success(true);
    }
    @GetMapping("/get")
    @Operation(summary = "获得会员")
    @Parameter(name = "id", description = "编号", required = true, example = "1024")
    @PreAuthorize("@ss.hasPermission('ums:member:query')")
    public CommonResult<UmsMemberRespVO> getMember(@RequestParam("id") Long id) {
        UmsMemberDO member = memberService.getMember(id);
        return success(buildMemberDetail(member));
    }
    @GetMapping("/get-by-user-id")
    @Operation(summary = "通过用户ID获得会员")
    @Parameter(name = "userId", description = "用户ID", required = true, example = "100")
    @PreAuthorize("@ss.hasPermission('ums:member:query')")
    public CommonResult<UmsMemberRespVO> getMemberByUserId(@RequestParam("userId") Long userId) {
        UmsMemberDO member = memberService.getMemberByUserId(userId);
        return success(buildMemberDetail(member));
    }
    @GetMapping("/page")
    @Operation(summary = "获得会员/通讯录分页")
    @PreAuthorize("@ss.hasPermission('ums:member:query')")
    public CommonResult<PageResult<UmsMemberRespVO>> getMemberPage(
            @Valid UmsMemberPageReqVO pageReqVO) {
        PageResult<UmsMemberDO> pageResult = memberService.getMemberPage(pageReqVO);
        return success(new PageResult<>(buildMemberDetailList(pageResult.getList()), pageResult.getTotal()));
    }
    // ==================== å†…部方法 ====================
    private UmsMemberRespVO buildMemberDetail(UmsMemberDO member) {
        if (member == null) {
            return null;
        }
        return buildMemberDetailList(Collections.singletonList(member)).get(0);
    }
    private List<UmsMemberRespVO> buildMemberDetailList(List<UmsMemberDO> list) {
        if (CollUtil.isEmpty(list)) {
            return Collections.emptyList();
        }
        // 1.1 èŽ·å–å…³è”ç³»ç»Ÿç”¨æˆ·ä¿¡æ¯
        Set<Long> userIds = convertSet(list, UmsMemberDO::getUserId);
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(userIds);
        // 1.2 èŽ·å–é™„ä»¶
        Map<Long, List<StorageBlobRespDTO>> attachmentMap = list.stream()
                .collect(Collectors.toMap(UmsMemberDO::getId,
                        r -> storageAttachmentApi.listAttachments(MEMBER_RECORD_TYPE, r.getId())));
        // 2. æ‹¼æŽ¥ç»“æžœ
        return BeanUtils.toBean(list, UmsMemberRespVO.class, (vo) -> {
            findAndThen(userMap, vo.getUserId(), user -> vo.setUsername(user.getNickname()));
            List<StorageBlobRespDTO> attachments = attachmentMap.getOrDefault(vo.getId(), Collections.emptyList());
            vo.setAttachmentList(attachments);
            vo.setHasAttachment(CollUtil.isNotEmpty(attachments));
        });
    }
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/member/vo/UmsMemberPageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,41 @@
package cn.iocoder.yudao.module.ums.controller.admin.member.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;
@Schema(description = "管理后台 - ä¼šå‘˜/通讯录分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class UmsMemberPageReqVO extends PageParam {
    @Schema(description = "会员编号", example = "HY20260928000001")
    private String no;
    @Schema(description = "姓名/单位名称", example = "张三")
    private String name;
    @Schema(description = "会员类型", example = "1")
    private Integer memberType;
    @Schema(description = "会员等级", example = "1")
    private String memberGrade;
    @Schema(description = "状态", example = "1")
    private Integer status;
    @Schema(description = "地区编码")
    private String regionCode;
    @Schema(description = "行业")
    private String industry;
    @Schema(description = "入会日期")
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate[] joinDate;
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/member/vo/UmsMemberRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,93 @@
package cn.iocoder.yudao.module.ums.controller.admin.member.vo;
import cn.iocoder.yudao.module.system.api.storage.dto.StorageBlobRespDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - ä¼šå‘˜ Response VO")
@Data
public class UmsMemberRespVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "会员编号", example = "HY20260928000001")
    private String no;
    @Schema(description = "关联系统用户 ID", example = "100")
    private Long userId;
    @Schema(description = "关联用户名", example = "zhangsan")
    private String username;
    @Schema(description = "来源申请单 ID", example = "1")
    private Long applicationId;
    @Schema(description = "会员类型", example = "1")
    private Integer memberType;
    @Schema(description = "会员等级", example = "1")
    private String memberGrade;
    @Schema(description = "姓名(个人)或单位名称(单位)", example = "张三")
    private String name;
    @Schema(description = "联系人(单位用)", example = "王五")
    private String contactUser;
    @Schema(description = "联系电话", example = "13800138000")
    private String contactPhone;
    @Schema(description = "邮箱", example = "zhangsan@example.com")
    private String contactEmail;
    @Schema(description = "法定代表人(单位专有)", example = "李四")
    private String legalPerson;
    @Schema(description = "统一社会信用代码", example = "91440101MA5XXXXXX")
    private String organizationCode;
    @Schema(description = "研究方向", example = "人工智能与智能制造")
    private String researchDirection;
    @Schema(description = "工作单位(个人专有)", example = "XX大学")
    private String workUnit;
    @Schema(description = "职称(个人专有)", example = "教授")
    private String title;
    @Schema(description = "单位简介(单位专有)", example = "专注于智能制造领域")
    private String unitIntro;
    @Schema(description = "地区编码", example = "110000")
    private String regionCode;
    @Schema(description = "行业", example = "1")
    private String industry;
    @Schema(description = "入会日期", example = "2026-01-01")
    private LocalDate joinDate;
    @Schema(description = "有效期至", example = "2028-01-01")
    private LocalDate expireDate;
    @Schema(description = "状态", example = "1")
    private Integer status;
    @Schema(description = "备注", example = "")
    private String remark;
    @Schema(description = "创建时间")
    private LocalDateTime createTime;
    @Schema(description = "附件列表")
    private List<StorageBlobRespDTO> attachmentList;
    /** æ˜¯å¦æœ‰é™„件(通讯录/列表用) */
    private Boolean hasAttachment;
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/member/vo/UmsMemberSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,78 @@
package cn.iocoder.yudao.module.ums.controller.admin.member.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
@Schema(description = "管理后台 - ä¼šå‘˜æ–°å¢ž/修改 Request VO")
@Data
public class UmsMemberSaveReqVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "会员类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "会员类型不能为空")
    private Integer memberType;
    @Schema(description = "会员等级", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotEmpty(message = "会员等级不能为空")
    private String memberGrade;
    @Schema(description = "姓名(个人)或单位名称(单位)", requiredMode = Schema.RequiredMode.REQUIRED, example = "张三")
    @NotEmpty(message = "姓名不能为空")
    private String name;
    @Schema(description = "联系人(单位用)", example = "王五")
    private String contactUser;
    @Schema(description = "联系电话", example = "13800138000")
    private String contactPhone;
    @Schema(description = "邮箱", example = "zhangsan@example.com")
    private String contactEmail;
    @Schema(description = "法定代表人(单位专有)", example = "李四")
    private String legalPerson;
    @Schema(description = "统一社会信用代码", example = "91440101MA5XXXXXX")
    private String organizationCode;
    @Schema(description = "研究方向", example = "人工智能与智能制造")
    private String researchDirection;
    @Schema(description = "工作单位(个人专有)", example = "XX大学")
    private String workUnit;
    @Schema(description = "职称(个人专有)", example = "教授")
    private String title;
    @Schema(description = "单位简介(单位专有)", example = "专注于智能制造领域")
    private String unitIntro;
    @Schema(description = "地区编码", example = "110000")
    private String regionCode;
    @Schema(description = "行业", example = "1")
    private String industry;
    @Schema(description = "入会日期", example = "2026-01-01")
    private LocalDate joinDate;
    @Schema(description = "有效期至", example = "2028-01-01")
    private LocalDate expireDate;
    @Schema(description = "状态", example = "1")
    private Integer status;
    @Schema(description = "备注", example = "")
    private String remark;
    @Schema(description = "附件BlobId列表")
    private List<Long> blobIds;
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/dal/dataobject/application/UmsApplicationDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,77 @@
package cn.iocoder.yudao.module.ums.dal.dataobject.application;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.time.LocalDate;
/**
 * ä¼šå‘˜-入会申请 DO
 *
 * @author è¶…级管理员
 */
@TableName("ums_application")
@KeySequence("ums_application_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UmsApplicationDO extends BaseDO {
    @TableId
    private Long id;
    /** ç”³è¯·ç¼–号 */
    private String no;
    /** ä¼šå‘˜ç±»åž‹ï¼š1-个人 2-单位 */
    private Integer memberType;
    /** ç”³è¯·äººå§“名(个人)或单位名称(单位) */
    private String applicantName;
    /** è”系电话 */
    private String contactPhone;
    /** é‚®ç®± */
    private String contactEmail;
    /** æ³•定代表人(单位专有) */
    private String legalPerson;
    /** ç»Ÿä¸€ç¤¾ä¼šä¿¡ç”¨ä»£ç ï¼ˆå•位专有) */
    private String organizationCode;
    /** ç ”究方向 */
    private String researchDirection;
    /** å·¥ä½œå•位(个人专有) */
    private String workUnit;
    /** èŒç§°ï¼ˆä¸ªäººä¸“有) */
    private String title;
    /** ç”³è¯·ç­‰çº§ï¼ˆæŒ‰ member_type å–不同字典) */
    private String memberGrade;
    /** åœ°åŒºç¼–码(Area æ ‘末级) */
    private String regionCode;
    /** è¡Œä¸šï¼ˆå­—å…¸ ums_industry) */
    private String industry;
    /** çŠ¶æ€ï¼š0-草稿 15-审批中 20-已通过 35-已驳回 */
    private Integer status;
    /** BPM æµç¨‹å®žä¾‹ ID */
    private String processInstanceId;
    /** å¤‡æ³¨ */
    private String remark;
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/dal/dataobject/member/UmsMemberDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,92 @@
package cn.iocoder.yudao.module.ums.dal.dataobject.member;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.time.LocalDate;
/**
 * ä¼šå‘˜ DO
 *
 * @author è¶…级管理员
 */
@TableName("ums_member")
@KeySequence("ums_member_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UmsMemberDO extends BaseDO {
    @TableId
    private Long id;
    /** ä¼šå‘˜ç¼–号 */
    private String no;
    /** å…³è”系统用户 ID */
    private Long userId;
    /** æ¥æºç”³è¯·å• ID */
    private Long applicationId;
    /** ä¼šå‘˜ç±»åž‹ï¼š1-个人 2-单位 */
    private Integer memberType;
    /** ä¼šå‘˜ç­‰çº§ï¼ˆå­—å…¸ ums_personal_grade / ums_unit_grade) */
    private String memberGrade;
    /** å§“名(个人)或单位名称(单位) */
    private String name;
    /** è”系人(单位用) */
    private String contactUser;
    /** è”系电话 */
    private String contactPhone;
    /** é‚®ç®± */
    private String contactEmail;
    /** æ³•定代表人(单位专有) */
    private String legalPerson;
    /** ç»Ÿä¸€ç¤¾ä¼šä¿¡ç”¨ä»£ç  */
    private String organizationCode;
    /** ç ”究方向 */
    private String researchDirection;
    /** å·¥ä½œå•位(个人专有) */
    private String workUnit;
    /** èŒç§°ï¼ˆä¸ªäººä¸“有) */
    private String title;
    /** å•位简介(单位专有) */
    private String unitIntro;
    /** åœ°åŒºç¼–码 */
    private String regionCode;
    /** è¡Œä¸š */
    private String industry;
    /** å…¥ä¼šæ—¥æœŸ */
    private LocalDate joinDate;
    /** æœ‰æ•ˆæœŸè‡³ */
    private LocalDate expireDate;
    /** çŠ¶æ€ï¼š1-正常 0-暂停 */
    private Integer status;
    /** å¤‡æ³¨ */
    private String remark;
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/dal/mysql/application/UmsApplicationMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,25 @@
package cn.iocoder.yudao.module.ums.dal.mysql.application;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.ums.controller.admin.application.vo.UmsApplicationPageReqVO;
import cn.iocoder.yudao.module.ums.dal.dataobject.application.UmsApplicationDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface UmsApplicationMapper extends BaseMapperX<UmsApplicationDO> {
    default UmsApplicationDO selectByNo(String no) {
        return selectOne(UmsApplicationDO::getNo, no);
    }
    default PageResult<UmsApplicationDO> selectPage(UmsApplicationPageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<UmsApplicationDO>()
                .likeIfPresent(UmsApplicationDO::getNo, reqVO.getNo())
                .eqIfPresent(UmsApplicationDO::getStatus, reqVO.getStatus())
                .eqIfPresent(UmsApplicationDO::getMemberType, reqVO.getMemberType())
                .orderByDesc(UmsApplicationDO::getId));
    }
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/dal/mysql/member/UmsMemberMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,34 @@
package cn.iocoder.yudao.module.ums.dal.mysql.member;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.ums.controller.admin.member.vo.UmsMemberPageReqVO;
import cn.iocoder.yudao.module.ums.dal.dataobject.member.UmsMemberDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface UmsMemberMapper extends BaseMapperX<UmsMemberDO> {
    default UmsMemberDO selectByNo(String no) {
        return selectOne(UmsMemberDO::getNo, no);
    }
    default UmsMemberDO selectByUserId(Long userId) {
        return selectOne(UmsMemberDO::getUserId, userId);
    }
    default PageResult<UmsMemberDO> selectPage(UmsMemberPageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<UmsMemberDO>()
                .likeIfPresent(UmsMemberDO::getNo, reqVO.getNo())
                .likeIfPresent(UmsMemberDO::getName, reqVO.getName())
                .eqIfPresent(UmsMemberDO::getMemberType, reqVO.getMemberType())
                .eqIfPresent(UmsMemberDO::getMemberGrade, reqVO.getMemberGrade())
                .eqIfPresent(UmsMemberDO::getStatus, reqVO.getStatus())
                .eqIfPresent(UmsMemberDO::getRegionCode, reqVO.getRegionCode())
                .eqIfPresent(UmsMemberDO::getIndustry, reqVO.getIndustry())
                .betweenIfPresent(UmsMemberDO::getJoinDate, reqVO.getJoinDate())
                .orderByDesc(UmsMemberDO::getId));
    }
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/dal/redis/RedisKeyConstants.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
package cn.iocoder.yudao.module.ums.dal.redis;
/**
 * UMS Redis Key å¸¸é‡
 *
 * @author è¶…级管理员
 */
public interface RedisKeyConstants {
    String NO = "ums:seq_no:";
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/dal/redis/UmsNoRedisDAO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,41 @@
package cn.iocoder.yudao.module.ums.dal.redis;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.DateUtil;
import jakarta.annotation.Resource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;
import java.time.Duration;
import java.time.LocalDateTime;
/**
 * UMS ç¼–号 Redis DAO
 *
 * @author è¶…级管理员
 */
@Repository
public class UmsNoRedisDAO {
    /**
     * å…¥ä¼šç”³è¯· {@link cn.iocoder.yudao.module.ums.dal.dataobject.application.UmsApplicationDO}
     */
    public static final String APPLICATION_NO_PREFIX = "SQ";
    /**
     * ä¼šå‘˜ {@link cn.iocoder.yudao.module.ums.dal.dataobject.member.UmsMemberDO}
     */
    public static final String MEMBER_NO_PREFIX = "HY";
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    public String generate(String prefix) {
        String noPrefix = prefix + DateUtil.format(LocalDateTime.now(), DatePattern.PURE_DATE_PATTERN);
        String key = RedisKeyConstants.NO + noPrefix;
        Long no = stringRedisTemplate.opsForValue().increment(key);
        stringRedisTemplate.expire(key, Duration.ofDays(1L));
        return noPrefix + String.format("%06d", no);
    }
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/enums/DictTypeConstants.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,17 @@
package cn.iocoder.yudao.module.ums.enums;
/**
 * UMS å­—典类型常量
 *
 * @author è¶…级管理员
 */
public interface DictTypeConstants {
    String APPLICATION_STATUS = "ums_application_status";
    String MEMBER_TYPE = "ums_member_type";
    String MEMBER_STATUS = "ums_member_status";
    String PERSONAL_GRADE = "ums_personal_grade";
    String UNIT_GRADE = "ums_unit_grade";
    String INDUSTRY = "ums_industry";
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/enums/ErrorCodeConstants.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,27 @@
package cn.iocoder.yudao.module.ums.enums;
import cn.iocoder.yudao.framework.common.exception.ErrorCode;
/**
 * UMS é”™è¯¯ç æžšä¸¾ç±»
 * <p>
 * ums ç³»ç»Ÿï¼Œä½¿ç”¨ 1-030-108-000 æ®µï¼ˆæŽ¥åœ¨ finance 1-030-107 ä¹‹åŽï¼‰
 */
public interface ErrorCodeConstants {
    // ========== å…¥ä¼šç”³è¯·ï¼ˆ1-030-108-000 ~ 108-009) ==========
    ErrorCode APPLICATION_NOT_EXISTS = new ErrorCode(1_030_108_000, "入会申请不存在");
    ErrorCode APPLICATION_NO_EXISTS = new ErrorCode(1_030_108_001, "生成申请编号失败,请重新提交");
    ErrorCode APPLICATION_UPDATE_FAIL_NOT_DRAFT = new ErrorCode(1_030_108_002, "入会申请({})不是草稿或已驳回状态,无法修改");
    ErrorCode APPLICATION_DELETE_FAIL_NOT_DRAFT = new ErrorCode(1_030_108_003, "入会申请({})不是草稿状态,无法删除");
    ErrorCode APPLICATION_SUBMIT_FAIL_STATUS = new ErrorCode(1_030_108_004, "提交审批失败,只有草稿或已驳回状态的入会申请才能提交");
    ErrorCode APPLICATION_BPM_CATEGORY_NOT_EXISTS = new ErrorCode(1_030_108_005, "入会审批分类不存在,请先在BPM模块创建入会审批流程分类");
    // ========== ä¼šå‘˜ï¼ˆ1-030-108-100 ~ 108-109) ==========
    ErrorCode MEMBER_NOT_EXISTS = new ErrorCode(1_030_108_100, "会员不存在");
    ErrorCode MEMBER_NO_EXISTS = new ErrorCode(1_030_108_101, "生成会员编号失败,请重新提交");
    ErrorCode MEMBER_USER_CREATE_FAIL = new ErrorCode(1_030_108_102, "创建会员系统账号失败,请联系管理员");
    ErrorCode MEMBER_USER_MOBILE_EXISTS = new ErrorCode(1_030_108_103, "该手机号已被其他会员账号使用");
    ErrorCode MEMBER_GRADE_NOT_MATCH_TYPE = new ErrorCode(1_030_108_104, "会员等级与会员类型不匹配:个人会员不能选择单位等级,单位会员不能选择个人等级");
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/enums/UmsApplicationStatusEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
package cn.iocoder.yudao.module.ums.enums;
import cn.hutool.core.util.ArrayUtil;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
 * å…¥ä¼šç”³è¯·çŠ¶æ€æžšä¸¾
 *
 * @author è¶…级管理员
 */
@Getter
@AllArgsConstructor
public enum UmsApplicationStatusEnum {
    DRAFT(0, "草稿"),
    APPROVING(15, "审批中"),
    APPROVED(20, "已通过"),
    REJECTED(35, "已驳回"),
    ;
    private final Integer status;
    private final String name;
    public static UmsApplicationStatusEnum valueOf(Integer status) {
        return ArrayUtil.firstMatch(e -> e.getStatus().equals(status), values());
    }
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/framework/web/config/UmsWebConfiguration.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,16 @@
package cn.iocoder.yudao.module.ums.framework.web.config;
import cn.iocoder.yudao.framework.swagger.config.YudaoSwaggerAutoConfiguration;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class UmsWebConfiguration {
    @Bean
    public GroupedOpenApi umsGroupedOpenApi() {
        return YudaoSwaggerAutoConfiguration.buildGroupedOpenApi("ums");
    }
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/service/application/UmsApplicationService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,32 @@
package cn.iocoder.yudao.module.ums.service.application;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.ums.controller.admin.application.vo.UmsApplicationPageReqVO;
import cn.iocoder.yudao.module.ums.controller.admin.application.vo.UmsApplicationSaveReqVO;
import cn.iocoder.yudao.module.ums.dal.dataobject.application.UmsApplicationDO;
import jakarta.validation.Valid;
import java.util.List;
import java.util.Map;
public interface UmsApplicationService {
    Long createApplication(@Valid UmsApplicationSaveReqVO createReqVO);
    void updateApplication(@Valid UmsApplicationSaveReqVO updateReqVO);
    void deleteApplication(Long id);
    UmsApplicationDO getApplication(Long id);
    PageResult<UmsApplicationDO> getApplicationPage(UmsApplicationPageReqVO pageReqVO);
    void submitApplication(Long id, String processDefinitionKey);
    List<Map<String, Object>> getApproveProcessDefinitionList();
    void updateApplicationAuditStatus(Long id, Integer bpmResult);
    void createMemberFromApplicationOnApproved(Long id);
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/service/application/UmsApplicationServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,233 @@
package cn.iocoder.yudao.module.ums.service.application;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
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.service.definition.BpmCategoryService;
import cn.iocoder.yudao.module.bpm.service.definition.BpmProcessDefinitionService;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import cn.iocoder.yudao.module.ums.controller.admin.application.vo.UmsApplicationPageReqVO;
import cn.iocoder.yudao.module.ums.controller.admin.application.vo.UmsApplicationSaveReqVO;
import cn.iocoder.yudao.module.ums.dal.dataobject.application.UmsApplicationDO;
import cn.iocoder.yudao.module.ums.dal.mysql.application.UmsApplicationMapper;
import cn.iocoder.yudao.module.ums.dal.redis.UmsNoRedisDAO;
import cn.iocoder.yudao.module.ums.enums.UmsApplicationStatusEnum;
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.util.*;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
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.ums.enums.ErrorCodeConstants.*;
@Service
@Validated
@Slf4j
public class UmsApplicationServiceImpl implements UmsApplicationService {
    private static final String APPLICATION_APPROVE_CATEGORY_CODE = "ums_membership_approve";
    public static final String APPLICATION_RECORD_TYPE = "ums_application";
    @Resource
    private UmsApplicationMapper applicationMapper;
    @Resource
    private UmsNoRedisDAO noRedisDAO;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @Resource
    private BpmProcessInstanceApi processInstanceApi;
    @Resource
    private BpmCategoryService bpmCategoryService;
    @Resource
    private BpmProcessDefinitionService bpmProcessDefinitionService;
    @Resource
    private UmsMemberService memberService;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createApplication(UmsApplicationSaveReqVO createReqVO) {
        String no = noRedisDAO.generate(UmsNoRedisDAO.APPLICATION_NO_PREFIX);
        if (applicationMapper.selectByNo(no) != null) {
            throw exception(APPLICATION_NO_EXISTS);
        }
        UmsApplicationDO application = BeanUtils.toBean(createReqVO, UmsApplicationDO.class, in -> in
                .setNo(no)
                .setStatus(UmsApplicationStatusEnum.DRAFT.getStatus()));
        applicationMapper.insert(application);
        storageAttachmentApi.bindAttachments("file", APPLICATION_RECORD_TYPE,
                application.getId(), createReqVO.getBlobIds());
        return application.getId();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateApplication(UmsApplicationSaveReqVO updateReqVO) {
        UmsApplicationDO application = validateApplicationExists(updateReqVO.getId());
        Integer status = application.getStatus();
        if (!UmsApplicationStatusEnum.DRAFT.getStatus().equals(status)
                && !UmsApplicationStatusEnum.REJECTED.getStatus().equals(status)) {
            throw exception(APPLICATION_UPDATE_FAIL_NOT_DRAFT, application.getNo());
        }
        UmsApplicationDO updateObj = BeanUtils.toBean(updateReqVO, UmsApplicationDO.class);
        applicationMapper.updateById(updateObj);
        storageAttachmentApi.updateAttachments("file", APPLICATION_RECORD_TYPE,
                updateReqVO.getId(), updateReqVO.getBlobIds());
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteApplication(Long id) {
        UmsApplicationDO application = validateApplicationExists(id);
        if (!UmsApplicationStatusEnum.DRAFT.getStatus().equals(application.getStatus())) {
            throw exception(APPLICATION_DELETE_FAIL_NOT_DRAFT, application.getNo());
        }
        applicationMapper.deleteById(id);
        storageAttachmentApi.deleteAttachmentsByRecord(APPLICATION_RECORD_TYPE, id);
    }
    @Override
    public UmsApplicationDO getApplication(Long id) {
        return applicationMapper.selectById(id);
    }
    @Override
    public PageResult<UmsApplicationDO> getApplicationPage(UmsApplicationPageReqVO pageReqVO) {
        return applicationMapper.selectPage(pageReqVO);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void submitApplication(Long id, String processDefinitionKey) {
        UmsApplicationDO application = validateApplicationExists(id);
        Integer status = application.getStatus();
        if (!UmsApplicationStatusEnum.DRAFT.getStatus().equals(status)
                && !UmsApplicationStatusEnum.REJECTED.getStatus().equals(status)) {
            throw exception(APPLICATION_SUBMIT_FAIL_STATUS);
        }
        String processInstanceId = processInstanceApi.createProcessInstance(getLoginUserId(),
                new BpmProcessInstanceCreateReqDTO()
                        .setProcessDefinitionKey(processDefinitionKey)
                        .setBusinessKey(String.valueOf(id)));
        applicationMapper.updateById(new UmsApplicationDO()
                .setId(id)
                .setStatus(UmsApplicationStatusEnum.APPROVING.getStatus())
                .setProcessInstanceId(processInstanceId));
    }
    @Override
    public List<Map<String, Object>> getApproveProcessDefinitionList() {
        validateApplicationApproveCategory();
        List<cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessDefinitionInfoDO> definitionInfoList =
                bpmProcessDefinitionService.getProcessDefinitionInfoListByCategory(APPLICATION_APPROVE_CATEGORY_CODE);
        if (CollUtil.isEmpty(definitionInfoList)) {
            return Collections.emptyList();
        }
        Map<String, ProcessDefinition> latestVersionMap = new HashMap<>();
        for (cn.iocoder.yudao.module.bpm.dal.dataobject.definition.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<Map<String, Object>> result = new ArrayList<>();
        for (ProcessDefinition pd : latestVersionMap.values()) {
            Map<String, Object> 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) {
        UmsApplicationDO application = validateApplicationExists(id);
        if (!UmsApplicationStatusEnum.APPROVING.getStatus().equals(application.getStatus())) {
            log.warn("[updateApplicationAuditStatus] å…¥ä¼šç”³è¯·({}) ä¸å¤„于审批中状态", id);
            return;
        }
        Integer newStatus = convertBpmResultToStatus(bpmResult);
        if (newStatus != null) {
            applicationMapper.updateById(new UmsApplicationDO()
                    .setId(id).setStatus(newStatus));
        }
    }
    // =================== å†…部方法 ===================
    private UmsApplicationDO validateApplicationExists(Long id) {
        UmsApplicationDO application = applicationMapper.selectById(id);
        if (application == null) {
            throw exception(APPLICATION_NOT_EXISTS);
        }
        return application;
    }
    private void validateApplicationApproveCategory() {
        List<BpmCategoryDO> 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 UmsApplicationStatusEnum.APPROVED.getStatus();
        } else if (cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum.REJECT.getStatus().equals(bpmResult)) {
            return UmsApplicationStatusEnum.REJECTED.getStatus();
        } else if (cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum.CANCEL.getStatus().equals(bpmResult)) {
            return UmsApplicationStatusEnum.DRAFT.getStatus();
        }
        return null;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void createMemberFromApplicationOnApproved(Long id) {
        UmsApplicationDO application = validateApplicationExists(id);
        if (!UmsApplicationStatusEnum.APPROVED.getStatus().equals(application.getStatus())) {
            log.warn("[createMemberFromApplicationOnApproved] å…¥ä¼šç”³è¯·({}) ä¸æ˜¯å·²é€šè¿‡çŠ¶æ€ï¼Œè·³è¿‡å»ºä¼šå‘˜", id);
            return;
        }
        memberService.createMemberFromApplication(application);
    }
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/service/application/listener/UmsApplicationStatusListener.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,61 @@
package cn.iocoder.yudao.module.ums.service.application.listener;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener;
import cn.iocoder.yudao.module.ums.service.application.UmsApplicationService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
 * å…¥ä¼šç”³è¯·å®¡æ‰¹çŠ¶æ€ç›‘å¬å™¨
 *
 * ç›‘听 BPM æµç¨‹å®žä¾‹çŠ¶æ€å˜æ›´äº‹ä»¶ï¼Œæ›´æ–°å…¥ä¼šç”³è¯·çš„å®¡æ‰¹çŠ¶æ€ï¼Œå¹¶åœ¨å®¡æ‰¹é€šè¿‡åŽè‡ªåŠ¨åˆ›å»ºä¼šå‘˜
 */
@Component
@Slf4j
public class UmsApplicationStatusListener extends BpmProcessInstanceStatusEventListener {
    private static final String PROCESS_DEFINITION_KEY = "ums_membership_approve";
    @Resource
    private UmsApplicationService applicationService;
    @Override
    protected String getProcessDefinitionKey() {
        return PROCESS_DEFINITION_KEY;
    }
    @Override
    protected void onEvent(BpmProcessInstanceStatusEvent event) {
        Long applicationId;
        try {
            applicationId = Long.parseLong(event.getBusinessKey());
        } catch (NumberFormatException e) {
            log.warn("[onEvent] businessKey({}) ä¸æ˜¯æœ‰æ•ˆçš„入会申请编号", event.getBusinessKey());
            return;
        }
        log.info("[onEvent] æ”¶åˆ°BPM审批事件,applicationId={}, status={}", applicationId, event.getStatus());
        // 1. æ›´æ–°ç”³è¯·å•审批状态
        try {
            applicationService.updateApplicationAuditStatus(applicationId, event.getStatus());
            log.info("[onEvent] å…¥ä¼šç”³è¯·({}) å®¡æ‰¹çŠ¶æ€æ›´æ–°å®Œæˆ", applicationId);
        } catch (Exception e) {
            log.error("[onEvent] æ›´æ–°å…¥ä¼šç”³è¯·å®¡æ‰¹çŠ¶æ€å¤±è´¥ï¼ŒapplicationId={}", applicationId, e);
            return;
        }
        // 2. å®¡æ‰¹é€šè¿‡åŽè‡ªåŠ¨åˆ›å»ºä¼šå‘˜
        if (cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum.APPROVE.getStatus().equals(event.getStatus())) {
            try {
                applicationService.createMemberFromApplicationOnApproved(applicationId);
                log.info("[onEvent] å…¥ä¼šç”³è¯·({}) å®¡æ‰¹é€šè¿‡ï¼Œä¼šå‘˜åˆ›å»ºå®Œæˆ", applicationId);
            } catch (Exception e) {
                log.error("[onEvent] å…¥ä¼šç”³è¯·({}) å®¡æ‰¹é€šè¿‡åŽåˆ›å»ºä¼šå‘˜å¤±è´¥", applicationId, e);
            }
        }
    }
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/service/member/UmsMemberService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
package cn.iocoder.yudao.module.ums.service.member;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.ums.controller.admin.member.vo.UmsMemberPageReqVO;
import cn.iocoder.yudao.module.ums.controller.admin.member.vo.UmsMemberSaveReqVO;
import cn.iocoder.yudao.module.ums.dal.dataobject.application.UmsApplicationDO;
import cn.iocoder.yudao.module.ums.dal.dataobject.member.UmsMemberDO;
import jakarta.validation.Valid;
public interface UmsMemberService {
    /**
     * ä»Žå…¥ä¼šç”³è¯·å•创建会员(审批通过钩子)
     */
    UmsMemberDO createMemberFromApplication(UmsApplicationDO application);
    Long createMember(@Valid UmsMemberSaveReqVO createReqVO);
    void updateMember(@Valid UmsMemberSaveReqVO updateReqVO);
    void deleteMember(Long id);
    UmsMemberDO getMember(Long id);
    UmsMemberDO getMemberByUserId(Long userId);
    PageResult<UmsMemberDO> getMemberPage(UmsMemberPageReqVO pageReqVO);
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/service/member/UmsMemberServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,196 @@
package cn.iocoder.yudao.module.ums.service.member;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserCreateReqDTO;
import cn.iocoder.yudao.module.ums.controller.admin.member.vo.UmsMemberPageReqVO;
import cn.iocoder.yudao.module.ums.controller.admin.member.vo.UmsMemberSaveReqVO;
import cn.iocoder.yudao.module.ums.dal.dataobject.application.UmsApplicationDO;
import cn.iocoder.yudao.module.ums.dal.dataobject.member.UmsMemberDO;
import cn.iocoder.yudao.module.ums.dal.mysql.member.UmsMemberMapper;
import cn.iocoder.yudao.module.ums.dal.redis.UmsNoRedisDAO;
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.time.LocalDate;
import java.util.Collections;
import java.util.Set;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.ums.enums.ErrorCodeConstants.*;
@Service
@Validated
@Slf4j
public class UmsMemberServiceImpl implements UmsMemberService {
    /**
     * ä¼šå‘˜ç³»ç»Ÿè´¦å·é»˜è®¤éƒ¨é—¨ ID(system_dept ä¸­ã€Œå­¦ä¼šä¼šå‘˜ã€ï¼‰
     */
    private static final Long MEMBER_DEPT_ID = 129L;
    public static final String MEMBER_RECORD_TYPE = "ums_member";
    @Resource
    private UmsMemberMapper memberMapper;
    @Resource
    private UmsNoRedisDAO noRedisDAO;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public UmsMemberDO createMemberFromApplication(UmsApplicationDO application) {
        // 1. ç”Ÿæˆä¼šå‘˜ç¼–号
        String no = noRedisDAO.generate(UmsNoRedisDAO.MEMBER_NO_PREFIX);
        if (memberMapper.selectByNo(no) != null) {
            throw exception(MEMBER_NO_EXISTS);
        }
        // 2. åˆ›å»ºç³»ç»Ÿç”¨æˆ·è´¦å·
        Long userId = createSystemUser(application);
        // 3. åˆ›å»ºä¼šå‘˜è®°å½•
        UmsMemberDO member = UmsMemberDO.builder()
                .no(no)
                .userId(userId)
                .applicationId(application.getId())
                .memberType(application.getMemberType())
                .memberGrade(application.getMemberGrade())
                .name(application.getApplicantName())
                .contactPhone(application.getContactPhone())
                .contactEmail(application.getContactEmail())
                .legalPerson(application.getLegalPerson())
                .organizationCode(application.getOrganizationCode())
                .researchDirection(application.getResearchDirection())
                .workUnit(application.getWorkUnit())
                .title(application.getTitle())
                .regionCode(application.getRegionCode())
                .industry(application.getIndustry())
                .joinDate(LocalDate.now())
                .status(1) // æ­£å¸¸
                .build();
        memberMapper.insert(member);
        return member;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createMember(UmsMemberSaveReqVO createReqVO) {
        String no = noRedisDAO.generate(UmsNoRedisDAO.MEMBER_NO_PREFIX);
        if (memberMapper.selectByNo(no) != null) {
            throw exception(MEMBER_NO_EXISTS);
        }
        // ä¼šå‘˜ç­‰çº§ä¸Žç±»åž‹åŒ¹é…æ ¡éªŒ
        validateMemberGrade(createReqVO.getMemberType(), createReqVO.getMemberGrade());
        UmsMemberDO member = BeanUtils.toBean(createReqVO, UmsMemberDO.class, in -> in
                .setNo(no)
                .setStatus(createReqVO.getStatus() != null ? createReqVO.getStatus() : 1)
                .setJoinDate(createReqVO.getJoinDate() != null ? createReqVO.getJoinDate() : LocalDate.now()));
        memberMapper.insert(member);
        storageAttachmentApi.bindAttachments("file", MEMBER_RECORD_TYPE,
                member.getId(), createReqVO.getBlobIds());
        return member.getId();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateMember(UmsMemberSaveReqVO updateReqVO) {
        validateMemberExists(updateReqVO.getId());
        validateMemberGrade(updateReqVO.getMemberType(), updateReqVO.getMemberGrade());
        UmsMemberDO updateObj = BeanUtils.toBean(updateReqVO, UmsMemberDO.class);
        memberMapper.updateById(updateObj);
        storageAttachmentApi.updateAttachments("file", MEMBER_RECORD_TYPE,
                updateReqVO.getId(), updateReqVO.getBlobIds());
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteMember(Long id) {
        validateMemberExists(id);
        memberMapper.deleteById(id);
        storageAttachmentApi.deleteAttachmentsByRecord(MEMBER_RECORD_TYPE, id);
    }
    @Override
    public UmsMemberDO getMember(Long id) {
        return memberMapper.selectById(id);
    }
    @Override
    public UmsMemberDO getMemberByUserId(Long userId) {
        return memberMapper.selectByUserId(userId);
    }
    @Override
    public PageResult<UmsMemberDO> getMemberPage(UmsMemberPageReqVO pageReqVO) {
        return memberMapper.selectPage(pageReqVO);
    }
    // =================== å†…部方法 ===================
    private Long createSystemUser(UmsApplicationDO application) {
        // æ‰‹æœºå·å¯ä½œä¸ºç”¨æˆ·å/密码(参考 HRM å‘˜å·¥æ¨¡å¼ï¼‰
        String mobile = StrUtil.blankToDefault(application.getContactPhone(), "unknown");
        String username = mobile;
        String nickname = application.getApplicantName();
        try {
            return adminUserApi.createUser(new AdminUserCreateReqDTO()
                    .setUsername(username)
                    .setNickname(nickname)
                    .setDeptId(MEMBER_DEPT_ID)
                    .setPostIds(Collections.emptySet())
                    .setMobile(mobile)
                    .setPassword(mobile) // é»˜è®¤å¯†ç ä¸ºæ‰‹æœºå·
                    .setEmail(application.getContactEmail())
                    .setRemark("由入会申请自动创建:" + application.getNo()));
        } catch (Exception e) {
            log.error("[createSystemUser] åˆ›å»ºä¼šå‘˜ç³»ç»Ÿè´¦å·å¤±è´¥ï¼Œç”³è¯·å•={}", application.getId(), e);
            // å¦‚果手机号重复,给出明确提示
            if (e.getMessage() != null && e.getMessage().contains("手机号")) {
                throw exception(MEMBER_USER_MOBILE_EXISTS);
            }
            throw exception(MEMBER_USER_CREATE_FAIL);
        }
    }
    /**
     * æ ¡éªŒä¼šå‘˜ç­‰çº§ä¸Žç±»åž‹åŒ¹é…ï¼šå•位会员(type=2)不可选个人专有等级「5-理事长」
     */
    private void validateMemberGrade(Integer memberType, String memberGrade) {
        if (memberType == null || StrUtil.isBlank(memberGrade)) {
            return;
        }
        if (memberType == 2 && "5".equals(memberGrade)) {
            throw exception(MEMBER_GRADE_NOT_MATCH_TYPE);
        }
    }
    private UmsMemberDO validateMemberExists(Long id) {
        UmsMemberDO member = memberMapper.selectById(id);
        if (member == null) {
            throw exception(MEMBER_NOT_EXISTS);
        }
        return member;
    }
}
yudao-server/pom.xml
@@ -171,6 +171,27 @@
            <version>${revision}</version>
        </dependency>
        <!-- è´¢åŠ¡ç®¡ç†æ¨¡å— -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-finance</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- ä¼šå‘˜ç®¡ç†æ¨¡å— -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-ums</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- è¯¾é¢˜ç®¡ç†æ¨¡å— -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-research</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- spring boot é…ç½®æ‰€éœ€ä¾èµ– -->
        <dependency>
            <groupId>org.springframework.boot</groupId>