liu
9 小时以前 5d34bdd3c426bb2ca5f6627424ae67984f745603
feat: 新增资产/用车/采购模块,完善课题、会员、财务与企微功能

- 财务报销:发票 OCR、发票类型、申请人只读与代发起
- 课题管理:联系电话必填、成员子表与成员维度统计导出
- 会员管理:会费标识、资料自主维护与审核、会员角色隔离
- 资产管理:资产台账、领用/归还与库存联动
- 用车管理:用车申请与 BPM 审批
- 采购管理:采购单与商品明细、金额自动汇总
- 企业微信:通知多模板、活动 5 种模板与采集评价
- 修复附件预览域名与中文文件名编码、Spring Bean 名称冲突

Co-Authored-By: Claude <noreply@anthropic.com>
已修改52个文件
已添加83个文件
6260 ■■■■■ 文件已修改
docs/project-business/data/flows.json 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/modules.json 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/objects.json 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/roles.json 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/12-finance-flow.mmd 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/13-ums-membership-flow.mmd 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/14-research-flow.mmd 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/wxcp_module.sql 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/wxcp_activity_integration.md 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pom.xml 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-asset/pom.xml 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/controller/admin/item/AssetItemController.java 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/controller/admin/item/vo/AssetItemPageReqVO.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/controller/admin/item/vo/AssetItemRespVO.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/controller/admin/item/vo/AssetItemSaveReqVO.java 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/controller/admin/requisition/AssetRequisitionController.java 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/controller/admin/requisition/vo/AssetRequisitionPageReqVO.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/controller/admin/requisition/vo/AssetRequisitionRespVO.java 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/controller/admin/requisition/vo/AssetRequisitionReturnReqVO.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/controller/admin/requisition/vo/AssetRequisitionSaveReqVO.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/dal/dataobject/item/AssetItemDO.java 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/dal/dataobject/requisition/AssetRequisitionDO.java 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/dal/mysql/item/AssetItemMapper.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/dal/mysql/requisition/AssetRequisitionMapper.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/dal/redis/AssetNoRedisDAO.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/dal/redis/RedisKeyConstants.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/enums/AssetCategoryEnum.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/enums/AssetReturnStatusEnum.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/enums/ErrorCodeConstants.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/service/item/AssetItemService.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/service/item/AssetItemServiceImpl.java 154 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/service/requisition/AssetRequisitionService.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/service/requisition/AssetRequisitionServiceImpl.java 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/pom.xml 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/api/reimbursement/FinanceReimbursementApiImpl.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/ai/FinanceReimbursementAiController.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/ai/vo/FinanceReimbursementOcrReqVO.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/ai/vo/FinanceReimbursementOcrRespVO.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/vo/FinanceReimbursementSaveReqVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/vo/item/FinanceReimbursementItemRespVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/vo/item/FinanceReimbursementItemSaveReqVO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/dal/dataobject/reimbursement/FinanceReimbursementItemDO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/enums/ErrorCodeConstants.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/service/reimbursement/FinanceReimbursementServiceImpl.java 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/service/reimbursement/ai/FinanceReimbursementAiService.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/service/reimbursement/ai/FinanceReimbursementAiServiceImpl.java 297 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-purchase/pom.xml 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/controller/admin/order/PurchaseOrderController.java 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/controller/admin/order/vo/PurchaseOrderItemRespVO.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/controller/admin/order/vo/PurchaseOrderItemSaveReqVO.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/controller/admin/order/vo/PurchaseOrderPageReqVO.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/controller/admin/order/vo/PurchaseOrderRespVO.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/controller/admin/order/vo/PurchaseOrderSaveReqVO.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/dal/dataobject/order/PurchaseOrderDO.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/dal/dataobject/order/PurchaseOrderItemDO.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/dal/mysql/order/PurchaseOrderLineMapper.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/dal/mysql/order/PurchaseOrderMainMapper.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/dal/redis/PurchaseNoRedisDAO.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/dal/redis/RedisKeyConstants.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/enums/ErrorCodeConstants.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/service/order/PurchaseOrderService.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/service/order/PurchaseOrderServiceImpl.java 152 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/application/ResearchApplicationController.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/application/vo/ResearchApplicationRespVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/application/vo/ResearchApplicationSaveReqVO.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/application/vo/member/ResearchApplicationMemberRespVO.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/application/vo/member/ResearchApplicationMemberSaveReqVO.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/application/vo/member/ResearchApplicationMemberStatisticsReqVO.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/application/vo/member/ResearchApplicationMemberStatisticsRespVO.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/dataobject/application/ResearchApplicationMemberDO.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/mysql/application/ResearchApplicationMemberMapper.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/application/ResearchApplicationService.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/application/ResearchApplicationServiceImpl.java 125 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/util/storage/StorageFileUtil.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/member/UmsMemberProfileChangeController.java 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/member/vo/UmsMemberRespVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/member/vo/UmsMemberSaveReqVO.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/member/vo/profile/UmsMemberProfileChangePageReqVO.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/member/vo/profile/UmsMemberProfileChangeRespVO.java 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/member/vo/profile/UmsMemberProfileChangeSaveReqVO.java 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/dal/dataobject/member/UmsMemberDO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/dal/dataobject/member/UmsMemberProfileChangeDO.java 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/dal/mysql/member/UmsMemberProfileChangeMapper.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/enums/ErrorCodeConstants.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/enums/UmsMemberProfileChangeStatusEnum.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/service/member/UmsMemberProfileChangeService.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/service/member/UmsMemberProfileChangeServiceImpl.java 167 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/service/member/UmsMemberServiceImpl.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-vehicle/pom.xml 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/controller/admin/application/VehicleApplicationController.java 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/controller/admin/application/vo/VehicleApplicationPageReqVO.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/controller/admin/application/vo/VehicleApplicationRespVO.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/controller/admin/application/vo/VehicleApplicationSaveReqVO.java 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/dal/dataobject/application/VehicleApplicationDO.java 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/dal/mysql/application/VehicleApplicationMapper.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/dal/redis/RedisKeyConstants.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/dal/redis/VehicleNoRedisDAO.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/enums/ErrorCodeConstants.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/enums/VehicleApplicationStatusEnum.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/service/application/VehicleApplicationService.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/service/application/VehicleApplicationServiceImpl.java 218 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/service/application/listener/VehicleApplicationStatusListener.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/WxCpActivityController.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/WxCpActivityPublicController.java 258 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpActivityFieldRespVO.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpActivityFieldSaveReqVO.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpActivityRespVO.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpActivitySaveReqVO.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpSignupPublicReqVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpSignupRespVO.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpSignupValueReqVO.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpSignupValueRespVO.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/WxCpNoticeController.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/vo/WxCpNoticeRespVO.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/vo/WxCpNoticeSaveReqVO.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/vo/WxCpPushReqVO.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/dataobject/activity/WxCpActivityDO.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/dataobject/activity/WxCpActivityFieldDO.java 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/dataobject/activity/WxCpActivitySignupDO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/dataobject/activity/WxCpActivitySignupValueDO.java 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/dataobject/notice/WxCpNoticeDO.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/mysql/activity/WxCpActivityFieldMapper.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/mysql/activity/WxCpActivitySignupValueMapper.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/enums/ErrorCodeConstants.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/enums/activity/WxCpActivityTypeEnum.java 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/enums/notice/WxCpNoticeTypeEnum.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/activity/WxCpActivityService.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/activity/WxCpActivityServiceImpl.java 433 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/member/WxCpMemberServiceImpl.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/notice/WxCpNoticeServiceImpl.java 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/push/WxCpMessagePushService.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/push/WxCpMessagePushServiceImpl.java 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/pom.xml 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-local.yaml 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/flows.json
@@ -54,13 +54,14 @@
       {"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 ç»‘定,模块不与其他业务模块产生任何单据关联或字段带入。",
     "summary": "独立财务模块(yudao-module-finance):报销单(主表 + 1..n æ˜Žç»†è¡Œ + å‘票附件) ä¸€æ¬¡æ€§ä¿å­˜ â†’ æäº¤ BPM å®¡æ‰¹ â†’ é€šè¿‡åŽå½’档终态;驳回可改发票重新提交。支持发票 OCR è‡ªåŠ¨å›žå¡«ã€å‘ç¥¨ç±»åž‹æžšä¸¾ã€æœ¬äºº/代他人发起(实际申请人 + ä»£å½•人)。附件走 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": "主表(申请人/报销标题/报销总金额/申请日期/备注) + è‡³å°‘ 1 è¡Œæ˜Žç»†(费用类型/发票类型/发票号码/发票金额/发票日期/开票方/费用说明);申请人本人发起时只读取登录用户,代他人发起时选择实际申请人;发票类型下拉:增值税专用发票/增值税普通发票/电子发票/收据/其他;总金额由明细求和回填,用户可手改,后端不校验一致性", "roles": ["报销人", "代录人"]},
       {"stage": "OCR è¯†åˆ«", "action": "上传发票图片/PDF åŽè°ƒç”¨ /finance/reimbursement/ai/ocr æ‰¹é‡è¯†åˆ«ï¼ˆå•次≤20 ä¸ªï¼‰ï¼ŒAI å¤šæ¨¡æ€æå–发票号码/类型/金额/日期/销售方并追加为明细行;识别结果仅预填,需人工核对,费用类型仍需手选", "roles": ["报销人", "代录人"]},
       {"stage": "附件", "action": "发票先调 /system/storage-blob/upload å– blobId;保存时把该单当前全部 blobId æ”¾è¿› blobIds ä¸€èµ·æäº¤ï¼ˆå…¨é‡æ›¿æ¢è¯­ä¹‰ï¼Œä¼ ç©ºæ•°ç»„=清空,不传=不动);OCR ä¸Šä¼ çš„æ–‡ä»¶è‡ªåŠ¨å¹¶å…¥å‘ç¥¨é™„ä»¶", "roles": ["报销人", "代录人"]},
       {"stage": "保存", "action": "create ç”± Redis ç”Ÿæˆå•号 BX+yyyyMMdd+6位流水(每日重置);申请人 applicantId ä¸ä¼ é»˜è®¤ç™»å½•用户,代发起时校验用户存在且启用;实际申请人存 applicant_id,代录人取 creator å®¡è®¡å­—段;明细先删后增全量覆盖且提交剥掉行 id;status=0 è‰ç¨¿", "roles": ["报销人", "代录人"]},
       {"stage": "修改", "action": "update ä»…放行 status=0 è‰ç¨¿ ä¸Ž status=35 å·²é©³å›žï¼›delete ä»…放行草稿(连带清理明细与发票附件)", "roles": ["报销人", "代录人"]},
       {"stage": "审批", "action": "submit ä¼  id ä¸Ž processDefinitionKey(query å‚数,请求体为空)→ status=15 å®¡æ‰¹ä¸­ å¹¶å›žå†™ processInstanceId;创建流程实例时写入变量 applicantId/applicantName/initiatorId/initiatorName/isProxy,审批流可展示实际申请人与代录人;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 é™„件中心。",
@@ -72,17 +73,41 @@
       {"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": ["会员管理员", "全体可查通讯录"]}]},
       {"stage": "资料自主维护", "action": "会员在「我的资料」页维护联系方式、单位/职称、研究方向、地区、行业等资料,提交后生成 ums_member_profile_change å¾…审核申请,不直接修改会员资料;同一会员同时只允许一条待审核申请,资料无变化时拒绝提交。管理员在「资料变更审核」查看字段级变更摘要,审核通过后全量写回 ums_member(允许清空字段),驳回必须填写原因并回显给会员", "roles": ["会员", "会员管理员"]},
       {"stage": "管理与检索", "action": "会员列表支持手工新增/编辑/删除(等级与类型校验:单位会员不可选『理事长』);会费缴纳标识记录 fee_paid(是否缴纳)与 fee_year(缴费年度),标记已缴时必须填写年度,取消缴纳时清空年度,列表展示「已缴(年度)/未缴」;会员通讯录为只读页,按地区编码与行业检索", "roles": ["会员管理员", "全体可查通讯录"]}]},
    {"id": "research", "name": "课题管理流程", "mermaid": "mermaid/14-research-flow.mmd", "color": "#e070a8",
     "summary": "独立科研模块(yudao-module-research):学会发布课题指南(重点/一般分类)→ ä¼šå‘˜åœ¨çº¿æäº¤è¯¾é¢˜ç”³æŠ¥ä¹¦ â†’ æäº¤ BPM ä¸‰çº§å®¡æ‰¹ï¼ˆç§˜ä¹¦å¤„形式审查 â†’ ä¸“家评审 â†’ å­¦ä¼šç«‹é¡¹å®¡æ‰¹ï¼‰ï¼Œä¸“家评分以结构化明细子表记录并由后端自动汇总加权均分、排名与推荐结论 â†’ å®¡æ‰¹é€šè¿‡ç”Ÿæˆç«‹é¡¹ç¼–号并发布系统公告 â†’ æŒ‰æ‰¹æ¬¡ç™»è®°ç»è´¹æ‹¨ä»˜ï¼ˆå‡­è¯å¿…填),每条拨款记录自动生成并提交一张 1:1 çš„财务报销单(finance_reimbursement),走 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": "从已发布且在申报期内的指南新建申报书;选会员(ums_member)自动带出姓名/工作单位/职称/联系电话/邮箱,可改;填课题名称、课题组成员(子表逐行录入姓名/电话/单位/职称/角色/排序,随主表先删后增)、申请经费、申报书正文,联系电话为必填项且必须为 11 ä½æ‰‹æœºå·ï¼ˆåŽç«¯åŒæ­¥æ ¡éªŒï¼Œé˜²æ­¢ç»•过前端);编号 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)」的申报单可登记拨款记录(批次名称/拨付金额/拨付日期/收款单位/备注/凭证附件),凭证必填。登记时一条龙完成:① è¯¾é¢˜ä¾§è½ research_fund_record å°è´¦ï¼ˆå…ˆç½®å¾…拨付)② åŒä¸€æ‰¹ blob åŒç»‘到课题台账与财务报销单 â‘¢ ç» yudao-module-finance-api è‡ªåŠ¨ç”Ÿæˆ 1:1 çš„ finance_reimbursement æŠ¥é”€å•(1 è¡Œæ˜Žç»†ï¼šè´¹ç”¨ç±»åž‹=其他、发票金额=拨付金额、发票日期=拨付日期、费用说明=「课题〔课题名〕第N期/第N次经费拨款」)④ ç«‹å³æäº¤ BPM æŠ¥é”€å®¡æ‰¹ â‘¤ å°è´¦å›žå†™æŠ¥é”€å•号并置「报销审批中(15)」。金额校验:已登记金额 + æœ¬æ¬¡é‡‘额 ä¸å¾—超出该单申请经费,超出时提示报出本次、已登记、合计、预算四项数值。状态由系统驱动:0待拨付/15报销审批中/20已拨付/30已驳回;只有「待拨付」可删(删除联动清理财务侧草稿报销单),「待拨付」「已驳回」可改并重新发起报销(已驳回改后清空驳回原因与时间);取消流程则退回「待拨付(0)」(发起人撤回只把实例退回发起节点、流程未结束,台账保持「报销审批中(15)」,需重新提交后才继续走审批)。财务审批分类下没有已发布流程时直接报错且不建单,不留孤儿草稿报销单", "roles": ["学会财务", "课题管理员", "财务审核人", "财务主管"]}]},
     {"stage": "成员统计", "action": "基于 research_application_member å­è¡¨æŒ‰ã€Œå§“名 + ç”µè¯ + å•位」聚合成员维度数据:参与课题数、课题名单、成员角色;支持按关键字与申报状态筛选,导出 Excel,并生成可直接复制到群内发布的名单文本。历史文本字段 team_members ä»…用于兼容展示,不再参与统计", "roles": ["课题管理员", "学会秘书处"]},
       {"stage": "经费拨付", "action": "仅「已立项(20)」的申报单可登记拨款记录(批次名称/拨付金额/拨付日期/收款单位/备注/凭证附件),凭证必填。登记时一条龙完成:① è¯¾é¢˜ä¾§è½ research_fund_record å°è´¦ï¼ˆå…ˆç½®å¾…拨付)② åŒä¸€æ‰¹ blob åŒç»‘到课题台账与财务报销单 â‘¢ ç» yudao-module-finance-api è‡ªåŠ¨ç”Ÿæˆ 1:1 çš„ finance_reimbursement æŠ¥é”€å•(1 è¡Œæ˜Žç»†ï¼šè´¹ç”¨ç±»åž‹=其他、发票金额=拨付金额、发票日期=拨付日期、费用说明=「课题〔课题名〕第N期/第N次经费拨款」)④ ç«‹å³æäº¤ BPM æŠ¥é”€å®¡æ‰¹ â‘¤ å°è´¦å›žå†™æŠ¥é”€å•号并置「报销审批中(15)」。金额校验:已登记金额 + æœ¬æ¬¡é‡‘额 ä¸å¾—超出该单申请经费,超出时提示报出本次、已登记、合计、预算四项数值。状态由系统驱动:0待拨付/15报销审批中/20已拨付/30已驳回;只有「待拨付」可删(删除联动清理财务侧草稿报销单),「待拨付」「已驳回」可改并重新发起报销(已驳回改后清空驳回原因与时间);取消流程则退回「待拨付(0)」(发起人撤回只把实例退回发起节点、流程未结束,台账保持「报销审批中(15)」,需重新提交后才继续走审批)。财务审批分类下没有已发布流程时直接报错且不建单,不留孤儿草稿报销单", "roles": ["学会财务", "课题管理员", "财务审核人", "财务主管"]}]},
    {"id": "asset", "name": "资产管理流程", "mermaid": "mermaid/16-asset-flow.mmd", "color": "#8e7cc3",
     "summary": "独立资产模块(yudao-module-asset):资产台账按「易耗品 / åŠžå…¬èµ„äº§ / å›ºå®šèµ„产」三类建档,记录库存、单价、保管人(管理人)与存放地点;领用登记从台账选资产、记录领用人/时间/数量/用途,自动扣减库存并生成领用单;归还按数量回补库存并更新未归还/部分归还/已归还状态。",
     "steps": [
       {"stage": "建档", "action": "资产台账新增:资产名称、分类(1易耗品/2办公资产/3固定资产)、规格型号、计量单位、库存数量、单价、购入日期、保管人、存放地点;资产编号 ZC+yyyyMMdd+6位流水(可手工指定),总价值=单价×数量", "roles": ["资产管理员"]},
       {"stage": "领用", "action": "选择资产与领用人,记录领用时间、数量、用途、预计归还日期;库存不足时拒绝并提示「当前可领用/本次申请」;成功后扣减库存、生成 LY é¢†ç”¨å•,并快照资产编号/名称/分类与保管人", "roles": ["资产管理员", "领用人"]},
       {"stage": "归还", "action": "按数量归还:回补库存,更新已归还数量;未归还完→部分归还,全部归还→已归还并记录归还时间;归还数量超过未归还数量时拒绝并报出领用/已归还/本次/可归还四项数值", "roles": ["资产管理员"]},
       {"stage": "冲销", "action": "删除领用记录仅在未发生归还时允许,删除时把未归还数量退回库存;存在未归还领用的资产禁止删除", "roles": ["资产管理员"]}]},
    {"id": "vehicle", "name": "用车管理流程", "mermaid": "mermaid/17-vehicle-flow.mmd", "color": "#5a9bd5",
     "summary": "独立用车模块(yudao-module-vehicle):申请人填写用车时间、出发地、目的地、随行人数、事由 â†’ æäº¤ BPM ç”¨è½¦å®¡æ‰¹ï¼ˆå®¡æ ¸ + é€çº§å®¡æ‰¹ï¼‰â†’ å®¡æ‰¹ç»“果回写申请单并反馈申请人,可查看逐级审批记录。",
     "steps": [
       {"stage": "申请", "action": "填写用车开始/结束时间、出发地、目的地、随行人数、用车事由、车牌号(选填);申请单号 YC+yyyyMMdd+6位流水;申请人=当前登录用户,状态=草稿", "roles": ["用车申请人"]},
       {"stage": "修改/删除", "action": "草稿与已驳回可修改;仅草稿可删除", "roles": ["用车申请人"]},
       {"stage": "提交审批", "action": "选择 BPM ç”¨è½¦å®¡æ‰¹æµç¨‹åŽæäº¤ï¼ŒçŠ¶æ€ç½® 15 å®¡æ‰¹ä¸­å¹¶å›žå†™ processInstanceId;流程 key å– approve-process-list å®žæ—¶åˆ—表", "roles": ["用车申请人"]},
       {"stage": "审核与逐级审批", "action": "BPM æµç¨‹æŒ‰é…ç½®çš„审核/逐级审批节点流转(节点与审批人在流程设计器中配置,支持逐级审批);任一节点驳回即结束", "roles": ["审核人", "部门负责人", "分管领导"]},
       {"stage": "结果反馈", "action": "审批通过→20 å·²é€šè¿‡å¹¶å›žå†™ approveTime;驳回→35 å·²é©³å›žï¼›å–消→退回 0 è‰ç¨¿ï¼›ç”³è¯·äººåœ¨åˆ—表直接看到结果,并可进入「查看审批」查看逐级审批记录与意见", "roles": ["用车申请人", "审批人"]}]},
    {"id": "purchase", "name": "采购管理流程", "mermaid": "mermaid/18-purchase-flow.mmd", "color": "#e0a458",
     "summary": "独立采购模块(yudao-module-purchase):采购单主表记录采购时间、采购人(经办人)与采购金额,1..n è¡Œé‡‡è´­å•†å“æ˜Žç»†è®°å½•商品名称、规格型号、数量、单价与金额;行金额=数量×单价,采购金额=明细合计。采购审批与转报销为后续需求。",
     "steps": [
       {"stage": "录单", "action": "填写采购时间、采购人(经办人),逐行录入采购商品明细(商品名称、规格型号、数量、单价),系统自动计算行金额与采购金额合计", "roles": ["采购经办人"]},
       {"stage": "保存", "action": "采购单号 CG+yyyyMMdd+6位流水;明细随主表一次性提交,修改时先删后增;状态=草稿", "roles": ["采购经办人"]},
       {"stage": "修改/删除", "action": "草稿可修改与删除,删除连带清理明细", "roles": ["采购经办人"]},
       {"stage": "审批与报销(后续)", "action": "后续需求:采购单提交审批(采购→领导通过)→ å®¡æ‰¹é€šè¿‡åŽè½¬å…¥è´¢åŠ¡æŠ¥é”€ç”³è¯·", "roles": ["采购经办人", "审批人", "财务"]}]},
    {"id": "archive", "name": "档案管理流程", "mermaid": "mermaid/15-archive-flow.mmd", "color": "#7fb3d5",
     "summary": "独立档案模块(yudao-module-archive):书架分类(分层分列)规整 â†’ æ¡£æ¡ˆç™»è®°ï¼ˆç”µå­é™„ä»¶ + å®žä½“位置四字段,密级三档)→ å€Ÿé˜…登记走 BPM ä¸¤çº§å®¡æ‰¹ï¼ˆæ¡£æ¡ˆç®¡ç†å‘˜å®¡æ ¸ â†’ åˆ†ç®¡é¢†å¯¼å®¡æ‰¹ï¼‰â†’ å®¡æ‰¹é€šè¿‡æ¡£æ¡ˆè½¬ã€Œå€Ÿå‡ºä¸­ã€â†’ å½’还登记回「在库」形成流转闭环 â†’ é€¾æœŸæœªè¿˜æŸ¥è¯¢æ—¶å®žæ—¶è®¡ç®—并在列表标红 â†’ å­˜é‡ä¸Žæµè½¬ç»Ÿè®¡ï¼ˆæ€»æ•°/在库/借出中/逾期 + åˆ†ç±»Â·å¯†çº§Â·è½½ä½“三组分布)。同时接收课题(已立项)、会员(正常)、财务(已通过)三方的「归档」联动,经新建的 yudao-module-archive-api ç”Ÿæˆæ¡£æ¡ˆå¹¶å›žå†™æ¥æºè¡¨ archive_document_id。会议/党建档案系统内无来源模块,仅手工录入。",
     "steps": [
docs/project-business/data/modules.json
@@ -35,7 +35,7 @@
    {"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 å¯¼å‡º", "对外提供 FinanceReimbursementApi(yudao-module-finance-api)供课题管理经费拨付自动建单并提交(1 è¡Œæ˜Žç»†Â·è´¹ç”¨ç±»åž‹=其他·金额一致)"],
     "features": ["报销单(主表 + 1..n æ˜Žç»†è¡Œ)", "发票附件多文件上传(附件中心多态关联 storage_attachment)", "发票 OCR è¯†åˆ«(图片/PDF·单次≤20 ä¸ªÂ·AI å¤šæ¨¡æ€å›žå¡«å·ç /类型/金额/日期/销售方)", "发票类型固定枚举(增值税专用发票/增值税普通发票/电子发票/收据/其他)", "申请人只读 + ä»£ä»–人发起(实际申请人 applicant_id + ä»£å½•人 creator)", "提交审批写入申请人与代录人流程变量(applicantId/applicantName/initiatorId/initiatorName/isProxy)", "单号 Redis ç”Ÿæˆ BX+yyyyMMdd+6位流水·每日重置", "明细随主表一次性提交(先删后增全量覆盖)", "BPM å®¡æ‰¹(通过20/驳回35/取消退回草稿0)", "已驳回可改发票后重新提交", "归档终态 40 å¹¶è®°å½•归档时间", "费用类型/报销状态双字典 + Excel å¯¼å‡º", "对外提供 FinanceReimbursementApi(yudao-module-finance-api)供课题管理经费拨付自动建单并提交(1 è¡Œæ˜Žç»†Â·è´¹ç”¨ç±»åž‹=其他·金额一致)"],
     "objects": ["报销单", "报销明细"],
     "roles": ["报销人", "财务", "审批人"],
     "upstream": ["课题管理(经费拨付 1:1 è‡ªåŠ¨ç”ŸæˆæŠ¥é”€å•Â·èµ° yudao-module-finance-api·本地 Bean è°ƒç”¨)"], "downstream": [],
@@ -99,7 +99,7 @@
    {"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)"],
     "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(自动创建登录账号)"],
@@ -107,12 +107,36 @@
    {"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 é˜²é‡å¤)", "经费拨付记录(批次/金额/日期/收款单位/备注/凭证附件)·凭证必填·金额不得超申请经费", "经费拨付 1:1 è‡ªåŠ¨ç”Ÿæˆå¹¶æäº¤è´¢åŠ¡æŠ¥é”€å•(FinanceReimbursementApi·1 è¡Œæ˜Žç»†Â·è´¹ç”¨ç±»åž‹=其他)", "拨付状态 0待拨付/15报销审批中/20已拨付/30已驳回·由 BPM ç›‘听器回写(含驳回原因与时间)", "已驳回可改金额/凭证后重新发起报销·待拨付可删并联动清理财务侧草稿报销单", "附件走 system é™„件中心(recordType=research_guideline / research_application / research_fund_record)"],
     "features": ["课题指南(重点1/一般2 åˆ†ç±»æ ‡æ³¨)", "指南状态 0草稿/20已发布/30已截止·发布/截止两个动作", "指南编号 GL+yyyyMMdd+6位流水(Redis æ¯æ—¥é‡ç½®)", "在线申报书(选指南+选会员带出申报人信息+课题组成员+申请经费+申报书正文)", "联系电话必填 + 11 ä½æ‰‹æœºå·æ ¼å¼æ ¡éªŒ(前后端双重校验)", "课题组成员子表(姓名/电话/单位/职称/角色/排序)", "成员维度统计(姓名+电话+单位聚合参与课题数)·导出 Excel·复制群发名单", "申报编号 SB+同日流水·类别冗余自指南不信任前端", "申报状态 0草稿/15审批中/20已立项/35已驳回", "提交前校验指南已发布且在申报期内(逐条报具体维度与时间)", "专家评审明细子表(一位专家一行·四项加权 0.3/0.3/0.2/0.2)", "后端自动重算加权合计与排名·回写主单均分与推荐结论", "BPM ä¸‰çº§å®¡æ‰¹ research_topic_approve(秘书处形式审查→专家评审→学会立项审批)", "审批通过生成立项编号 LX+同日流水 ä¸Žç«‹é¡¹æ—¶é—´(幂等)", "发布立项通知(生成 system_notice å…¬å‘ŠÂ·å†™å›ž noticeId é˜²é‡å¤)", "经费拨付记录(批次/金额/日期/收款单位/备注/凭证附件)·凭证必填·金额不得超申请经费", "经费拨付 1:1 è‡ªåŠ¨ç”Ÿæˆå¹¶æäº¤è´¢åŠ¡æŠ¥é”€å•(FinanceReimbursementApi·1 è¡Œæ˜Žç»†Â·è´¹ç”¨ç±»åž‹=其他)", "拨付状态 0待拨付/15报销审批中/20已拨付/30已驳回·由 BPM ç›‘听器回写(含驳回原因与时间)", "已驳回可改金额/凭证后重新发起报销·待拨付可删并联动清理财务侧草稿报销单", "附件走 system é™„件中心(recordType=research_guideline / research_application / research_fund_record)"],
     "objects": ["课题指南", "课题申报书", "专家评审明细", "经费拨付记录"],
     "roles": ["课题申报人(会员)", "课题管理员", "评审专家", "学会秘书处", "学会理事会", "学会财务"],
     "upstream": ["会员管理(ums_member å–申报人姓名/单位/职称/电话)", "BPM(流程定义 research_topic_approve / finance_reimbursement_approve)"],
     "downstream": ["system(system_notice ç«‹é¡¹é€šçŸ¥å…¬å‘Š)", "system(附件中心)", "财务(经费拨付生成 finance_reimbursement æŠ¥é”€å•·已对接)"],
     "coreTables": ["research_guideline", "research_application", "research_topic_review", "research_fund_record"]},
    {"key": "ast", "name": "资产管理(独立)", "color": "#8e7cc3", "tag": "AST", "flow": "asset",
     "positioning": "资产台账 + é¢†ç”¨å½’还的独立管理模块",
     "problem": "资产分散在个人手里、领用无记录、保管人与领用人不清、库存账实不符",
     "features": ["资产台账三类: æ˜“耗品/办公资产(打印机等)/固定资产", "资产编号 ZC+yyyyMMdd+6位流水·可手工指定", "库存数量/单价/总价值自动计算", "保管人(管理人)与存放地点记录", "领用登记: é¢†ç”¨äºº/领用时间/数量/用途/预计归还", "领用时校验库存并自动扣减·不足时精确提示", "归还登记: éƒ¨åˆ†å½’还/全部归还·自动回补库存", "删除领用冲销库存(仅未发生归还)·有未归还记录禁止删资产", "领用单号 LY+yyyyMMdd+6位流水"],
     "objects": ["资产台账", "资产领用"],
     "roles": ["资产管理员", "领用人"],
     "upstream": ["system(用户信息用于保管人/领用人展示)"], "downstream": [],
     "coreTables": ["asset_item", "asset_requisition"]},
    {"key": "veh", "name": "用车管理(独立)", "color": "#5a9bd5", "tag": "VEH", "flow": "vehicle",
     "positioning": "用车申请 + å®¡æ ¸ + é€çº§å®¡æ‰¹",
     "problem": "用车申请靠口头/纸质、审批无留痕、结果无法反馈申请人",
     "features": ["用车申请(时间/出发地/目的地/随行人数/事由/车牌)", "申请单号 YC+yyyyMMdd+6位流水", "状态 0草稿/15审批中/20已通过/35已驳回", "提交 BPM ç”¨è½¦å®¡æ‰¹(审核 + é€çº§å®¡æ‰¹èŠ‚ç‚¹å¯é…ç½®)", "审批结果监听器回写申请单", "审批通过回写 approveTime", "取消流程退回草稿", "查看逐级审批记录与意见"],
     "objects": ["用车申请"],
     "roles": ["用车申请人", "审核人", "部门负责人", "分管领导"],
     "upstream": ["BPM(流程定义 vehicle_application_approve)"], "downstream": [],
     "coreTables": ["vehicle_application"]},
    {"key": "pur", "name": "采购管理(独立)", "color": "#e0a458", "tag": "PUR", "flow": "purchase",
     "positioning": "采购单主表 + å•†å“æ˜Žç»†è¡Œ",
     "problem": "采购商品、金额、经办人记录分散,明细无法追溯",
     "features": ["采购单(采购时间/采购人/采购金额)", "采购商品明细 1..n è¡Œ(商品名称/规格型号/数量/单价/金额)", "行金额与采购金额自动计算", "采购单号 CG+yyyyMMdd+6位流水", "明细随主表一次性提交(先删后增)", "草稿可改可删", "预留审批与转报销扩展"],
     "objects": ["采购单", "采购商品明细"],
     "roles": ["采购经办人"],
     "upstream": [], "downstream": ["财务报销(后续需求)"],
     "coreTables": ["purchase_order", "purchase_order_item"]},
    {"key": "arc", "name": "档案管理(独立)", "color": "#7fb3d5", "tag": "ARC", "flow": "archive",
     "positioning": "学会/协会型实体与电子档案全生命周期:分类书架规整 â†’ æ¡£æ¡ˆç™»è®°ä¸Žæ£€ç´¢ â†’ å€Ÿé˜…审批 â†’ å½’还闭环 â†’ å­˜é‡ä¸Žæµè½¬ç»Ÿè®¡ï¼Œå¹¶æŽ¥æ”¶è¯¾é¢˜/会员/财务三方的归档联动",
     "problem": "课题材料、会员材料、财务凭证散落各模块无处归集;纸质档案存放位置靠线下台账,借阅与归还无留痕、逾期无人管;财务等敏感档案无密级管控",
docs/project-business/data/objects.json
@@ -48,14 +48,21 @@
    {"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": "finance_reimbursement", "doc": "单号可自定义(中间不允许空格,与已有单号重复则报错);新增时留空或纯空格由系统生成 BX+yyyyMMdd+6位流水(Redis æ¯æ—¥é‡ç½®),修改时留空表示保持不变。status: 0草稿/15审批中/20已通过/35已驳回/40已归档;仅草稿可删、草稿与已驳回可改、仅已通过可归档;提交后由 BPM ç›‘听器按结果回写状态(取消退回草稿),审批中 processInstanceId è¢«å›žå†™ã€‚来源两条:① è´¢åŠ¡æ¨¡å—é¡µé¢äººå·¥å¡«æŠ¥ â‘¡è¯¾é¢˜ç®¡ç†ã€Œç»è´¹æ‹¨ä»˜ã€ç» FinanceReimbursementApi(yudao-module-finance-api)自动建单并立即提交(1:1、金额一致、系统固定 1 è¡Œæ˜Žç»†Â·è´¹ç”¨ç±»åž‹=其他)——后者的审批结果由课题侧 ResearchFundStatusListener è®¢é˜…同一流程回写拨款台账,报销单本身不回写任何外部单据;archive_document_id éžç©ºè¡¨ç¤ºå·²å½’æ¡£", "upstream": ["课题管理(经费拨付·经 FinanceReimbursementApi è‡ªåŠ¨å»ºå•)", "人工填报"], "downstream": ["报销明细", "发票附件(storage_attachment·recordType=finance_reimbursement)", "课题拨款台账(仅经 BPM äº‹ä»¶å›žå†™çŠ¶æ€)", "档案管理(既有归档动作同事务叠加·生成「保密 3」级财务档案并回写 archive_document_id)"], "keywords": "报销单 æŠ¥é”€ å‘票 reimbursement BX å½’æ¡£ è´¢åŠ¡ è´¹ç”¨ è‡ªå®šä¹‰å•号 è¯¾é¢˜ç»è´¹æ‹¨ä»˜ è‡ªåŠ¨å»ºå• FinanceReimbursementApi è´¢åŠ¡æ¡£æ¡ˆ ä¿å¯† archive_document_id"},
    {"name": "报销明细", "module": "财务报销", "table": "finance_reimbursement_item", "doc": "报销单的费用行:费用类型(1差旅/2办公/3招待/4交通/5其他)、发票号码、发票金额、发票日期、开票方、费用说明;随主表一次性提交(先删后增全量覆盖),提交时必须剥掉行 id ä¸Ž reimbursementId â€”— æ˜Žç»†åˆ é™¤æ˜¯è½¯åˆ ï¼Œå›žä¼ åŽŸ id ä¼šä¸Žå·²è½¯åˆ è¡Œä¸»é”®å†²çª", "upstream": ["报销单"], "downstream": [], "keywords": "报销明细 è´¹ç”¨æ˜Žç»† è´¹ç”¨ç±»åž‹ å‘票金额 item æ˜Žç»†è¡Œ å·®æ—…"},
    {"name": "报销单", "module": "财务报销", "table": "finance_reimbursement", "doc": "申请人支持本人发起与代他人发起:applicant_id å­˜å®žé™…申请人,creator å­˜ä»£å½•人(创建人);新增/修改时 applicantId ä¸ä¼ é»˜è®¤ç™»å½•用户,传则校验用户存在且启用;提交审批时写入流程变量 applicantId/applicantName/initiatorId/initiatorName/isProxy。单号可自定义(中间不允许空格,与已有单号重复则报错);新增时留空或纯空格由系统生成 BX+yyyyMMdd+6位流水(Redis æ¯æ—¥é‡ç½®),修改时留空表示保持不变。status: 0草稿/15审批中/20已通过/35已驳回/40已归档;仅草稿可删、草稿与已驳回可改、仅已通过可归档;提交后由 BPM ç›‘听器按结果回写状态(取消退回草稿),审批中 processInstanceId è¢«å›žå†™ã€‚来源两条:① è´¢åŠ¡æ¨¡å—é¡µé¢äººå·¥å¡«æŠ¥ â‘¡è¯¾é¢˜ç®¡ç†ã€Œç»è´¹æ‹¨ä»˜ã€ç» FinanceReimbursementApi(yudao-module-finance-api)自动建单并立即提交(1:1、金额一致、系统固定 1 è¡Œæ˜Žç»†Â·è´¹ç”¨ç±»åž‹=其他)——后者的审批结果由课题侧 ResearchFundStatusListener è®¢é˜…同一流程回写拨款台账,报销单本身不回写任何外部单据;archive_document_id éžç©ºè¡¨ç¤ºå·²å½’æ¡£", "upstream": ["课题管理(经费拨付·经 FinanceReimbursementApi è‡ªåŠ¨å»ºå•)", "人工填报"], "downstream": ["报销明细", "发票附件(storage_attachment·recordType=finance_reimbursement)", "课题拨款台账(仅经 BPM äº‹ä»¶å›žå†™çŠ¶æ€)", "档案管理(既有归档动作同事务叠加·生成「保密 3」级财务档案并回写 archive_document_id)"], "keywords": "报销单 æŠ¥é”€ å‘票 reimbursement ä»£å‘èµ· ä»£å½•人 å®žé™…申请人 OCR å‘票类型 BX å½’æ¡£ è´¢åŠ¡ è´¹ç”¨ è‡ªå®šä¹‰å•号 è¯¾é¢˜ç»è´¹æ‹¨ä»˜ è‡ªåŠ¨å»ºå• FinanceReimbursementApi è´¢åŠ¡æ¡£æ¡ˆ ä¿å¯† archive_document_id"},
    {"name": "报销明细", "module": "财务报销", "table": "finance_reimbursement_item", "doc": "报销单的费用行:费用类型(1差旅/2办公/3招待/4交通/5其他)、发票类型(增值税专用发票/增值税普通发票/电子发票/收据/其他)、发票号码、发票金额、发票日期、开票方、费用说明;随主表一次性提交(先删后增全量覆盖),提交时必须剥掉行 id ä¸Ž reimbursementId â€”— æ˜Žç»†åˆ é™¤æ˜¯è½¯åˆ ï¼Œå›žä¼ åŽŸ id ä¼šä¸Žå·²è½¯åˆ è¡Œä¸»é”®å†²çªï¼›å‘票类型/号码/金额/日期/销售方可由发票 OCR è¯†åˆ«è‡ªåŠ¨å›žå¡«ï¼Œéœ€äººå·¥æ ¸å¯¹", "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(申请单无对应字段);自动建的系统账号用户名为联系电话且初始密码同为联系电话,部门固定为「学会会员」;手机号重复会明确报错且整单事务回滚;archive_document_id éžç©ºè¡¨ç¤ºå·²å½’档(归档后列表页隐藏「归档」按钮)", "upstream": ["入会申请单(审批通过)", "人工新增", "附件(storage_attachment·recordType=ums_member)"], "downstream": ["会员通讯录(按 regionCode/industry æ£€ç´¢)", "档案管理(手动「归档」·仅正常状态可归档·经 yudao-module-archive-api ç”Ÿæˆæ¡£æ¡ˆå¹¶å›žå†™ archive_document_id)", "system_users(登录账号)"], "keywords": "会员 member HY ä¼šå‘˜ç¼–号 ä¼šå‘˜ç­‰çº§ ä¼šå‘˜ç±»åž‹ é€šè®¯å½• åœ°åŒº è¡Œä¸š å…¥ä¼šæ—¥æœŸ æœ‰æ•ˆæœŸ å½’æ¡£ archive_document_id"},
    {"name": "会员", "module": "会员管理", "table": "ums_member", "doc": "单号 HY+yyyyMMdd+6位流水(Redis æ¯æ—¥é‡ç½®);两个来源:①入会申请审批通过自动创建(回填 applicationId ä¸Ž userName/系统账号) â‘¡ä¼šå‘˜åˆ—表手工新增。status: 1正常/0暂停;memberGrade æŒ‰ memberType å–对应字典,校验规则是单位会员(type=2)不可选『理事长』(grade=5)。自动建档时入会日期=当天、不搬运 contactUser/unitIntro/expireDate(申请单无对应字段);自动建的系统账号用户名为联系电话且初始密码同为联系电话,部门固定为「学会会员」;手机号重复会明确报错且整单事务回滚;archive_document_id éžç©ºè¡¨ç¤ºå·²å½’档(归档后列表页隐藏「归档」按钮);fee_paid è¡¨ç¤ºæ˜¯å¦ç¼´çº³ä¼šè´¹ï¼Œfee_year è®°å½•缴费年度(如 2026),标记已缴时年度必填、取消缴纳时置空,列表展示「已缴(年度)/未缴」;会员可通过「我的资料」提交资料变更申请,管理员审核通过后才写回本表", "upstream": ["入会申请单(审批通过)", "人工新增", "附件(storage_attachment·recordType=ums_member)"], "downstream": ["会员通讯录(按 regionCode/industry æ£€ç´¢)", "档案管理(手动「归档」·仅正常状态可归档·经 yudao-module-archive-api ç”Ÿæˆæ¡£æ¡ˆå¹¶å›žå†™ archive_document_id)", "system_users(登录账号)"], "keywords": "会员 member HY ä¼šå‘˜ç¼–号 ä¼šå‘˜ç­‰çº§ ä¼šå‘˜ç±»åž‹ é€šè®¯å½• åœ°åŒº è¡Œä¸š å…¥ä¼šæ—¥æœŸ æœ‰æ•ˆæœŸ å½’æ¡£ archive_document_id ä¼šè´¹ ä¼šè´¹ç¼´çº³ ç¼´è´¹å¹´åº¦ fee_paid fee_year"},
    {"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 è®°å½•已发布的立项通知公告,重复发布被拦截;archive_document_id éžç©ºè¡¨ç¤ºå·²å½’档(归档后列表页隐藏「归档」按钮)", "upstream": ["课题指南(必须已发布且在申报期内)", "会员(ums_member·带出申报人信息)", "申报附件(storage_attachment·recordType=research_application)"], "downstream": ["专家评审明细", "经费拨付记录", "立项通知(system_notice å…¬å‘Š)", "BPM æµç¨‹å®žä¾‹(research_topic_approve åˆ†ç±»)", "档案管理(手动「归档」·仅已立项可归档·经 yudao-module-archive-api ç”Ÿæˆæ¡£æ¡ˆå¹¶å›žå†™ archive_document_id)", "附件中心"], "keywords": "课题申报 ç”³æŠ¥ä¹¦ ç”³æŠ¥å• SB ç”³æŠ¥ç¼–号 ç«‹é¡¹ ç«‹é¡¹ç¼–号 LX ç”³è¯·ç»è´¹ ç”³æŠ¥ä¹¦æ­£æ–‡ è¯¾é¢˜ç»„成员 ä¸“家评审 è¯„审汇总 ç«‹é¡¹é€šçŸ¥ å‘布通知 æäº¤å®¡æ‰¹ é©³å›ž é‡æ å½’æ¡£ archive_document_id"},
    {"name": "课题申报书", "module": "课题管理", "table": "research_application", "doc": "申报编号 SB+yyyyMMdd+6位流水(Redis æ¯æ—¥é‡ç½®);guidelineId å…³è”指南、memberId å…³è” ums_member(软关联无外键,会员删除后会留下悬空 memberId éœ€å®¹é”™æ˜¾ç¤º);applicantName/workUnit/jobTitle/contactPhone/contactEmail ç”±é€‰ä¼šå‘˜è‡ªåŠ¨å¸¦å‡ºå¯æ‰‹æ”¹ï¼Œå…¶ä¸­ contactPhone ä¸ºå¿…填项且必须为 11 ä½æ‰‹æœºå·ï¼ˆå‰åŽç«¯åŒé‡æ ¡éªŒï¼‰ï¼›è¯¾é¢˜ç»„成员由 teamMembers æ–‡æœ¬å­—段改造为子表 research_application_member(旧字段仅兼容展示),成员随主表先删后增;category å†—余自指南且不信任前端传值;budgetAmount ç”³è¯·ç»è´¹ä¸ºæ‹¨ä»˜ä¸Šé™ï¼›status: 0草稿/15审批中/20已立项/35已驳回。仅草稿可删(连带清附件)、草稿与已驳回可改可重提;submit å‰é€æ¡æ ¡éªŒã€ŒæŒ‡å—已发布」与「现在在申报期内」,任一不满足即拒绝并按维度给出指南名与实际值;审批通过由监听器生成立项编号 researchNo(LX+同日流水)与 approveTime(已有编号幂等跳过);reviewAvgScore/reviewPass ä¸ºè¯„审汇总回写值,明细删空时清成未汇总;noticeId è®°å½•已发布的立项通知公告,重复发布被拦截;archive_document_id éžç©ºè¡¨ç¤ºå·²å½’档(归档后列表页隐藏「归档」按钮)", "upstream": ["课题指南(必须已发布且在申报期内)", "会员(ums_member·带出申报人信息)", "申报附件(storage_attachment·recordType=research_application)"], "downstream": ["专家评审明细", "经费拨付记录", "立项通知(system_notice å…¬å‘Š)", "BPM æµç¨‹å®žä¾‹(research_topic_approve åˆ†ç±»)", "档案管理(手动「归档」·仅已立项可归档·经 yudao-module-archive-api ç”Ÿæˆæ¡£æ¡ˆå¹¶å›žå†™ archive_document_id)", "附件中心"], "keywords": "课题申报 ç”³æŠ¥ä¹¦ ç”³æŠ¥å• SB ç”³æŠ¥ç¼–号 ç«‹é¡¹ ç«‹é¡¹ç¼–号 LX ç”³è¯·ç»è´¹ ç”³æŠ¥ä¹¦æ­£æ–‡ è¯¾é¢˜ç»„成员 ä¸“家评审 è¯„审汇总 ç«‹é¡¹é€šçŸ¥ å‘布通知 æäº¤å®¡æ‰¹ é©³å›ž é‡æ å½’æ¡£ archive_document_id"},
    {"name": "课题申报成员", "module": "课题管理", "table": "research_application_member", "doc": "课题申报书的成员子表:name æˆå‘˜å§“名 / contactPhone è”系电话(选填·填写时须为11位手机号) / workUnit æ‰€å±žå•位 / jobTitle èŒç§°èŒåŠ¡ / memberRole æˆå‘˜è§’色(主持人/核心成员/参与人) / sortNo æŽ’序号。随主表一次性提交,修改时先删后增全量覆盖;审批详情只读展示。成员维度统计按「姓名+电话+单位」视为同一成员聚合参与课题数与课题名单,支持导出 Excel ä¸Žç”Ÿæˆç¾¤å‘名单;旧 research_application.team_members æ–‡æœ¬ä»…保留兼容展示,不再参与统计", "upstream": ["课题申报书"], "downstream": ["成员维度统计与导出"], "keywords": "课题成员 æˆå‘˜å­è¡¨ è¯¾é¢˜ç»„成员 research_application_member ä¸»æŒäºº æ ¸å¿ƒæˆå‘˜ å‚与人 æˆå‘˜ç»Ÿè®¡ æˆå‘˜åå• ç¾¤å‘名单 å¯¼å‡º"},
    {"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": "archive_category", "doc": "树形书架分类(parentId·0=顶级),承载「分层分列」规整:6 æ¡åˆå§‹åˆ†ç±» ä¼šå‘˜æ¡£æ¡ˆ member / è¯¾é¢˜æ¡£æ¡ˆ research / ä¼šè®®æ¡£æ¡ˆ meeting / è´¢åŠ¡æ¡£æ¡ˆ finance / æ–‡ä¹¦æ¡£æ¡ˆ document / å…šå»ºæ¡£æ¡ˆ party;code å…¨å±€å”¯ä¸€ uk_code;defaultSecretLevel ä¸ºè¯¥åˆ†ç±»ä¸‹æ¡£æ¡ˆçš„默认密级(1公开/2内部/3保密,财务分类=3);status 0启用/1停用。仅可改可删,有子分类禁删、该分类下有档案禁删、父分类不能设为自身或其子分类。会议/党建档案系统内无来源模块,仅支持手工录入档案", "upstream": ["人工维护(档案管理员)"], "downstream": ["档案(新增时带出默认密级)"], "keywords": "档案分类 ä¹¦æž¶ åˆ†å±‚分列 ä¼šå‘˜æ¡£æ¡ˆ è¯¾é¢˜æ¡£æ¡ˆ ä¼šè®®æ¡£æ¡ˆ è´¢åŠ¡æ¡£æ¡ˆ æ–‡ä¹¦æ¡£æ¡ˆ å…šå»ºæ¡£æ¡ˆ category é»˜è®¤å¯†çº§"},
    {"name": "会员资料变更申请", "module": "会员管理", "table": "ums_member_profile_change", "doc": "会员自助维护资料产生的变更申请:member_id/member_no/member_name å¿«ç…§ + å˜æ›´åŽçš„联系人/电话/邮箱/工作单位/职称/研究方向/单位简介/法定代表人/统一社会信用代码/地区/行业;status 0待审核/10已通过/20已驳回;同一会员同时只允许一条待审核申请,资料无变化时拒绝提交;change_summary è®°å½•字段级差异供审核;审核通过时全量写回 ums_member(允许清空字段),驳回必须填写 audit_remark å¹¶å›žæ˜¾ç»™ä¼šå‘˜", "upstream": ["会员(ums_member·会员本人提交)"], "downstream": ["会员(审核通过后写回资料)"], "keywords": "会员资料 è‡ªä¸»ç»´æŠ¤ èµ„料变更 å˜æ›´ç”³è¯· æˆ‘的资料 å®¡æ ¸ å¾…审核 å·²é€šè¿‡ å·²é©³å›ž é©³å›žåŽŸå›  ums_member_profile_change"},
    {"name": "资产台账", "module": "资产管理", "table": "asset_item", "doc": "资产主数据:assetNo èµ„产编号 ZC+yyyyMMdd+6位流水(可手工指定)、name åç§°ã€category 1易耗品/2办公资产/3固定资产、spec è§„格型号、unit è®¡é‡å•位、quantity å½“前库存数量、unitPrice å•价、totalAmount æ€»ä»·å€¼=单价×数量、purchaseDate è´­å…¥æ—¥æœŸã€custodianName ä¿ç®¡äºº(管理人)、location å­˜æ”¾åœ°ç‚¹ã€‚领用扣减库存、归还回补库存;存在未归还领用记录时禁止删除", "upstream": ["人工建档"], "downstream": ["资产领用(扣减/回补库存)"], "keywords": "资产 å°è´¦ æ˜“耗品 åŠžå…¬èµ„äº§ å›ºå®šèµ„产 æ‰“印机 èµ„产编号 ZC åº“å­˜ ä¿ç®¡äºº ç®¡ç†äºº å­˜æ”¾åœ°ç‚¹ asset_item"},
    {"name": "资产领用", "module": "资产管理", "table": "asset_requisition", "doc": "领用单:no é¢†ç”¨å•号 LY+yyyyMMdd+6位流水、assetId/assetNo/assetName/category èµ„产快照、custodianName ä¿ç®¡äºº(管理人·随资产快照)、recipientName é¢†ç”¨äººã€requisitionTime é¢†ç”¨æ—¶é—´ã€quantity é¢†ç”¨æ•°é‡ã€purpose ç”¨é€”、expectedReturnDate é¢„计归还日期、returnedQuantity å·²å½’还数量、returnTime æœ€è¿‘归还时间、returnStatus 1未归还/2部分归还/3已归还。创建即扣减库存;归还按数量回补库存;删除仅未发生归还时允许并冲销库存", "upstream": ["资产台账"], "downstream": ["资产台账(库存回补)"], "keywords": "资产领用 é¢†ç”¨å• é¢†ç”¨äºº ä¿ç®¡äºº ç®¡ç†äºº é¢†ç”¨æ—¶é—´ é¢†ç”¨æ•°é‡ ç”¨é€” å½’还 éƒ¨åˆ†å½’还 å·²å½’还 æœªå½’还 å†²é”€ LY asset_requisition"},
    {"name": "用车申请", "module": "用车管理", "table": "vehicle_application", "doc": "用车申请单:no ç”³è¯·å•号 YC+yyyyMMdd+6位流水、applicantId/applicantName ç”³è¯·äººã€useStartTime/useEndTime ç”¨è½¦èµ·æ­¢æ—¶é—´ã€departure å‡ºå‘地、destination ç›®çš„地、passengers éšè¡Œäººæ•°ã€reason ç”¨è½¦äº‹ç”±ã€vehicleNo è½¦ç‰Œå·ã€status 0草稿/15审批中/20已通过/35已驳回、processInstanceId BPM流程实例、approveTime å®¡æ‰¹é€šè¿‡æ—¶é—´ã€‚提交时校验草稿/已驳回状态并创建 BPM æµç¨‹ï¼›ç›‘听器回写审批结果,取消流程退回草稿;仅草稿可删、草稿与已驳回可改", "upstream": ["申请人填报"], "downstream": ["BPM ç”¨è½¦å®¡æ‰¹æµç¨‹(逐级审批)"], "keywords": "用车 ç”¨è½¦ç”³è¯· ç”¨è½¦å®¡æ‰¹ ç”¨è½¦å®¡æ ¸ é€çº§å®¡æ‰¹ å‡ºå‘地 ç›®çš„地 éšè½¦äººæ•° ç”¨è½¦äº‹ç”± è½¦ç‰Œ YC vehicle_application"},
    {"name": "采购单", "module": "采购管理", "table": "purchase_order", "doc": "采购单主表:no é‡‡è´­å•号 CG+yyyyMMdd+6位流水、purchaseDate é‡‡è´­æ—¶é—´ã€buyerId/buyerName é‡‡è´­äººï¼ˆç»åŠžäººï¼‰ã€totalAmount é‡‡è´­é‡‘额(明细合计)、status 0草稿、remark。明细随主表一次性提交,修改时先删后增;草稿可改可删", "upstream": ["采购经办人录单"], "downstream": ["采购商品明细", "财务报销(后续需求)"], "keywords": "采购 é‡‡è´­å• é‡‡è´­å•†å“ é‡‡è´­é‡‘额 é‡‡è´­äºº ç»åŠžäºº é‡‡è´­æ—¶é—´ CG purchase_order"},
    {"name": "采购商品明细", "module": "采购管理", "table": "purchase_order_item", "doc": "采购单商品明细行:itemName å•†å“åç§°ã€spec è§„格型号、quantity æ•°é‡ã€unitPrice å•价、amount é‡‘额(=数量×单价,后端计算)、sortNo æŽ’序号。随主表一次性提交,提交时剥掉行 id ä¸Ž orderId", "upstream": ["采购单"], "downstream": [], "keywords": "采购明细 å•†å“æ˜Žç»† å•†å“åç§° è§„格型号 æ•°é‡ å•ä»· é‡‘额 purchase_order_item"},
    {"name": "档案", "module": "档案管理", "table": "archive_document", "doc": "档案主表,编号 DA+yyyyMMdd+6位流水(Redis æ¯æ—¥é‡ç½®)。电子与实体兼具:电子走 system é™„件中心 recordType=archive_document(表内不存文件地址)、实体记 shelfNo ä¹¦æž¶å·/layerNo å±‚号/columnNo åˆ—号/boxNo å†Œå·æ¡£æ¡ˆç›’ + storePlace å­˜æ”¾ä½ç½®æè¿°ã€‚carrierType 1电子/2实体/3电子+实体;secretLevel 1公开/2内部/3保密(为 3 ä¸”当前用户无 archive:document:sensitive æ—¶è¯¦æƒ…报错、列表与导出强制过滤);sourceType 1手工/2课题/3会员/4财务 + sourceNo/sourceId/sourceTitle è®°å½•来源快照以便检索;status 10在库/20借出中(借出中禁改、存在未归还借阅单时禁删)。来源为 2/3/4 çš„æ¡£æ¡ˆç”±ä¸‰æ–¹åˆ—表页「归档」按钮经 ArchiveDocumentApi.archiveBySource ç”Ÿæˆ(幂等),并回写来源表 archive_document_id", "upstream": ["档案分类", "档案登记(人工)", "课题归档(已立项申报书)", "会员归档(正常会员)", "财务归档(已通过报销单)"], "downstream": ["借阅单", "附件中心(recordType=archive_document)", "来源表 archive_document_id å›žå†™"], "keywords": "档案 æ–‡æ¡£ document DA æ¡£æ¡ˆç¼–号 å¯†çº§ ä¿å¯† å…¬å¼€ å†…部 æ•æ„Ÿæ¡£æ¡ˆ è½½ä½“ ç”µå­ å®žä½“ ä¹¦æž¶å· å±‚号 åˆ—号 å†Œå· æ¡£æ¡ˆç›’ å½’档日期 åœ¨åº“ å€Ÿå‡ºä¸­ æ£€ç´¢ å…³é”®è¯ tags æ¥æºå•号 å½’档联动"},
    {"name": "借阅单", "module": "档案管理", "table": "archive_borrow", "doc": "借阅与归还合一,编号 JY+yyyyMMdd+6位流水(Redis æ¯æ—¥é‡ç½®)。记录借阅人/部门/电话、借阅日期、借阅目的、归还期限(returnDeadline·逾期实时计算基准);documentNo/documentTitle ä¸ºæ¡£æ¡ˆå¿«ç…§ã€‚status 0草稿/15审批中/20借出中/30已归还/35已驳回:仅 0 ä¸Ž 35 å¯æ”¹å¯é‡æã€ä»… 0 å¯åˆ ã€ä»… 20 å¯ç™»è®°å½’还。submit å‰æ ¡éªŒåŒä¸€æ¡£æ¡ˆä¸å¾—已有 15/20 çš„借阅单(命中报已借用并带档案名与已存在借阅单号);流程 key archive_borrow_approve ä¸¤çº§(档案管理员审核→分管领导审批),前端不写死 key。审批通过由 ArchiveBorrowStatusListener å›žå†™ 20+approveTime å¹¶æ¡ä»¶æ›´æ–°æ¡£æ¡ˆ SET status=20 WHERE status=10(影响 0 è¡ŒæŠ¥æ¡£æ¡ˆå·²è¢«å ç”¨å¹¶å›žæ»š);归还登记条件更新 20→30 å¹¶å›žå†™ returnTime/归还确认人。逾期不落库,查询时实时算 overdueDays å¹¶åœ¨åˆ—表渲染红色「逾期 N å¤©ã€ï¼Œå¦æœ‰ /overdue-page ç‹¬ç«‹å…¥å£", "upstream": ["档案(仅 åœ¨åº“ 10 å¯é€‰)", "BPM(流程 archive_borrow_approve)", "人工登记"], "downstream": ["档案(状态 20 å€Ÿå‡ºä¸­ / å½’还回 10 åœ¨åº“)"], "keywords": "借阅 å€Ÿé˜…单 å€Ÿé˜…登记 å½’还 å½’还登记 é€¾æœŸ é€¾æœŸæœªè¿˜ å€Ÿé˜…人 å€Ÿé˜…目的 å½’还期限 JY å€Ÿé˜…单号 æ¡£æ¡ˆç®¡ç†å‘˜å®¡æ ¸ åˆ†ç®¡é¢†å¯¼å®¡æ‰¹ æµè½¬é—­çޝ"},
    {"name": "经费拨付记录", "module": "课题管理", "table": "research_fund_record", "doc": "一主多行的拨款台账:batchName æ‰¹æ¬¡åç§°/amount æ‹¨ä»˜é‡‘额/payDate æ‹¨ä»˜æ—¥æœŸ/payee æ”¶æ¬¾å•位/remark,凭证附件走 system é™„件中心 recordType=research_fund_record(表内不存文件地址)。status: 0待拨付/15报销审批中/20已拨付/30已驳回,由系统与 BPM é©±åŠ¨ï¼Œæ–°å¢ž/修改接口已移除 status å…¥å‚(防伪造「已拨付」)。登记即一条龙:落台账 â†’ åŒä¸€æ‰¹ blob åŒç»‘ â†’ ç» FinanceReimbursementApi è‡ªåŠ¨ç”Ÿæˆ 1:1 çš„ finance_reimbursement å¹¶ç«‹å³æäº¤ BPM â†’ å›žå†™ reimbursement_id/reimbursement_no å¹¶ç½® 15;审批结果由监听器回写:通过→20、驳回→30(+reject_reason/reject_time)、取消流程→退回 0(发起人撤回只退回发起节点、流程未结束,状态不变)。仅申报单 status=20 å·²ç«‹é¡¹å¯ç™»è®°ï¼›å‡­è¯å¿…填;金额校验:已登记金额(该单全部记录求和) + æœ¬æ¬¡é‡‘额 ä¸å¾—超出申报单 budgetAmount,超出时提示报出本次、已登记、合计、预算四项数值;仅「待拨付」可删(联动删除财务侧草稿报销单,非草稿则拒绝),「待拨付」「已驳回」可改并重新发起报销(已驳回改后清空 reject_reason/reject_time),编辑时把本行金额加回可用余额展示避免误解。与报销单为软关联(无外键),财务侧直接删单会留下悬空 reimbursement_id,此时修改报「报销单已不存在,请删除本条记录后重新登记」", "upstream": ["课题申报书(必须已立项)", "财务报销单(1:1 è‡ªåŠ¨ç”ŸæˆÂ·è½¯å…³è” reimbursement_id)"], "downstream": ["拨付凭证(storage_attachment·recordType=research_fund_record)", "财务报销单(生成并提交出账·审批结果回写本台账)"], "keywords": "经费拨付 æ‹¨æ¬¾ æ‹¨ä»˜è®°å½• æ‰¹æ¬¡ æ‹¨ä»˜é‡‘额 å‡­è¯ å¾…拨付 æŠ¥é”€å®¡æ‰¹ä¸­ å·²æ‹¨ä»˜ å·²é©³å›ž é©³å›žåŽŸå›  è´¢åŠ¡æŠ¥é”€å•å· reimbursement_id æ”¶æ¬¾å•位 èµ„助金额 é¢„ç®— è¶…额 é‡æ–°å‘起报销"}
docs/project-business/data/roles.json
@@ -21,11 +21,15 @@
    {"role": "HR", "modules": ["HRM"], "operations": ["员工档案入转调离", "考勤规则排班", "薪酬核算发放", "离职交接"], "data": ["hrm_*"], "scope": "部门范围 dataScope"},
    {"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"], "operations": ["登录后进入「我的资料」维护联系方式等资料", "提交资料变更申请并查看审核状态"], "data": ["ums_member(本人)", "ums_member_profile_change(本人提交)"], "scope": "普通会员角色 id=9100 ä»…授予「我的资料」菜单(1075563);不授予档案管理(1075600~1075699)及其他后台菜单;档案接口仍由 archive:document:* æƒé™ç å…œåº•拦截"},
    {"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": ["ARC"], "operations": ["档案分类(书架)维护:分层建类、设默认密级、启用停用", "档案登记与多条件检索/导出(电子附件 + å®žä½“位置四字段、密级三档)", "借阅登记、提交审批", "归还登记与归还备注", "查看档案统计(存量与流转)"], "data": ["archive_category", "archive_document", "archive_borrow"], "scope": "权限码 archive:category:create/update/delete/query Â· archive:document:create/update/delete/query/export Â· archive:borrow:create/update/delete/query/submit/return Â· archive:stat:query;密级=保密(3) çš„æ¡£æ¡ˆå¦éœ€ archive:document:sensitive æ‰èƒ½æŸ¥çœ‹ï¼Œæ— è¯¥æƒé™æ—¶åˆ—表与导出强制过滤;无数据权限隔离(列表不按人过滤),按钮凭权限码控制"},
    {"role": "借阅人", "modules": ["ARC"], "operations": ["登记借阅单(选在库档案、填借阅人信息/借阅日期/借阅目的/归还期限)", "提交 BPM å€Ÿé˜…审批", "查看借阅进度(跳 BPM æµç¨‹å®žä¾‹è¯¦æƒ…)"], "data": ["archive_borrow", "archive_document(仅 åœ¨åº“ 10 å¯é€‰)"], "scope": "权限码 archive:borrow:create/query/submit;仅草稿(0)与已驳回(35)可改可重提、仅草稿可删;同一档案不得同时存在审批中(15)或借出中(20)的借阅单"},
    {"role": "档案管理员/分管领导(借阅审批人)", "modules": ["ARC", "BPM"], "operations": ["借阅审批两级:档案管理员审核 â†’ åˆ†ç®¡é¢†å¯¼å®¡æ‰¹", "在审批中心通过自定义表单只读查看借阅单与档案信息"], "data": ["archive_borrow(状态回写)", "archive_document(状态随审批结果条件更新)", "bpm_task"], "scope": "审批人与审批方法在 BPM æµç¨‹å®šä¹‰ archive_borrow_approve å†…配置(默认依次审批·角色兜底),后端不写死审批人,部署后由管理员在流程设计器按实际组织调整"},
    {"role": "资产管理员/领用人", "modules": ["AST"], "operations": ["维护资产台账(易耗品/办公资产/固定资产)", "登记资产领用并记录领用人、时间、数量、用途", "办理部分/全部归还", "删除未发生归还的领用记录并冲销库存"], "data": ["asset_item", "asset_requisition"], "scope": "权限码 asset:item:query/create/update/delete Â· asset:requisition:query/create/update/return/delete;保管人随资产台账、领用人随领用单分别记录"},
    {"role": "用车申请人/审批人", "modules": ["VEH", "BPM"], "operations": ["提交与修改用车申请", "选择审批流程提交逐级审批", "在 BPM å®¡æ‰¹ä¸­å¿ƒå¤„理用车审核/审批节点", "查看审批结果与逐级审批记录"], "data": ["vehicle_application", "bpm_task"], "scope": "权限码 vehicle:application:query/create/update/delete/submit;审批节点与审批人由 BPM æµç¨‹å®šä¹‰é…ç½®"},
    {"role": "采购经办人", "modules": ["PUR"], "operations": ["录入采购单与采购商品明细", "维护采购时间、采购人(经办人)与备注", "草稿修改/删除"], "data": ["purchase_order", "purchase_order_item"], "scope": "权限码 purchase:order:query/create/update/delete;采购金额由明细自动汇总"},
    {"role": "归档经办人(课题/会员/财务)", "modules": ["ARC", "RES", "UMS", "FIN"], "operations": ["在课题申报书列表对「已立项」单据点「归档」", "在会员列表对「正常」会员点「归档」", "在报销单列表对「已通过」单据点「归档」(与其既有 20→40 å½’档同事务叠加)"], "data": ["archive_document(生成)", "research_application / ums_member / finance_reimbursement(回写 archive_document_id)"], "scope": "权限码 research:application:archive Â· ums:member:archive Â· finance:reimbursement:archive;调用 yudao-module-archive-api çš„ archiveBySource å¹‚等生成档案,重复点击由来源侧按错误码拦截并给出已生成档案编号"},
    {"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/mermaid/12-finance-flow.mmd
@@ -1,13 +1,15 @@
flowchart TD
  U1["报销人填报报销单·主表: æŠ¥é”€å•号(选填) æŠ¥é”€æ ‡é¢˜ æŠ¥é”€æ€»é‡‘额 ç”³è¯·æ—¥æœŸ å¤‡æ³¨"] --> U2["录入报销明细 1..n è¡Œ: è´¹ç”¨ç±»åž‹ å‘票号码 å‘票金额 å‘票日期 å¼€ç¥¨æ–¹ è´¹ç”¨è¯´æ˜Ž"]
  U1["报销人/代录人填报报销单·主表: æŠ¥é”€å•号(选填) ç”³è¯·äºº(本人只读/代发起可选) æŠ¥é”€æ ‡é¢˜ æŠ¥é”€æ€»é‡‘额 ç”³è¯·æ—¥æœŸ å¤‡æ³¨"] --> U2["录入报销明细 1..n è¡Œ: è´¹ç”¨ç±»åž‹ å‘票类型 å‘票号码 å‘票金额 å‘票日期 å¼€ç¥¨æ–¹ è´¹ç”¨è¯´æ˜Ž"]
  O1["发票 OCR è¯†åˆ«: å›¾ç‰‡/PDF ä¸Šä¼  storage-blob â†’ AI å¤šæ¨¡æ€è¯†åˆ« â†’ å›žå¡«å‘票类型/号码/金额/日期/销售方"] --> U2
  U2 --> U3[发票附件: å›¾ç‰‡æˆ– PDF å…ˆä¼  system storage-blob upload å– blobId]
  U3 --> S1[保存 create·主表与明细与全部 blobIds ä¸€æ¬¡æ€§æäº¤]
  S1 --> G1["报销单号:可自定义;留空(含纯空格)则由后端生成 BX + yyyyMMdd + 6位流水(Redis·每日重置)"]
  S1 --> P1["申请人: ä¸ä¼ é»˜è®¤ç™»å½•用户;代他人发起传实际申请人工号并校验启用;实际申请人存 applicant_id,代录人取 creator å®¡è®¡å­—段"]
  P1 --> 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]
  K1 -->|submit é€‰å®¡æ‰¹æµç¨‹ processDefinitionKey| K2["审批中 15·回写 processInstanceId;流程变量携带 applicantId/applicantName/initiatorId/initiatorName/isProxy"]
  K2 -->|BPM å®¡æ‰¹é€šè¿‡| K3[已通过 20]
  K2 -->|BPM å®¡æ‰¹é©³å›ž| K4[已驳回 35]
  K2 -->|BPM å®¡æ‰¹å–消| K1
docs/project-business/mermaid/13-ums-membership-flow.mmd
@@ -17,7 +17,12 @@
  M1 --> M2["会员编号 HY + yyyyMMdd + 6位流水(Redis)"]
  M2 --> M3["创建系统登录账号 AdminUserApi.createUser·用户名与初始密码=联系电话·昵称=申请人姓名·部门=学会会员"]
  M3 --> M4[会员落库·入会日期=当天·状态 1正常·来源申请单 applicationId å›žå¡«]
  M4 --> D2[会员列表·可手工 create / update / delete]
  M4 --> P1["会员自助维护资料: æˆ‘的资料页修改联系方式/单位/职称/研究方向等 â†’ æäº¤å˜æ›´ç”³è¯·(待审核)"]
  P1 --> P2["管理员资料变更审核: é€šè¿‡â†’全量写回会员资料 / é©³å›žâ†’必须填写原因并回显给会员"]
  P2 --> M4
  M4 --> R1["权限隔离: æ™®é€šä¼šå‘˜è§’色(9100)仅授予「我的资料」菜单·不含档案管理(1075600~1075699)·档案接口仍需 archive:document:* æƒé™"]
  M4 --> F1["会费缴纳标识: fee_paid æ˜¯å¦ç¼´çº³ä¼šè´¹ + fee_year ç¼´è´¹å¹´åº¦(已缴必填·未缴清空)"]
  F1 --> D2[会员列表·可手工 create / update / delete·列表展示已缴年度]
  D2 --> V1["等级类型校验: å•位会员(memberType=2) ä¸å¯é€‰ã€Žç†äº‹é•¿ã€(memberGrade=5) ç›´æŽ¥æ‹’绝"]
  M4 --> D1[会员通讯录·只读·按地区 regionCode ä¸Žè¡Œä¸š industry æ£€ç´¢]
  ATT1["附件 recordType = ums_application"] -.-> K1
docs/project-business/mermaid/14-research-flow.mmd
@@ -10,10 +10,12 @@
  G4 -->|"指南已发布才可作为申报前提"| A1
  subgraph P2 [在线申报]
    A1[选指南·要求已发布 ä¸”在申报期内] --> A2[选会员 ums_member è‡ªåŠ¨å¸¦å‡º å§“名 å·¥ä½œå•位 èŒç§° ç”µè¯ é‚®ç®±]
    A2 --> A3[填课题名称 è¯¾é¢˜ç»„成员 ç”³è¯·ç»è´¹ ç”³æŠ¥ä¹¦æ­£æ–‡ ç”³æŠ¥é™„ä»¶]
    A2 --> A3["填课题名称 è¯¾é¢˜ç»„成员(子表) ç”³è¯·ç»è´¹ ç”³æŠ¥ä¹¦æ­£æ–‡ ç”³æŠ¥é™„件;联系电话必填且校验 11 ä½æ‰‹æœºå·"]
    A3 --> A3M["成员子表: å§“名/联系电话/所属单位/职称职务/成员角色/排序 é€è¡Œå½•入·随主表先删后增"]
    A3 --> A4["申报编号 SB + yyyyMMdd + 6位流水·类别冗余自指南不信任前端传值"]
    A4 --> K1[草稿 0]
  end
  M1["成员维度统计: å§“名+电话+单位相同视为同一成员 â†’ å‚与课题数与课题名单 â†’ å¯¼å‡º Excel / å¤åˆ¶ç¾¤å‘名单"] --- K1
  K1 -->|update ä¿®æ”¹Â·ä»…草稿 0 ä¸Žå·²é©³å›ž 35 æ”¾è¡Œ| K1
  K1 -->|delete åˆ é™¤Â·ä»…草稿 0 æ”¾è¡ŒÂ·è¿žå¸¦æ¸…理附件| K1
  K1 -->|submit é€‰å®¡æ‰¹æµç¨‹ processDefinitionKey| K2["审批中 15·回写 processInstanceId"]
docs/sql/wxcp_module.sql
@@ -3,9 +3,11 @@
-- ç›®æ ‡åº“:mom-jssm(36.213.150.237:10000)
-- è¯´æ˜Žï¼šé¡¹ç›®ä¸ä½¿ç”¨å¤šç§Ÿæˆ·ï¼Œå› æ­¤ä¸å« tenant_id å­—段
--
-- æ´»åŠ¨å°é¢ã€æŠ¥åæäº¤çš„å›¾ç‰‡/附件都不落本模块的表:
-- æ´»åŠ¨å°é¢ã€è¯¦æƒ…å›¾ç‰‡ã€æŠ¥åæäº¤çš„å›¾ç‰‡/附件都不落本模块的表:
-- ç»Ÿä¸€èµ° system_storage_attachment ä¸­é—´è¡¨ï¼Œ
-- æ´»åŠ¨å°é¢ record_type = 'wxcp_activity',报名附件 record_type = 'wxcp_activity_signup'
-- æ´»åŠ¨å°é¢ record_type = 'wxcp_activity',活动详情图片 record_type = 'wxcp_activity_detail',
-- å›¾ç‰‡é€šçŸ¥ record_type = 'wxcp_activity_image',附件通知 record_type = 'wxcp_activity_file',
-- æŠ¥åé™„ä»¶ record_type = 'wxcp_activity_signup'
-- ============================================================
-- ----------------------------
@@ -14,7 +16,12 @@
CREATE TABLE IF NOT EXISTS `wxcp_activity` (
  `id`                 bigint       NOT NULL AUTO_INCREMENT                COMMENT '编号',
  `title`              varchar(128) NOT NULL                               COMMENT '活动标题',
  `activity_type`      tinyint      NOT NULL DEFAULT 5                     COMMENT '活动模板:1纯通知 2单条消息 3图片通知 4附件通知 5数据采集/评价',
  `content`            text                                                COMMENT '活动详情',
  `button_text`        varchar(32)           DEFAULT NULL                  COMMENT '卡片按钮文案',
  `rating_enabled`     bit(1)       NOT NULL DEFAULT b'0'                  COMMENT '是否开启评价',
  `rating_title`       varchar(128)          DEFAULT NULL                  COMMENT '评价标题',
  `rating_max`         int          NOT NULL DEFAULT 5                     COMMENT '评价最高星级',
  `location`           varchar(255)          DEFAULT ''                   COMMENT '活动地点',
  `start_time`         datetime              DEFAULT NULL                 COMMENT '活动开始时间',
  `end_time`           datetime              DEFAULT NULL                 COMMENT '活动结束时间',
@@ -51,6 +58,7 @@
  `mobile`        varchar(32)           DEFAULT ''                        COMMENT '手机号',
  `dept_name`     varchar(255)          DEFAULT ''                        COMMENT '部门名称',
  `content`       text                                                    COMMENT '参与说明(成员提交的文字内容)',
  `rating_score`  int                   DEFAULT NULL                      COMMENT '评价星级',
  `status`        tinyint      NOT NULL DEFAULT 0                         COMMENT '状态:0已报名 1已取消 2已签到',
  `signup_time`   datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP         COMMENT '报名时间',
  `check_in_time` datetime              DEFAULT NULL                      COMMENT '签到时间',
@@ -72,6 +80,13 @@
  `id`            bigint       NOT NULL AUTO_INCREMENT                    COMMENT '编号',
  `title`         varchar(128) NOT NULL                                   COMMENT '通知标题',
  `content`       text                                                    COMMENT '通知正文',
  `notice_type`   tinyint      NOT NULL DEFAULT 1                         COMMENT '通知模板:1纯文本 2图文卡片(填报/链接) 3文件通知 4图文消息',
  `summary`       varchar(512)          DEFAULT NULL                      COMMENT '摘要(图文卡片/图文消息)',
  `link_url`      varchar(1024)         DEFAULT NULL                      COMMENT '跳转链接(填报/详情)',
  `link_btn_text` varchar(32)           DEFAULT NULL                      COMMENT '链接按钮文案',
  `cover_url`     varchar(1024)         DEFAULT NULL                      COMMENT '图文消息封面图地址',
  `file_blob_id`  bigint                DEFAULT NULL                      COMMENT '文件通知的附件 Blob ID',
  `file_name`     varchar(255)          DEFAULT NULL                      COMMENT '文件通知的附件文件名',
  `status`        tinyint      NOT NULL DEFAULT 0                         COMMENT '状态:0草稿 1已发布 2已撤回',
  `push_scope`    tinyint      NOT NULL DEFAULT 0                         COMMENT '推送范围:0全员 1指定部门 2指定成员',
  `push_target`   varchar(1024)         DEFAULT ''                        COMMENT '推送目标:部门ID或成员账号,多个用逗号分隔',
@@ -100,3 +115,51 @@
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ============================================================
-- æ´»åŠ¨è‡ªå®šä¹‰é‡‡é›†å­—æ®µï¼ˆæ•°æ®é‡‡é›†/评价模板使用)
-- ============================================================
CREATE TABLE IF NOT EXISTS `wxcp_activity_field` (
  `id`            bigint       NOT NULL AUTO_INCREMENT                    COMMENT '编号',
  `activity_id`   bigint       NOT NULL                                   COMMENT '活动编号',
  `field_label`   varchar(128) NOT NULL                                   COMMENT '字段名称',
  `field_type`    varchar(32)  NOT NULL DEFAULT 'text'                    COMMENT '字段类型:text/textarea/number/date/select/radio/checkbox/rating',
  `field_options` varchar(1000)         DEFAULT NULL                      COMMENT '选项,多个用英文逗号分隔',
  `required`      bit(1)       NOT NULL DEFAULT b'0'                      COMMENT '是否必填',
  `placeholder`   varchar(255)          DEFAULT NULL                      COMMENT '输入提示',
  `sort_no`       int          NOT NULL DEFAULT 0                         COMMENT '排序号',
  `creator`       varchar(64)           DEFAULT ''                        COMMENT '创建者',
  `create_time`   datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP         COMMENT '创建时间',
  `updater`       varchar(64)           DEFAULT ''                        COMMENT '更新者',
  `update_time`   datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP         COMMENT '更新时间',
  `deleted`       bit(1)       NOT NULL DEFAULT b'0'                      COMMENT '是否删除',
  PRIMARY KEY (`id`),
  KEY `idx_activity_id` (`activity_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '企业微信活动采集字段';
-- ============================================================
-- æŠ¥åé‡‡é›†å€¼
-- ============================================================
CREATE TABLE IF NOT EXISTS `wxcp_activity_signup_value` (
  `id`           bigint        NOT NULL AUTO_INCREMENT                    COMMENT '编号',
  `signup_id`    bigint        NOT NULL                                   COMMENT '报名记录编号',
  `activity_id`  bigint        NOT NULL                                   COMMENT '活动编号',
  `field_id`     bigint        NOT NULL                                   COMMENT '字段编号',
  `field_label`  varchar(128)  NOT NULL                                   COMMENT '字段名称(快照)',
  `field_type`   varchar(32)            DEFAULT NULL                      COMMENT '字段类型(快照)',
  `field_value`  varchar(2000)          DEFAULT NULL                      COMMENT '字段值,多选用英文逗号分隔',
  `sort_no`      int           NOT NULL DEFAULT 0                         COMMENT '排序号',
  `creator`      varchar(64)            DEFAULT ''                        COMMENT '创建者',
  `create_time`  datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP         COMMENT '创建时间',
  `updater`      varchar(64)            DEFAULT ''                        COMMENT '更新者',
  `update_time`  datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP         COMMENT '更新时间',
  `deleted`      bit(1)        NOT NULL DEFAULT b'0'                      COMMENT '是否删除',
  PRIMARY KEY (`id`),
  KEY `idx_signup_id` (`signup_id`),
  KEY `idx_activity_id` (`activity_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '企业微信活动报名采集值';
-- ============================================================
-- å‡çº§è„šæœ¬ï¼šæ´»åŠ¨å¤šæ¨¡æ¿å­—æ®µï¼ˆå·²å»ºåº“çŽ¯å¢ƒæ‰§è¡Œï¼Œæ–°åº“æ— éœ€æ‰§è¡Œï¼‰
-- å…·ä½“见 docs/sql/wxcp_activity_templates_20261021.sql
-- ============================================================
docs/wxcp_activity_integration.md
@@ -53,6 +53,7 @@
| title | String | æ˜¯ | æ´»åŠ¨æ ‡é¢˜ï¼Œâ‰¤128 å­—符 |
| content | String | å¦ | æ´»åŠ¨è¯¦æƒ… |
| blobIds | Long[] | å¦ | **封面图文件编号**,最多 1 ä¸ªï¼›ä¸ä¼  = ä¸æ”¹åŠ¨å°é¢ï¼Œä¼  `[]` = æ¸…空封面 |
| detailBlobIds | Long[] | å¦ | **详情图片文件编号**,最多 5 ä¸ªï¼›ä¸ä¼  = ä¸æ”¹åŠ¨ï¼Œä¼  `[]` = æ¸…空 |
| location | String | å¦ | æ´»åŠ¨åœ°ç‚¹ï¼Œâ‰¤255 å­—符 |
| startTime | DateTime | å¦ | æ´»åŠ¨å¼€å§‹æ—¶é—´ |
| endTime | DateTime | å¦ | æ´»åŠ¨ç»“æŸæ—¶é—´ |
@@ -73,6 +74,7 @@
| pushStatus | Integer | 0未推送 1已推送 |
| pushTime | DateTime | æœ€è¿‘推送时间 |
| attachmentList | Object[] | **封面图附件列表**(最多 1 æ¡ï¼‰ï¼Œå­—段同报名附件:`id`、`name`、`url`、`previewURL`、`downloadURL`。列表缩略图、表单回显都从这里取 |
| detailAttachmentList | Object[] | **详情图片附件列表**(最多 5 æ¡ï¼ŒæŒ‰ä¸Šä¼ å…ˆåŽæŽ’序),字段同上。表单回显从这里取 |
### æŽ¨é€ä¸ŽäºŒç»´ç 
@@ -237,7 +239,9 @@
| å–消后重新报名 | å¤ç”¨åŽŸè®°å½•ï¼Œä¸ä¼šäº§ç”Ÿç¬¬äºŒæ¡åŽ†å² |
| å–消报名 | æ´»åЍ `signupCount` è‡ªåЍ -1 |
| å°é¢å›¾ | ä¸å­˜ä¸šåŠ¡è¡¨å­—æ®µï¼Œå­˜ `system_storage_attachment`(`record_type = wxcp_activity`)。删除活动时一并清掉附件关系 |
| å°é¢å›¾å±•示 | æŠ¥åé¡µé¡¶éƒ¨ä»¥ `object-fit: cover` å±•示;管理后台列表在标题左侧显示缩略图 |
| å°é¢å›¾å±•示 | æŠ¥åé¡µé¡¶éƒ¨ä»¥ `object-fit: cover` å±•示(会按 307×260 è£å‰ªï¼‰ï¼›ç®¡ç†åŽå°åˆ—表在标题左侧显示缩略图 |
| è¯¦æƒ…图片 | åŒæ ·å­˜ `system_storage_attachment`,但 `record_type = wxcp_activity_detail`,与封面互不影响,最多 5 å¼ ï¼Œåˆ é™¤æ´»åŠ¨æ—¶ä¸€å¹¶æ¸…æŽ‰ |
| è¯¦æƒ…图片展示 | æŠ¥åé¡µåœ¨ã€Œæ´»åŠ¨è¯¦æƒ…ã€æ–‡å­—ä¸‹æ–¹æŒ‰**原图比例全宽展示,不裁剪**,点一下打开原图。**二维码、海报这类被裁就失效的图必须放详情图片,不能放封面** |
## æ³¨æ„äº‹é¡¹
@@ -253,6 +257,8 @@
- **附件上传走 system æ¨¡å—的公共上传接口**:报名页先调 `POST /admin-api/system/storage-blob/public-upload`(表单字段名 `files`)拿到 `blobId`,再把 `blobIds` éšæŠ¥åæäº¤ï¼Œç”±**服务端**完成与报名记录的绑定。管理后台查附件用 `/wxcp/activity/signup/attachments`。
- **报名的 `recordType` å›ºå®šä¸º `wxcp_activity_signup`**,已注册进 system æ¨¡å—çš„ `StorageRecordTypeEnum` ç™½åå•。以后新增别的附件关联类型,必须同步注册,否则绑定会报「无效的记录类型」。
- **活动封面的 `recordType` ä¸º `wxcp_activity`**,同样已注册进白名单。封面**不再有 `coverUrl` å­—段**,`POST/PUT /wxcp/activity/create|update` æäº¤ `blobIds`,`GET` è¯» `attachmentList`。
- **活动详情图片的 `recordType` ä¸º `wxcp_activity_detail`**,已注册进白名单。提交 `detailBlobIds`、`GET` è¯» `detailAttachmentList`(按 blobId å‡åº = ä¸Šä¼ å…ˆåŽï¼Œå› ä¸ºåº•层 `selectByIds` ä¸æ‰¿è¯ºé¡ºåºï¼‰ã€‚
- **封面和详情图片是两套独立关系**,各自「不传 = ä¸æ”¹åŠ¨ã€ä¼  `[]` = æ¸…空」,改一个不会影响另一个。
- **封面图在报名页显示时会去掉协议和域名**。附件地址里的域名取自 `yudao.storage.domain`(一般是内网地址),而手机扫码是从公网域名打开页面的,直连内网地址加载不出图片,所以后端只保留 `/admin-api/...` è·¯å¾„交给同源代理。**如果部署时管理后台与报名页不同源,这个处理需要重新评估。**
- è¡¨å•里的封面用 `ImageUpload` ç»„件(`maxNumber: 1`)时,组件返回的是**单值而不是数组**,提交前必须包成数组(`blobIds` åŽç«¯ç­¾åæ˜¯ `List<Long>`)。
- é€šè®¯å½•接口**实时调企业微信**,有频率限制,前端务必缓存,不要随输入实时请求。
pom.xml
@@ -61,6 +61,9 @@
        <!-- æ¡£æ¡ˆç®¡ç†æ¨¡å— -->
        <module>yudao-module-archive</module>
        <module>yudao-module-archive-api</module>
        <module>yudao-module-asset</module>
        <module>yudao-module-vehicle</module>
        <module>yudao-module-purchase</module>
    </modules>
    <name>${project.artifactId}</name>
yudao-module-asset/pom.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,61 @@
<?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-asset</artifactId>
    <packaging>jar</packaging>
    <name>${project.artifactId}</name>
    <description>
        asset åŒ…下,资产管理模块(资产台账:易耗品/办公资产/固定资产;资产领用:保管人、领用人、领用时间、数量、用途、归还)。
    </description>
    <dependencies>
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-system</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-asset/src/main/java/cn/iocoder/yudao/module/asset/controller/admin/item/AssetItemController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,93 @@
package cn.iocoder.yudao.module.asset.controller.admin.item;
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.asset.controller.admin.item.vo.AssetItemPageReqVO;
import cn.iocoder.yudao.module.asset.controller.admin.item.vo.AssetItemRespVO;
import cn.iocoder.yudao.module.asset.controller.admin.item.vo.AssetItemSaveReqVO;
import cn.iocoder.yudao.module.asset.dal.dataobject.item.AssetItemDO;
import cn.iocoder.yudao.module.asset.enums.AssetCategoryEnum;
import cn.iocoder.yudao.module.asset.service.item.AssetItemService;
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.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - èµ„产台账")
@RestController
@RequestMapping("/asset/item")
@Validated
public class AssetItemController {
    @Resource
    private AssetItemService itemService;
    @PostMapping("/create")
    @Operation(summary = "创建资产")
    @PreAuthorize("@ss.hasPermission('asset:item:create')")
    public CommonResult<Long> createItem(@Valid @RequestBody AssetItemSaveReqVO reqVO) {
        return success(itemService.createItem(reqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "更新资产")
    @PreAuthorize("@ss.hasPermission('asset:item:update')")
    public CommonResult<Boolean> updateItem(@Valid @RequestBody AssetItemSaveReqVO reqVO) {
        itemService.updateItem(reqVO);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除资产")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('asset:item:delete')")
    public CommonResult<Boolean> deleteItem(@RequestParam("id") Long id) {
        itemService.deleteItem(id);
        return success(true);
    }
    @GetMapping("/get")
    @Operation(summary = "获得资产")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('asset:item:query')")
    public CommonResult<AssetItemRespVO> getItem(@RequestParam("id") Long id) {
        return success(buildRespVO(itemService.getItem(id)));
    }
    @GetMapping("/page")
    @Operation(summary = "获得资产分页")
    @PreAuthorize("@ss.hasPermission('asset:item:query')")
    public CommonResult<PageResult<AssetItemRespVO>> getItemPage(@Valid AssetItemPageReqVO reqVO) {
        PageResult<AssetItemDO> pageResult = itemService.getItemPage(reqVO);
        return success(new PageResult<>(buildRespVOList(pageResult.getList()), pageResult.getTotal()));
    }
    @GetMapping("/simple-list")
    @Operation(summary = "获得资产精简列表(用于领用选择)")
    @PreAuthorize("@ss.hasPermission('asset:item:query')")
    public CommonResult<List<AssetItemRespVO>> getItemSimpleList() {
        return success(buildRespVOList(itemService.getItemSimpleList()));
    }
    private AssetItemRespVO buildRespVO(AssetItemDO item) {
        if (item == null) {
            return null;
        }
        return buildRespVOList(java.util.Collections.singletonList(item)).get(0);
    }
    private List<AssetItemRespVO> buildRespVOList(List<AssetItemDO> list) {
        return BeanUtils.toBean(list, AssetItemRespVO.class, vo ->
                vo.setCategoryName(AssetCategoryEnum.nameOf(vo.getCategory())));
    }
}
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/controller/admin/item/vo/AssetItemPageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,25 @@
package cn.iocoder.yudao.module.asset.controller.admin.item.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 AssetItemPageReqVO extends PageParam {
    @Schema(description = "资产编号", example = "ZC20261020000001")
    private String assetNo;
    @Schema(description = "资产名称", example = "打印机")
    private String name;
    @Schema(description = "资产分类:1-易耗品 2-办公资产 3-固定资产", example = "2")
    private Integer category;
    @Schema(description = "保管人姓名", example = "张三")
    private String custodianName;
}
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/controller/admin/item/vo/AssetItemRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,62 @@
package cn.iocoder.yudao.module.asset.controller.admin.item.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - èµ„产台账 Response VO")
@Data
public class AssetItemRespVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "资产编号", example = "ZC20261020000001")
    private String assetNo;
    @Schema(description = "资产名称", example = "激光打印机")
    private String name;
    @Schema(description = "资产分类", example = "2")
    private Integer category;
    @Schema(description = "资产分类名称", example = "办公资产")
    private String categoryName;
    @Schema(description = "规格型号", example = "HP M405dn")
    private String spec;
    @Schema(description = "计量单位", example = "台")
    private String unit;
    @Schema(description = "当前库存数量", example = "10")
    private Integer quantity;
    @Schema(description = "单价(元)", example = "1999.00")
    private BigDecimal unitPrice;
    @Schema(description = "总价值(元)", example = "19990.00")
    private BigDecimal totalAmount;
    @Schema(description = "购入日期", example = "2026-10-20")
    private LocalDate purchaseDate;
    @Schema(description = "保管人用户 ID", example = "1024")
    private Long custodianId;
    @Schema(description = "保管人姓名", example = "张三")
    private String custodianName;
    @Schema(description = "存放地点", example = "行政楼 302")
    private String location;
    @Schema(description = "备注")
    private String remark;
    @Schema(description = "创建时间")
    private LocalDateTime createTime;
}
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/controller/admin/item/vo/AssetItemSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,66 @@
package cn.iocoder.yudao.module.asset.controller.admin.item.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
@Schema(description = "管理后台 - èµ„产台账新增/修改 Request VO")
@Data
public class AssetItemSaveReqVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "资产编号,留空自动生成", example = "ZC20261020000001")
    private String assetNo;
    @Schema(description = "资产名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "激光打印机")
    @NotBlank(message = "资产名称不能为空")
    @Size(max = 128, message = "资产名称不能超过 128 ä¸ªå­—符")
    private String name;
    @Schema(description = "资产分类:1-易耗品 2-办公资产 3-固定资产", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
    @NotNull(message = "请选择资产分类")
    private Integer category;
    @Schema(description = "规格型号", example = "HP M405dn")
    @Size(max = 128, message = "规格型号不能超过 128 ä¸ªå­—符")
    private String spec;
    @Schema(description = "计量单位", example = "台")
    @Size(max = 16, message = "计量单位不能超过 16 ä¸ªå­—符")
    private String unit;
    @Schema(description = "当前库存数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
    @NotNull(message = "库存数量不能为空")
    @Min(value = 0, message = "库存数量不能小于 0")
    private Integer quantity;
    @Schema(description = "单价(元)", example = "1999.00")
    private BigDecimal unitPrice;
    @Schema(description = "购入日期", example = "2026-10-20")
    private LocalDate purchaseDate;
    @Schema(description = "保管人(管理人)用户 ID", example = "1024")
    private Long custodianId;
    @Schema(description = "保管人(管理人)姓名", example = "张三")
    @Size(max = 64, message = "保管人姓名不能超过 64 ä¸ªå­—符")
    private String custodianName;
    @Schema(description = "存放地点", example = "行政楼 302")
    @Size(max = 128, message = "存放地点不能超过 128 ä¸ªå­—符")
    private String location;
    @Schema(description = "备注")
    @Size(max = 512, message = "备注不能超过 512 ä¸ªå­—符")
    private String remark;
}
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/controller/admin/requisition/AssetRequisitionController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,102 @@
package cn.iocoder.yudao.module.asset.controller.admin.requisition;
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.asset.controller.admin.requisition.vo.AssetRequisitionPageReqVO;
import cn.iocoder.yudao.module.asset.controller.admin.requisition.vo.AssetRequisitionRespVO;
import cn.iocoder.yudao.module.asset.controller.admin.requisition.vo.AssetRequisitionReturnReqVO;
import cn.iocoder.yudao.module.asset.controller.admin.requisition.vo.AssetRequisitionSaveReqVO;
import cn.iocoder.yudao.module.asset.dal.dataobject.requisition.AssetRequisitionDO;
import cn.iocoder.yudao.module.asset.enums.AssetCategoryEnum;
import cn.iocoder.yudao.module.asset.enums.AssetReturnStatusEnum;
import cn.iocoder.yudao.module.asset.service.requisition.AssetRequisitionService;
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.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - èµ„产领用")
@RestController
@RequestMapping("/asset/requisition")
@Validated
public class AssetRequisitionController {
    @Resource
    private AssetRequisitionService requisitionService;
    @PostMapping("/create")
    @Operation(summary = "创建资产领用")
    @PreAuthorize("@ss.hasPermission('asset:requisition:create')")
    public CommonResult<Long> createRequisition(@Valid @RequestBody AssetRequisitionSaveReqVO reqVO) {
        return success(requisitionService.createRequisition(reqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "更新资产领用(仅领用人、用途、预计归还日期、备注)")
    @PreAuthorize("@ss.hasPermission('asset:requisition:update')")
    public CommonResult<Boolean> updateRequisition(@Valid @RequestBody AssetRequisitionSaveReqVO reqVO) {
        requisitionService.updateRequisition(reqVO);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除资产领用(未发生归还时冲销库存)")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('asset:requisition:delete')")
    public CommonResult<Boolean> deleteRequisition(@RequestParam("id") Long id) {
        requisitionService.deleteRequisition(id);
        return success(true);
    }
    @PutMapping("/return")
    @Operation(summary = "归还资产")
    @PreAuthorize("@ss.hasPermission('asset:requisition:return')")
    public CommonResult<Boolean> returnRequisition(@Valid @RequestBody AssetRequisitionReturnReqVO reqVO) {
        requisitionService.returnRequisition(reqVO);
        return success(true);
    }
    @GetMapping("/get")
    @Operation(summary = "获得资产领用")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('asset:requisition:query')")
    public CommonResult<AssetRequisitionRespVO> getRequisition(@RequestParam("id") Long id) {
        return success(buildRespVO(requisitionService.getRequisition(id)));
    }
    @GetMapping("/page")
    @Operation(summary = "获得资产领用分页")
    @PreAuthorize("@ss.hasPermission('asset:requisition:query')")
    public CommonResult<PageResult<AssetRequisitionRespVO>> getRequisitionPage(
            @Valid AssetRequisitionPageReqVO reqVO) {
        PageResult<AssetRequisitionDO> pageResult = requisitionService.getRequisitionPage(reqVO);
        return success(new PageResult<>(buildRespVOList(pageResult.getList()), pageResult.getTotal()));
    }
    private AssetRequisitionRespVO buildRespVO(AssetRequisitionDO requisition) {
        if (requisition == null) {
            return null;
        }
        return buildRespVOList(java.util.Collections.singletonList(requisition)).get(0);
    }
    private List<AssetRequisitionRespVO> buildRespVOList(List<AssetRequisitionDO> list) {
        return BeanUtils.toBean(list, AssetRequisitionRespVO.class, vo -> {
            vo.setCategoryName(AssetCategoryEnum.nameOf(vo.getCategory()));
            vo.setReturnStatusName(AssetReturnStatusEnum.nameOf(vo.getReturnStatus()));
            int quantity = vo.getQuantity() == null ? 0 : vo.getQuantity();
            int returned = vo.getReturnedQuantity() == null ? 0 : vo.getReturnedQuantity();
            vo.setRemainingQuantity(Math.max(quantity - returned, 0));
        });
    }
}
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/controller/admin/requisition/vo/AssetRequisitionPageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
package cn.iocoder.yudao.module.asset.controller.admin.requisition.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 AssetRequisitionPageReqVO extends PageParam {
    @Schema(description = "领用单号", example = "LY20261020000001")
    private String no;
    @Schema(description = "资产名称", example = "打印机")
    private String assetName;
    @Schema(description = "领用人姓名", example = "李四")
    private String recipientName;
    @Schema(description = "资产分类:1-易耗品 2-办公资产 3-固定资产", example = "2")
    private Integer category;
    @Schema(description = "归还状态:1-未归还 2-部分归还 3-已归还", example = "1")
    private Integer returnStatus;
}
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/controller/admin/requisition/vo/AssetRequisitionRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,79 @@
package cn.iocoder.yudao.module.asset.controller.admin.requisition.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - èµ„产领用 Response VO")
@Data
public class AssetRequisitionRespVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "领用单号", example = "LY20261020000001")
    private String no;
    @Schema(description = "资产 ID", example = "1")
    private Long assetId;
    @Schema(description = "资产编号", example = "ZC20261020000001")
    private String assetNo;
    @Schema(description = "资产名称", example = "激光打印机")
    private String assetName;
    @Schema(description = "资产分类", example = "2")
    private Integer category;
    @Schema(description = "资产分类名称", example = "办公资产")
    private String categoryName;
    @Schema(description = "保管人(管理人)用户 ID", example = "1024")
    private Long custodianId;
    @Schema(description = "保管人(管理人)姓名", example = "张三")
    private String custodianName;
    @Schema(description = "领用人用户 ID", example = "1025")
    private Long recipientId;
    @Schema(description = "领用人姓名", example = "李四")
    private String recipientName;
    @Schema(description = "领用时间")
    private LocalDateTime requisitionTime;
    @Schema(description = "领用数量", example = "1")
    private Integer quantity;
    @Schema(description = "用途", example = "行政办公打印")
    private String purpose;
    @Schema(description = "预计归还日期")
    private LocalDate expectedReturnDate;
    @Schema(description = "已归还数量", example = "0")
    private Integer returnedQuantity;
    @Schema(description = "未归还数量", example = "1")
    private Integer remainingQuantity;
    @Schema(description = "最近归还时间")
    private LocalDateTime returnTime;
    @Schema(description = "归还状态:1-未归还 2-部分归还 3-已归还", example = "1")
    private Integer returnStatus;
    @Schema(description = "归还状态名称", example = "未归还")
    private String returnStatusName;
    @Schema(description = "备注")
    private String remark;
    @Schema(description = "创建时间")
    private LocalDateTime createTime;
}
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/controller/admin/requisition/vo/AssetRequisitionReturnReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
package cn.iocoder.yudao.module.asset.controller.admin.requisition.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - èµ„产归还 Request VO")
@Data
public class AssetRequisitionReturnReqVO {
    @Schema(description = "领用记录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "领用记录编号不能为空")
    private Long id;
    @Schema(description = "本次归还数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "归还数量不能为空")
    @Min(value = 1, message = "归还数量必须大于 0")
    private Integer returnQuantity;
    @Schema(description = "归还时间,不传默认当前时间")
    private LocalDateTime returnTime;
    @Schema(description = "备注")
    private String remark;
}
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/controller/admin/requisition/vo/AssetRequisitionSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,50 @@
package cn.iocoder.yudao.module.asset.controller.admin.requisition.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - èµ„产领用新增/修改 Request VO")
@Data
public class AssetRequisitionSaveReqVO {
    @Schema(description = "编号(修改时必填)", example = "1")
    private Long id;
    @Schema(description = "资产 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "请选择领用资产")
    private Long assetId;
    @Schema(description = "领用人用户 ID", example = "1024")
    private Long recipientId;
    @Schema(description = "领用人姓名", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四")
    @Size(max = 64, message = "领用人姓名不能超过 64 ä¸ªå­—符")
    private String recipientName;
    @Schema(description = "领用时间", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotNull(message = "领用时间不能为空")
    private LocalDateTime requisitionTime;
    @Schema(description = "领用数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "领用数量不能为空")
    @Min(value = 1, message = "领用数量必须大于 0")
    private Integer quantity;
    @Schema(description = "用途", example = "行政办公打印")
    @Size(max = 512, message = "用途不能超过 512 ä¸ªå­—符")
    private String purpose;
    @Schema(description = "预计归还日期", example = "2026-11-20")
    private LocalDate expectedReturnDate;
    @Schema(description = "备注")
    @Size(max = 512, message = "备注不能超过 512 ä¸ªå­—符")
    private String remark;
}
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/dal/dataobject/item/AssetItemDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,67 @@
package cn.iocoder.yudao.module.asset.dal.dataobject.item;
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
 */
@TableName("asset_item")
@KeySequence("asset_item_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AssetItemDO extends BaseDO {
    @TableId
    private Long id;
    /** èµ„产编号 */
    private String assetNo;
    /** èµ„产名称 */
    private String name;
    /** èµ„产分类:1-易耗品 2-办公资产 3-固定资产 */
    private Integer category;
    /** è§„格型号 */
    private String spec;
    /** è®¡é‡å•位 */
    private String unit;
    /** å½“前库存数量 */
    private Integer quantity;
    /** å•价(元) */
    private BigDecimal unitPrice;
    /** æ€»ä»·å€¼ï¼ˆå…ƒï¼‰ */
    private BigDecimal totalAmount;
    /** è´­å…¥æ—¥æœŸ */
    private LocalDate purchaseDate;
    /** ä¿ç®¡äººï¼ˆç®¡ç†äººï¼‰ç”¨æˆ· ID */
    private Long custodianId;
    /** ä¿ç®¡äººï¼ˆç®¡ç†äººï¼‰å§“名 */
    private String custodianName;
    /** å­˜æ”¾åœ°ç‚¹ */
    private String location;
    /** å¤‡æ³¨ */
    private String remark;
}
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/dal/dataobject/requisition/AssetRequisitionDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,79 @@
package cn.iocoder.yudao.module.asset.dal.dataobject.requisition;
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;
import java.time.LocalDateTime;
/**
 * èµ„产领用 DO
 */
@TableName("asset_requisition")
@KeySequence("asset_requisition_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AssetRequisitionDO extends BaseDO {
    @TableId
    private Long id;
    /** é¢†ç”¨å•号 */
    private String no;
    /** èµ„产 ID */
    private Long assetId;
    /** èµ„产编号(快照) */
    private String assetNo;
    /** èµ„产名称(快照) */
    private String assetName;
    /** èµ„产分类(快照) */
    private Integer category;
    /** ä¿ç®¡äººï¼ˆç®¡ç†äººï¼‰ç”¨æˆ· ID */
    private Long custodianId;
    /** ä¿ç®¡äººï¼ˆç®¡ç†äººï¼‰å§“名 */
    private String custodianName;
    /** é¢†ç”¨äººç”¨æˆ· ID */
    private Long recipientId;
    /** é¢†ç”¨äººå§“名 */
    private String recipientName;
    /** é¢†ç”¨æ—¶é—´ */
    private LocalDateTime requisitionTime;
    /** é¢†ç”¨æ•°é‡ */
    private Integer quantity;
    /** ç”¨é€” */
    private String purpose;
    /** é¢„计归还日期 */
    private LocalDate expectedReturnDate;
    /** å·²å½’还数量 */
    private Integer returnedQuantity;
    /** æœ€è¿‘归还时间 */
    private LocalDateTime returnTime;
    /** å½’还状态:1-未归还 2-部分归还 3-已归还 */
    private Integer returnStatus;
    /** å¤‡æ³¨ */
    private String remark;
}
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/dal/mysql/item/AssetItemMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
package cn.iocoder.yudao.module.asset.dal.mysql.item;
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.asset.controller.admin.item.vo.AssetItemPageReqVO;
import cn.iocoder.yudao.module.asset.dal.dataobject.item.AssetItemDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface AssetItemMapper extends BaseMapperX<AssetItemDO> {
    @Update("UPDATE asset_item SET quantity = quantity - #{quantity}, update_time = NOW() "
            + "WHERE id = #{id} AND deleted = 0 AND quantity >= #{quantity}")
    int decreaseStock(@Param("id") Long id, @Param("quantity") Integer quantity);
    @Update("UPDATE asset_item SET quantity = quantity + #{quantity}, update_time = NOW() "
            + "WHERE id = #{id} AND deleted = 0")
    int increaseStock(@Param("id") Long id, @Param("quantity") Integer quantity);
    default AssetItemDO selectByAssetNo(String assetNo) {
        return selectOne(AssetItemDO::getAssetNo, assetNo);
    }
    default PageResult<AssetItemDO> selectPage(AssetItemPageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<AssetItemDO>()
                .likeIfPresent(AssetItemDO::getAssetNo, reqVO.getAssetNo())
                .likeIfPresent(AssetItemDO::getName, reqVO.getName())
                .eqIfPresent(AssetItemDO::getCategory, reqVO.getCategory())
                .likeIfPresent(AssetItemDO::getCustodianName, reqVO.getCustodianName())
                .orderByDesc(AssetItemDO::getId));
    }
}
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/dal/mysql/requisition/AssetRequisitionMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
package cn.iocoder.yudao.module.asset.dal.mysql.requisition;
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.asset.controller.admin.requisition.vo.AssetRequisitionPageReqVO;
import cn.iocoder.yudao.module.asset.dal.dataobject.requisition.AssetRequisitionDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AssetRequisitionMapper extends BaseMapperX<AssetRequisitionDO> {
    default PageResult<AssetRequisitionDO> selectPage(AssetRequisitionPageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<AssetRequisitionDO>()
                .likeIfPresent(AssetRequisitionDO::getNo, reqVO.getNo())
                .likeIfPresent(AssetRequisitionDO::getAssetName, reqVO.getAssetName())
                .likeIfPresent(AssetRequisitionDO::getRecipientName, reqVO.getRecipientName())
                .eqIfPresent(AssetRequisitionDO::getCategory, reqVO.getCategory())
                .eqIfPresent(AssetRequisitionDO::getReturnStatus, reqVO.getReturnStatus())
                .orderByDesc(AssetRequisitionDO::getId));
    }
    default Long selectCountByAssetIdAndNotReturned(Long assetId) {
        return selectCount(new LambdaQueryWrapperX<AssetRequisitionDO>()
                .eq(AssetRequisitionDO::getAssetId, assetId)
                .ne(AssetRequisitionDO::getReturnStatus, 3));
    }
}
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/dal/redis/AssetNoRedisDAO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
package cn.iocoder.yudao.module.asset.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;
/**
 * Asset ç¼–号 Redis DAO
 */
@Repository
public class AssetNoRedisDAO {
    /** èµ„产编号前缀 */
    public static final String ASSET_ITEM_NO_PREFIX = "ZC";
    /** é¢†ç”¨å•号前缀 */
    public static final String ASSET_REQUISITION_NO_PREFIX = "LY";
    @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-asset/src/main/java/cn/iocoder/yudao/module/asset/dal/redis/RedisKeyConstants.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,16 @@
package cn.iocoder.yudao.module.asset.dal.redis;
/**
 * Asset Redis Key æžšä¸¾ç±»
 */
public interface RedisKeyConstants {
    /**
     * å•号自增序列
     * <p>
     * KEY æ ¼å¼ï¼šasset:no:{prefix}{yyyyMMdd}
     * VALUE æ ¼å¼ï¼šInteger
     */
    String NO = "asset:no:";
}
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/enums/AssetCategoryEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
package cn.iocoder.yudao.module.asset.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
 * èµ„产分类枚举
 */
@Getter
@AllArgsConstructor
public enum AssetCategoryEnum {
    CONSUMABLE(1, "易耗品"),
    OFFICE(2, "办公资产"),
    FIXED(3, "固定资产");
    private final Integer category;
    private final String name;
    public static String nameOf(Integer category) {
        for (AssetCategoryEnum value : values()) {
            if (value.category.equals(category)) {
                return value.name;
            }
        }
        return "未知";
    }
}
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/enums/AssetReturnStatusEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
package cn.iocoder.yudao.module.asset.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
 * èµ„产领用归还状态枚举
 */
@Getter
@AllArgsConstructor
public enum AssetReturnStatusEnum {
    NOT_RETURNED(1, "未归还"),
    PARTIAL(2, "部分归还"),
    RETURNED(3, "已归还");
    private final Integer status;
    private final String name;
    public static String nameOf(Integer status) {
        for (AssetReturnStatusEnum value : values()) {
            if (value.status.equals(status)) {
                return value.name;
            }
        }
        return "未知";
    }
}
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/enums/ErrorCodeConstants.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,25 @@
package cn.iocoder.yudao.module.asset.enums;
import cn.iocoder.yudao.framework.common.exception.ErrorCode;
/**
 * Asset é”™è¯¯ç æžšä¸¾ç±»
 * <p>
 * asset ç³»ç»Ÿï¼Œä½¿ç”¨ 1-030-109-000 æ®µï¼ˆæŽ¥åœ¨ ums 1-030-108 ä¹‹åŽï¼‰
 */
public interface ErrorCodeConstants {
    // ========== èµ„产台账(1-030-109-000 ~ 009) ==========
    ErrorCode ASSET_ITEM_NOT_EXISTS = new ErrorCode(1_030_109_000, "资产不存在,请刷新列表后重新选择");
    ErrorCode ASSET_ITEM_STOCK_NOT_ENOUGH = new ErrorCode(1_030_109_001, "资产「{}」库存不足:当前可领用 {},本次申请 {},请减少领用数量或先补充入库");
    ErrorCode ASSET_ITEM_HAS_ACTIVE_REQUISITION = new ErrorCode(1_030_109_002, "资产「{}」仍有 {} æ¡æœªå½’还的领用记录,请先处理归还后再删除");
    ErrorCode ASSET_ITEM_QUANTITY_INVALID = new ErrorCode(1_030_109_003, "资产数量必须大于 0,请修正后再保存");
    ErrorCode ASSET_ITEM_NO_EXISTS = new ErrorCode(1_030_109_004, "资产编号「{}」已存在,请更换编号后重新保存");
    // ========== èµ„产领用(1-030-109-100 ~ 109) ==========
    ErrorCode ASSET_REQUISITION_NOT_EXISTS = new ErrorCode(1_030_109_100, "资产领用记录不存在,请刷新列表后重试");
    ErrorCode ASSET_REQUISITION_RETURN_QUANTITY_INVALID = new ErrorCode(1_030_109_101, "归还数量不正确:领用 {},已归还 {},本次归还 {},本次归还数量不能超过剩余未归还数量 {}");
    ErrorCode ASSET_REQUISITION_DELETE_FAIL_RETURNED = new ErrorCode(1_030_109_102, "该领用记录已发生归还,不能删除;如需冲销请先完成全部归还");
    ErrorCode ASSET_REQUISITION_RETURN_COMPLETED = new ErrorCode(1_030_109_103, "该领用记录已全部归还,无需重复归还");
}
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/service/item/AssetItemService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
package cn.iocoder.yudao.module.asset.service.item;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.asset.controller.admin.item.vo.AssetItemPageReqVO;
import cn.iocoder.yudao.module.asset.controller.admin.item.vo.AssetItemSaveReqVO;
import cn.iocoder.yudao.module.asset.dal.dataobject.item.AssetItemDO;
import java.util.Collection;
import java.util.List;
/**
 * èµ„产台账 Service æŽ¥å£
 */
public interface AssetItemService {
    Long createItem(AssetItemSaveReqVO reqVO);
    void updateItem(AssetItemSaveReqVO reqVO);
    void deleteItem(Long id);
    AssetItemDO getItem(Long id);
    List<AssetItemDO> getItemList(Collection<Long> ids);
    List<AssetItemDO> getItemSimpleList();
    PageResult<AssetItemDO> getItemPage(AssetItemPageReqVO reqVO);
    /** æ‰£å‡åº“存(领用),库存不足时抛出业务异常 */
    void decreaseStock(Long id, Integer quantity);
    /** å¢žåŠ åº“å­˜ï¼ˆå½’è¿˜/冲销) */
    void increaseStock(Long id, Integer quantity);
}
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/service/item/AssetItemServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,154 @@
package cn.iocoder.yudao.module.asset.service.item;
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.asset.controller.admin.item.vo.AssetItemPageReqVO;
import cn.iocoder.yudao.module.asset.controller.admin.item.vo.AssetItemSaveReqVO;
import cn.iocoder.yudao.module.asset.dal.dataobject.item.AssetItemDO;
import cn.iocoder.yudao.module.asset.dal.mysql.item.AssetItemMapper;
import cn.iocoder.yudao.module.asset.dal.mysql.requisition.AssetRequisitionMapper;
import cn.iocoder.yudao.module.asset.dal.redis.AssetNoRedisDAO;
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.Collection;
import java.util.Collections;
import java.util.List;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.asset.enums.ErrorCodeConstants.*;
/**
 * èµ„产台账 Service å®žçŽ°ç±»
 */
@Service
@Validated
@Slf4j
public class AssetItemServiceImpl implements AssetItemService {
    @Resource
    private AssetItemMapper itemMapper;
    @Resource
    private AssetRequisitionMapper requisitionMapper;
    @Resource
    private AssetNoRedisDAO noRedisDAO;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createItem(AssetItemSaveReqVO reqVO) {
        String assetNo = StrUtil.trimToNull(reqVO.getAssetNo());
        if (assetNo == null) {
            assetNo = noRedisDAO.generate(AssetNoRedisDAO.ASSET_ITEM_NO_PREFIX);
        } else if (itemMapper.selectByAssetNo(assetNo) != null) {
            throw exception(ASSET_ITEM_NO_EXISTS, assetNo);
        }
        final String finalAssetNo = assetNo;
        AssetItemDO item = BeanUtils.toBean(reqVO, AssetItemDO.class, in -> in
                .setAssetNo(finalAssetNo)
                .setUnit(StrUtil.blankToDefault(reqVO.getUnit(), "个"))
                .setQuantity(reqVO.getQuantity() == null ? 0 : reqVO.getQuantity())
                .setTotalAmount(calcTotalAmount(reqVO.getUnitPrice(), reqVO.getQuantity())));
        itemMapper.insert(item);
        return item.getId();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateItem(AssetItemSaveReqVO reqVO) {
        AssetItemDO old = validateItemExists(reqVO.getId());
        String assetNo = StrUtil.trimToNull(reqVO.getAssetNo());
        if (assetNo != null && !assetNo.equals(old.getAssetNo())) {
            AssetItemDO exists = itemMapper.selectByAssetNo(assetNo);
            if (exists != null && !exists.getId().equals(reqVO.getId())) {
                throw exception(ASSET_ITEM_NO_EXISTS, assetNo);
            }
        }
        AssetItemDO updateObj = BeanUtils.toBean(reqVO, AssetItemDO.class, in -> in
                .setAssetNo(assetNo)
                .setUnit(StrUtil.blankToDefault(reqVO.getUnit(), "个"))
                .setTotalAmount(calcTotalAmount(reqVO.getUnitPrice(), reqVO.getQuantity())));
        itemMapper.updateById(updateObj);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteItem(Long id) {
        AssetItemDO item = validateItemExists(id);
        Long activeCount = requisitionMapper.selectCountByAssetIdAndNotReturned(id);
        if (activeCount != null && activeCount > 0) {
            throw exception(ASSET_ITEM_HAS_ACTIVE_REQUISITION, item.getName(), activeCount);
        }
        itemMapper.deleteById(id);
    }
    @Override
    public AssetItemDO getItem(Long id) {
        return itemMapper.selectById(id);
    }
    @Override
    public List<AssetItemDO> getItemList(Collection<Long> ids) {
        if (CollUtil.isEmpty(ids)) {
            return Collections.emptyList();
        }
        return itemMapper.selectBatchIds(ids);
    }
    @Override
    public List<AssetItemDO> getItemSimpleList() {
        return itemMapper.selectList();
    }
    @Override
    public PageResult<AssetItemDO> getItemPage(AssetItemPageReqVO reqVO) {
        return itemMapper.selectPage(reqVO);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void decreaseStock(Long id, Integer quantity) {
        if (quantity == null || quantity <= 0) {
            throw exception(ASSET_ITEM_QUANTITY_INVALID);
        }
        AssetItemDO item = validateItemExists(id);
        int rows = itemMapper.decreaseStock(id, quantity);
        if (rows == 0) {
            throw exception(ASSET_ITEM_STOCK_NOT_ENOUGH, item.getName(),
                    item.getQuantity() == null ? 0 : item.getQuantity(), quantity);
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void increaseStock(Long id, Integer quantity) {
        if (quantity == null || quantity <= 0) {
            throw exception(ASSET_ITEM_QUANTITY_INVALID);
        }
        validateItemExists(id);
        itemMapper.increaseStock(id, quantity);
    }
    private AssetItemDO validateItemExists(Long id) {
        AssetItemDO item = id != null ? itemMapper.selectById(id) : null;
        if (item == null) {
            throw exception(ASSET_ITEM_NOT_EXISTS);
        }
        return item;
    }
    private BigDecimal calcTotalAmount(BigDecimal unitPrice, Integer quantity) {
        if (unitPrice == null || quantity == null) {
            return BigDecimal.ZERO;
        }
        return unitPrice.multiply(BigDecimal.valueOf(quantity));
    }
}
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/service/requisition/AssetRequisitionService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,27 @@
package cn.iocoder.yudao.module.asset.service.requisition;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.asset.controller.admin.requisition.vo.AssetRequisitionPageReqVO;
import cn.iocoder.yudao.module.asset.controller.admin.requisition.vo.AssetRequisitionReturnReqVO;
import cn.iocoder.yudao.module.asset.controller.admin.requisition.vo.AssetRequisitionSaveReqVO;
import cn.iocoder.yudao.module.asset.dal.dataobject.requisition.AssetRequisitionDO;
/**
 * èµ„产领用 Service æŽ¥å£
 */
public interface AssetRequisitionService {
    Long createRequisition(AssetRequisitionSaveReqVO reqVO);
    void updateRequisition(AssetRequisitionSaveReqVO reqVO);
    void deleteRequisition(Long id);
    /** å½’还资产 */
    void returnRequisition(AssetRequisitionReturnReqVO reqVO);
    AssetRequisitionDO getRequisition(Long id);
    PageResult<AssetRequisitionDO> getRequisitionPage(AssetRequisitionPageReqVO reqVO);
}
yudao-module-asset/src/main/java/cn/iocoder/yudao/module/asset/service/requisition/AssetRequisitionServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,141 @@
package cn.iocoder.yudao.module.asset.service.requisition;
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.asset.controller.admin.requisition.vo.AssetRequisitionPageReqVO;
import cn.iocoder.yudao.module.asset.controller.admin.requisition.vo.AssetRequisitionReturnReqVO;
import cn.iocoder.yudao.module.asset.controller.admin.requisition.vo.AssetRequisitionSaveReqVO;
import cn.iocoder.yudao.module.asset.dal.dataobject.item.AssetItemDO;
import cn.iocoder.yudao.module.asset.dal.dataobject.requisition.AssetRequisitionDO;
import cn.iocoder.yudao.module.asset.dal.mysql.requisition.AssetRequisitionMapper;
import cn.iocoder.yudao.module.asset.dal.redis.AssetNoRedisDAO;
import cn.iocoder.yudao.module.asset.enums.AssetReturnStatusEnum;
import cn.iocoder.yudao.module.asset.service.item.AssetItemService;
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 static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.asset.enums.ErrorCodeConstants.*;
/**
 * èµ„产领用 Service å®žçŽ°ç±»
 */
@Service
@Validated
@Slf4j
public class AssetRequisitionServiceImpl implements AssetRequisitionService {
    @Resource
    private AssetRequisitionMapper requisitionMapper;
    @Resource
    private AssetItemService itemService;
    @Resource
    private AssetNoRedisDAO noRedisDAO;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createRequisition(AssetRequisitionSaveReqVO reqVO) {
        AssetItemDO item = itemService.getItem(reqVO.getAssetId());
        if (item == null) {
            throw exception(ASSET_ITEM_NOT_EXISTS);
        }
        // å…ˆæ‰£åº“存,库存不足会抛出精确提示
        itemService.decreaseStock(item.getId(), reqVO.getQuantity());
        AssetRequisitionDO requisition = BeanUtils.toBean(reqVO, AssetRequisitionDO.class, in -> in
                .setNo(noRedisDAO.generate(AssetNoRedisDAO.ASSET_REQUISITION_NO_PREFIX))
                .setAssetNo(item.getAssetNo())
                .setAssetName(item.getName())
                .setCategory(item.getCategory())
                .setCustodianId(item.getCustodianId())
                .setCustodianName(item.getCustodianName())
                .setReturnedQuantity(0)
                .setReturnStatus(AssetReturnStatusEnum.NOT_RETURNED.getStatus()));
        requisitionMapper.insert(requisition);
        return requisition.getId();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateRequisition(AssetRequisitionSaveReqVO reqVO) {
        AssetRequisitionDO old = validateRequisitionExists(reqVO.getId());
        // ä¿®æ”¹ä»…允许调整领用人、用途、预计归还日期与备注;资产与数量不允许直接改,避免库存账实不符
        AssetRequisitionDO updateObj = new AssetRequisitionDO()
                .setId(old.getId())
                .setRecipientId(reqVO.getRecipientId())
                .setRecipientName(reqVO.getRecipientName())
                .setPurpose(reqVO.getPurpose())
                .setExpectedReturnDate(reqVO.getExpectedReturnDate())
                .setRemark(reqVO.getRemark());
        requisitionMapper.updateById(updateObj);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteRequisition(Long id) {
        AssetRequisitionDO requisition = validateRequisitionExists(id);
        int returned = requisition.getReturnedQuantity() == null ? 0 : requisition.getReturnedQuantity();
        if (returned > 0) {
            throw exception(ASSET_REQUISITION_DELETE_FAIL_RETURNED);
        }
        // å†²é”€ï¼šæŠŠæœªå½’还数量退回库存
        itemService.increaseStock(requisition.getAssetId(), requisition.getQuantity());
        requisitionMapper.deleteById(id);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void returnRequisition(AssetRequisitionReturnReqVO reqVO) {
        AssetRequisitionDO requisition = validateRequisitionExists(reqVO.getId());
        int total = requisition.getQuantity() == null ? 0 : requisition.getQuantity();
        int returned = requisition.getReturnedQuantity() == null ? 0 : requisition.getReturnedQuantity();
        int remaining = total - returned;
        if (remaining <= 0) {
            throw exception(ASSET_REQUISITION_RETURN_COMPLETED);
        }
        if (reqVO.getReturnQuantity() == null || reqVO.getReturnQuantity() <= 0
                || reqVO.getReturnQuantity() > remaining) {
            throw exception(ASSET_REQUISITION_RETURN_QUANTITY_INVALID,
                    total, returned, reqVO.getReturnQuantity(), remaining);
        }
        int newReturned = returned + reqVO.getReturnQuantity();
        int newStatus = newReturned >= total
                ? AssetReturnStatusEnum.RETURNED.getStatus()
                : AssetReturnStatusEnum.PARTIAL.getStatus();
        requisitionMapper.updateById(new AssetRequisitionDO()
                .setId(requisition.getId())
                .setReturnedQuantity(newReturned)
                .setReturnTime(reqVO.getReturnTime() != null ? reqVO.getReturnTime() : LocalDateTime.now())
                .setReturnStatus(newStatus)
                .setRemark(StrUtil.blankToDefault(reqVO.getRemark(), requisition.getRemark())));
        itemService.increaseStock(requisition.getAssetId(), reqVO.getReturnQuantity());
    }
    @Override
    public AssetRequisitionDO getRequisition(Long id) {
        return requisitionMapper.selectById(id);
    }
    @Override
    public PageResult<AssetRequisitionDO> getRequisitionPage(AssetRequisitionPageReqVO reqVO) {
        return requisitionMapper.selectPage(reqVO);
    }
    private AssetRequisitionDO validateRequisitionExists(Long id) {
        AssetRequisitionDO requisition = id != null ? requisitionMapper.selectById(id) : null;
        if (requisition == null) {
            throw exception(ASSET_REQUISITION_NOT_EXISTS);
        }
        return requisition;
    }
}
yudao-module-finance/pom.xml
@@ -43,6 +43,13 @@
            <version>${revision}</version>
        </dependency>
        <!-- AI å¤§æ¨¡åž‹ API(报销发票 OCR è¯†åˆ«ï¼‰ -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-ai-api</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- Web ç›¸å…³ -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
@@ -76,6 +83,13 @@
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-spring-boot-starter-test</artifactId>
        </dependency>
        <!-- PDF è§£æž -->
        <dependency>
            <groupId>org.apache.pdfbox</groupId>
            <artifactId>pdfbox</artifactId>
            <version>3.0.4</version>
        </dependency>
    </dependencies>
</project>
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/api/reimbursement/FinanceReimbursementApiImpl.java
@@ -53,7 +53,7 @@
        String processDefinitionKey = resolveApproveProcessDefinitionKey();
        // 2. åˆ›å»ºæŠ¥é”€å•(1 è¡Œæ˜Žç»†ï¼‰
        FinanceReimbursementSaveReqVO createReqVO = buildSaveReqVO(null, reqDTO.getTitle(),
        FinanceReimbursementSaveReqVO createReqVO = buildSaveReqVO(null, reqDTO.getApplicantId(), reqDTO.getTitle(),
                reqDTO.getTotalAmount(), reqDTO.getApplyDate(), reqDTO.getRemark(),
                reqDTO.getBlobIds(), reqDTO.getExpenseDescription());
        Long reimbursementId = reimbursementService.createReimbursement(createReqVO);
@@ -72,7 +72,7 @@
        String processDefinitionKey = resolveApproveProcessDefinitionKey();
        // 2. ä¿®æ”¹æŠ¥é”€å•(明细重建为 1 è¡Œï¼‰
        FinanceReimbursementSaveReqVO updateReqVO = buildSaveReqVO(reqDTO.getReimbursementId(),
        FinanceReimbursementSaveReqVO updateReqVO = buildSaveReqVO(reqDTO.getReimbursementId(), null,
                reqDTO.getTitle(), reqDTO.getTotalAmount(), reqDTO.getApplyDate(), reqDTO.getRemark(),
                reqDTO.getBlobIds(), reqDTO.getExpenseDescription());
        reimbursementService.updateReimbursement(updateReqVO);
@@ -110,7 +110,7 @@
    }
    /** ç»„装报销单保存 VO:明细固定 1 è¡Œï¼Œè´¹ç”¨ç±»åž‹ã€Œå…¶ä»–」,发票金额=报销总金额 */
    private FinanceReimbursementSaveReqVO buildSaveReqVO(Long id, String title, BigDecimal totalAmount,
    private FinanceReimbursementSaveReqVO buildSaveReqVO(Long id, Long applicantId, String title, BigDecimal totalAmount,
                                                         LocalDate applyDate, String remark, List<Long> blobIds,
                                                         String expenseDescription) {
        FinanceReimbursementItemSaveReqVO item = new FinanceReimbursementItemSaveReqVO();
@@ -121,6 +121,7 @@
        FinanceReimbursementSaveReqVO reqVO = new FinanceReimbursementSaveReqVO();
        reqVO.setId(id);
        reqVO.setApplicantId(applicantId);
        reqVO.setTitle(title);
        reqVO.setTotalAmount(totalAmount);
        reqVO.setApplyDate(applyDate != null ? applyDate : LocalDate.now());
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/ai/FinanceReimbursementAiController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,39 @@
package cn.iocoder.yudao.module.finance.controller.admin.reimbursement.ai;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.finance.controller.admin.reimbursement.ai.vo.FinanceReimbursementOcrReqVO;
import cn.iocoder.yudao.module.finance.controller.admin.reimbursement.ai.vo.FinanceReimbursementOcrRespVO;
import cn.iocoder.yudao.module.finance.service.reimbursement.ai.FinanceReimbursementAiService;
import io.swagger.v3.oas.annotations.Operation;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - è´¢åŠ¡æŠ¥é”€å• AI OCR")
@RestController
@RequestMapping("/finance/reimbursement/ai")
@Validated
public class FinanceReimbursementAiController {
    @Resource
    private FinanceReimbursementAiService reimbursementAiService;
    @PostMapping("/ocr")
    @Operation(summary = "AI OCR æ‰¹é‡è¯†åˆ«æŠ¥é”€å‘票文件")
    @PreAuthorize("@ss.hasPermission('finance:reimbursement:query')")
    public CommonResult<List<FinanceReimbursementOcrRespVO>> ocrInvoices(
            @Valid @RequestBody FinanceReimbursementOcrReqVO reqVO) {
        return success(reimbursementAiService.ocrInvoices(reqVO));
    }
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/ai/vo/FinanceReimbursementOcrReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,20 @@
package cn.iocoder.yudao.module.finance.controller.admin.reimbursement.ai.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
@Schema(description = "管理后台 - æŠ¥é”€å•发票 OCR è¯†åˆ« Request VO")
@Data
public class FinanceReimbursementOcrReqVO {
    @Schema(description = "发票文件 Blob ç¼–号列表", requiredMode = Schema.RequiredMode.REQUIRED,
            example = "[1024, 1025]")
    @NotEmpty(message = "请先上传发票文件")
    @Size(max = 20, message = "单次最多识别 20 å¼ å‘票")
    private List<Long> blobIds;
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/ai/vo/FinanceReimbursementOcrRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,46 @@
package cn.iocoder.yudao.module.finance.controller.admin.reimbursement.ai.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
@Schema(description = "管理后台 - æŠ¥é”€å•发票 OCR è¯†åˆ« Response VO")
@Data
public class FinanceReimbursementOcrRespVO {
    @Schema(description = "发票文件 Blob ç¼–号", example = "1024")
    private Long blobId;
    @Schema(description = "发票号码", example = "02564178")
    private String invoiceNo;
    @Schema(description = "发票代码(数电票可能为空)", example = "044001900111")
    private String invoiceCode;
    @Schema(description = "发票类型", example = "增值税电子普通发票")
    private String invoiceType;
    @Schema(description = "购买方名称(发票抬头)", example = "江苏商贸职业学院")
    private String invoiceTitle;
    @Schema(description = "销售方名称(开票方)", example = "XX科技有限公司")
    private String sellerName;
    @Schema(description = "价税合计金额,单位:元", example = "500.00")
    private BigDecimal invoiceAmount;
    @Schema(description = "税额合计,单位:元", example = "57.52")
    private BigDecimal taxAmount;
    @Schema(description = "开票日期", example = "2026-08-01")
    private LocalDate invoiceDate;
    @Schema(description = "备注", example = "含税含运费")
    private String remark;
    @Schema(description = "识别失败时的错误提示(成功时为空)")
    private String rawText;
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/vo/FinanceReimbursementSaveReqVO.java
@@ -18,6 +18,10 @@
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "申请人编号。代他人发起时传实际申请人;不传时默认当前登录用户",
            example = "1024")
    private Long applicantId;
    @Schema(description = "报销单号。留空(含只填空格)则由系统按 BX+日期+6位流水自动生成;"
            + "修改时不传或留空表示保持原单号不变", example = "BX20260928000001")
    @Size(max = 36, message = "报销单号长度不能超过 36 ä¸ªå­—符")
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/vo/item/FinanceReimbursementItemRespVO.java
@@ -19,6 +19,9 @@
    @Schema(description = "费用类型", example = "1")
    private Integer expenseType;
    @Schema(description = "发票类型", example = "增值税专用发票")
    private String invoiceType;
    @Schema(description = "发票号码", example = "12345678")
    private String invoiceNo;
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/controller/admin/reimbursement/vo/item/FinanceReimbursementItemSaveReqVO.java
@@ -2,6 +2,7 @@
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.math.BigDecimal;
@@ -18,6 +19,10 @@
    @NotNull(message = "费用类型不能为空")
    private Integer expenseType;
    @Schema(description = "发票类型", example = "增值税专用发票")
    @Size(max = 32, message = "发票类型长度不能超过 32 ä¸ªå­—符")
    private String invoiceType;
    @Schema(description = "发票号码", example = "12345678")
    private String invoiceNo;
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/dal/dataobject/reimbursement/FinanceReimbursementItemDO.java
@@ -33,6 +33,9 @@
    /** è´¹ç”¨ç±»åž‹ï¼š1-差旅 2-办公 3-招待 4-交通 5-其他 */
    private Integer expenseType;
    /** å‘票类型:增值税专用发票 / å¢žå€¼ç¨Žæ™®é€šå‘票 / ç”µå­å‘票 / æ”¶æ® / å…¶ä»– */
    private String invoiceType;
    /** å‘票号码 */
    private String invoiceNo;
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/enums/ErrorCodeConstants.java
@@ -23,5 +23,6 @@
    ErrorCode REIMBURSEMENT_CUSTOM_NO_EXISTS = new ErrorCode(1_030_107_010, "报销单号({})已被其他报销单使用,请更换其他单号,或清空该字段由系统自动生成");
    ErrorCode REIMBURSEMENT_CUSTOM_NO_INVALID = new ErrorCode(1_030_107_011, "报销单号({})中间不能包含空格,请去掉空格后重新输入,或清空该字段由系统自动生成");
    ErrorCode REIMBURSEMENT_ALREADY_ARCHIVED = new ErrorCode(1_030_107_012, "重复归档:报销单({})已于此前归档并生成档案(编号={}),请勿重复归档,如需查看请前往档案管理模块");
    ErrorCode REIMBURSEMENT_APPLICANT_NOT_EXISTS = new ErrorCode(1_030_107_013, "申请人(ID={})不存在或已被禁用,请重新选择实际申请人");
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/service/reimbursement/FinanceReimbursementServiceImpl.java
@@ -2,6 +2,7 @@
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.archive.api.document.ArchiveDocumentApi;
@@ -24,6 +25,8 @@
import cn.iocoder.yudao.module.finance.enums.FinanceReimbursementStatusEnum;
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 jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.flowable.engine.repository.ProcessDefinition;
@@ -82,6 +85,9 @@
    private StorageAttachmentApi storageAttachmentApi;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private ArchiveDocumentApi archiveDocumentApi;
    @Resource
@@ -97,21 +103,26 @@
        // 1. æŠ¥é”€å•号:允许用户自定义,未填时由系统生成
        String no = resolveCreateNo(createReqVO.getNo());
        // 2.1 æ’入报销单
        // 2.1 ç”³è¯·äººï¼šæ”¯æŒä»£ä»–人发起;未指定时默认当前登录用户(代录人由 creator å®¡è®¡å­—段记录)
        Long applicantId = createReqVO.getApplicantId() != null
                ? createReqVO.getApplicantId() : getLoginUserId();
        validateApplicant(applicantId);
        // 2.2 æ’入报销单
        FinanceReimbursementDO reimbursement = BeanUtils.toBean(createReqVO, FinanceReimbursementDO.class, in -> in
                .setNo(no)
                .setStatus(FinanceReimbursementStatusEnum.DRAFT.getStatus())
                .setApplicantId(getLoginUserId())
                .setApplicantId(applicantId)
                .setApplyDate(createReqVO.getApplyDate() != null ? createReqVO.getApplyDate() : LocalDate.now()));
        reimbursementMapper.insert(reimbursement);
        // 2.2 æ’入报销明细
        // 2.3 æ’入报销明细
        List<FinanceReimbursementItemDO> items = buildItems(reimbursement.getId(), createReqVO.getItems());
        if (CollUtil.isNotEmpty(items)) {
            reimbursementItemMapper.insertBatch(items);
        }
        // 2.3 ç»‘定发票附件
        // 2.4 ç»‘定发票附件
        storageAttachmentApi.bindAttachments("file", REIMBURSEMENT_RECORD_TYPE,
                reimbursement.getId(), createReqVO.getBlobIds());
@@ -132,6 +143,11 @@
        // 2.1 æ›´æ–°æŠ¥é”€å•
        FinanceReimbursementDO updateObj = BeanUtils.toBean(updateReqVO, FinanceReimbursementDO.class);
        // ç”³è¯·äººï¼šä»£ä»–人发起时允许修改实际申请人;不传则保持原值
        if (updateReqVO.getApplicantId() != null) {
            validateApplicant(updateReqVO.getApplicantId());
            updateObj.setApplicantId(updateReqVO.getApplicantId());
        }
        // æŠ¥é”€å•号:未填表示不变更,填了则校验后覆盖
        String customNo = StrUtil.trimToNull(updateReqVO.getNo());
        if (customNo == null || customNo.equals(reimbursement.getNo())) {
@@ -205,10 +221,12 @@
            throw exception(REIMBURSEMENT_SUBMIT_FAIL_ITEM_EMPTY);
        }
        // 2. åˆ›å»º BPM æµç¨‹å®žä¾‹
        // 2. åˆ›å»º BPM æµç¨‹å®žä¾‹ï¼ŒæŠŠå®žé™…申请人与代录人写入流程变量,供审批流展示与路由使用
        Map<String, Object> variables = buildProcessVariables(reimbursement, userId);
        String processInstanceId = processInstanceApi.createProcessInstance(userId,
                new BpmProcessInstanceCreateReqDTO()
                        .setProcessDefinitionKey(processDefinitionKey)
                        .setVariables(variables)
                        .setBusinessKey(String.valueOf(id)));
        // 3. æ›´æ–°çŠ¶æ€ä¸ºå®¡æ‰¹ä¸­
@@ -361,6 +379,35 @@
        }
    }
    /**
     * æ ¡éªŒå®žé™…申请人存在且为启用状态
     */
    private void validateApplicant(Long applicantId) {
        AdminUserRespDTO applicant = applicantId != null ? adminUserApi.getUser(applicantId) : null;
        if (applicant == null || !CommonStatusEnum.ENABLE.getStatus().equals(applicant.getStatus())) {
            throw exception(REIMBURSEMENT_APPLICANT_NOT_EXISTS, applicantId);
        }
    }
    /**
     * æž„建报销审批流程变量:实际申请人 + ä»£å½•(发起)人
     *
     * <p>审批流中可通过 applicantName å±•示实际申请人,通过 initiatorName å±•示代录人,
     * å¹¶é€šè¿‡ isProxy åˆ¤æ–­æ˜¯å¦ä¸ºä»£ä»–人发起
     */
    private Map<String, Object> buildProcessVariables(FinanceReimbursementDO reimbursement, Long submitUserId) {
        Long applicantId = reimbursement.getApplicantId();
        AdminUserRespDTO applicant = applicantId != null ? adminUserApi.getUser(applicantId) : null;
        AdminUserRespDTO submitter = submitUserId != null ? adminUserApi.getUser(submitUserId) : null;
        Map<String, Object> variables = new HashMap<>(8);
        variables.put("applicantId", applicantId);
        variables.put("applicantName", applicant != null ? applicant.getNickname() : "");
        variables.put("initiatorId", submitUserId);
        variables.put("initiatorName", submitter != null ? submitter.getNickname() : "");
        variables.put("isProxy", applicantId != null && !applicantId.equals(submitUserId));
        return variables;
    }
    private List<FinanceReimbursementItemDO> buildItems(Long reimbursementId,
                                                         List<FinanceReimbursementItemSaveReqVO> itemVOs) {
        if (CollUtil.isEmpty(itemVOs)) {
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/service/reimbursement/ai/FinanceReimbursementAiService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,23 @@
package cn.iocoder.yudao.module.finance.service.reimbursement.ai;
import cn.iocoder.yudao.module.finance.controller.admin.reimbursement.ai.vo.FinanceReimbursementOcrReqVO;
import cn.iocoder.yudao.module.finance.controller.admin.reimbursement.ai.vo.FinanceReimbursementOcrRespVO;
import java.util.List;
/**
 * è´¢åŠ¡æŠ¥é”€å• AI OCR Service æŽ¥å£
 */
public interface FinanceReimbursementAiService {
    /**
     * æ‰¹é‡è¯†åˆ«å‘票文件,逐个返回识别结果
     *
     * <p>单个文件识别失败不影响其它文件,失败信息放在 {@link FinanceReimbursementOcrRespVO#getRawText()} ä¸­
     *
     * @param reqVO è¯†åˆ«è¯·æ±‚
     * @return ä¸Ž blobIds é¡ºåºä¸€è‡´çš„识别结果
     */
    List<FinanceReimbursementOcrRespVO> ocrInvoices(FinanceReimbursementOcrReqVO reqVO);
}
yudao-module-finance/src/main/java/cn/iocoder/yudao/module/finance/service/reimbursement/ai/FinanceReimbursementAiServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,297 @@
package cn.iocoder.yudao.module.finance.service.reimbursement.ai;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import cn.iocoder.yudao.module.ai.api.chat.AiChatApi;
import cn.iocoder.yudao.module.finance.controller.admin.reimbursement.ai.vo.FinanceReimbursementOcrReqVO;
import cn.iocoder.yudao.module.finance.controller.admin.reimbursement.ai.vo.FinanceReimbursementOcrRespVO;
import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageBlobDO;
import cn.iocoder.yudao.module.system.service.storage.SystemStorageBlobService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
 * è´¢åŠ¡æŠ¥é”€å• AI OCR Service å®žçŽ°ç±»
 *
 * <p>
 * æ”¯æŒè¯†åˆ«å‘票图片 / PDF / æ–‡æœ¬æ–‡ä»¶ï¼Œæå–发票号码、发票代码、发票类型、购买方、销售方、
 * ä»·ç¨Žåˆè®¡ã€ç¨Žé¢ã€å¼€ç¥¨æ—¥æœŸç­‰ç»“构化信息,供前端自动回填报销明细。
 * å›¾ç‰‡èµ°å¤šæ¨¡æ€è§†è§‰è¯†åˆ«ï¼ŒPDF / æ–‡æœ¬å…ˆæŠ½å–文本再走纯文本识别。
 */
@Slf4j
@Service
public class FinanceReimbursementAiServiceImpl implements FinanceReimbursementAiService {
    private static final Set<String> SUPPORTED_EXTENSIONS = Set.of(
            "pdf", "png", "jpg", "jpeg", "gif", "bmp", "txt", "csv"
    );
    private static final Set<String> IMAGE_EXTENSIONS = Set.of("png", "jpg", "jpeg", "gif", "bmp");
    /** å•个文件最大 20MB,避免超大文件拖垮识别服务 */
    private static final long MAX_FILE_SIZE = 20L * 1024 * 1024;
    private static final String SYSTEM_PROMPT = """
            ä½ æ˜¯ä¸€ä½ä¸“业的中国发票信息提取专家。请从发票文件中提取结构化信息。
            ## æå–规则
            1. invoiceNo ä¸ºå‘票号码(数电票为 20 ä½å·ç ï¼‰ï¼ŒåŽŸæ ·è¿”å›žï¼Œä¿ç•™å‰å¯¼ 0
            2. invoiceCode ä¸ºå‘票代码;数电票、电子发票没有发票代码时不要返回
            3. invoiceType ä¸ºå‘票类型,如:增值税专用发票 / å¢žå€¼ç¨Žæ™®é€šå‘票 / å¢žå€¼ç¨Žç”µå­æ™®é€šå‘票 / ç”µå­å‘票(普通发票)/ æ”¶æ® / ç«è½¦ç¥¨ / å…¶ä»–
            4. invoiceTitle ä¸ºè´­ä¹°æ–¹åç§°ï¼ˆå‘票抬头)
            5. sellerName ä¸ºé”€å”®æ–¹åç§°ï¼ˆå¼€ç¥¨æ–¹ï¼‰ï¼Œå–"销售方"或"销方"信息
            6. invoiceAmount ä¸ºä»·ç¨Žåˆè®¡ï¼ˆå°å†™ï¼‰é‡‘额,单位元,保留两位小数
            7. taxAmount ä¸ºç¨Žé¢åˆè®¡ï¼Œå•位元,保留两位小数;发票未标注税额时不返回
            8. invoiceDate ç»Ÿä¸€è½¬æ¢ä¸º yyyy-MM-dd æ ¼å¼ï¼ˆå…¼å®¹ 2026å¹´08月01日、2026/08/01 ç­‰å†™æ³•)
            9. æ•°å­—字段仅保留数字,去掉货币符号(¥、¥)和千分位逗号
            10. remark ä¸ºå‘票备注栏内容,没有则不返回
            ## è¾“出格式
            è¯·ä¸¥æ ¼æŒ‰ä»¥ä¸‹ JSON æ ¼å¼è¿”回(不要包含 Markdown ä»£ç å—或其它内容):
            {"invoiceNo": "02564178", "invoiceCode": "044001900111", "invoiceType": "增值税电子普通发票", "invoiceTitle": "江苏商贸职业学院", "sellerName": "XX科技有限公司", "invoiceAmount": 500.00, "taxAmount": 57.52, "invoiceDate": "2026-08-01", "remark": "备注信息"}
            æ‰€æœ‰å­—段均为可选,未提取到的字段不要返回。
            å¦‚果文件内容无法识别为发票,返回空 JSON:{}
            """;
    private static final String SYSTEM_PROMPT_IMAGE = """
            ä½ æ˜¯ä¸€ä½ä¸“业的中国发票 OCR è¯†åˆ«ä¸“家。请从发票图片中提取结构化信息。
            ## æå–规则
            1. ä»”细识别图片中的发票文字,注意发票可能倾斜、有印章遮挡或为拍照件
            2. invoiceNo ä¸ºå‘票号码(数电票为 20 ä½å·ç ï¼‰ï¼ŒåŽŸæ ·è¿”å›žï¼Œä¿ç•™å‰å¯¼ 0
            3. invoiceCode ä¸ºå‘票代码;数电票、电子发票没有发票代码时不要返回
            4. invoiceType ä¸ºå‘票类型,如:增值税专用发票 / å¢žå€¼ç¨Žæ™®é€šå‘票 / å¢žå€¼ç¨Žç”µå­æ™®é€šå‘票 / ç”µå­å‘票(普通发票)/ æ”¶æ® / ç«è½¦ç¥¨ / å…¶ä»–
            5. invoiceTitle ä¸ºè´­ä¹°æ–¹åç§°ï¼ˆå‘票抬头)
            6. sellerName ä¸ºé”€å”®æ–¹åç§°ï¼ˆå¼€ç¥¨æ–¹ï¼‰ï¼Œå–"销售方"或"销方"信息
            7. invoiceAmount ä¸ºä»·ç¨Žåˆè®¡ï¼ˆå°å†™ï¼‰é‡‘额,单位元,保留两位小数
            8. taxAmount ä¸ºç¨Žé¢åˆè®¡ï¼Œå•位元,保留两位小数;发票未标注税额时不返回
            9. invoiceDate ç»Ÿä¸€è½¬æ¢ä¸º yyyy-MM-dd æ ¼å¼
            10. æ•°å­—字段仅保留数字,去掉货币符号(¥、¥)和千分位逗号
            11. remark ä¸ºå‘票备注栏内容,没有则不返回
            ## è¾“出格式
            è¯·ä¸¥æ ¼æŒ‰ä»¥ä¸‹ JSON æ ¼å¼è¿”回(不要包含 Markdown ä»£ç å—或其它内容):
            {"invoiceNo": "02564178", "invoiceCode": "044001900111", "invoiceType": "增值税电子普通发票", "invoiceTitle": "江苏商贸职业学院", "sellerName": "XX科技有限公司", "invoiceAmount": 500.00, "taxAmount": 57.52, "invoiceDate": "2026-08-01", "remark": "备注信息"}
            æ‰€æœ‰å­—段均为可选,未提取到的字段不要返回。
            å¦‚果图片内容无法识别为发票,返回空 JSON:{}
            """;
    @Resource
    private SystemStorageBlobService blobService;
    @Resource
    private AiChatApi aiChatApi;
    @Override
    public List<FinanceReimbursementOcrRespVO> ocrInvoices(FinanceReimbursementOcrReqVO reqVO) {
        List<FinanceReimbursementOcrRespVO> results = new ArrayList<>(reqVO.getBlobIds().size());
        for (Long blobId : reqVO.getBlobIds()) {
            FinanceReimbursementOcrRespVO result = new FinanceReimbursementOcrRespVO();
            result.setBlobId(blobId);
            try {
                ocrSingleInvoice(blobId, result);
            } catch (Exception e) {
                log.warn("AI OCR è¯†åˆ«æŠ¥é”€å‘票失败,blobId={}", blobId, e);
                result.setRawText("AI è¯†åˆ«æš‚时不可用,请手动录入");
            }
            results.add(result);
        }
        return results;
    }
    private void ocrSingleInvoice(Long blobId, FinanceReimbursementOcrRespVO result) throws IOException {
        // 1. èŽ·å–æ–‡ä»¶
        SystemStorageBlobDO blob = blobService.getStorageBlob(blobId);
        if (blob == null) {
            result.setRawText("发票文件不存在或已被删除,请重新上传");
            return;
        }
        // 2. æ ¡éªŒæ–‡ä»¶ç±»åž‹
        String ext = StrUtil.isBlank(blob.getOriginalFilename()) ? ""
                : FileUtil.extName(blob.getOriginalFilename()).toLowerCase();
        if (!SUPPORTED_EXTENSIONS.contains(ext)) {
            result.setRawText("不支持的文件类型,请上传 PDF æˆ–图片文件");
            return;
        }
        if (blob.getByteSize() != null && blob.getByteSize() > MAX_FILE_SIZE) {
            result.setRawText("文件超过 20MB,请压缩后重新上传");
            return;
        }
        // 3. è¯»å–文件字节(磁盘上真实存储的 blob,不能删除)
        File blobFile = blobService.getPublicFile(blob.getUidFilename(), blob.getResourceKey());
        if (blobFile == null || !blobFile.exists()) {
            result.setRawText("文件读取失败,请重新上传");
            return;
        }
        byte[] fileBytes = Files.readAllBytes(blobFile.toPath());
        if (fileBytes.length == 0) {
            result.setRawText("文件内容为空,无法识别");
            return;
        }
        // 4. AI è¯†åˆ«ï¼ˆå›¾ç‰‡èµ°å¤šæ¨¡æ€ï¼Œæ–‡æœ¬èµ°çº¯æ–‡æœ¬ï¼‰
        boolean isImage = IMAGE_EXTENSIONS.contains(ext);
        String aiResponse;
        if (isImage) {
            String imageBase64 = Base64.getEncoder().encodeToString(fileBytes);
            aiResponse = aiChatApi.chatWithImage(SYSTEM_PROMPT_IMAGE,
                    "请识别图片中的发票信息", imageBase64, getMimeType(ext));
        } else {
            String extractedText = extractTextByType(ext, fileBytes);
            if (StrUtil.isEmpty(extractedText)) {
                result.setRawText("文件内容为空或为扫描件,无法识别,请上传发票图片");
                return;
            }
            aiResponse = aiChatApi.chat(SYSTEM_PROMPT, extractedText);
        }
        log.info("AI OCR è¯†åˆ«æŠ¥é”€å‘票结果: blobId={}, file={}, response={}",
                blobId, blob.getOriginalFilename(), aiResponse);
        // 5. è§£æžå“åº”
        parseOcrResponse(aiResponse, result);
    }
    private String extractTextByType(String ext, byte[] bytes) throws IOException {
        return switch (ext) {
            case "pdf" -> extractPdfText(bytes);
            case "txt", "csv" -> new String(bytes, StandardCharsets.UTF_8);
            default -> "";
        };
    }
    private String extractPdfText(byte[] bytes) throws IOException {
        try (PDDocument doc = Loader.loadPDF(bytes)) {
            PDFTextStripper stripper = new PDFTextStripper();
            stripper.setSortByPosition(true);
            String text = stripper.getText(doc);
            return StrUtil.isNotBlank(text) ? text.trim() : "";
        }
    }
    private String getMimeType(String ext) {
        return switch (ext) {
            case "jpg", "jpeg" -> "image/jpeg";
            case "gif" -> "image/gif";
            case "bmp" -> "image/bmp";
            default -> "image/png";
        };
    }
    private void parseOcrResponse(String aiResponse, FinanceReimbursementOcrRespVO result) {
        if (StrUtil.isBlank(aiResponse)) {
            result.setRawText("未识别到发票内容,请手动录入或重新上传");
            return;
        }
        try {
            String json = aiResponse;
            int start = json.indexOf("{");
            int end = json.lastIndexOf("}");
            if (start >= 0 && end > start) {
                json = json.substring(start, end + 1);
            }
            Map<String, Object> parsed = JSONUtil.toBean(json, Map.class);
            result.setInvoiceNo(toStr(parsed.get("invoiceNo")));
            result.setInvoiceCode(toStr(parsed.get("invoiceCode")));
            result.setInvoiceType(toStr(parsed.get("invoiceType")));
            result.setInvoiceTitle(toStr(parsed.get("invoiceTitle")));
            result.setSellerName(toStr(parsed.get("sellerName")));
            result.setRemark(toStr(parsed.get("remark")));
            result.setInvoiceAmount(toBigDecimal(parsed.get("invoiceAmount")));
            result.setTaxAmount(toBigDecimal(parsed.get("taxAmount")));
            String invoiceDate = toStr(parsed.get("invoiceDate"));
            if (StrUtil.isNotBlank(invoiceDate)) {
                result.setInvoiceDate(parseDate(invoiceDate));
            }
            if (!hasRecognizedField(result)) {
                result.setRawText("未识别到发票内容,请手动录入或重新上传");
            }
        } catch (Exception e) {
            log.warn("解析 AI OCR å“åº”失败: {}", aiResponse, e);
            result.setRawText("识别结果解析失败,请手动录入");
        }
    }
    /**
     * åˆ¤æ–­æ˜¯å¦è¯†åˆ«å‡ºä»»ä½•有效业务字段
     */
    private boolean hasRecognizedField(FinanceReimbursementOcrRespVO result) {
        return StrUtil.isNotBlank(result.getInvoiceNo())
                || StrUtil.isNotBlank(result.getInvoiceCode())
                || StrUtil.isNotBlank(result.getInvoiceType())
                || StrUtil.isNotBlank(result.getInvoiceTitle())
                || StrUtil.isNotBlank(result.getSellerName())
                || StrUtil.isNotBlank(result.getRemark())
                || result.getInvoiceAmount() != null
                || result.getTaxAmount() != null
                || result.getInvoiceDate() != null;
    }
    private String toStr(Object value) {
        return value == null ? null : StrUtil.trimToNull(String.valueOf(value));
    }
    /**
     * å°† AI è¿”回的数字(可能是 Number æˆ–数字字符串)转换为 BigDecimal
     */
    private BigDecimal toBigDecimal(Object value) {
        if (value == null) {
            return null;
        }
        if (value instanceof Number n) {
            return BigDecimal.valueOf(n.doubleValue());
        }
        if (value instanceof String s) {
            s = s.replace(",", "").replace("Â¥", "").replace("ï¿¥", "").trim();
            if (StrUtil.isBlank(s)) {
                return null;
            }
            try {
                return new BigDecimal(s);
            } catch (NumberFormatException ignored) {
                return null;
            }
        }
        return null;
    }
    /**
     * è§£æžæ—¥æœŸï¼Œå…¼å®¹ yyyy-MM-dd、yyyy/MM/dd、yyyyå¹´MM月dd日 ç­‰å¸¸è§æ ¼å¼
     */
    private LocalDate parseDate(String value) {
        String s = value.trim()
                .replace("å¹´", "-")
                .replace("月", "-")
                .replace("日", "")
                .replace("/", "-");
        try {
            return LocalDate.parse(s);
        } catch (Exception ignored) {
            return null;
        }
    }
}
yudao-module-purchase/pom.xml
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,61 @@
<?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-purchase</artifactId>
    <packaging>jar</packaging>
    <name>${project.artifactId}</name>
    <description>
        purchase åŒ…下,采购管理模块(采购单主表 + é‡‡è´­å•†å“æ˜Žç»†è¡Œï¼ŒåŽç»­å¯¹æŽ¥å®¡æ‰¹ä¸ŽæŠ¥é”€ï¼‰ã€‚
    </description>
    <dependencies>
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-system</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-purchase/src/main/java/cn/iocoder/yudao/module/purchase/controller/admin/order/PurchaseOrderController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,84 @@
package cn.iocoder.yudao.module.purchase.controller.admin.order;
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.purchase.controller.admin.order.vo.PurchaseOrderItemRespVO;
import cn.iocoder.yudao.module.purchase.controller.admin.order.vo.PurchaseOrderPageReqVO;
import cn.iocoder.yudao.module.purchase.controller.admin.order.vo.PurchaseOrderRespVO;
import cn.iocoder.yudao.module.purchase.controller.admin.order.vo.PurchaseOrderSaveReqVO;
import cn.iocoder.yudao.module.purchase.dal.dataobject.order.PurchaseOrderDO;
import cn.iocoder.yudao.module.purchase.service.order.PurchaseOrderService;
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 static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - é‡‡è´­å•")
@RestController
@RequestMapping("/purchase/order")
@Validated
public class PurchaseOrderController {
    @Resource
    private PurchaseOrderService orderService;
    @PostMapping("/create")
    @Operation(summary = "创建采购单")
    @PreAuthorize("@ss.hasPermission('purchase:order:create')")
    public CommonResult<Long> createOrder(@Valid @RequestBody PurchaseOrderSaveReqVO reqVO) {
        return success(orderService.createOrder(reqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "更新采购单")
    @PreAuthorize("@ss.hasPermission('purchase:order:update')")
    public CommonResult<Boolean> updateOrder(@Valid @RequestBody PurchaseOrderSaveReqVO reqVO) {
        orderService.updateOrder(reqVO);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除采购单")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('purchase:order:delete')")
    public CommonResult<Boolean> deleteOrder(@RequestParam("id") Long id) {
        orderService.deleteOrder(id);
        return success(true);
    }
    @GetMapping("/get")
    @Operation(summary = "获得采购单(含明细)")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('purchase:order:query')")
    public CommonResult<PurchaseOrderRespVO> getOrder(@RequestParam("id") Long id) {
        PurchaseOrderDO order = orderService.getOrder(id);
        if (order == null) {
            return success(null);
        }
        PurchaseOrderRespVO vo = BeanUtils.toBean(order, PurchaseOrderRespVO.class);
        vo.setItems(BeanUtils.toBean(orderService.getItemListByOrderId(id), PurchaseOrderItemRespVO.class));
        return success(vo);
    }
    @GetMapping("/page")
    @Operation(summary = "获得采购单分页")
    @PreAuthorize("@ss.hasPermission('purchase:order:query')")
    public CommonResult<PageResult<PurchaseOrderRespVO>> getOrderPage(@Valid PurchaseOrderPageReqVO reqVO) {
        PageResult<PurchaseOrderDO> pageResult = orderService.getOrderPage(reqVO);
        List<PurchaseOrderRespVO> list = BeanUtils.toBean(pageResult.getList(), PurchaseOrderRespVO.class);
        // åˆ—表不返回明细,避免 N+1 æŸ¥è¯¢
        list.forEach(vo -> vo.setItems(Collections.emptyList()));
        return success(new PageResult<>(list, pageResult.getTotal()));
    }
}
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/controller/admin/order/vo/PurchaseOrderItemRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
package cn.iocoder.yudao.module.purchase.controller.admin.order.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
@Schema(description = "管理后台 - é‡‡è´­å•†å“æ˜Žç»† Response VO")
@Data
public class PurchaseOrderItemRespVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "采购单 ID", example = "1")
    private Long orderId;
    @Schema(description = "商品名称", example = "A4 å¤å°çº¸")
    private String itemName;
    @Schema(description = "规格型号", example = "70g / 500å¼ ")
    private String spec;
    @Schema(description = "数量", example = "10")
    private BigDecimal quantity;
    @Schema(description = "单价(元)", example = "25.00")
    private BigDecimal unitPrice;
    @Schema(description = "金额(元)", example = "250.00")
    private BigDecimal amount;
    @Schema(description = "排序号", example = "1")
    private Integer sortNo;
}
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/controller/admin/order/vo/PurchaseOrderItemSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,41 @@
package cn.iocoder.yudao.module.purchase.controller.admin.order.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.math.BigDecimal;
@Schema(description = "管理后台 - é‡‡è´­å•†å“æ˜Žç»†æ–°å¢ž/修改 Request VO")
@Data
public class PurchaseOrderItemSaveReqVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "商品名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "A4 å¤å°çº¸")
    @NotBlank(message = "商品名称不能为空")
    @Size(max = 128, message = "商品名称不能超过 128 ä¸ªå­—符")
    private String itemName;
    @Schema(description = "规格型号", example = "70g / 500å¼ ")
    @Size(max = 128, message = "规格型号不能超过 128 ä¸ªå­—符")
    private String spec;
    @Schema(description = "数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
    @NotNull(message = "数量不能为空")
    @DecimalMin(value = "0.01", message = "数量必须大于 0")
    private BigDecimal quantity;
    @Schema(description = "单价(元)", requiredMode = Schema.RequiredMode.REQUIRED, example = "25.00")
    @NotNull(message = "单价不能为空")
    @DecimalMin(value = "0", message = "单价不能小于 0")
    private BigDecimal unitPrice;
    @Schema(description = "排序号", example = "1")
    private Integer sortNo;
}
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/controller/admin/order/vo/PurchaseOrderPageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package cn.iocoder.yudao.module.purchase.controller.admin.order.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 PurchaseOrderPageReqVO extends PageParam {
    @Schema(description = "采购单号", example = "CG20261020000001")
    private String no;
    @Schema(description = "采购人(经办人)", example = "张三")
    private String buyerName;
    @Schema(description = "采购时间范围")
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate[] purchaseDate;
}
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/controller/admin/order/vo/PurchaseOrderRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,45 @@
package cn.iocoder.yudao.module.purchase.controller.admin.order.vo;
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 PurchaseOrderRespVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "采购单号", example = "CG20261020000001")
    private String no;
    @Schema(description = "采购时间", example = "2026-10-20")
    private LocalDate purchaseDate;
    @Schema(description = "采购人(经办人)用户 ID", example = "1024")
    private Long buyerId;
    @Schema(description = "采购人(经办人)姓名", example = "张三")
    private String buyerName;
    @Schema(description = "采购金额(元)", example = "1250.00")
    private BigDecimal totalAmount;
    @Schema(description = "状态:0-草稿", example = "0")
    private Integer status;
    @Schema(description = "备注")
    private String remark;
    @Schema(description = "采购商品明细")
    private List<PurchaseOrderItemRespVO> items;
    @Schema(description = "创建时间")
    private LocalDateTime createTime;
}
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/controller/admin/order/vo/PurchaseOrderSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
package cn.iocoder.yudao.module.purchase.controller.admin.order.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
@Schema(description = "管理后台 - é‡‡è´­å•新增/修改 Request VO")
@Data
public class PurchaseOrderSaveReqVO {
    @Schema(description = "编号(修改时必填)", example = "1")
    private Long id;
    @Schema(description = "采购时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-10-20")
    @NotNull(message = "采购时间不能为空")
    private LocalDate purchaseDate;
    @Schema(description = "采购人(经办人)用户 ID", example = "1024")
    private Long buyerId;
    @Schema(description = "采购人(经办人)姓名", requiredMode = Schema.RequiredMode.REQUIRED, example = "张三")
    @NotBlank(message = "采购人(经办人)不能为空")
    @Size(max = 64, message = "采购人姓名不能超过 64 ä¸ªå­—符")
    private String buyerName;
    @Schema(description = "备注")
    @Size(max = 512, message = "备注不能超过 512 ä¸ªå­—符")
    private String remark;
    @Schema(description = "采购商品明细", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotEmpty(message = "采购单至少需要 1 è¡Œé‡‡è´­å•†å“æ˜Žç»†")
    @Valid
    private List<PurchaseOrderItemSaveReqVO> items;
}
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/dal/dataobject/order/PurchaseOrderDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,49 @@
package cn.iocoder.yudao.module.purchase.dal.dataobject.order;
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
 */
@TableName("purchase_order")
@KeySequence("purchase_order_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PurchaseOrderDO extends BaseDO {
    @TableId
    private Long id;
    /** é‡‡è´­å•号 */
    private String no;
    /** é‡‡è´­æ—¶é—´ */
    private LocalDate purchaseDate;
    /** é‡‡è´­äººï¼ˆç»åŠžäººï¼‰ç”¨æˆ· ID */
    private Long buyerId;
    /** é‡‡è´­äººï¼ˆç»åŠžäººï¼‰å§“å */
    private String buyerName;
    /** é‡‡è´­é‡‘额(明细金额合计) */
    private BigDecimal totalAmount;
    /** çŠ¶æ€ï¼š0-草稿 */
    private Integer status;
    /** å¤‡æ³¨ */
    private String remark;
}
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/dal/dataobject/order/PurchaseOrderItemDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
package cn.iocoder.yudao.module.purchase.dal.dataobject.order;
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;
/**
 * é‡‡è´­å•商品明细 DO
 */
@TableName("purchase_order_item")
@KeySequence("purchase_order_item_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PurchaseOrderItemDO extends BaseDO {
    @TableId
    private Long id;
    /** é‡‡è´­å• ID */
    private Long orderId;
    /** å•†å“åç§° */
    private String itemName;
    /** è§„格型号 */
    private String spec;
    /** æ•°é‡ */
    private BigDecimal quantity;
    /** å•价(元) */
    private BigDecimal unitPrice;
    /** é‡‘额(元)= æ•°é‡ Ã— å•ä»· */
    private BigDecimal amount;
    /** æŽ’序号 */
    private Integer sortNo;
}
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/dal/mysql/order/PurchaseOrderLineMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,23 @@
package cn.iocoder.yudao.module.purchase.dal.mysql.order;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.purchase.dal.dataobject.order.PurchaseOrderItemDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface PurchaseOrderLineMapper extends BaseMapperX<PurchaseOrderItemDO> {
    default List<PurchaseOrderItemDO> selectListByOrderId(Long orderId) {
        return selectList(new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX<PurchaseOrderItemDO>()
                .eq(PurchaseOrderItemDO::getOrderId, orderId)
                .orderByAsc(PurchaseOrderItemDO::getSortNo)
                .orderByAsc(PurchaseOrderItemDO::getId));
    }
    default int deleteByOrderId(Long orderId) {
        return delete(PurchaseOrderItemDO::getOrderId, orderId);
    }
}
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/dal/mysql/order/PurchaseOrderMainMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,25 @@
package cn.iocoder.yudao.module.purchase.dal.mysql.order;
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.purchase.controller.admin.order.vo.PurchaseOrderPageReqVO;
import cn.iocoder.yudao.module.purchase.dal.dataobject.order.PurchaseOrderDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PurchaseOrderMainMapper extends BaseMapperX<PurchaseOrderDO> {
    default PurchaseOrderDO selectByNo(String no) {
        return selectOne(PurchaseOrderDO::getNo, no);
    }
    default PageResult<PurchaseOrderDO> selectPage(PurchaseOrderPageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<PurchaseOrderDO>()
                .likeIfPresent(PurchaseOrderDO::getNo, reqVO.getNo())
                .likeIfPresent(PurchaseOrderDO::getBuyerName, reqVO.getBuyerName())
                .betweenIfPresent(PurchaseOrderDO::getPurchaseDate, reqVO.getPurchaseDate())
                .orderByDesc(PurchaseOrderDO::getId));
    }
}
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/dal/redis/PurchaseNoRedisDAO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,32 @@
package cn.iocoder.yudao.module.purchase.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;
/**
 * Purchase ç¼–号 Redis DAO
 */
@Repository
public class PurchaseNoRedisDAO {
    /** é‡‡è´­å•号前缀 */
    public static final String ORDER_NO_PREFIX = "CG";
    @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-purchase/src/main/java/cn/iocoder/yudao/module/purchase/dal/redis/RedisKeyConstants.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,15 @@
package cn.iocoder.yudao.module.purchase.dal.redis;
/**
 * Purchase Redis Key æžšä¸¾ç±»
 */
public interface RedisKeyConstants {
    /**
     * é‡‡è´­å•号自增序列
     * <p>
     * KEY æ ¼å¼ï¼špurchase:no:{prefix}{yyyyMMdd}
     */
    String NO = "purchase:no:";
}
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/enums/ErrorCodeConstants.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
package cn.iocoder.yudao.module.purchase.enums;
import cn.iocoder.yudao.framework.common.exception.ErrorCode;
/**
 * Purchase é”™è¯¯ç æžšä¸¾ç±»
 * <p>
 * purchase ç³»ç»Ÿï¼Œä½¿ç”¨ 1-030-111-000 æ®µï¼ˆæŽ¥åœ¨ vehicle 1-030-110 ä¹‹åŽï¼‰
 */
public interface ErrorCodeConstants {
    ErrorCode PURCHASE_ORDER_NOT_EXISTS = new ErrorCode(1_030_111_000, "采购单不存在,请刷新列表后重试");
    ErrorCode PURCHASE_ORDER_NO_EXISTS = new ErrorCode(1_030_111_001, "生成采购单号失败,请重新提交");
    ErrorCode PURCHASE_ORDER_UPDATE_FAIL = new ErrorCode(1_030_111_002, "采购单({})当前状态为〔{}〕,只有草稿状态才能修改");
    ErrorCode PURCHASE_ORDER_DELETE_FAIL = new ErrorCode(1_030_111_003, "采购单({})当前状态为〔{}〕,只有草稿状态才能删除");
    ErrorCode PURCHASE_ORDER_ITEM_EMPTY = new ErrorCode(1_030_111_004, "采购单至少需要 1 è¡Œé‡‡è´­å•†å“æ˜Žç»†ï¼Œè¯·æ·»åŠ æ˜Žç»†åŽå†ä¿å­˜");
    ErrorCode PURCHASE_ORDER_ITEM_NAME_EMPTY = new ErrorCode(1_030_111_005, "第 {} è¡Œé‡‡è´­å•†å“ï¼šå•†å“åç§°ä¸èƒ½ä¸ºç©ºï¼Œè¯·å¡«å†™åŽå†ä¿å­˜");
}
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/service/order/PurchaseOrderService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
package cn.iocoder.yudao.module.purchase.service.order;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.purchase.controller.admin.order.vo.PurchaseOrderPageReqVO;
import cn.iocoder.yudao.module.purchase.controller.admin.order.vo.PurchaseOrderSaveReqVO;
import cn.iocoder.yudao.module.purchase.dal.dataobject.order.PurchaseOrderDO;
import cn.iocoder.yudao.module.purchase.dal.dataobject.order.PurchaseOrderItemDO;
import java.util.List;
/**
 * é‡‡è´­å• Service æŽ¥å£
 */
public interface PurchaseOrderService {
    Long createOrder(PurchaseOrderSaveReqVO reqVO);
    void updateOrder(PurchaseOrderSaveReqVO reqVO);
    void deleteOrder(Long id);
    PurchaseOrderDO getOrder(Long id);
    List<PurchaseOrderItemDO> getItemListByOrderId(Long orderId);
    PageResult<PurchaseOrderDO> getOrderPage(PurchaseOrderPageReqVO reqVO);
}
yudao-module-purchase/src/main/java/cn/iocoder/yudao/module/purchase/service/order/PurchaseOrderServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,152 @@
package cn.iocoder.yudao.module.purchase.service.order;
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.purchase.controller.admin.order.vo.PurchaseOrderItemSaveReqVO;
import cn.iocoder.yudao.module.purchase.controller.admin.order.vo.PurchaseOrderPageReqVO;
import cn.iocoder.yudao.module.purchase.controller.admin.order.vo.PurchaseOrderSaveReqVO;
import cn.iocoder.yudao.module.purchase.dal.dataobject.order.PurchaseOrderDO;
import cn.iocoder.yudao.module.purchase.dal.dataobject.order.PurchaseOrderItemDO;
import cn.iocoder.yudao.module.purchase.dal.mysql.order.PurchaseOrderLineMapper;
import cn.iocoder.yudao.module.purchase.dal.mysql.order.PurchaseOrderMainMapper;
import cn.iocoder.yudao.module.purchase.dal.redis.PurchaseNoRedisDAO;
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.util.ArrayList;
import java.util.List;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.purchase.enums.ErrorCodeConstants.*;
/**
 * é‡‡è´­å• Service å®žçŽ°ç±»
 */
@Service
@Validated
@Slf4j
public class PurchaseOrderServiceImpl implements PurchaseOrderService {
    /** è‰ç¨¿çŠ¶æ€ */
    private static final Integer STATUS_DRAFT = 0;
    @Resource
    private PurchaseOrderMainMapper orderMapper;
    @Resource
    private PurchaseOrderLineMapper itemMapper;
    @Resource
    private PurchaseNoRedisDAO noRedisDAO;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createOrder(PurchaseOrderSaveReqVO reqVO) {
        List<PurchaseOrderItemDO> items = buildItems(null, reqVO.getItems());
        PurchaseOrderDO order = BeanUtils.toBean(reqVO, PurchaseOrderDO.class, in -> in
                .setNo(noRedisDAO.generate(PurchaseNoRedisDAO.ORDER_NO_PREFIX))
                .setStatus(STATUS_DRAFT)
                .setTotalAmount(sumAmount(items)));
        orderMapper.insert(order);
        items.forEach(item -> item.setOrderId(order.getId()));
        itemMapper.insertBatch(items);
        return order.getId();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateOrder(PurchaseOrderSaveReqVO reqVO) {
        PurchaseOrderDO old = validateOrderExists(reqVO.getId());
        if (!STATUS_DRAFT.equals(old.getStatus())) {
            throw exception(PURCHASE_ORDER_UPDATE_FAIL, old.getNo(), "非草稿");
        }
        List<PurchaseOrderItemDO> items = buildItems(reqVO.getId(), reqVO.getItems());
        PurchaseOrderDO updateObj = BeanUtils.toBean(reqVO, PurchaseOrderDO.class, in -> in
                .setTotalAmount(sumAmount(items)));
        // å•号与状态不允许通过修改接口变更
        updateObj.setNo(null).setStatus(null);
        orderMapper.updateById(updateObj);
        itemMapper.deleteByOrderId(reqVO.getId());
        itemMapper.insertBatch(items);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteOrder(Long id) {
        PurchaseOrderDO order = validateOrderExists(id);
        if (!STATUS_DRAFT.equals(order.getStatus())) {
            throw exception(PURCHASE_ORDER_DELETE_FAIL, order.getNo(), "非草稿");
        }
        itemMapper.deleteByOrderId(id);
        orderMapper.deleteById(id);
    }
    @Override
    public PurchaseOrderDO getOrder(Long id) {
        return orderMapper.selectById(id);
    }
    @Override
    public List<PurchaseOrderItemDO> getItemListByOrderId(Long orderId) {
        return itemMapper.selectListByOrderId(orderId);
    }
    @Override
    public PageResult<PurchaseOrderDO> getOrderPage(PurchaseOrderPageReqVO reqVO) {
        return orderMapper.selectPage(reqVO);
    }
    private PurchaseOrderDO validateOrderExists(Long id) {
        PurchaseOrderDO order = id != null ? orderMapper.selectById(id) : null;
        if (order == null) {
            throw exception(PURCHASE_ORDER_NOT_EXISTS);
        }
        return order;
    }
    /** ç»„装明细:计算每行金额与排序号 */
    private List<PurchaseOrderItemDO> buildItems(Long orderId, List<PurchaseOrderItemSaveReqVO> items) {
        if (CollUtil.isEmpty(items)) {
            throw exception(PURCHASE_ORDER_ITEM_EMPTY);
        }
        List<PurchaseOrderItemDO> result = new ArrayList<>(items.size());
        int index = 1;
        for (PurchaseOrderItemSaveReqVO item : items) {
            if (item == null || StrUtil.isBlank(item.getItemName())) {
                throw exception(PURCHASE_ORDER_ITEM_NAME_EMPTY, index);
            }
            BigDecimal quantity = item.getQuantity() == null ? BigDecimal.ZERO : item.getQuantity();
            BigDecimal unitPrice = item.getUnitPrice() == null ? BigDecimal.ZERO : item.getUnitPrice();
            BigDecimal amount = quantity.multiply(unitPrice).setScale(2, RoundingMode.HALF_UP);
            result.add(PurchaseOrderItemDO.builder()
                    .orderId(orderId)
                    .itemName(StrUtil.trim(item.getItemName()))
                    .spec(StrUtil.trim(item.getSpec()))
                    .quantity(quantity)
                    .unitPrice(unitPrice)
                    .amount(amount)
                    .sortNo(item.getSortNo() != null ? item.getSortNo() : index)
                    .build());
            index++;
        }
        return result;
    }
    private BigDecimal sumAmount(List<PurchaseOrderItemDO> items) {
        return items.stream()
                .map(PurchaseOrderItemDO::getAmount)
                .filter(java.util.Objects::nonNull)
                .reduce(BigDecimal.ZERO, BigDecimal::add)
                .setScale(2, RoundingMode.HALF_UP);
    }
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/application/ResearchApplicationController.java
@@ -1,13 +1,18 @@
package cn.iocoder.yudao.module.research.controller.admin.application;
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.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.controller.admin.application.vo.member.ResearchApplicationMemberRespVO;
import cn.iocoder.yudao.module.research.controller.admin.application.vo.member.ResearchApplicationMemberStatisticsReqVO;
import cn.iocoder.yudao.module.research.controller.admin.application.vo.member.ResearchApplicationMemberStatisticsRespVO;
import cn.iocoder.yudao.module.research.dal.dataobject.application.ResearchApplicationDO;
import cn.iocoder.yudao.module.research.dal.dataobject.guideline.ResearchGuidelineDO;
import cn.iocoder.yudao.module.research.service.application.ResearchApplicationService;
@@ -21,14 +26,17 @@
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.*;
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.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen;
@@ -83,7 +91,30 @@
    @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)));
        ResearchApplicationRespVO vo = buildApplicationDetail(applicationService.getApplication(id));
        if (vo != null) {
            vo.setMembers(BeanUtils.toBean(applicationService.getMemberListByApplicationId(id),
                    ResearchApplicationMemberRespVO.class));
        }
        return success(vo);
    }
    @GetMapping("/member-statistics")
    @Operation(summary = "按成员维度统计课题参与情况")
    @PreAuthorize("@ss.hasPermission('research:application:query')")
    public CommonResult<List<ResearchApplicationMemberStatisticsRespVO>> getMemberStatistics(
            @Valid ResearchApplicationMemberStatisticsReqVO reqVO) {
        return success(applicationService.getMemberStatistics(reqVO));
    }
    @GetMapping("/member-statistics/export-excel")
    @Operation(summary = "导出课题成员维度统计 Excel")
    @PreAuthorize("@ss.hasPermission('research:application:query')")
    @ApiAccessLog(operateType = EXPORT)
    public void exportMemberStatisticsExcel(@Valid ResearchApplicationMemberStatisticsReqVO reqVO,
                                            HttpServletResponse response) throws IOException {
        List<ResearchApplicationMemberStatisticsRespVO> list = applicationService.getMemberStatistics(reqVO);
        ExcelUtils.write(response, "课题成员统计.xls", "数据", ResearchApplicationMemberStatisticsRespVO.class, list);
    }
    @GetMapping("/page")
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/application/vo/ResearchApplicationRespVO.java
@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.research.controller.admin.application.vo;
import cn.iocoder.yudao.module.research.controller.admin.application.vo.member.ResearchApplicationMemberRespVO;
import cn.iocoder.yudao.module.system.api.storage.dto.StorageBlobRespDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -51,9 +52,12 @@
    @Schema(description = "联系邮箱", example = "zhangsan@example.com")
    private String contactEmail;
    @Schema(description = "课题组成员")
    @Schema(description = "课题组成员(旧版文本字段,仅用于兼容历史数据)")
    private String teamMembers;
    @Schema(description = "课题组成员列表(子表)")
    private List<ResearchApplicationMemberRespVO> members;
    @Schema(description = "申请经费(元)", example = "100000.00")
    private BigDecimal budgetAmount;
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/application/vo/ResearchApplicationSaveReqVO.java
@@ -1,8 +1,12 @@
package cn.iocoder.yudao.module.research.controller.admin.application.vo;
import cn.iocoder.yudao.module.research.controller.admin.application.vo.member.ResearchApplicationMemberSaveReqVO;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Data;
@@ -37,15 +41,22 @@
    @Schema(description = "职称", example = "教授")
    private String jobTitle;
    @Schema(description = "联系电话", example = "13800138000")
    @Schema(description = "联系电话(必填,11 ä½æ‰‹æœºå·ï¼‰", requiredMode = Schema.RequiredMode.REQUIRED,
            example = "13800138000")
    @NotBlank(message = "联系电话不能为空")
    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "联系电话格式不正确,请输入正确的 11 ä½æ‰‹æœºå·")
    private String contactPhone;
    @Schema(description = "联系邮箱", example = "zhangsan@example.com")
    private String contactEmail;
    @Schema(description = "课题组成员(每行一条:姓名|单位|职称)")
    @Schema(description = "课题组成员(旧版文本字段,仅保留兼容;新数据请使用 members)")
    private String teamMembers;
    @Schema(description = "课题组成员列表(子表)")
    @Valid
    private List<ResearchApplicationMemberSaveReqVO> members;
    @Schema(description = "申请经费(元)", example = "100000.00")
    @PositiveOrZero(message = "申请经费不能为负数")
    private BigDecimal budgetAmount;
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/application/vo/member/ResearchApplicationMemberRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,34 @@
package cn.iocoder.yudao.module.research.controller.admin.application.vo.member;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - è¯¾é¢˜ç”³æŠ¥ä¹¦æˆå‘˜ Response VO")
@Data
public class ResearchApplicationMemberRespVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "课题申报书 ID", example = "1")
    private Long applicationId;
    @Schema(description = "成员姓名", example = "李四")
    private String name;
    @Schema(description = "联系电话", example = "13800138000")
    private String contactPhone;
    @Schema(description = "所属单位", example = "XX大学")
    private String workUnit;
    @Schema(description = "职称/职务", example = "副教授")
    private String jobTitle;
    @Schema(description = "成员角色", example = "核心成员")
    private String memberRole;
    @Schema(description = "排序号", example = "1")
    private Integer sortNo;
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/application/vo/member/ResearchApplicationMemberSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,41 @@
package cn.iocoder.yudao.module.research.controller.admin.application.vo.member;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Schema(description = "管理后台 - è¯¾é¢˜ç”³æŠ¥ä¹¦æˆå‘˜æ–°å¢ž/修改 Request VO")
@Data
public class ResearchApplicationMemberSaveReqVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "成员姓名", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四")
    @NotBlank(message = "成员姓名不能为空")
    @Size(max = 64, message = "成员姓名长度不能超过 64 ä¸ªå­—符")
    private String name;
    @Schema(description = "联系电话(选填,填写时须为 11 ä½æ‰‹æœºå·ï¼‰", example = "13800138000")
    @Size(max = 20, message = "联系电话长度不能超过 20 ä¸ªå­—符")
    @Pattern(regexp = "^$|^1[3-9]\\d{9}$", message = "成员联系电话格式不正确,请输入正确的 11 ä½æ‰‹æœºå·")
    private String contactPhone;
    @Schema(description = "所属单位", example = "XX大学")
    @Size(max = 128, message = "所属单位长度不能超过 128 ä¸ªå­—符")
    private String workUnit;
    @Schema(description = "职称/职务", example = "副教授")
    @Size(max = 64, message = "职称/职务长度不能超过 64 ä¸ªå­—符")
    private String jobTitle;
    @Schema(description = "成员角色:主持人 / æ ¸å¿ƒæˆå‘˜ / å‚与人", example = "核心成员")
    @Size(max = 32, message = "成员角色长度不能超过 32 ä¸ªå­—符")
    private String memberRole;
    @Schema(description = "排序号", example = "1")
    private Integer sortNo;
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/application/vo/member/ResearchApplicationMemberStatisticsReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
package cn.iocoder.yudao.module.research.controller.admin.application.vo.member;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Schema(description = "管理后台 - è¯¾é¢˜æˆå‘˜ç»´åº¦ç»Ÿè®¡ Request VO")
@Data
public class ResearchApplicationMemberStatisticsReqVO {
    @Schema(description = "关键字:成员姓名 / è”系电话 / æ‰€å±žå•位", example = "张三")
    @Size(max = 64, message = "关键字长度不能超过 64 ä¸ªå­—符")
    private String keyword;
    @Schema(description = "申报书状态过滤:0草稿 15审批中 20已立项 35已驳回;不传统计全部状态",
            example = "20")
    private Integer status;
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/controller/admin/application/vo/member/ResearchApplicationMemberStatisticsRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,39 @@
package cn.iocoder.yudao.module.research.controller.admin.application.vo.member;
import cn.idev.excel.annotation.ExcelProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - è¯¾é¢˜æˆå‘˜ç»´åº¦ç»Ÿè®¡ Response VO")
@Data
public class ResearchApplicationMemberStatisticsRespVO {
    @Schema(description = "成员姓名", example = "李四")
    @ExcelProperty("成员姓名")
    private String name;
    @Schema(description = "联系电话", example = "13800138000")
    @ExcelProperty("联系电话")
    private String contactPhone;
    @Schema(description = "所属单位", example = "XX大学")
    @ExcelProperty("所属单位")
    private String workUnit;
    @Schema(description = "职称/职务", example = "副教授")
    @ExcelProperty("职称/职务")
    private String jobTitle;
    @Schema(description = "成员角色(多个角色以顿号分隔)", example = "主持人、核心成员")
    @ExcelProperty("成员角色")
    private String memberRoles;
    @Schema(description = "参与课题数", example = "2")
    @ExcelProperty("参与课题数")
    private Integer projectCount;
    @Schema(description = "参与课题名称(多个以分号分隔)", example = "课题A;课题B")
    @ExcelProperty("参与课题")
    private String projectTitles;
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/dataobject/application/ResearchApplicationMemberDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
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.*;
/**
 * è¯¾é¢˜-申报书成员子表 DO
 *
 * @author è¶…级管理员
 */
@TableName("research_application_member")
@KeySequence("research_application_member_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ResearchApplicationMemberDO extends BaseDO {
    @TableId
    private Long id;
    /** è¯¾é¢˜ç”³æŠ¥ä¹¦ ID */
    private Long applicationId;
    /** æˆå‘˜å§“名 */
    private String name;
    /** è”系电话 */
    private String contactPhone;
    /** æ‰€å±žå•位 */
    private String workUnit;
    /** èŒç§°/职务 */
    private String jobTitle;
    /** æˆå‘˜è§’色:主持人 / æ ¸å¿ƒæˆå‘˜ / å‚与人 */
    private String memberRole;
    /** æŽ’序号 */
    private Integer sortNo;
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/dal/mysql/application/ResearchApplicationMemberMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,20 @@
package cn.iocoder.yudao.module.research.dal.mysql.application;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.research.dal.dataobject.application.ResearchApplicationMemberDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface ResearchApplicationMemberMapper extends BaseMapperX<ResearchApplicationMemberDO> {
    default List<ResearchApplicationMemberDO> selectListByApplicationId(Long applicationId) {
        return selectList(ResearchApplicationMemberDO::getApplicationId, applicationId);
    }
    default int deleteByApplicationId(Long applicationId) {
        return delete(ResearchApplicationMemberDO::getApplicationId, applicationId);
    }
}
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/application/ResearchApplicationService.java
@@ -3,7 +3,10 @@
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.controller.admin.application.vo.member.ResearchApplicationMemberStatisticsReqVO;
import cn.iocoder.yudao.module.research.controller.admin.application.vo.member.ResearchApplicationMemberStatisticsRespVO;
import cn.iocoder.yudao.module.research.dal.dataobject.application.ResearchApplicationDO;
import cn.iocoder.yudao.module.research.dal.dataobject.application.ResearchApplicationMemberDO;
import jakarta.validation.Valid;
import java.math.BigDecimal;
@@ -20,6 +23,12 @@
    ResearchApplicationDO getApplication(Long id);
    /** æŸ¥è¯¢æŸç”³æŠ¥ä¹¦çš„æˆå‘˜å­è¡¨ */
    List<ResearchApplicationMemberDO> getMemberListByApplicationId(Long applicationId);
    /** æŒ‰æˆå‘˜ç»´åº¦ç»Ÿè®¡æˆå‘˜å‚与课题情况 */
    List<ResearchApplicationMemberStatisticsRespVO> getMemberStatistics(ResearchApplicationMemberStatisticsReqVO reqVO);
    PageResult<ResearchApplicationDO> getApplicationPage(ResearchApplicationPageReqVO pageReqVO);
    void submitApplication(Long id, String processDefinitionKey);
yudao-module-research/src/main/java/cn/iocoder/yudao/module/research/service/application/ResearchApplicationServiceImpl.java
@@ -7,6 +7,7 @@
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.archive.api.document.ArchiveDocumentApi;
import cn.iocoder.yudao.module.archive.api.document.dto.ArchiveDocumentCreateReqDTO;
import cn.iocoder.yudao.module.archive.api.document.dto.ArchiveDocumentRespDTO;
@@ -18,9 +19,14 @@
import cn.iocoder.yudao.module.bpm.service.definition.BpmProcessDefinitionService;
import cn.iocoder.yudao.module.research.controller.admin.application.vo.ResearchApplicationPageReqVO;
import cn.iocoder.yudao.module.research.controller.admin.application.vo.ResearchApplicationSaveReqVO;
import cn.iocoder.yudao.module.research.controller.admin.application.vo.member.ResearchApplicationMemberSaveReqVO;
import cn.iocoder.yudao.module.research.controller.admin.application.vo.member.ResearchApplicationMemberStatisticsReqVO;
import cn.iocoder.yudao.module.research.controller.admin.application.vo.member.ResearchApplicationMemberStatisticsRespVO;
import cn.iocoder.yudao.module.research.dal.dataobject.application.ResearchApplicationDO;
import cn.iocoder.yudao.module.research.dal.dataobject.application.ResearchApplicationMemberDO;
import cn.iocoder.yudao.module.research.dal.dataobject.guideline.ResearchGuidelineDO;
import cn.iocoder.yudao.module.research.dal.mysql.application.ResearchApplicationMapper;
import cn.iocoder.yudao.module.research.dal.mysql.application.ResearchApplicationMemberMapper;
import cn.iocoder.yudao.module.research.dal.redis.ResearchNoRedisDAO;
import cn.iocoder.yudao.module.research.enums.ResearchApplicationStatusEnum;
import cn.iocoder.yudao.module.research.enums.ResearchGuidelineCategoryEnum;
@@ -78,6 +84,9 @@
    private ResearchApplicationMapper applicationMapper;
    @Resource
    private ResearchApplicationMemberMapper memberMapper;
    @Resource
    private ArchiveDocumentApi archiveDocumentApi;
    @Resource
@@ -125,6 +134,7 @@
                .setStatus(ResearchApplicationStatusEnum.DRAFT.getStatus())
                .setBudgetAmount(in.getBudgetAmount() == null ? BigDecimal.ZERO : in.getBudgetAmount()));
        applicationMapper.insert(application);
        saveMembers(application.getId(), createReqVO.getMembers());
        storageAttachmentApi.bindAttachments("file", APPLICATION_RECORD_TYPE,
                application.getId(), createReqVO.getBlobIds());
@@ -147,6 +157,10 @@
        updateObj.setCategory(guideline.getCategory());
        applicationMapper.updateById(updateObj);
        // æˆå‘˜å­è¡¨ï¼šå…ˆåˆ åŽå¢žï¼Œå…¨é‡è¦†ç›–
        memberMapper.deleteByApplicationId(updateReqVO.getId());
        saveMembers(updateReqVO.getId(), updateReqVO.getMembers());
        storageAttachmentApi.updateAttachments("file", APPLICATION_RECORD_TYPE,
                updateReqVO.getId(), updateReqVO.getBlobIds());
    }
@@ -160,6 +174,7 @@
                    application.getNo(), ResearchApplicationStatusEnum.nameOf(application.getStatus()));
        }
        memberMapper.deleteByApplicationId(id);
        applicationMapper.deleteById(id);
        storageAttachmentApi.deleteAttachmentsByRecord(APPLICATION_RECORD_TYPE, id);
    }
@@ -170,6 +185,116 @@
    }
    @Override
    public List<ResearchApplicationMemberDO> getMemberListByApplicationId(Long applicationId) {
        return memberMapper.selectListByApplicationId(applicationId);
    }
    @Override
    public List<ResearchApplicationMemberStatisticsRespVO> getMemberStatistics(ResearchApplicationMemberStatisticsReqVO reqVO) {
        // 1. æŒ‰çŠ¶æ€è¿‡æ»¤ç”³æŠ¥å•
        List<ResearchApplicationDO> applications = applicationMapper.selectList(
                new LambdaQueryWrapperX<ResearchApplicationDO>()
                        .eqIfPresent(ResearchApplicationDO::getStatus, reqVO.getStatus()));
        if (CollUtil.isEmpty(applications)) {
            return Collections.emptyList();
        }
        Map<Long, ResearchApplicationDO> applicationMap = applications.stream()
                .collect(Collectors.toMap(ResearchApplicationDO::getId, application -> application, (a, b) -> a));
        // 2. æˆå‘˜å­è¡¨èšåˆï¼šå§“名 + ç”µè¯ + å•位 ç›¸åŒè§†ä¸ºåŒä¸€æˆå‘˜
        String keyword = StrUtil.trimToNull(reqVO.getKeyword());
        Map<String, ResearchApplicationMemberStatisticsRespVO> resultMap = new LinkedHashMap<>();
        Map<String, Set<String>> roleMap = new HashMap<>();
        Map<String, Set<Long>> projectIdMap = new HashMap<>();
        Map<String, Set<String>> projectTitleMap = new HashMap<>();
        for (ResearchApplicationMemberDO member : memberMapper.selectList()) {
            ResearchApplicationDO application = applicationMap.get(member.getApplicationId());
            if (application == null || !matchesMemberKeyword(member, keyword)) {
                continue;
            }
            String key = buildMemberKey(member);
            ResearchApplicationMemberStatisticsRespVO vo = resultMap.computeIfAbsent(key, k -> {
                ResearchApplicationMemberStatisticsRespVO item = new ResearchApplicationMemberStatisticsRespVO();
                item.setName(StrUtil.trim(member.getName()));
                item.setContactPhone(StrUtil.trim(member.getContactPhone()));
                item.setWorkUnit(StrUtil.trim(member.getWorkUnit()));
                item.setJobTitle(StrUtil.trim(member.getJobTitle()));
                return item;
            });
            if (StrUtil.isBlank(vo.getJobTitle()) && StrUtil.isNotBlank(member.getJobTitle())) {
                vo.setJobTitle(StrUtil.trim(member.getJobTitle()));
            }
            if (StrUtil.isNotBlank(member.getMemberRole())) {
                roleMap.computeIfAbsent(key, k -> new LinkedHashSet<>()).add(StrUtil.trim(member.getMemberRole()));
            }
            projectIdMap.computeIfAbsent(key, k -> new LinkedHashSet<>()).add(member.getApplicationId());
            if (StrUtil.isNotBlank(application.getTitle())) {
                projectTitleMap.computeIfAbsent(key, k -> new LinkedHashSet<>()).add(application.getTitle());
            }
        }
        // 3. æ±‡æ€»æˆå‘˜ç»´åº¦ç»“æžœ
        List<ResearchApplicationMemberStatisticsRespVO> result = new ArrayList<>(resultMap.size());
        for (Map.Entry<String, ResearchApplicationMemberStatisticsRespVO> entry : resultMap.entrySet()) {
            String key = entry.getKey();
            ResearchApplicationMemberStatisticsRespVO vo = entry.getValue();
            vo.setMemberRoles(String.join("、", roleMap.getOrDefault(key, Collections.emptySet())));
            vo.setProjectCount(projectIdMap.getOrDefault(key, Collections.emptySet()).size());
            vo.setProjectTitles(String.join(";", projectTitleMap.getOrDefault(key, Collections.emptySet())));
            result.add(vo);
        }
        result.sort((a, b) -> {
            int compare = Integer.compare(
                    b.getProjectCount() == null ? 0 : b.getProjectCount(),
                    a.getProjectCount() == null ? 0 : a.getProjectCount());
            return compare != 0 ? compare : StrUtil.compare(a.getName(), b.getName(), true);
        });
        return result;
    }
    /** ä¿å­˜ç”³æŠ¥ä¹¦æˆå‘˜å­è¡¨ï¼ˆè°ƒç”¨æ–¹éœ€è‡ªè¡Œä¿è¯ update åœºæ™¯å·²å…ˆåˆ é™¤æ—§æ•°æ®ï¼‰ */
    private void saveMembers(Long applicationId, List<ResearchApplicationMemberSaveReqVO> members) {
        if (CollUtil.isEmpty(members)) {
            return;
        }
        List<ResearchApplicationMemberDO> list = new ArrayList<>(members.size());
        int index = 1;
        for (ResearchApplicationMemberSaveReqVO member : members) {
            if (member == null || StrUtil.isBlank(member.getName())) {
                continue;
            }
            list.add(ResearchApplicationMemberDO.builder()
                    .applicationId(applicationId)
                    .name(StrUtil.trim(member.getName()))
                    .contactPhone(StrUtil.trim(member.getContactPhone()))
                    .workUnit(StrUtil.trim(member.getWorkUnit()))
                    .jobTitle(StrUtil.trim(member.getJobTitle()))
                    .memberRole(StrUtil.trim(member.getMemberRole()))
                    .sortNo(member.getSortNo() != null ? member.getSortNo() : index)
                    .build());
            index++;
        }
        if (CollUtil.isNotEmpty(list)) {
            memberMapper.insertBatch(list);
        }
    }
    private String buildMemberKey(ResearchApplicationMemberDO member) {
        return StrUtil.join("|", StrUtil.nullToEmpty(member.getName()).trim(),
                StrUtil.nullToEmpty(member.getContactPhone()).trim(),
                StrUtil.nullToEmpty(member.getWorkUnit()).trim());
    }
    private boolean matchesMemberKeyword(ResearchApplicationMemberDO member, String keyword) {
        if (StrUtil.isBlank(keyword)) {
            return true;
        }
        return StrUtil.containsIgnoreCase(member.getName(), keyword)
                || StrUtil.containsIgnoreCase(member.getContactPhone(), keyword)
                || StrUtil.containsIgnoreCase(member.getWorkUnit(), keyword);
    }
    @Override
    public PageResult<ResearchApplicationDO> getApplicationPage(ResearchApplicationPageReqVO pageReqVO) {
        return applicationMapper.selectPage(pageReqVO);
    }
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java
@@ -86,6 +86,9 @@
    // ===== ä¼ä¸šå¾®ä¿¡æ´»åЍ =====
    WXCP_ACTIVITY("wxcp_activity", "企业微信活动封面"),
    WXCP_ACTIVITY_DETAIL("wxcp_activity_detail", "企业微信活动详情图"),
    WXCP_ACTIVITY_IMAGE("wxcp_activity_image", "企业微信活动图片通知"),
    WXCP_ACTIVITY_FILE("wxcp_activity_file", "企业微信活动附件通知"),
    WXCP_ACTIVITY_SIGNUP("wxcp_activity_signup", "企业微信活动报名");
    private final String type;
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/util/storage/StorageFileUtil.java
@@ -23,6 +23,7 @@
import javax.crypto.SecretKey;
import java.io.File;
import java.math.BigDecimal;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
@@ -461,7 +462,10 @@
        }
        String domain = StringUtils.trimTrailingCharacter(properties.getDomain(), '/');
        String prefix = properties.getUrlPrefix().startsWith("/") ? properties.getUrlPrefix() : "/" + properties.getUrlPrefix();
        String baseUrl = domain + prefix + actionPath + uidFilename;
        // æ–‡ä»¶åå¯èƒ½åŒ…含中文、空格等字符,必须编码后再拼接到 URL,否则部分客户端/容器会直接返回 400
        String encodedUidFilename = URLEncoder.encode(uidFilename, StandardCharsets.UTF_8)
                .replace("+", "%20");
        String baseUrl = domain + prefix + actionPath + encodedUidFilename;
        // -1 è¡¨ç¤ºæ°¸ä¹…有效,不生成 token,改为 publicKey ç»„合校验
        if (expired != null && BigDecimal.valueOf(-1L).compareTo(expired) == 0) {
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/member/UmsMemberProfileChangeController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,80 @@
package cn.iocoder.yudao.module.ums.controller.admin.member;
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.ums.controller.admin.member.vo.UmsMemberRespVO;
import cn.iocoder.yudao.module.ums.controller.admin.member.vo.profile.UmsMemberProfileChangePageReqVO;
import cn.iocoder.yudao.module.ums.controller.admin.member.vo.profile.UmsMemberProfileChangeRespVO;
import cn.iocoder.yudao.module.ums.controller.admin.member.vo.profile.UmsMemberProfileChangeSaveReqVO;
import cn.iocoder.yudao.module.ums.dal.dataobject.member.UmsMemberProfileChangeDO;
import cn.iocoder.yudao.module.ums.service.member.UmsMemberProfileChangeService;
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 static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - ä¼šå‘˜èµ„料自主维护")
@RestController
@RequestMapping("/ums/member/profile")
@Validated
public class UmsMemberProfileChangeController {
    @Resource
    private UmsMemberProfileChangeService profileChangeService;
    @GetMapping("/get")
    @Operation(summary = "获得当前登录会员的个人资料")
    public CommonResult<UmsMemberRespVO> getMyProfile() {
        return success(BeanUtils.toBean(profileChangeService.getMyProfile(), UmsMemberRespVO.class));
    }
    @GetMapping("/change/latest")
    @Operation(summary = "获得当前会员最近一次资料变更申请")
    public CommonResult<UmsMemberProfileChangeRespVO> getLatestChange() {
        return success(BeanUtils.toBean(profileChangeService.getLatestChange(),
                UmsMemberProfileChangeRespVO.class));
    }
    @PostMapping("/change/apply")
    @Operation(summary = "提交资料变更申请")
    public CommonResult<Long> applyChange(@Valid @RequestBody UmsMemberProfileChangeSaveReqVO reqVO) {
        return success(profileChangeService.applyChange(reqVO));
    }
    @GetMapping("/change/page")
    @Operation(summary = "获得资料变更申请分页")
    @PreAuthorize("@ss.hasPermission('ums:member:change:query')")
    public CommonResult<PageResult<UmsMemberProfileChangeRespVO>> getChangePage(
            @Valid UmsMemberProfileChangePageReqVO reqVO) {
        PageResult<UmsMemberProfileChangeDO> pageResult = profileChangeService.getChangePage(reqVO);
        return success(new PageResult<>(BeanUtils.toBean(pageResult.getList(),
                UmsMemberProfileChangeRespVO.class), pageResult.getTotal()));
    }
    @PutMapping("/change/approve")
    @Operation(summary = "审核通过资料变更申请")
    @Parameter(name = "id", description = "申请编号", required = true)
    @PreAuthorize("@ss.hasPermission('ums:member:change:audit')")
    public CommonResult<Boolean> approveChange(@RequestParam("id") Long id) {
        profileChangeService.approve(id);
        return success(true);
    }
    @PutMapping("/change/reject")
    @Operation(summary = "驳回资料变更申请")
    @Parameter(name = "id", description = "申请编号", required = true)
    @PreAuthorize("@ss.hasPermission('ums:member:change:audit')")
    public CommonResult<Boolean> rejectChange(@RequestParam("id") Long id,
                                              @RequestParam("remark") String remark) {
        profileChangeService.reject(id, remark);
        return success(true);
    }
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/member/vo/UmsMemberRespVO.java
@@ -75,6 +75,12 @@
    @Schema(description = "有效期至", example = "2028-01-01")
    private LocalDate expireDate;
    @Schema(description = "是否缴纳会费", example = "true")
    private Boolean feePaid;
    @Schema(description = "缴费年度", example = "2026")
    private Integer feeYear;
    @Schema(description = "状态", example = "1")
    private Integer status;
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/member/vo/UmsMemberSaveReqVO.java
@@ -1,6 +1,8 @@
package cn.iocoder.yudao.module.ums.controller.admin.member.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@@ -66,6 +68,14 @@
    @Schema(description = "有效期至", example = "2028-01-01")
    private LocalDate expireDate;
    @Schema(description = "是否缴纳会费", example = "true")
    private Boolean feePaid;
    @Schema(description = "缴费年度(如 2026);标记为已缴纳时必填", example = "2026")
    @Min(value = 2000, message = "缴费年度不能早于 2000 å¹´")
    @Max(value = 2100, message = "缴费年度不能晚于 2100 å¹´")
    private Integer feeYear;
    @Schema(description = "状态", example = "1")
    private Integer status;
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/member/vo/profile/UmsMemberProfileChangePageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,22 @@
package cn.iocoder.yudao.module.ums.controller.admin.member.vo.profile;
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 UmsMemberProfileChangePageReqVO extends PageParam {
    @Schema(description = "会员编号", example = "HY20261020000001")
    private String memberNo;
    @Schema(description = "会员名称", example = "张三")
    private String memberName;
    @Schema(description = "状态:0-待审核 10-已通过 20-已驳回", example = "0")
    private Integer status;
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/member/vo/profile/UmsMemberProfileChangeRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,78 @@
package cn.iocoder.yudao.module.ums.controller.admin.member.vo.profile;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - ä¼šå‘˜èµ„料变更申请 Response VO")
@Data
public class UmsMemberProfileChangeRespVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "会员 ID", example = "1")
    private Long memberId;
    @Schema(description = "会员编号", example = "HY20261020000001")
    private String memberNo;
    @Schema(description = "会员名称", example = "张三")
    private String memberName;
    @Schema(description = "提交人用户 ID", example = "100")
    private Long userId;
    @Schema(description = "状态:0-待审核 10-已通过 20-已驳回", example = "0")
    private Integer status;
    @Schema(description = "变更后的联系人")
    private String contactUser;
    @Schema(description = "变更后的联系电话")
    private String contactPhone;
    @Schema(description = "变更后的邮箱")
    private String contactEmail;
    @Schema(description = "变更后的工作单位")
    private String workUnit;
    @Schema(description = "变更后的职称")
    private String title;
    @Schema(description = "变更后的研究方向")
    private String researchDirection;
    @Schema(description = "变更后的单位简介")
    private String unitIntro;
    @Schema(description = "变更后的法定代表人")
    private String legalPerson;
    @Schema(description = "变更后的统一社会信用代码")
    private String organizationCode;
    @Schema(description = "变更后的地区编码")
    private String regionCode;
    @Schema(description = "变更后的行业")
    private String industry;
    @Schema(description = "变更摘要")
    private String changeSummary;
    @Schema(description = "审核人用户 ID")
    private Long auditUserId;
    @Schema(description = "审核时间")
    private LocalDateTime auditTime;
    @Schema(description = "审核意见(驳回原因)")
    private String auditRemark;
    @Schema(description = "提交时间")
    private LocalDateTime createTime;
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/controller/admin/member/vo/profile/UmsMemberProfileChangeSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,59 @@
package cn.iocoder.yudao.module.ums.controller.admin.member.vo.profile;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Schema(description = "管理后台 - ä¼šå‘˜èµ„料变更申请 Request VO")
@Data
public class UmsMemberProfileChangeSaveReqVO {
    @Schema(description = "联系人(单位用)", example = "王五")
    @Size(max = 64, message = "联系人长度不能超过 64 ä¸ªå­—符")
    private String contactUser;
    @Schema(description = "联系电话", example = "13800138000")
    @Size(max = 20, message = "联系电话长度不能超过 20 ä¸ªå­—符")
    @Pattern(regexp = "^$|^1[3-9]\\d{9}$", message = "联系电话格式不正确,请输入正确的 11 ä½æ‰‹æœºå·")
    private String contactPhone;
    @Schema(description = "邮箱", example = "zhangsan@example.com")
    @Size(max = 128, message = "邮箱长度不能超过 128 ä¸ªå­—符")
    @Email(message = "邮箱格式不正确")
    private String contactEmail;
    @Schema(description = "工作单位(个人专有)", example = "XX大学")
    @Size(max = 128, message = "工作单位长度不能超过 128 ä¸ªå­—符")
    private String workUnit;
    @Schema(description = "职称(个人专有)", example = "教授")
    @Size(max = 64, message = "职称长度不能超过 64 ä¸ªå­—符")
    private String title;
    @Schema(description = "研究方向", example = "人工智能与智能制造")
    @Size(max = 256, message = "研究方向长度不能超过 256 ä¸ªå­—符")
    private String researchDirection;
    @Schema(description = "单位简介(单位专有)", example = "专注于智能制造领域")
    @Size(max = 512, message = "单位简介长度不能超过 512 ä¸ªå­—符")
    private String unitIntro;
    @Schema(description = "法定代表人(单位专有)", example = "李四")
    @Size(max = 64, message = "法定代表人长度不能超过 64 ä¸ªå­—符")
    private String legalPerson;
    @Schema(description = "统一社会信用代码", example = "91440101MA5XXXXXX")
    @Size(max = 64, message = "统一社会信用代码长度不能超过 64 ä¸ªå­—符")
    private String organizationCode;
    @Schema(description = "地区编码", example = "110000")
    @Size(max = 32, message = "地区编码长度不能超过 32 ä¸ªå­—符")
    private String regionCode;
    @Schema(description = "行业", example = "1")
    @Size(max = 32, message = "行业长度不能超过 32 ä¸ªå­—符")
    private String industry;
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/dal/dataobject/member/UmsMemberDO.java
@@ -83,6 +83,12 @@
    /** æœ‰æ•ˆæœŸè‡³ */
    private LocalDate expireDate;
    /** æ˜¯å¦ç¼´çº³ä¼šè´¹ */
    private Boolean feePaid;
    /** ç¼´è´¹å¹´åº¦ï¼ˆå¦‚ 2026) */
    private Integer feeYear;
    /** çŠ¶æ€ï¼š1-正常 0-暂停 */
    private Integer status;
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/dal/dataobject/member/UmsMemberProfileChangeDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,91 @@
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.LocalDateTime;
/**
 * ä¼šå‘˜èµ„料变更申请 DO
 *
 * <p>会员自助修改资料后生成一条待审核记录,管理员审核通过后才写回会员主表。
 *
 * @author è¶…级管理员
 */
@TableName("ums_member_profile_change")
@KeySequence("ums_member_profile_change_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UmsMemberProfileChangeDO extends BaseDO {
    @TableId
    private Long id;
    /** ä¼šå‘˜ ID */
    private Long memberId;
    /** ä¼šå‘˜ç¼–号(快照) */
    private String memberNo;
    /** ä¼šå‘˜åç§°ï¼ˆå¿«ç…§ï¼‰ */
    private String memberName;
    /** æäº¤äººç”¨æˆ· ID */
    private Long userId;
    /** çŠ¶æ€ï¼š0-待审核 10-已通过 20-已驳回 */
    private Integer status;
    /** å˜æ›´åŽçš„联系人 */
    private String contactUser;
    /** å˜æ›´åŽçš„联系电话 */
    private String contactPhone;
    /** å˜æ›´åŽçš„邮箱 */
    private String contactEmail;
    /** å˜æ›´åŽçš„工作单位 */
    private String workUnit;
    /** å˜æ›´åŽçš„职称 */
    private String title;
    /** å˜æ›´åŽçš„研究方向 */
    private String researchDirection;
    /** å˜æ›´åŽçš„单位简介 */
    private String unitIntro;
    /** å˜æ›´åŽçš„æ³•定代表人 */
    private String legalPerson;
    /** å˜æ›´åŽçš„统一社会信用代码 */
    private String organizationCode;
    /** å˜æ›´åŽçš„地区编码 */
    private String regionCode;
    /** å˜æ›´åŽçš„行业 */
    private String industry;
    /** å˜æ›´æ‘˜è¦ï¼ˆå­—段级差异,供审核查看) */
    private String changeSummary;
    /** å®¡æ ¸äººç”¨æˆ· ID */
    private Long auditUserId;
    /** å®¡æ ¸æ—¶é—´ */
    private LocalDateTime auditTime;
    /** å®¡æ ¸æ„è§ï¼ˆé©³å›žåŽŸå› ï¼‰ */
    private String auditRemark;
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/dal/mysql/member/UmsMemberProfileChangeMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,38 @@
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.profile.UmsMemberProfileChangePageReqVO;
import cn.iocoder.yudao.module.ums.dal.dataobject.member.UmsMemberProfileChangeDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface UmsMemberProfileChangeMapper extends BaseMapperX<UmsMemberProfileChangeDO> {
    default UmsMemberProfileChangeDO selectPendingByMemberId(Long memberId) {
        return selectOne(new LambdaQueryWrapperX<UmsMemberProfileChangeDO>()
                .eq(UmsMemberProfileChangeDO::getMemberId, memberId)
                .eq(UmsMemberProfileChangeDO::getStatus, 0)
                .last("LIMIT 1"));
    }
    default UmsMemberProfileChangeDO selectLatestByMemberId(Long memberId) {
        List<UmsMemberProfileChangeDO> list = selectList(new LambdaQueryWrapperX<UmsMemberProfileChangeDO>()
                .eq(UmsMemberProfileChangeDO::getMemberId, memberId)
                .orderByDesc(UmsMemberProfileChangeDO::getId)
                .last("LIMIT 1"));
        return list.isEmpty() ? null : list.get(0);
    }
    default PageResult<UmsMemberProfileChangeDO> selectPage(UmsMemberProfileChangePageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<UmsMemberProfileChangeDO>()
                .likeIfPresent(UmsMemberProfileChangeDO::getMemberNo, reqVO.getMemberNo())
                .likeIfPresent(UmsMemberProfileChangeDO::getMemberName, reqVO.getMemberName())
                .eqIfPresent(UmsMemberProfileChangeDO::getStatus, reqVO.getStatus())
                .orderByDesc(UmsMemberProfileChangeDO::getId));
    }
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/enums/ErrorCodeConstants.java
@@ -25,5 +25,14 @@
    ErrorCode MEMBER_GRADE_NOT_MATCH_TYPE = new ErrorCode(1_030_108_104, "会员等级与会员类型不匹配:个人会员不能选择单位等级,单位会员不能选择个人等级");
    ErrorCode MEMBER_ARCHIVE_FAIL_STATUS = new ErrorCode(1_030_108_105, "归档失败:会员({})当前状态为〔{}〕,只有「正常」状态的会员才能归档到档案管理,请先启用该会员");
    ErrorCode MEMBER_ALREADY_ARCHIVED = new ErrorCode(1_030_108_106, "重复归档:会员({})已于此前归档并生成档案(编号={}),请勿重复归档,如需查看请前往档案管理模块");
    ErrorCode MEMBER_FEE_YEAR_REQUIRED = new ErrorCode(1_030_108_107, "会费缴纳年度为空:已标记为「已缴纳会费」,请选择缴费年度(例如 2026)后再保存");
    // ========== ä¼šå‘˜èµ„料变更申请(1-030-108-108 ~ 108-119) ==========
    ErrorCode MEMBER_PROFILE_NOT_LINKED = new ErrorCode(1_030_108_108, "当前登录账号未关联会员,无法维护个人资料,请联系学会管理员绑定会员账号");
    ErrorCode MEMBER_PROFILE_CHANGE_NOT_EXISTS = new ErrorCode(1_030_108_109, "资料变更申请不存在");
    ErrorCode MEMBER_PROFILE_CHANGE_PENDING_EXISTS = new ErrorCode(1_030_108_110, "您已有一条待审核的资料变更申请,请等待管理员审核后再提交新的变更");
    ErrorCode MEMBER_PROFILE_CHANGE_STATUS_INVALID = new ErrorCode(1_030_108_111, "资料变更申请({})当前状态为〔{}〕,只有「待审核」的申请才能审核");
    ErrorCode MEMBER_PROFILE_CHANGE_REJECT_REASON_REQUIRED = new ErrorCode(1_030_108_112, "驳回资料变更申请时必须填写驳回原因,请补充原因后再操作");
    ErrorCode MEMBER_PROFILE_CHANGE_EMPTY = new ErrorCode(1_030_108_113, "资料未发生任何变更,无需提交审核");
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/enums/UmsMemberProfileChangeStatusEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
package cn.iocoder.yudao.module.ums.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
 * ä¼šå‘˜èµ„料变更申请状态
 */
@Getter
@AllArgsConstructor
public enum UmsMemberProfileChangeStatusEnum {
    PENDING(0, "待审核"),
    APPROVED(10, "已通过"),
    REJECTED(20, "已驳回");
    private final Integer status;
    private final String name;
    public static String nameOf(Integer status) {
        for (UmsMemberProfileChangeStatusEnum value : values()) {
            if (value.getStatus().equals(status)) {
                return value.getName();
            }
        }
        return String.valueOf(status);
    }
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/service/member/UmsMemberProfileChangeService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,32 @@
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.profile.UmsMemberProfileChangePageReqVO;
import cn.iocoder.yudao.module.ums.controller.admin.member.vo.profile.UmsMemberProfileChangeSaveReqVO;
import cn.iocoder.yudao.module.ums.dal.dataobject.member.UmsMemberDO;
import cn.iocoder.yudao.module.ums.dal.dataobject.member.UmsMemberProfileChangeDO;
/**
 * ä¼šå‘˜èµ„料变更申请 Service æŽ¥å£
 */
public interface UmsMemberProfileChangeService {
    /** èŽ·å–å½“å‰ç™»å½•è´¦å·å…³è”çš„ä¼šå‘˜èµ„æ–™ */
    UmsMemberDO getMyProfile();
    /** èŽ·å–å½“å‰ä¼šå‘˜æœ€è¿‘ä¸€æ¬¡èµ„æ–™å˜æ›´ç”³è¯·ï¼ˆç”¨äºŽå±•ç¤ºå®¡æ ¸çŠ¶æ€ï¼‰ */
    UmsMemberProfileChangeDO getLatestChange();
    /** ä¼šå‘˜æäº¤èµ„料变更申请 */
    Long applyChange(UmsMemberProfileChangeSaveReqVO reqVO);
    /** ç®¡ç†å‘˜åˆ†é¡µæŸ¥è¯¢èµ„料变更申请 */
    PageResult<UmsMemberProfileChangeDO> getChangePage(UmsMemberProfileChangePageReqVO reqVO);
    /** å®¡æ ¸é€šè¿‡ï¼šæŠŠå˜æ›´å†…容写回会员主表 */
    void approve(Long id);
    /** å®¡æ ¸é©³å›ž */
    void reject(Long id, String remark);
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/service/member/UmsMemberProfileChangeServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,167 @@
package cn.iocoder.yudao.module.ums.service.member;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.ums.controller.admin.member.vo.profile.UmsMemberProfileChangePageReqVO;
import cn.iocoder.yudao.module.ums.controller.admin.member.vo.profile.UmsMemberProfileChangeSaveReqVO;
import cn.iocoder.yudao.module.ums.dal.dataobject.member.UmsMemberDO;
import cn.iocoder.yudao.module.ums.dal.dataobject.member.UmsMemberProfileChangeDO;
import cn.iocoder.yudao.module.ums.dal.mysql.member.UmsMemberMapper;
import cn.iocoder.yudao.module.ums.dal.mysql.member.UmsMemberProfileChangeMapper;
import cn.iocoder.yudao.module.ums.enums.UmsMemberProfileChangeStatusEnum;
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.util.ArrayList;
import java.util.List;
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 å®žçŽ°ç±»
 */
@Service
@Validated
@Slf4j
public class UmsMemberProfileChangeServiceImpl implements UmsMemberProfileChangeService {
    private static final int SUMMARY_MAX_LENGTH = 1000;
    @Resource
    private UmsMemberMapper memberMapper;
    @Resource
    private UmsMemberProfileChangeMapper changeMapper;
    @Override
    public UmsMemberDO getMyProfile() {
        return getLoginMember();
    }
    @Override
    public UmsMemberProfileChangeDO getLatestChange() {
        return changeMapper.selectLatestByMemberId(getLoginMember().getId());
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long applyChange(UmsMemberProfileChangeSaveReqVO reqVO) {
        UmsMemberDO member = getLoginMember();
        if (changeMapper.selectPendingByMemberId(member.getId()) != null) {
            throw exception(MEMBER_PROFILE_CHANGE_PENDING_EXISTS);
        }
        String summary = buildChangeSummary(member, reqVO);
        if (StrUtil.isBlank(summary)) {
            throw exception(MEMBER_PROFILE_CHANGE_EMPTY);
        }
        UmsMemberProfileChangeDO change = BeanUtils.toBean(reqVO, UmsMemberProfileChangeDO.class, in -> in
                .setMemberId(member.getId())
                .setMemberNo(member.getNo())
                .setMemberName(member.getName())
                .setUserId(getLoginUserId())
                .setStatus(UmsMemberProfileChangeStatusEnum.PENDING.getStatus())
                .setChangeSummary(StrUtil.sub(summary, 0, SUMMARY_MAX_LENGTH)));
        changeMapper.insert(change);
        return change.getId();
    }
    @Override
    public PageResult<UmsMemberProfileChangeDO> getChangePage(UmsMemberProfileChangePageReqVO reqVO) {
        return changeMapper.selectPage(reqVO);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void approve(Long id) {
        UmsMemberProfileChangeDO change = validatePendingChange(id);
        // æ˜¾å¼ set æ¯ä¸ªå­—段:允许把资料清空(updateById ä¼šå¿½ç•¥ null)
        memberMapper.update(null, new LambdaUpdateWrapper<UmsMemberDO>()
                .eq(UmsMemberDO::getId, change.getMemberId())
                .set(UmsMemberDO::getContactUser, change.getContactUser())
                .set(UmsMemberDO::getContactPhone, change.getContactPhone())
                .set(UmsMemberDO::getContactEmail, change.getContactEmail())
                .set(UmsMemberDO::getWorkUnit, change.getWorkUnit())
                .set(UmsMemberDO::getTitle, change.getTitle())
                .set(UmsMemberDO::getResearchDirection, change.getResearchDirection())
                .set(UmsMemberDO::getUnitIntro, change.getUnitIntro())
                .set(UmsMemberDO::getLegalPerson, change.getLegalPerson())
                .set(UmsMemberDO::getOrganizationCode, change.getOrganizationCode())
                .set(UmsMemberDO::getRegionCode, change.getRegionCode())
                .set(UmsMemberDO::getIndustry, change.getIndustry()));
        updateAudit(change.getId(), UmsMemberProfileChangeStatusEnum.APPROVED, "审核通过");
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void reject(Long id, String remark) {
        UmsMemberProfileChangeDO change = validatePendingChange(id);
        if (StrUtil.isBlank(remark)) {
            throw exception(MEMBER_PROFILE_CHANGE_REJECT_REASON_REQUIRED);
        }
        updateAudit(change.getId(), UmsMemberProfileChangeStatusEnum.REJECTED, StrUtil.trim(remark));
    }
    private UmsMemberDO getLoginMember() {
        Long userId = getLoginUserId();
        UmsMemberDO member = userId != null ? memberMapper.selectByUserId(userId) : null;
        if (member == null) {
            throw exception(MEMBER_PROFILE_NOT_LINKED);
        }
        return member;
    }
    private UmsMemberProfileChangeDO validatePendingChange(Long id) {
        UmsMemberProfileChangeDO change = changeMapper.selectById(id);
        if (change == null) {
            throw exception(MEMBER_PROFILE_CHANGE_NOT_EXISTS);
        }
        if (!UmsMemberProfileChangeStatusEnum.PENDING.getStatus().equals(change.getStatus())) {
            throw exception(MEMBER_PROFILE_CHANGE_STATUS_INVALID, id,
                    UmsMemberProfileChangeStatusEnum.nameOf(change.getStatus()));
        }
        return change;
    }
    private void updateAudit(Long id, UmsMemberProfileChangeStatusEnum status, String remark) {
        changeMapper.updateById(new UmsMemberProfileChangeDO()
                .setId(id)
                .setStatus(status.getStatus())
                .setAuditUserId(getLoginUserId())
                .setAuditTime(LocalDateTime.now())
                .setAuditRemark(remark));
    }
    /** ç”Ÿæˆå­—段级变更摘要,供管理员审核时快速查看 */
    private String buildChangeSummary(UmsMemberDO member, UmsMemberProfileChangeSaveReqVO reqVO) {
        List<String> changes = new ArrayList<>();
        appendChange(changes, "联系人", member.getContactUser(), reqVO.getContactUser());
        appendChange(changes, "联系电话", member.getContactPhone(), reqVO.getContactPhone());
        appendChange(changes, "邮箱", member.getContactEmail(), reqVO.getContactEmail());
        appendChange(changes, "工作单位", member.getWorkUnit(), reqVO.getWorkUnit());
        appendChange(changes, "职称", member.getTitle(), reqVO.getTitle());
        appendChange(changes, "研究方向", member.getResearchDirection(), reqVO.getResearchDirection());
        appendChange(changes, "单位简介", member.getUnitIntro(), reqVO.getUnitIntro());
        appendChange(changes, "法定代表人", member.getLegalPerson(), reqVO.getLegalPerson());
        appendChange(changes, "统一社会信用代码", member.getOrganizationCode(), reqVO.getOrganizationCode());
        appendChange(changes, "地区编码", member.getRegionCode(), reqVO.getRegionCode());
        appendChange(changes, "行业", member.getIndustry(), reqVO.getIndustry());
        return String.join(";", changes);
    }
    private void appendChange(List<String> changes, String label, String oldValue, String newValue) {
        String oldText = StrUtil.blankToDefault(oldValue, "-");
        String newText = StrUtil.blankToDefault(newValue, "-");
        if (!oldText.equals(newText)) {
            changes.add(label + ":" + StrUtil.sub(oldText, 0, 60) + " â†’ " + StrUtil.sub(newText, 0, 60));
        }
    }
}
yudao-module-ums/src/main/java/cn/iocoder/yudao/module/ums/service/member/UmsMemberServiceImpl.java
@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.ums.service.member;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.archive.api.document.ArchiveDocumentApi;
@@ -40,6 +41,13 @@
     * ä¼šå‘˜ç³»ç»Ÿè´¦å·é»˜è®¤éƒ¨é—¨ ID(system_dept ä¸­ã€Œå­¦ä¼šä¼šå‘˜ã€ï¼‰
     */
    private static final Long MEMBER_DEPT_ID = 129L;
    /**
     * æ™®é€šä¼šå‘˜è§’色 ID(system_role「普通会员」)
     *
     * <p>该角色只分配「我的资料」菜单,不包含档案管理等后台菜单,用于实现权限隔离。
     */
    private static final Long MEMBER_ROLE_ID = 9100L;
    public static final String MEMBER_RECORD_TYPE = "ums_member";
@@ -117,6 +125,7 @@
        // ä¼šå‘˜ç­‰çº§ä¸Žç±»åž‹åŒ¹é…æ ¡éªŒ
        validateMemberGrade(createReqVO.getMemberType(), createReqVO.getMemberGrade());
        validateFeePaid(createReqVO);
        UmsMemberDO member = BeanUtils.toBean(createReqVO, UmsMemberDO.class, in -> in
                .setNo(no)
@@ -135,9 +144,21 @@
    public void updateMember(UmsMemberSaveReqVO updateReqVO) {
        validateMemberExists(updateReqVO.getId());
        validateMemberGrade(updateReqVO.getMemberType(), updateReqVO.getMemberGrade());
        validateFeePaid(updateReqVO);
        // å–消「已缴纳会费」时需要把缴费年度真正清空(updateById ä¼šå¿½ç•¥ null å­—段)
        boolean clearFeeYear = Boolean.FALSE.equals(updateReqVO.getFeePaid());
        if (clearFeeYear) {
            updateReqVO.setFeeYear(null);
        }
        UmsMemberDO updateObj = BeanUtils.toBean(updateReqVO, UmsMemberDO.class);
        memberMapper.updateById(updateObj);
        if (clearFeeYear) {
            memberMapper.update(null, new LambdaUpdateWrapper<UmsMemberDO>()
                    .eq(UmsMemberDO::getId, updateReqVO.getId())
                    .set(UmsMemberDO::getFeeYear, null));
        }
        storageAttachmentApi.updateAttachments("file", MEMBER_RECORD_TYPE,
                updateReqVO.getId(), updateReqVO.getBlobIds());
@@ -213,6 +234,7 @@
                    .setNickname(nickname)
                    .setDeptId(MEMBER_DEPT_ID)
                    .setPostIds(Collections.emptySet())
                    .setRoleIds(Collections.singleton(MEMBER_ROLE_ID))
                    .setMobile(mobile)
                    .setPassword(mobile) // é»˜è®¤å¯†ç ä¸ºæ‰‹æœºå·
                    .setEmail(application.getContactEmail())
@@ -230,6 +252,13 @@
    /**
     * æ ¡éªŒä¼šå‘˜ç­‰çº§ä¸Žç±»åž‹åŒ¹é…ï¼šå•位会员(type=2)不可选个人专有等级「5-理事长」
     */
    /** å·²ç¼´çº³ä¼šè´¹æ—¶å¿…须填写缴费年度 */
    private void validateFeePaid(UmsMemberSaveReqVO reqVO) {
        if (Boolean.TRUE.equals(reqVO.getFeePaid()) && reqVO.getFeeYear() == null) {
            throw exception(MEMBER_FEE_YEAR_REQUIRED);
        }
    }
    private void validateMemberGrade(Integer memberType, String memberGrade) {
        if (memberType == null || StrUtil.isBlank(memberGrade)) {
            return;
yudao-module-vehicle/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-vehicle</artifactId>
    <packaging>jar</packaging>
    <name>${project.artifactId}</name>
    <description>
        vehicle åŒ…下,用车管理模块(用车申请、逐级审批、审批结果反馈申请人)。
    </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-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/controller/admin/application/VehicleApplicationController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,104 @@
package cn.iocoder.yudao.module.vehicle.controller.admin.application;
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.vehicle.controller.admin.application.vo.VehicleApplicationPageReqVO;
import cn.iocoder.yudao.module.vehicle.controller.admin.application.vo.VehicleApplicationRespVO;
import cn.iocoder.yudao.module.vehicle.controller.admin.application.vo.VehicleApplicationSaveReqVO;
import cn.iocoder.yudao.module.vehicle.dal.dataobject.application.VehicleApplicationDO;
import cn.iocoder.yudao.module.vehicle.enums.VehicleApplicationStatusEnum;
import cn.iocoder.yudao.module.vehicle.service.application.VehicleApplicationService;
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.List;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - ç”¨è½¦ç”³è¯·")
@RestController
@RequestMapping("/vehicle/application")
@Validated
public class VehicleApplicationController {
    @Resource
    private VehicleApplicationService applicationService;
    @PostMapping("/create")
    @Operation(summary = "创建用车申请")
    @PreAuthorize("@ss.hasPermission('vehicle:application:create')")
    public CommonResult<Long> createApplication(@Valid @RequestBody VehicleApplicationSaveReqVO reqVO) {
        return success(applicationService.createApplication(reqVO));
    }
    @PutMapping("/update")
    @Operation(summary = "更新用车申请")
    @PreAuthorize("@ss.hasPermission('vehicle:application:update')")
    public CommonResult<Boolean> updateApplication(@Valid @RequestBody VehicleApplicationSaveReqVO reqVO) {
        applicationService.updateApplication(reqVO);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除用车申请")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('vehicle: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)
    @PreAuthorize("@ss.hasPermission('vehicle:application:query')")
    public CommonResult<VehicleApplicationRespVO> getApplication(@RequestParam("id") Long id) {
        return success(buildRespVO(applicationService.getApplication(id)));
    }
    @GetMapping("/page")
    @Operation(summary = "获得用车申请分页")
    @PreAuthorize("@ss.hasPermission('vehicle:application:query')")
    public CommonResult<PageResult<VehicleApplicationRespVO>> getApplicationPage(
            @Valid VehicleApplicationPageReqVO reqVO) {
        PageResult<VehicleApplicationDO> pageResult = applicationService.getApplicationPage(reqVO);
        return success(new PageResult<>(buildRespVOList(pageResult.getList()), pageResult.getTotal()));
    }
    @PostMapping("/submit")
    @Operation(summary = "提交用车审批")
    @PreAuthorize("@ss.hasPermission('vehicle: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('vehicle:application:query')")
    public CommonResult<List<Map<String, Object>>> getApproveProcessDefinitionList() {
        return success(applicationService.getApproveProcessDefinitionList());
    }
    private VehicleApplicationRespVO buildRespVO(VehicleApplicationDO application) {
        if (application == null) {
            return null;
        }
        return buildRespVOList(java.util.Collections.singletonList(application)).get(0);
    }
    private List<VehicleApplicationRespVO> buildRespVOList(List<VehicleApplicationDO> list) {
        return BeanUtils.toBean(list, VehicleApplicationRespVO.class, vo ->
                vo.setStatusName(VehicleApplicationStatusEnum.nameOf(vo.getStatus())));
    }
}
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/controller/admin/application/vo/VehicleApplicationPageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,32 @@
package cn.iocoder.yudao.module.vehicle.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;
@Schema(description = "管理后台 - ç”¨è½¦ç”³è¯·åˆ†é¡µ Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class VehicleApplicationPageReqVO extends PageParam {
    @Schema(description = "申请单号", example = "YC20261020000001")
    private String no;
    @Schema(description = "申请人姓名", example = "张三")
    private String applicantName;
    @Schema(description = "目的地", example = "南通市政务中心")
    private String destination;
    @Schema(description = "状态:0-草稿 15-审批中 20-已通过 35-已驳回", example = "15")
    private Integer status;
    @Schema(description = "用车时间范围")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime[] useTime;
}
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/controller/admin/application/vo/VehicleApplicationRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,63 @@
package cn.iocoder.yudao.module.vehicle.controller.admin.application.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - ç”¨è½¦ç”³è¯· Response VO")
@Data
public class VehicleApplicationRespVO {
    @Schema(description = "编号", example = "1")
    private Long id;
    @Schema(description = "申请单号", example = "YC20261020000001")
    private String no;
    @Schema(description = "申请人用户 ID", example = "1024")
    private Long applicantId;
    @Schema(description = "申请人姓名", example = "张三")
    private String applicantName;
    @Schema(description = "用车开始时间")
    private LocalDateTime useStartTime;
    @Schema(description = "用车结束时间")
    private LocalDateTime useEndTime;
    @Schema(description = "出发地", example = "学院行政楼")
    private String departure;
    @Schema(description = "目的地", example = "南通市政务中心")
    private String destination;
    @Schema(description = "随行人数", example = "3")
    private Integer passengers;
    @Schema(description = "用车事由", example = "参加产教融合项目对接会")
    private String reason;
    @Schema(description = "车牌号", example = "苏F12345")
    private String vehicleNo;
    @Schema(description = "状态", example = "15")
    private Integer status;
    @Schema(description = "状态名称", example = "审批中")
    private String statusName;
    @Schema(description = "BPM æµç¨‹å®žä¾‹ ID")
    private String processInstanceId;
    @Schema(description = "审批通过时间")
    private LocalDateTime approveTime;
    @Schema(description = "备注")
    private String remark;
    @Schema(description = "创建时间")
    private LocalDateTime createTime;
}
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/controller/admin/application/vo/VehicleApplicationSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,54 @@
package cn.iocoder.yudao.module.vehicle.controller.admin.application.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - ç”¨è½¦ç”³è¯·æ–°å¢ž/修改 Request VO")
@Data
public class VehicleApplicationSaveReqVO {
    @Schema(description = "编号(修改时必填)", example = "1")
    private Long id;
    @Schema(description = "用车开始时间", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotNull(message = "用车开始时间不能为空")
    private LocalDateTime useStartTime;
    @Schema(description = "用车结束时间", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotNull(message = "用车结束时间不能为空")
    private LocalDateTime useEndTime;
    @Schema(description = "出发地", requiredMode = Schema.RequiredMode.REQUIRED, example = "学院行政楼")
    @NotBlank(message = "出发地不能为空")
    @Size(max = 128, message = "出发地不能超过 128 ä¸ªå­—符")
    private String departure;
    @Schema(description = "目的地", requiredMode = Schema.RequiredMode.REQUIRED, example = "南通市政务中心")
    @NotBlank(message = "目的地不能为空")
    @Size(max = 128, message = "目的地不能超过 128 ä¸ªå­—符")
    private String destination;
    @Schema(description = "随行人数", example = "3")
    @Min(value = 1, message = "随行人数必须大于 0")
    private Integer passengers;
    @Schema(description = "用车事由", requiredMode = Schema.RequiredMode.REQUIRED, example = "参加产教融合项目对接会")
    @NotBlank(message = "用车事由不能为空")
    @Size(max = 512, message = "用车事由不能超过 512 ä¸ªå­—符")
    private String reason;
    @Schema(description = "车牌号(可选)", example = "苏F12345")
    @Size(max = 32, message = "车牌号不能超过 32 ä¸ªå­—符")
    private String vehicleNo;
    @Schema(description = "备注")
    @Size(max = 512, message = "备注不能超过 512 ä¸ªå­—符")
    private String remark;
}
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/dal/dataobject/application/VehicleApplicationDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,69 @@
package cn.iocoder.yudao.module.vehicle.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.LocalDateTime;
/**
 * ç”¨è½¦ç”³è¯· DO
 */
@TableName("vehicle_application")
@KeySequence("vehicle_application_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class VehicleApplicationDO extends BaseDO {
    @TableId
    private Long id;
    /** ç”³è¯·å•号 */
    private String no;
    /** ç”³è¯·äººç”¨æˆ· ID */
    private Long applicantId;
    /** ç”³è¯·äººå§“名 */
    private String applicantName;
    /** ç”¨è½¦å¼€å§‹æ—¶é—´ */
    private LocalDateTime useStartTime;
    /** ç”¨è½¦ç»“束时间 */
    private LocalDateTime useEndTime;
    /** å‡ºå‘地 */
    private String departure;
    /** ç›®çš„地 */
    private String destination;
    /** éšè¡Œäººæ•° */
    private Integer passengers;
    /** ç”¨è½¦äº‹ç”± */
    private String reason;
    /** è½¦ç‰Œå·ï¼ˆå¯é€‰ï¼‰ */
    private String vehicleNo;
    /** çŠ¶æ€ï¼š0-草稿 15-审批中 20-已通过 35-已驳回 */
    private Integer status;
    /** BPM æµç¨‹å®žä¾‹ ID */
    private String processInstanceId;
    /** å®¡æ‰¹é€šè¿‡æ—¶é—´ */
    private LocalDateTime approveTime;
    /** å¤‡æ³¨ */
    private String remark;
}
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/dal/mysql/application/VehicleApplicationMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,27 @@
package cn.iocoder.yudao.module.vehicle.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.vehicle.controller.admin.application.vo.VehicleApplicationPageReqVO;
import cn.iocoder.yudao.module.vehicle.dal.dataobject.application.VehicleApplicationDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface VehicleApplicationMapper extends BaseMapperX<VehicleApplicationDO> {
    default VehicleApplicationDO selectByNo(String no) {
        return selectOne(VehicleApplicationDO::getNo, no);
    }
    default PageResult<VehicleApplicationDO> selectPage(VehicleApplicationPageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<VehicleApplicationDO>()
                .likeIfPresent(VehicleApplicationDO::getNo, reqVO.getNo())
                .likeIfPresent(VehicleApplicationDO::getApplicantName, reqVO.getApplicantName())
                .likeIfPresent(VehicleApplicationDO::getDestination, reqVO.getDestination())
                .eqIfPresent(VehicleApplicationDO::getStatus, reqVO.getStatus())
                .betweenIfPresent(VehicleApplicationDO::getUseStartTime, reqVO.getUseTime())
                .orderByDesc(VehicleApplicationDO::getId));
    }
}
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/dal/redis/RedisKeyConstants.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,15 @@
package cn.iocoder.yudao.module.vehicle.dal.redis;
/**
 * Vehicle Redis Key æžšä¸¾ç±»
 */
public interface RedisKeyConstants {
    /**
     * å•号自增序列
     * <p>
     * KEY æ ¼å¼ï¼švehicle:no:{prefix}{yyyyMMdd}
     */
    String NO = "vehicle:no:";
}
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/dal/redis/VehicleNoRedisDAO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,32 @@
package cn.iocoder.yudao.module.vehicle.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;
/**
 * Vehicle ç¼–号 Redis DAO
 */
@Repository
public class VehicleNoRedisDAO {
    /** ç”¨è½¦ç”³è¯·å•号前缀 */
    public static final String APPLICATION_NO_PREFIX = "YC";
    @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-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/enums/ErrorCodeConstants.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,20 @@
package cn.iocoder.yudao.module.vehicle.enums;
import cn.iocoder.yudao.framework.common.exception.ErrorCode;
/**
 * Vehicle é”™è¯¯ç æžšä¸¾ç±»
 * <p>
 * vehicle ç³»ç»Ÿï¼Œä½¿ç”¨ 1-030-110-000 æ®µï¼ˆæŽ¥åœ¨ asset 1-030-109 ä¹‹åŽï¼‰
 */
public interface ErrorCodeConstants {
    ErrorCode VEHICLE_APPLICATION_NOT_EXISTS = new ErrorCode(1_030_110_000, "用车申请不存在,请刷新列表后重试");
    ErrorCode VEHICLE_APPLICATION_NO_EXISTS = new ErrorCode(1_030_110_001, "生成用车申请单号失败,请重新提交");
    ErrorCode VEHICLE_APPLICATION_UPDATE_FAIL = new ErrorCode(1_030_110_002, "用车申请({})当前状态为〔{}〕,只有草稿或已驳回状态才能修改");
    ErrorCode VEHICLE_APPLICATION_DELETE_FAIL = new ErrorCode(1_030_110_003, "用车申请({})当前状态为〔{}〕,只有草稿状态才能删除");
    ErrorCode VEHICLE_APPLICATION_SUBMIT_FAIL = new ErrorCode(1_030_110_004, "提交审批失败:用车申请({})当前状态为〔{}〕,只有草稿或已驳回状态才能提交");
    ErrorCode VEHICLE_APPLICATION_BPM_CATEGORY_NOT_EXISTS = new ErrorCode(1_030_110_005, "用车审批分类不存在,请先在 BPM æ¨¡å—创建「用车审批」分类并发布审批流程");
    ErrorCode VEHICLE_APPLICATION_TIME_INVALID = new ErrorCode(1_030_110_006, "用车结束时间必须晚于开始时间,请调整用车时间段后重新提交");
}
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/enums/VehicleApplicationStatusEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
package cn.iocoder.yudao.module.vehicle.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
 * ç”¨è½¦ç”³è¯·çŠ¶æ€æžšä¸¾
 */
@Getter
@AllArgsConstructor
public enum VehicleApplicationStatusEnum {
    DRAFT(0, "草稿"),
    APPROVING(15, "审批中"),
    APPROVED(20, "已通过"),
    REJECTED(35, "已驳回");
    private final Integer status;
    private final String name;
    public static String nameOf(Integer status) {
        for (VehicleApplicationStatusEnum value : values()) {
            if (value.status.equals(status)) {
                return value.name;
            }
        }
        return "未知";
    }
}
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/service/application/VehicleApplicationService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
package cn.iocoder.yudao.module.vehicle.service.application;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.vehicle.controller.admin.application.vo.VehicleApplicationPageReqVO;
import cn.iocoder.yudao.module.vehicle.controller.admin.application.vo.VehicleApplicationSaveReqVO;
import cn.iocoder.yudao.module.vehicle.dal.dataobject.application.VehicleApplicationDO;
import java.util.List;
import java.util.Map;
/**
 * ç”¨è½¦ç”³è¯· Service æŽ¥å£
 */
public interface VehicleApplicationService {
    Long createApplication(VehicleApplicationSaveReqVO reqVO);
    void updateApplication(VehicleApplicationSaveReqVO reqVO);
    void deleteApplication(Long id);
    VehicleApplicationDO getApplication(Long id);
    PageResult<VehicleApplicationDO> getApplicationPage(VehicleApplicationPageReqVO reqVO);
    /** æäº¤å®¡æ‰¹ï¼ˆèµ° BPM é€çº§å®¡æ‰¹ï¼‰ */
    void submitApplication(Long id, String processDefinitionKey);
    /** BPM å®¡æ‰¹ç»“果回写 */
    void updateApplicationAuditStatus(Long id, Integer bpmResult);
    /** èŽ·å–ç”¨è½¦å®¡æ‰¹å¯ç”¨çš„æµç¨‹å®šä¹‰åˆ—è¡¨ */
    List<Map<String, Object>> getApproveProcessDefinitionList();
}
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/service/application/VehicleApplicationServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,218 @@
package cn.iocoder.yudao.module.vehicle.service.application;
import cn.hutool.core.collection.CollUtil;
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.enums.task.BpmTaskStatusEnum;
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.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import cn.iocoder.yudao.module.vehicle.controller.admin.application.vo.VehicleApplicationPageReqVO;
import cn.iocoder.yudao.module.vehicle.controller.admin.application.vo.VehicleApplicationSaveReqVO;
import cn.iocoder.yudao.module.vehicle.dal.dataobject.application.VehicleApplicationDO;
import cn.iocoder.yudao.module.vehicle.dal.mysql.application.VehicleApplicationMapper;
import cn.iocoder.yudao.module.vehicle.dal.redis.VehicleNoRedisDAO;
import cn.iocoder.yudao.module.vehicle.enums.VehicleApplicationStatusEnum;
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.LocalDateTime;
import java.util.*;
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.vehicle.enums.ErrorCodeConstants.*;
/**
 * ç”¨è½¦ç”³è¯· Service å®žçŽ°ç±»
 */
@Service
@Validated
@Slf4j
public class VehicleApplicationServiceImpl implements VehicleApplicationService {
    /** ç”¨è½¦å®¡æ‰¹ BPM åˆ†ç±»ç¼–码 */
    public static final String VEHICLE_APPROVE_CATEGORY_CODE = "vehicle_application_approve";
    @Resource
    private VehicleApplicationMapper applicationMapper;
    @Resource
    private VehicleNoRedisDAO noRedisDAO;
    @Resource
    private BpmProcessInstanceApi processInstanceApi;
    @Resource
    private BpmCategoryService bpmCategoryService;
    @Resource
    private BpmProcessDefinitionService bpmProcessDefinitionService;
    @Resource
    private AdminUserApi adminUserApi;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createApplication(VehicleApplicationSaveReqVO reqVO) {
        validateUseTime(reqVO);
        Long userId = getLoginUserId();
        AdminUserRespDTO user = userId != null ? adminUserApi.getUser(userId) : null;
        VehicleApplicationDO application = BeanUtils.toBean(reqVO, VehicleApplicationDO.class, in -> in
                .setNo(noRedisDAO.generate(VehicleNoRedisDAO.APPLICATION_NO_PREFIX))
                .setApplicantId(userId)
                .setApplicantName(user != null ? user.getNickname() : "未知用户")
                .setStatus(VehicleApplicationStatusEnum.DRAFT.getStatus()));
        applicationMapper.insert(application);
        return application.getId();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateApplication(VehicleApplicationSaveReqVO reqVO) {
        VehicleApplicationDO old = validateApplicationExists(reqVO.getId());
        if (!VehicleApplicationStatusEnum.DRAFT.getStatus().equals(old.getStatus())
                && !VehicleApplicationStatusEnum.REJECTED.getStatus().equals(old.getStatus())) {
            throw exception(VEHICLE_APPLICATION_UPDATE_FAIL, old.getNo(),
                    VehicleApplicationStatusEnum.nameOf(old.getStatus()));
        }
        validateUseTime(reqVO);
        VehicleApplicationDO updateObj = BeanUtils.toBean(reqVO, VehicleApplicationDO.class);
        applicationMapper.updateById(updateObj);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteApplication(Long id) {
        VehicleApplicationDO application = validateApplicationExists(id);
        if (!VehicleApplicationStatusEnum.DRAFT.getStatus().equals(application.getStatus())) {
            throw exception(VEHICLE_APPLICATION_DELETE_FAIL, application.getNo(),
                    VehicleApplicationStatusEnum.nameOf(application.getStatus()));
        }
        applicationMapper.deleteById(id);
    }
    @Override
    public VehicleApplicationDO getApplication(Long id) {
        return applicationMapper.selectById(id);
    }
    @Override
    public PageResult<VehicleApplicationDO> getApplicationPage(VehicleApplicationPageReqVO reqVO) {
        return applicationMapper.selectPage(reqVO);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void submitApplication(Long id, String processDefinitionKey) {
        VehicleApplicationDO application = validateApplicationExists(id);
        if (!VehicleApplicationStatusEnum.DRAFT.getStatus().equals(application.getStatus())
                && !VehicleApplicationStatusEnum.REJECTED.getStatus().equals(application.getStatus())) {
            throw exception(VEHICLE_APPLICATION_SUBMIT_FAIL, application.getNo(),
                    VehicleApplicationStatusEnum.nameOf(application.getStatus()));
        }
        String processInstanceId = processInstanceApi.createProcessInstance(getLoginUserId(),
                new BpmProcessInstanceCreateReqDTO()
                        .setProcessDefinitionKey(processDefinitionKey)
                        .setBusinessKey(String.valueOf(id)));
        applicationMapper.updateById(new VehicleApplicationDO()
                .setId(id)
                .setStatus(VehicleApplicationStatusEnum.APPROVING.getStatus())
                .setProcessInstanceId(processInstanceId));
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateApplicationAuditStatus(Long id, Integer bpmResult) {
        VehicleApplicationDO application = validateApplicationExists(id);
        if (!VehicleApplicationStatusEnum.APPROVING.getStatus().equals(application.getStatus())) {
            log.warn("[updateApplicationAuditStatus] ç”¨è½¦ç”³è¯·({}) ä¸å¤„于审批中状态", id);
            return;
        }
        Integer newStatus = convertBpmResultToStatus(bpmResult);
        if (newStatus == null) {
            return;
        }
        VehicleApplicationDO updateObj = new VehicleApplicationDO().setId(id).setStatus(newStatus);
        if (VehicleApplicationStatusEnum.APPROVED.getStatus().equals(newStatus)) {
            updateObj.setApproveTime(LocalDateTime.now());
        }
        applicationMapper.updateById(updateObj);
    }
    @Override
    public List<Map<String, Object>> getApproveProcessDefinitionList() {
        validateVehicleApproveCategory();
        List<BpmProcessDefinitionInfoDO> definitionInfoList =
                bpmProcessDefinitionService.getProcessDefinitionInfoListByCategory(VEHICLE_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;
    }
    private void validateVehicleApproveCategory() {
        List<BpmCategoryDO> categories = bpmCategoryService.getCategoryListByCode(
                Collections.singletonList(VEHICLE_APPROVE_CATEGORY_CODE));
        if (CollUtil.isEmpty(categories)) {
            throw exception(VEHICLE_APPLICATION_BPM_CATEGORY_NOT_EXISTS);
        }
    }
    private void validateUseTime(VehicleApplicationSaveReqVO reqVO) {
        if (reqVO.getUseStartTime() != null && reqVO.getUseEndTime() != null
                && !reqVO.getUseEndTime().isAfter(reqVO.getUseStartTime())) {
            throw exception(VEHICLE_APPLICATION_TIME_INVALID);
        }
    }
    private VehicleApplicationDO validateApplicationExists(Long id) {
        VehicleApplicationDO application = id != null ? applicationMapper.selectById(id) : null;
        if (application == null) {
            throw exception(VEHICLE_APPLICATION_NOT_EXISTS);
        }
        return application;
    }
    private Integer convertBpmResultToStatus(Integer bpmResult) {
        if (BpmTaskStatusEnum.APPROVE.getStatus().equals(bpmResult)) {
            return VehicleApplicationStatusEnum.APPROVED.getStatus();
        }
        if (BpmTaskStatusEnum.REJECT.getStatus().equals(bpmResult)) {
            return VehicleApplicationStatusEnum.REJECTED.getStatus();
        }
        if (BpmTaskStatusEnum.CANCEL.getStatus().equals(bpmResult)) {
            return VehicleApplicationStatusEnum.DRAFT.getStatus();
        }
        return null;
    }
}
yudao-module-vehicle/src/main/java/cn/iocoder/yudao/module/vehicle/service/application/listener/VehicleApplicationStatusListener.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,47 @@
package cn.iocoder.yudao.module.vehicle.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.vehicle.service.application.VehicleApplicationService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
 * ç”¨è½¦ç”³è¯·å®¡æ‰¹çŠ¶æ€ç›‘å¬å™¨
 *
 * <p>监听 BPM æµç¨‹å®žä¾‹çŠ¶æ€å˜æ›´ï¼ŒæŠŠå®¡æ‰¹ç»“æžœå›žå†™åˆ°ç”¨è½¦ç”³è¯·ï¼Œç”³è¯·äººå¯åœ¨åˆ—è¡¨ä¸­çœ‹åˆ°å®¡æ‰¹ç»“æžœ
 */
@Component
@Slf4j
public class VehicleApplicationStatusListener extends BpmProcessInstanceStatusEventListener {
    /** ç”¨è½¦å®¡æ‰¹æµç¨‹å®šä¹‰ key(BPM ä¸­å‘布流程时必须使用该 key) */
    private static final String PROCESS_DEFINITION_KEY = "vehicle_application_approve";
    @Resource
    private VehicleApplicationService 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] æ”¶åˆ°ç”¨è½¦å®¡æ‰¹äº‹ä»¶ï¼ŒapplicationId={}, status={}", applicationId, event.getStatus());
        try {
            applicationService.updateApplicationAuditStatus(applicationId, event.getStatus());
        } catch (Exception e) {
            log.error("[onEvent] å›žå†™ç”¨è½¦ç”³è¯·å®¡æ‰¹çŠ¶æ€å¤±è´¥ï¼ŒapplicationId={}", applicationId, e);
        }
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/WxCpActivityController.java
@@ -10,6 +10,7 @@
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivityDO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivitySignupDO;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpActivityStatusEnum;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpActivityTypeEnum;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpPushScopeEnum;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpSignupStatusEnum;
import cn.iocoder.yudao.module.wxcp.service.activity.WxCpActivityService;
@@ -163,6 +164,9 @@
            }
            WxCpSignupStatusEnum status = WxCpSignupStatusEnum.valueOf(vo.getStatus());
            vo.setStatusName(status != null ? status.getName() : null);
            vo.setFieldValues(activityService.getSignupValues(vo.getId()).stream()
                    .map(value -> BeanUtils.toBean(value, WxCpSignupValueRespVO.class))
                    .toList());
        });
        return success(result);
    }
@@ -215,16 +219,24 @@
    /**
     * è¡¥é½çŠ¶æ€åã€æŽ¨é€èŒƒå›´åä¸Žå°é¢é™„ä»¶
     *
     * å°é¢ä¸è½ä¸šåŠ¡è¡¨å­—æ®µï¼Œç»Ÿä¸€ä»Ž system_storage_attachment ä¸­é—´è¡¨è¯»ï¼Œ
     * å‰ç«¯å– attachmentList[0].previewURL å±•示。
     * å°é¢/详情图不落业务表字段,统一从 system_storage_attachment ä¸­é—´è¡¨è¯»ï¼Œ
     * å‰ç«¯å– attachmentList[0].previewURL å±•示封面,detailAttachmentList å›žæ˜¾è¯¦æƒ…图。
     */
    private void fillActivityExtra(WxCpActivityRespVO vo) {
        WxCpActivityStatusEnum status = WxCpActivityStatusEnum.valueOf(vo.getStatus());
        vo.setStatusName(status != null ? status.getName() : null);
        WxCpPushScopeEnum scope = WxCpPushScopeEnum.valueOf(vo.getPushScope());
        vo.setPushScopeName(scope != null ? scope.getName() : null);
        WxCpActivityTypeEnum type = WxCpActivityTypeEnum.valueOf(vo.getActivityType());
        vo.setActivityTypeName(type != null ? type.getName() : null);
        if (vo.getId() != null) {
            vo.setAttachmentList(activityService.getActivityAttachments(vo.getId()));
            vo.setDetailAttachmentList(activityService.getActivityDetailAttachments(vo.getId()));
            vo.setImageAttachmentList(activityService.getActivityImageAttachments(vo.getId()));
            vo.setFileAttachmentList(activityService.getActivityFileAttachments(vo.getId()));
            vo.setFieldList(activityService.getActivityFields(vo.getId()).stream()
                    .map(field -> BeanUtils.toBean(field, WxCpActivityFieldRespVO.class))
                    .toList());
        }
    }
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/WxCpActivityPublicController.java
@@ -6,7 +6,9 @@
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageBlobRespVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.activity.vo.WxCpSignupPublicReqVO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivityDO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivityFieldDO;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpActivityStatusEnum;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpActivityTypeEnum;
import cn.iocoder.yudao.module.wxcp.service.activity.WxCpActivityService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -22,6 +24,7 @@
import java.net.URI;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@@ -57,9 +60,13 @@
        if (activity == null) {
            return renderMessagePage("活动不存在", "活动可能已被删除,请联系活动发起人确认。");
        }
        if (!isSignupOpen(activity)) {
            return renderMessagePage("报名已结束",
                    "该活动当前不可报名(可能未发布、已结束或不在报名时间内)。");
        if (!WxCpActivityStatusEnum.PUBLISHED.getStatus().equals(activity.getStatus())) {
            return renderMessagePage("活动未开放",
                    "该活动尚未发布(或已结束/取消),请联系活动发起人确认。");
        }
        // åªæœ‰æ•°æ®é‡‡é›†æ¨¡æ¿æœ‰è¡¨å•;其它模板只展示详情内容
        if (WxCpActivityTypeEnum.needSignup(activity.getActivityType()) && !isInSignupPeriod(activity)) {
            return renderMessagePage("报名已结束", "该活动当前不在报名时间内,请联系活动发起人确认。");
        }
        return renderSignupPage(activity, wxUserId);
    }
@@ -72,12 +79,9 @@
    }
    /**
     * æŠ¥åæ˜¯å¦å¼€æ”¾ï¼šæ´»åŠ¨éœ€ä¸ºå·²å‘å¸ƒï¼Œä¸”å¤„äºŽæŠ¥åæ—¶é—´çª—å£å†…
     * æ˜¯å¦å¤„于报名时间窗口内
     */
    private boolean isSignupOpen(WxCpActivityDO activity) {
        if (!WxCpActivityStatusEnum.PUBLISHED.getStatus().equals(activity.getStatus())) {
            return false;
        }
    private boolean isInSignupPeriod(WxCpActivityDO activity) {
        LocalDateTime now = LocalDateTime.now();
        if (activity.getSignupStartTime() != null && now.isBefore(activity.getSignupStartTime())) {
            return false;
@@ -96,6 +100,9 @@
                .append("background:#f5f6f8;color:#1f2329;font-size:15px;line-height:1.6}")
                .append(".card{background:#fff;border-radius:12px;padding:18px;margin-bottom:12px;box-shadow:0 1px 4px rgba(0,0,0,.06)}")
                .append(".cover{display:block;width:100%;max-height:260px;object-fit:cover;border-radius:8px;margin-bottom:14px;background:#f2f3f5}")
                // è¯¦æƒ…图必须按原图比例完整展示:二维码/海报被裁边就会失效,所以这里不能用 object-fit:cover
                .append(".detail-img{display:block;margin:12px 0 0}")
                .append(".detail-img img{display:block;width:100%;height:auto;border-radius:8px;background:#f2f3f5}")
                .append("h1{font-size:19px;margin:0 0 14px;font-weight:600;word-break:break-all}")
                .append(".meta{color:#646a73;font-size:14px}")
                .append(".meta div{margin-bottom:6px;display:flex}")
@@ -104,8 +111,13 @@
                .append(".desc{white-space:pre-wrap;word-break:break-word;color:#3f444d}")
                .append("label{display:block;font-size:14px;color:#646a73;margin:14px 0 6px}")
                .append("input,textarea{width:100%;padding:11px 12px;border:1px solid #dee0e3;border-radius:8px;font-size:15px;outline:none;background:#fff;font-family:inherit}")
                .append("input:focus,textarea:focus{border-color:#3370ff}")
                .append("input:focus,textarea:focus,select:focus{border-color:#3370ff}")
                .append("select{width:100%;padding:11px 12px;border:1px solid #dee0e3;border-radius:8px;font-size:15px;outline:none;background:#fff;font-family:inherit}")
                .append("textarea{resize:vertical;min-height:84px}")
                .append(".file-title{font-weight:600;margin-bottom:8px}")
                .append(".file-list a{color:#3370ff;text-decoration:none;word-break:break-all}")
                .append(".choice-row{display:flex;flex-wrap:wrap;gap:14px;margin-top:4px}")
                .append(".choice{display:inline-flex;align-items:center;gap:5px;margin:0;color:#1f2329;font-size:15px}")
                .append(".file-row{margin-top:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}")
                .append(".file-btn{display:inline-block;padding:8px 14px;border:1px dashed #c9cdd4;border-radius:8px;color:#3370ff;font-size:14px;background:#fff}")
                .append(".file-list{margin-top:8px;font-size:13px;color:#646a73;word-break:break-all}")
@@ -148,8 +160,80 @@
        if (StrUtil.isNotBlank(activity.getContent())) {
            sb.append("<div class=\"desc\">").append(esc(activity.getContent())).append("</div>");
        }
        // è¯¦æƒ…图片:紧跟在活动详情文字下方(同一张卡片内),按原图比例全宽展示,
        // ç‚¹ä¸€ä¸‹ç”¨æµè§ˆå™¨æ‰“开原图,方便成员放大看二维码/海报
        for (String detailImgUrl : resolveDetailImageUrls(activity.getId())) {
            sb.append("<a class=\"detail-img\" href=\"").append(esc(detailImgUrl))
                    .append("\" target=\"_blank\" rel=\"noopener\">")
                    .append("<img src=\"").append(esc(detailImgUrl)).append("\" alt=\"活动详情图片\">")
                    .append("</a>");
        }
        sb.append("</div>");
        // å›¾ç‰‡é€šçŸ¥æ¨¡æ¿ï¼šå±•示上传的图片,按原图比例完整展示
        appendImageGallery(sb, activity);
        // é™„件通知模板:列出附件,成员点开即可下载
        appendFileList(sb, activity);
        // åªæœ‰ã€Œæ•°æ®é‡‡é›†/评价」模板才有填写表单
        if (WxCpActivityTypeEnum.needSignup(activity.getActivityType())) {
            appendSignupForm(sb, activity, wxUserId);
        }
        return sb.toString();
    }
    /**
     * å›¾ç‰‡é€šçŸ¥æ¨¡æ¿çš„图片展示区
     */
    private void appendImageGallery(StringBuilder sb, WxCpActivityDO activity) {
        List<SystemStorageBlobRespVO> images = activityService.getActivityImageAttachments(activity.getId());
        if (CollUtil.isEmpty(images)) {
            return;
        }
        sb.append("<div class=\"card\">");
        for (SystemStorageBlobRespVO image : images) {
            String raw = StrUtil.blankToDefault(image.getPreviewURL(), image.getUrl());
            if (StrUtil.isBlank(raw)) {
                continue;
            }
            String url = toSameOriginUrl(raw);
            sb.append("<a class=\"detail-img\" href=\"").append(esc(url))
                    .append("\" target=\"_blank\" rel=\"noopener\">")
                    .append("<img src=\"").append(esc(url)).append("\" alt=\"活动图片\">")
                    .append("</a>");
        }
        sb.append("</div>");
    }
    /**
     * é™„件通知模板的附件列表区
     */
    private void appendFileList(StringBuilder sb, WxCpActivityDO activity) {
        List<SystemStorageBlobRespVO> files = activityService.getActivityFileAttachments(activity.getId());
        if (CollUtil.isEmpty(files)) {
            return;
        }
        sb.append("<div class=\"card\"><div class=\"file-title\">附件下载</div>")
                .append("<div class=\"file-list\">");
        for (SystemStorageBlobRespVO file : files) {
            String raw = StrUtil.blankToDefault(file.getDownloadURL(),
                    StrUtil.blankToDefault(file.getUrl(), file.getPreviewURL()));
            if (StrUtil.isBlank(raw)) {
                continue;
            }
            String url = toSameOriginUrl(raw);
            String name = StrUtil.blankToDefault(file.getOriginalFilename(),
                    StrUtil.blankToDefault(file.getName(), "附件"));
            sb.append("<div><a href=\"").append(esc(url)).append("\" target=\"_blank\" rel=\"noopener\">")
                    .append(esc(name)).append("</a></div>");
        }
        sb.append("</div></div>");
    }
    /**
     * æ•°æ®é‡‡é›†/评价模板的填写表单
     */
    private void appendSignupForm(StringBuilder sb, WxCpActivityDO activity, String wxUserId) {
        // æŠ¥åè¡¨å•:姓名、手机号均为选填,只填了手机号才校验格式
        sb.append("<div class=\"card\">")
                .append("<label>姓名</label>")
@@ -157,13 +241,14 @@
                .append("<label>手机号</label>")
                .append("<input id=\"mobile\" type=\"tel\" inputmode=\"numeric\" placeholder=\"请输入 11 ä½æ‰‹æœºå·ï¼ˆé€‰å¡«ï¼‰\" maxlength=\"11\" autocomplete=\"tel\">")
                .append("<label>参与说明</label>")
                .append("<textarea id=\"content\" placeholder=\"可填写备注、需求或其他说明(选填)\" maxlength=\"1000\"></textarea>")
                .append("<label>图片 / é™„ä»¶</label>")
                .append("<textarea id=\"content\" placeholder=\"可填写备注、需求或其他说明(选填)\" maxlength=\"1000\"></textarea>");
        appendCollectFields(sb, activity);
        sb.append("<label>图片 / é™„ä»¶</label>")
                .append("<div class=\"file-row\">")
                .append("<label class=\"file-btn\" for=\"files\">+ é€‰æ‹©å›¾ç‰‡æˆ–文件</label>")
                .append("<input id=\"files\" type=\"file\" multiple accept=\"image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt\" style=\"display:none\">")
                .append("</div><div class=\"file-list\" id=\"fileList\"></div>")
                .append("<button id=\"submit\">提交报名</button>")
                .append("<button id=\"submit\">提交</button>")
                .append("<div class=\"tip\">提交后如需修改,请联系活动发起人</div>")
                .append("</div><div class=\"toast\" id=\"toast\"></div>");
@@ -171,10 +256,27 @@
        sb.append("<script>(function(){")
                .append("var AID=").append(activity.getId()).append(";")
                .append("var WXUID=").append(wxUserId == null ? "null" : "\"" + esc(jsStr(wxUserId)) + "\"").append(";")
                .append("var FIELDS=").append(buildFieldsJson(activity)).append(";")
                .append("var RATING_ENABLED=").append(Boolean.TRUE.equals(activity.getRatingEnabled())).append(";")
                .append("var btn=document.getElementById('submit'),toast=document.getElementById('toast');")
                .append("var fileInput=document.getElementById('files'),fileList=document.getElementById('fileList');")
                .append("function tip(m){toast.textContent=m;toast.style.display='block';setTimeout(function(){toast.style.display='none'},2200)}")
                .append("function reset(){btn.disabled=false;btn.textContent='提交报名'}")
                .append("function reset(){btn.disabled=false;btn.textContent='提交'}")
                .append("function collectFields(){")
                .append("var arr=[],bad=null;")
                .append("for(var i=0;i<FIELDS.length;i++){var f=FIELDS[i],v='';")
                .append("if(f.type==='checkbox'){var cs=document.querySelectorAll('input[name=\"f_'+f.id+'\"]:checked'),vs=[];")
                .append("for(var j=0;j<cs.length;j++){vs.push(cs[j].value)}v=vs.join(',')}")
                .append("else if(f.type==='radio'){var r=document.querySelector('input[name=\"f_'+f.id+'\"]:checked');v=r?r.value:''}")
                .append("else{var el=document.getElementById('f_'+f.id);v=el?el.value.trim():''}")
                .append("if(f.required&&!v){bad=f.label;break}")
                .append("if(v){arr.push({fieldId:f.id,fieldValue:v})}}")
                .append("if(bad){tip('【'+bad+'】为必填项,请填写后再提交');return null}return arr}")
                .append("function getRating(){")
                .append("if(!RATING_ENABLED){return 0}")
                .append("var r=document.querySelector('input[name=\"rating\"]:checked');")
                .append("if(!r){tip('请先完成评价打分后再提交');return -1}")
                .append("return parseInt(r.value,10)}")
                .append("fileInput.addEventListener('change',function(){")
                .append("var fs=fileInput.files,h='';")
                .append("for(var i=0;i<fs.length;i++){h+='<div>· '+fs[i].name+'</div>'}")
@@ -184,6 +286,8 @@
                .append("var m=document.getElementById('mobile').value.trim();")
                .append("var c=document.getElementById('content').value.trim();")
                .append("if(m&&!/^1[3-9]\\d{9}$/.test(m)){tip('手机号格式不正确,请填 11 ä½æ‰‹æœºå·æˆ–留空');return}")
                .append("var fv=collectFields();if(fv===null){return}")
                .append("var rs=getRating();if(rs<0){return}")
                .append("btn.disabled=true;")
                .append("var fs=fileInput.files;")
                .append("var upload=Promise.resolve([]);")
@@ -202,6 +306,8 @@
                .append("if(c){body.content=c}")
                .append("if(WXUID){body.wxUserId=WXUID}")
                .append("if(blobIds.length>0){body.blobIds=blobIds}")
                .append("if(fv.length>0){body.fieldValues=fv}")
                .append("if(rs>0){body.ratingScore=rs}")
                .append("return fetch('signup',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})")
                .append(".then(function(r){return r.json()})")
                .append(".then(function(r){")
@@ -209,7 +315,131 @@
                .append("else{tip(r.msg||'报名失败');reset()}})}")
                .append(".catch(function(e){tip((e&&e.message)||'网络异常,请稍后重试');reset()})")
                .append("})})();</script></body></html>");
        return sb.toString();
    }
    /**
     * æ¸²æŸ“数据采集模板的自定义字段与评价
     */
    private void appendCollectFields(StringBuilder sb, WxCpActivityDO activity) {
        String base = "f_";
        for (WxCpActivityFieldDO field : activityService.getActivityFields(activity.getId())) {
            String id = base + field.getId();
            String label = esc(field.getFieldLabel());
            String required = Boolean.TRUE.equals(field.getRequired()) ? " *" : "";
            String placeholder = esc(StrUtil.blankToDefault(field.getPlaceholder(),
                    "请输入" + field.getFieldLabel()));
            String type = StrUtil.blankToDefault(field.getFieldType(), WxCpActivityFieldDO.TYPE_TEXT);
            switch (type) {
                case WxCpActivityFieldDO.TYPE_TEXTAREA ->
                        sb.append("<label>").append(label).append(required).append("</label>")
                                .append("<textarea id=\"").append(id).append("\" placeholder=\"")
                                .append(placeholder).append("\" maxlength=\"2000\"></textarea>");
                case WxCpActivityFieldDO.TYPE_NUMBER ->
                        sb.append("<label>").append(label).append(required).append("</label>")
                                .append("<input id=\"").append(id)
                                .append("\" type=\"number\" placeholder=\"").append(placeholder).append("\">");
                case WxCpActivityFieldDO.TYPE_DATE ->
                        sb.append("<label>").append(label).append(required).append("</label>")
                                .append("<input id=\"").append(id).append("\" type=\"date\">");
                case WxCpActivityFieldDO.TYPE_SELECT -> {
                    sb.append("<label>").append(label).append(required).append("</label>")
                            .append("<select id=\"").append(id).append("\"><option value=\"\">请选择</option>");
                    for (String option : splitOptions(field.getFieldOptions())) {
                        sb.append("<option value=\"").append(esc(option)).append("\">")
                                .append(esc(option)).append("</option>");
                    }
                    sb.append("</select>");
                }
                case WxCpActivityFieldDO.TYPE_RADIO -> {
                    sb.append("<label>").append(label).append(required).append("</label><div class=\"choice-row\">");
                    for (String option : splitOptions(field.getFieldOptions())) {
                        sb.append("<label class=\"choice\"><input type=\"radio\" name=\"")
                                .append(id).append("\" value=\"").append(esc(option)).append("\">")
                                .append(esc(option)).append("</label>");
                    }
                    sb.append("</div>");
                }
                case WxCpActivityFieldDO.TYPE_CHECKBOX -> {
                    sb.append("<label>").append(label).append(required).append("</label><div class=\"choice-row\">");
                    for (String option : splitOptions(field.getFieldOptions())) {
                        sb.append("<label class=\"choice\"><input type=\"checkbox\" name=\"")
                                .append(id).append("\" value=\"").append(esc(option)).append("\">")
                                .append(esc(option)).append("</label>");
                    }
                    sb.append("</div>");
                }
                case WxCpActivityFieldDO.TYPE_RATING -> {
                    sb.append("<label>").append(label).append(required).append("</label><div class=\"choice-row\">");
                    int max = activity.getRatingMax() != null && activity.getRatingMax() > 0 ? activity.getRatingMax() : 5;
                    for (int i = 1; i <= max; i++) {
                        sb.append("<label class=\"choice\"><input type=\"radio\" name=\"")
                                .append(id).append("\" value=\"").append(i).append("\">")
                                .append(i).append(" æ˜Ÿ</label>");
                    }
                    sb.append("</div>");
                }
                default ->
                        sb.append("<label>").append(label).append(required).append("</label>")
                                .append("<input id=\"").append(id).append("\" type=\"text\" placeholder=\"")
                                .append(placeholder).append("\" maxlength=\"255\">");
            }
        }
        // ç‹¬ç«‹è¯„价项:开启后必须选择星级
        if (Boolean.TRUE.equals(activity.getRatingEnabled())) {
            int max = activity.getRatingMax() != null && activity.getRatingMax() > 0
                    ? activity.getRatingMax() : 5;
            sb.append("<label>").append(esc(StrUtil.blankToDefault(activity.getRatingTitle(), "活动评价")))
                    .append(" *</label><div class=\"choice-row\">");
            for (int i = 1; i <= max; i++) {
                sb.append("<label class=\"choice\"><input type=\"radio\" name=\"rating\" value=\"")
                        .append(i).append("\">").append(i).append(" æ˜Ÿ</label>");
            }
            sb.append("</div>");
        }
    }
    private List<String> splitOptions(String options) {
        if (StrUtil.isBlank(options)) {
            return Collections.emptyList();
        }
        return StrUtil.split(options, ',', true, true);
    }
    /**
     * æŠŠè‡ªå®šä¹‰å­—段定义转成前端提交脚本可用的 JSON
     */
    private String buildFieldsJson(WxCpActivityDO activity) {
        List<WxCpActivityFieldDO> fields = activityService.getActivityFields(activity.getId());
        StringBuilder json = new StringBuilder("[");
        for (int i = 0; i < fields.size(); i++) {
            WxCpActivityFieldDO field = fields.get(i);
            if (i > 0) {
                json.append(',');
            }
            json.append("{\"id\":").append(field.getId())
                    .append(",\"label\":\"").append(esc(jsStr(field.getFieldLabel()))).append("\"")
                    .append(",\"type\":\"").append(esc(jsStr(field.getFieldType()))).append("\"")
                    .append(",\"required\":").append(Boolean.TRUE.equals(field.getRequired())).append('}');
        }
        return json.append(']').toString();
    }
    /**
     * å–活动详情图片地址列表(按上传先后排序,地址同样转成同源相对路径)
     *
     * ä¸Žå°é¢åˆ†å¼€å–:封面按 16:9 è£å‰ªå±•示,详情图按原图比例完整展示,
     * äºŒç»´ç è¿™ç±»ä¸èƒ½è£çš„图要放详情图里。
     */
    private List<String> resolveDetailImageUrls(Long activityId) {
        List<SystemStorageBlobRespVO> attachments = activityService.getActivityDetailAttachments(activityId);
        if (CollUtil.isEmpty(attachments)) {
            return Collections.emptyList();
        }
        return attachments.stream()
                .map(attachment -> StrUtil.blankToDefault(attachment.getPreviewURL(), attachment.getUrl()))
                .filter(StrUtil::isNotBlank)
                .map(this::toSameOriginUrl)
                .toList();
    }
    /**
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpActivityFieldRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,31 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.activity.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - æ´»åŠ¨é‡‡é›†å­—æ®µ Response VO")
@Data
public class WxCpActivityFieldRespVO {
    @Schema(description = "字段编号")
    private Long id;
    @Schema(description = "字段名称", example = "所在部门")
    private String fieldLabel;
    @Schema(description = "字段类型", example = "select")
    private String fieldType;
    @Schema(description = "选项,多个用英文逗号分隔")
    private String fieldOptions;
    @Schema(description = "是否必填")
    private Boolean required;
    @Schema(description = "输入提示")
    private String placeholder;
    @Schema(description = "排序号")
    private Integer sortNo;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpActivityFieldSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,39 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.activity.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Schema(description = "管理后台 - æ´»åŠ¨é‡‡é›†å­—æ®µ åˆ›å»º/修改 Request VO")
@Data
public class WxCpActivityFieldSaveReqVO {
    @Schema(description = "字段编号(修改时传)")
    private Long id;
    @Schema(description = "字段名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "所在部门")
    @NotBlank(message = "字段名称不能为空")
    @Size(max = 128, message = "字段名称长度不能超过 128 ä¸ªå­—符")
    private String fieldLabel;
    @Schema(description = "字段类型:text/textarea/number/date/select/radio/checkbox/rating",
            requiredMode = Schema.RequiredMode.REQUIRED, example = "select")
    @NotBlank(message = "字段类型不能为空")
    private String fieldType;
    @Schema(description = "选项,多个用英文逗号分隔;select/radio/checkbox å¿…å¡«", example = "教学,行政,后勤")
    @Size(max = 1000, message = "选项长度不能超过 1000 ä¸ªå­—符")
    private String fieldOptions;
    @Schema(description = "是否必填", example = "true")
    private Boolean required;
    @Schema(description = "输入提示", example = "请选择所在部门")
    @Size(max = 255, message = "输入提示长度不能超过 255 ä¸ªå­—符")
    private String placeholder;
    @Schema(description = "排序号", example = "1")
    private Integer sortNo;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpActivityRespVO.java
@@ -17,12 +17,42 @@
    @Schema(description = "活动标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026 å¹´æ˜¥å­£å›¢å»º")
    private String title;
    @Schema(description = "活动模板类型:1纯通知 2单条消息 3图片通知 4附件通知 5数据采集/评价")
    private Integer activityType;
    @Schema(description = "活动模板类型名", example = "数据采集/评价")
    private String activityTypeName;
    @Schema(description = "活动详情")
    private String content;
    @Schema(description = "卡片按钮文案")
    private String buttonText;
    @Schema(description = "是否开启评价")
    private Boolean ratingEnabled;
    @Schema(description = "评价标题")
    private String ratingTitle;
    @Schema(description = "评价最高星级")
    private Integer ratingMax;
    @Schema(description = "封面图附件列表(最多 1 å¼ ï¼‰")
    private List<SystemStorageBlobRespVO> attachmentList;
    @Schema(description = "详情图片附件列表(最多 5 å¼ ï¼ŒæŒ‰ä¸Šä¼ å…ˆåŽæŽ’序)")
    private List<SystemStorageBlobRespVO> detailAttachmentList;
    @Schema(description = "图片通知的图片附件列表(最多 9 å¼ ï¼‰")
    private List<SystemStorageBlobRespVO> imageAttachmentList;
    @Schema(description = "附件通知的附件列表(最多 5 ä¸ªï¼‰")
    private List<SystemStorageBlobRespVO> fileAttachmentList;
    @Schema(description = "数据采集模板的自定义字段")
    private List<WxCpActivityFieldRespVO> fieldList;
    @Schema(description = "活动地点", example = "公司大会议室")
    private String location;
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpActivitySaveReqVO.java
@@ -23,12 +23,46 @@
    @Size(max = 128, message = "活动标题长度不能超过 128 ä¸ªå­—符")
    private String title;
    @Schema(description = "活动模板类型:1纯通知 2单条消息 3图片通知 4附件通知 5数据采集/评价",
            requiredMode = Schema.RequiredMode.REQUIRED, example = "5")
    @NotNull(message = "活动模板类型不能为空")
    private Integer activityType;
    @Schema(description = "活动详情")
    private String content;
    @Schema(description = "卡片按钮文案(单条消息/数据采集模板使用)", example = "立即报名")
    @Size(max = 32, message = "按钮文案长度不能超过 32 ä¸ªå­—符")
    private String buttonText;
    @Schema(description = "是否开启评价(数据采集模板使用)", example = "true")
    private Boolean ratingEnabled;
    @Schema(description = "评价标题", example = "活动满意度评价")
    @Size(max = 128, message = "评价标题长度不能超过 128 ä¸ªå­—符")
    private String ratingTitle;
    @Schema(description = "评价最高星级,默认 5", example = "5")
    private Integer ratingMax;
    @Schema(description = "图片通知的图片文件编号列表(最多 9 å¼ ï¼‰", example = "[1,2]")
    @Size(max = 9, message = "图片最多上传 9 å¼ ")
    private List<Long> imageBlobIds;
    @Schema(description = "附件通知的附件文件编号列表(最多 5 ä¸ªï¼‰", example = "[1,2]")
    @Size(max = 5, message = "附件最多上传 5 ä¸ª")
    private List<Long> fileBlobIds;
    @Schema(description = "数据采集模板的自定义字段列表;null = ä¸æ”¹åŠ¨ï¼Œ[] = æ¸…空")
    private List<WxCpActivityFieldSaveReqVO> fieldList;
    @Schema(description = "封面图文件编号列表(最多 1 å¼ ï¼‰", example = "[1]")
    @Size(max = 1, message = "封面图最多上传 1 å¼ ")
    private List<Long> blobIds;
    @Schema(description = "详情图片文件编号列表(最多 5 å¼ ï¼‰ï¼›ä¸ä¼  = ä¸æ”¹åŠ¨ï¼Œä¼  [] = æ¸…空", example = "[2,3]")
    @Size(max = 5, message = "详情图片最多上传 5 å¼ ")
    private List<Long> detailBlobIds;
    @Schema(description = "活动地点", example = "公司大会议室")
    @Size(max = 255, message = "活动地点长度不能超过 255 ä¸ªå­—符")
@@ -46,8 +80,7 @@
    @Schema(description = "报名结束时间")
    private LocalDateTime signupEndTime;
    @Schema(description = "报名人数上限,0 è¡¨ç¤ºä¸é™", example = "50")
    @NotNull(message = "报名人数上限不能为空,不限请填 0")
    @Schema(description = "报名人数上限,0 è¡¨ç¤ºä¸é™ï¼›ä»…数据采集模板使用,不传默认 0", example = "50")
    private Integer signupLimit;
    @Schema(description = "推送范围(选填,不传默认全员);推送时仍可在推送弹窗里重新选择", example = "0")
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpSignupPublicReqVO.java
@@ -35,6 +35,12 @@
    @Schema(description = "参与说明(成员填写的文字内容)")
    private String content;
    @Schema(description = "评价星级(1~5,数据采集/评价模板使用)", example = "5")
    private Integer ratingScore;
    @Schema(description = "自定义采集字段值列表")
    private List<WxCpSignupValueReqVO> fieldValues;
    @Schema(description = "附件文件编号列表", example = "[1,2]")
    private List<Long> blobIds;
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpSignupRespVO.java
@@ -4,6 +4,7 @@
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - ä¼ä¸šå¾®ä¿¡æ´»åŠ¨æŠ¥å Response VO")
@Data
@@ -30,6 +31,15 @@
    @Schema(description = "部门名称")
    private String deptName;
    @Schema(description = "参与说明")
    private String content;
    @Schema(description = "评价星级", example = "5")
    private Integer ratingScore;
    @Schema(description = "自定义采集字段值")
    private List<WxCpSignupValueRespVO> fieldValues;
    @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
    private Integer status;
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpSignupValueReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,20 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.activity.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Schema(description = "用户 App - æ´»åŠ¨æŠ¥åé‡‡é›†å­—æ®µå€¼")
@Data
public class WxCpSignupValueReqVO {
    @Schema(description = "字段编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "采集字段编号不能为空")
    private Long fieldId;
    @Schema(description = "字段值;多选用英文逗号分隔", example = "教学")
    @Size(max = 2000, message = "字段值长度不能超过 2000 ä¸ªå­—符")
    private String fieldValue;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpSignupValueRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,22 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.activity.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - æ´»åŠ¨æŠ¥åé‡‡é›†å­—æ®µå€¼ Response VO")
@Data
public class WxCpSignupValueRespVO {
    @Schema(description = "字段编号")
    private Long fieldId;
    @Schema(description = "字段名称")
    private String fieldLabel;
    @Schema(description = "字段类型")
    private String fieldType;
    @Schema(description = "字段值")
    private String fieldValue;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/WxCpNoticeController.java
@@ -11,6 +11,7 @@
import cn.iocoder.yudao.module.wxcp.dal.dataobject.notice.WxCpNoticeDO;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpPushScopeEnum;
import cn.iocoder.yudao.module.wxcp.enums.notice.WxCpNoticeStatusEnum;
import cn.iocoder.yudao.module.wxcp.enums.notice.WxCpNoticeTypeEnum;
import cn.iocoder.yudao.module.wxcp.service.notice.WxCpNoticeService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -117,6 +118,8 @@
    private void fillNames(WxCpNoticeRespVO vo) {
        WxCpNoticeStatusEnum status = WxCpNoticeStatusEnum.valueOf(vo.getStatus());
        vo.setStatusName(status != null ? status.getName() : null);
        WxCpNoticeTypeEnum type = WxCpNoticeTypeEnum.valueOf(vo.getNoticeType());
        vo.setNoticeTypeName(type != null ? type.getName() : null);
        WxCpPushScopeEnum scope = WxCpPushScopeEnum.valueOf(vo.getPushScope());
        vo.setPushScopeName(scope != null ? scope.getName() : null);
    }
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/vo/WxCpNoticeRespVO.java
@@ -18,6 +18,30 @@
    @Schema(description = "通知正文")
    private String content;
    @Schema(description = "通知模板类型", example = "1")
    private Integer noticeType;
    @Schema(description = "通知模板类型名称", example = "纯文本通知")
    private String noticeTypeName;
    @Schema(description = "摘要")
    private String summary;
    @Schema(description = "跳转链接")
    private String linkUrl;
    @Schema(description = "链接按钮文案")
    private String linkBtnText;
    @Schema(description = "图文消息封面图地址")
    private String coverUrl;
    @Schema(description = "文件通知的附件 Blob ID")
    private Long fileBlobId;
    @Schema(description = "文件通知的附件文件名")
    private String fileName;
    @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Integer status;
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/vo/WxCpNoticeSaveReqVO.java
@@ -2,6 +2,7 @@
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpPushScopeEnum;
import cn.iocoder.yudao.module.wxcp.enums.notice.WxCpNoticeTypeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
@@ -19,10 +20,37 @@
    @Size(max = 128, message = "通知标题长度不能超过 128 ä¸ªå­—符")
    private String title;
    @Schema(description = "通知正文", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotEmpty(message = "通知正文不能为空")
    @Schema(description = "通知正文;纯文本通知必填,其它类型作为补充说明")
    @Size(max = 10000, message = "通知正文长度不能超过 10000 ä¸ªå­—符")
    private String content;
    @Schema(description = "通知模板类型:1-纯文本 2-图文卡片(填报/链接) 3-文件通知 4-图文消息", example = "1")
    @InEnum(WxCpNoticeTypeEnum.class)
    private Integer noticeType;
    @Schema(description = "摘要(图文卡片/图文消息使用)", example = "请于本周五前完成填报")
    @Size(max = 512, message = "摘要长度不能超过 512 ä¸ªå­—符")
    private String summary;
    @Schema(description = "跳转链接(填报、查看详情等)", example = "https://example.com/form/1")
    @Size(max = 1024, message = "跳转链接长度不能超过 1024 ä¸ªå­—符")
    private String linkUrl;
    @Schema(description = "链接按钮文案", example = "去填报")
    @Size(max = 32, message = "按钮文案长度不能超过 32 ä¸ªå­—符")
    private String linkBtnText;
    @Schema(description = "图文消息封面图地址", example = "https://example.com/cover.png")
    @Size(max = 1024, message = "封面图地址长度不能超过 1024 ä¸ªå­—符")
    private String coverUrl;
    @Schema(description = "文件通知的附件 Blob ID(先调用 /system/storage-blob/upload ä¸Šä¼ ï¼‰", example = "1024")
    private Long fileBlobId;
    @Schema(description = "文件通知的附件文件名", example = "关于开展XX工作的通知.pdf")
    @Size(max = 255, message = "文件名长度不能超过 255 ä¸ªå­—符")
    private String fileName;
    @Schema(description = "推送范围(选填,不传默认全员);推送时仍可在推送弹窗里重新选择", example = "0")
    @InEnum(WxCpPushScopeEnum.class)
    private Integer pushScope;
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/vo/WxCpPushReqVO.java
@@ -23,4 +23,31 @@
    @Size(max = 1024, message = "推送目标长度不能超过 1024 ä¸ªå­—符")
    private String pushTarget;
    @Schema(description = "推送时选择的模板类型:1纯文本 2图文卡片 3文件通知 4图文消息;不传沿用通知配置", example = "2")
    @InEnum(cn.iocoder.yudao.module.wxcp.enums.notice.WxCpNoticeTypeEnum.class)
    private Integer noticeType;
    @Schema(description = "摘要(图文卡片/图文消息)")
    @Size(max = 512, message = "摘要长度不能超过 512 ä¸ªå­—符")
    private String summary;
    @Schema(description = "跳转链接(图文卡片/图文消息)")
    @Size(max = 1024, message = "跳转链接长度不能超过 1024 ä¸ªå­—符")
    private String linkUrl;
    @Schema(description = "按钮文案(图文卡片)")
    @Size(max = 32, message = "按钮文案长度不能超过 32 ä¸ªå­—符")
    private String linkBtnText;
    @Schema(description = "封面图地址(图文消息)")
    @Size(max = 1024, message = "封面图地址长度不能超过 1024 ä¸ªå­—符")
    private String coverUrl;
    @Schema(description = "文件通知的附件 Blob ID")
    private Long fileBlobId;
    @Schema(description = "文件通知的附件文件名")
    @Size(max = 255, message = "文件名长度不能超过 255 ä¸ªå­—符")
    private String fileName;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/dataobject/activity/WxCpActivityDO.java
@@ -35,10 +35,32 @@
     */
    private String title;
    /**
     * æ´»åŠ¨æ¨¡æ¿ç±»åž‹
     *
     * æžšä¸¾ {@link cn.iocoder.yudao.module.wxcp.enums.activity.WxCpActivityTypeEnum}
     */
    private Integer activityType;
    /**
     * æ´»åŠ¨è¯¦æƒ…
     */
    private String content;
    /**
     * å¡ç‰‡æŒ‰é’®æ–‡æ¡ˆï¼ˆå•条消息/数据采集模板使用)
     */
    private String buttonText;
    /**
     * æ˜¯å¦å¼€å¯è¯„价(数据采集模板使用)
     */
    private Boolean ratingEnabled;
    /**
     * è¯„价标题
     */
    private String ratingTitle;
    /**
     * è¯„价最高星级,默认 5
     */
    private Integer ratingMax;
    /**
     * æ´»åŠ¨åœ°ç‚¹
     */
    private String location;
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/dataobject/activity/WxCpActivityFieldDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,74 @@
package cn.iocoder.yudao.module.wxcp.dal.dataobject.activity;
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.*;
/**
 * ä¼ä¸šå¾®ä¿¡æ´»åЍ - è‡ªå®šä¹‰é‡‡é›†å­—段 DO
 *
 * ä»…「数据采集/评价」模板使用,用于定义报名页上需要成员填写的字段。
 *
 * @author è¶…级管理员
 */
@TableName("wxcp_activity_field")
@KeySequence("wxcp_activity_field_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class WxCpActivityFieldDO extends BaseDO {
    /** å­—段类型:文本 */
    public static final String TYPE_TEXT = "text";
    /** å­—段类型:多行文本 */
    public static final String TYPE_TEXTAREA = "textarea";
    /** å­—段类型:数字 */
    public static final String TYPE_NUMBER = "number";
    /** å­—段类型:日期 */
    public static final String TYPE_DATE = "date";
    /** å­—段类型:下拉单选 */
    public static final String TYPE_SELECT = "select";
    /** å­—段类型:单选 */
    public static final String TYPE_RADIO = "radio";
    /** å­—段类型:多选 */
    public static final String TYPE_CHECKBOX = "checkbox";
    /** å­—段类型:评分(1~maxScore æ˜Ÿï¼‰ */
    public static final String TYPE_RATING = "rating";
    @TableId
    private Long id;
    /**
     * æ´»åŠ¨ç¼–å·
     */
    private Long activityId;
    /**
     * å­—段名称
     */
    private String fieldLabel;
    /**
     * å­—段类型,见本类常量
     */
    private String fieldType;
    /**
     * é€‰é¡¹ï¼Œå¤šä¸ªç”¨è‹±æ–‡é€—号分隔(select/radio/checkbox ä½¿ç”¨ï¼‰
     */
    private String fieldOptions;
    /**
     * æ˜¯å¦å¿…å¡«
     */
    private Boolean required;
    /**
     * è¾“入提示
     */
    private String placeholder;
    /**
     * æŽ’序号
     */
    private Integer sortNo;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/dataobject/activity/WxCpActivitySignupDO.java
@@ -56,6 +56,10 @@
     * å‚与说明(成员提交的文字内容)
     */
    private String content;
    /**
     * è¯„价星级(数据采集/评价模板使用)
     */
    private Integer ratingScore;
    /**
     * çŠ¶æ€
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/dataobject/activity/WxCpActivitySignupValueDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,58 @@
package cn.iocoder.yudao.module.wxcp.dal.dataobject.activity;
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.*;
/**
 * ä¼ä¸šå¾®ä¿¡æ´»åЍ - æŠ¥åé‡‡é›†å€¼ DO
 *
 * ä¸€æ¡æŠ¥åè®°å½•里每个自定义字段的值存一行,字段名/类型做快照,
 * åŽç»­æ´»åŠ¨ä¿®æ”¹å­—æ®µä¹Ÿä¸ä¼šå½±å“åŽ†å²æ•°æ®çš„å«ä¹‰ã€‚
 *
 * @author è¶…级管理员
 */
@TableName("wxcp_activity_signup_value")
@KeySequence("wxcp_activity_signup_value_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class WxCpActivitySignupValueDO extends BaseDO {
    @TableId
    private Long id;
    /**
     * æŠ¥åè®°å½•编号
     */
    private Long signupId;
    /**
     * æ´»åŠ¨ç¼–å·
     */
    private Long activityId;
    /**
     * å­—段编号
     */
    private Long fieldId;
    /**
     * å­—段名称(快照)
     */
    private String fieldLabel;
    /**
     * å­—段类型(快照)
     */
    private String fieldType;
    /**
     * å­—段值;多选用英文逗号拼接
     */
    private String fieldValue;
    /**
     * æŽ’序号
     */
    private Integer sortNo;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/dataobject/notice/WxCpNoticeDO.java
@@ -40,6 +40,37 @@
    private String content;
    /**
     * é€šçŸ¥æ¨¡æ¿ç±»åž‹
     *
     * æžšä¸¾ {@link cn.iocoder.yudao.module.wxcp.enums.notice.WxCpNoticeTypeEnum}
     */
    private Integer noticeType;
    /**
     * æ‘˜è¦ï¼ˆå›¾æ–‡å¡ç‰‡/图文消息使用)
     */
    private String summary;
    /**
     * è·³è½¬é“¾æŽ¥ï¼ˆå¡«æŠ¥ã€æŸ¥çœ‹è¯¦æƒ…等)
     */
    private String linkUrl;
    /**
     * é“¾æŽ¥æŒ‰é’®æ–‡æ¡ˆ
     */
    private String linkBtnText;
    /**
     * å›¾æ–‡æ¶ˆæ¯å°é¢å›¾åœ°å€
     */
    private String coverUrl;
    /**
     * æ–‡ä»¶é€šçŸ¥çš„附件 Blob ID
     */
    private Long fileBlobId;
    /**
     * æ–‡ä»¶é€šçŸ¥çš„附件文件名(快照)
     */
    private String fileName;
    /**
     * çŠ¶æ€
     *
     * æžšä¸¾ {@link WxCpNoticeStatusEnum}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/mysql/activity/WxCpActivityFieldMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
package cn.iocoder.yudao.module.wxcp.dal.mysql.activity;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivityFieldDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
 * ä¼ä¸šå¾®ä¿¡æ´»åŠ¨é‡‡é›†å­—æ®µ Mapper
 *
 * @author è¶…级管理员
 */
@Mapper
public interface WxCpActivityFieldMapper extends BaseMapperX<WxCpActivityFieldDO> {
    default List<WxCpActivityFieldDO> selectListByActivityId(Long activityId) {
        return selectList(new LambdaQueryWrapperX<WxCpActivityFieldDO>()
                .eq(WxCpActivityFieldDO::getActivityId, activityId)
                .orderByAsc(WxCpActivityFieldDO::getSortNo)
                .orderByAsc(WxCpActivityFieldDO::getId));
    }
    default int deleteByActivityId(Long activityId) {
        return delete(new LambdaQueryWrapperX<WxCpActivityFieldDO>()
                .eq(WxCpActivityFieldDO::getActivityId, activityId));
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/mysql/activity/WxCpActivitySignupValueMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
package cn.iocoder.yudao.module.wxcp.dal.mysql.activity;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivitySignupValueDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
 * ä¼ä¸šå¾®ä¿¡æ´»åŠ¨æŠ¥åé‡‡é›†å€¼ Mapper
 *
 * @author è¶…级管理员
 */
@Mapper
public interface WxCpActivitySignupValueMapper extends BaseMapperX<WxCpActivitySignupValueDO> {
    default List<WxCpActivitySignupValueDO> selectListBySignupId(Long signupId) {
        return selectList(new LambdaQueryWrapperX<WxCpActivitySignupValueDO>()
                .eq(WxCpActivitySignupValueDO::getSignupId, signupId)
                .orderByAsc(WxCpActivitySignupValueDO::getSortNo)
                .orderByAsc(WxCpActivitySignupValueDO::getId));
    }
    default int deleteBySignupId(Long signupId) {
        return delete(new LambdaQueryWrapperX<WxCpActivitySignupValueDO>()
                .eq(WxCpActivitySignupValueDO::getSignupId, signupId));
    }
    default int deleteByActivityId(Long activityId) {
        return delete(new LambdaQueryWrapperX<WxCpActivitySignupValueDO>()
                .eq(WxCpActivitySignupValueDO::getActivityId, activityId));
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/enums/ErrorCodeConstants.java
@@ -18,6 +18,14 @@
    ErrorCode ACTIVITY_STATUS_FINISHED = new ErrorCode(1_050_000_003, "活动已结束或已取消,无法继续操作");
    ErrorCode ACTIVITY_PUSH_TARGET_EMPTY = new ErrorCode(1_050_000_004, "推送范围选择「指定成员」或「指定部门」时,推送目标不能为空");
    ErrorCode ACTIVITY_PUSH_FAILED = new ErrorCode(1_050_000_005, "活动推送失败");
    ErrorCode ACTIVITY_IMAGE_REQUIRED = new ErrorCode(1_050_000_006, "图片通知模板必须至少上传 1 å¼ å›¾ç‰‡ï¼Œè¯·ä¸Šä¼ åŽå†æŽ¨é€");
    ErrorCode ACTIVITY_FILE_REQUIRED = new ErrorCode(1_050_000_007, "附件通知模板必须至少上传 1 ä¸ªé™„件,请上传后再推送");
    ErrorCode ACTIVITY_FILE_NOT_EXISTS = new ErrorCode(1_050_000_008, "活动附件不存在或已被删除,请重新上传");
    ErrorCode ACTIVITY_FILE_TOO_LARGE = new ErrorCode(1_050_000_009, "活动附件超过企业微信 20MB é™åˆ¶ï¼Œè¯·åŽ‹ç¼©åŽé‡æ–°ä¸Šä¼ ");
    ErrorCode ACTIVITY_FIELD_LABEL_REQUIRED = new ErrorCode(1_050_000_010, "第 {} ä¸ªé‡‡é›†å­—段的名称不能为空,请填写字段名称");
    ErrorCode ACTIVITY_FIELD_OPTIONS_REQUIRED = new ErrorCode(1_050_000_011,
            "采集字段【{}】是选择型字段,必须填写选项,多个选项用英文逗号分隔");
    ErrorCode ACTIVITY_IMAGE_ONLY = new ErrorCode(1_050_000_012, "图片通知只能上传图片文件,请重新选择");
    // ========== æŠ¥å 1-050-001-000 ==========
    ErrorCode SIGNUP_ACTIVITY_NOT_PUBLISHED = new ErrorCode(1_050_001_000, "活动未发布,暂不能报名");
@@ -29,6 +37,10 @@
    ErrorCode SIGNUP_ALREADY_CANCELED = new ErrorCode(1_050_001_006, "该报名已取消");
    ErrorCode SIGNUP_ALREADY_CHECKED_IN = new ErrorCode(1_050_001_007, "该报名已签到,请勿重复操作");
    ErrorCode SIGNUP_WX_USER_REQUIRED = new ErrorCode(1_050_001_008, "未获取到企业微信成员身份,请从企业微信内打开报名页");
    ErrorCode SIGNUP_NOT_SUPPORTED = new ErrorCode(1_050_001_009, "该活动模板不需要报名/填写,无法提交表单");
    ErrorCode SIGNUP_FIELD_REQUIRED = new ErrorCode(1_050_001_010, "【{}】为必填项,请填写后再提交");
    ErrorCode SIGNUP_RATING_REQUIRED = new ErrorCode(1_050_001_011, "请先完成评价打分后再提交");
    ErrorCode SIGNUP_RATING_INVALID = new ErrorCode(1_050_001_012, "评价星级必须在 1~{} ä¹‹é—´ï¼Œè¯·é‡æ–°é€‰æ‹©");
    // ========== äºŒç»´ç  1-050-002-000 ==========
    ErrorCode QRCODE_GENERATE_FAILED = new ErrorCode(1_050_002_000, "生成报名二维码失败");
@@ -40,8 +52,13 @@
    ErrorCode NOTICE_CANNOT_UPDATE = new ErrorCode(1_050_003_001, "通知已撤回,无法修改");
    ErrorCode NOTICE_STATUS_REVOKED = new ErrorCode(1_050_003_002, "通知已撤回,无法发布");
    ErrorCode NOTICE_STATUS_NOT_PUBLISHED = new ErrorCode(1_050_003_003, "通知尚未发布,请先发布后再推送");
    ErrorCode NOTICE_CONTENT_REQUIRED = new ErrorCode(1_050_003_004, "纯文本通知的正文不能为空,请填写通知内容");
    ErrorCode NOTICE_LINK_URL_REQUIRED = new ErrorCode(1_050_003_005, "图文卡片/图文消息的跳转链接不能为空,请填写链接地址");
    ErrorCode NOTICE_FILE_REQUIRED = new ErrorCode(1_050_003_006, "文件通知必须上传文件,请上传后再推送");
    ErrorCode NOTICE_FILE_NOT_EXISTS = new ErrorCode(1_050_003_007, "通知文件不存在或已被删除,请重新上传");
    ErrorCode NOTICE_FILE_TOO_LARGE = new ErrorCode(1_050_003_008, "通知文件超过企业微信 20MB é™åˆ¶ï¼Œè¯·åŽ‹ç¼©æˆ–æ”¹ç”¨å›¾æ–‡å¡ç‰‡æŽ¨é€ä¸‹è½½é“¾æŽ¥");
    // ========== ä¼ä¸šå¾®ä¿¡é€šè®¯å½• 1-050-004-000 ==========
    ErrorCode WXCP_API_ERROR = new ErrorCode(1_050_004_000, "调用企业微信接口失败");
    ErrorCode WXCP_API_ERROR = new ErrorCode(1_050_004_000, "调用企业微信接口失败:errcode={},errmsg={}");
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/enums/activity/WxCpActivityTypeEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,52 @@
package cn.iocoder.yudao.module.wxcp.enums.activity;
import cn.hutool.core.util.ArrayUtil;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
 * ä¼ä¸šå¾®ä¿¡æ´»åŠ¨æ¨¡æ¿ç±»åž‹
 *
 * ä¸åŒæ¨¡æ¿å†³å®šï¼šè¡¨å•需要填哪些字段、推送时发什么形态的消息、是否需要报名页。
 */
@Getter
@AllArgsConstructor
public enum WxCpActivityTypeEnum implements ArrayValuable<Integer> {
    TEXT(1, "纯通知", "只推送一条纯文本消息,不展示报名表单"),
    MESSAGE(2, "单条消息", "推送一张可点击的卡片消息,点击打开活动详情页"),
    IMAGE(3, "图片通知", "上传图片后推送图片消息,可附带文字说明"),
    FILE(4, "附件通知", "上传文件后推送文件消息,接收人可直接下载查看"),
    FORM(5, "数据采集/评价", "推送卡片到采集页,成员填写自定义字段并可提交评价"),
    ;
    public static final Integer[] ARRAYS = Arrays.stream(values())
            .map(WxCpActivityTypeEnum::getType).toArray(Integer[]::new);
    private final Integer type;
    private final String name;
    /**
     * æ¨¡æ¿è¯´æ˜Žï¼Œå‰ç«¯é€‰æ‹©æ¨¡æ¿æ—¶å±•示
     */
    private final String description;
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
    public static WxCpActivityTypeEnum valueOf(Integer type) {
        return ArrayUtil.firstMatch(o -> o.getType().equals(type), values());
    }
    /**
     * æ˜¯å¦éœ€è¦å±•示报名/采集表单
     */
    public static boolean needSignup(Integer type) {
        return FORM.getType().equals(type);
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/enums/notice/WxCpNoticeTypeEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,38 @@
package cn.iocoder.yudao.module.wxcp.enums.notice;
import cn.hutool.core.util.ArrayUtil;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
 * ä¼ä¸šå¾®ä¿¡é€šçŸ¥æ¨¡æ¿ç±»åž‹
 */
@Getter
@AllArgsConstructor
public enum WxCpNoticeTypeEnum implements ArrayValuable<Integer> {
    TEXT(1, "纯文本通知"),
    CARD(2, "图文卡片(填报/链接)"),
    FILE(3, "文件通知"),
    NEWS(4, "图文消息"),
    ;
    public static final Integer[] ARRAYS = Arrays.stream(values())
            .map(WxCpNoticeTypeEnum::getType).toArray(Integer[]::new);
    private final Integer type;
    private final String name;
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
    public static WxCpNoticeTypeEnum valueOf(Integer type) {
        return ArrayUtil.firstMatch(o -> o.getType().equals(type), values());
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/activity/WxCpActivityService.java
@@ -6,7 +6,9 @@
import cn.iocoder.yudao.module.wxcp.controller.admin.vo.WxCpPushReqVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.vo.WxCpPushResultVO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivityDO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivityFieldDO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivitySignupDO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivitySignupValueDO;
import jakarta.validation.Valid;
import java.util.List;
@@ -67,6 +69,46 @@
    List<SystemStorageBlobRespVO> getActivityAttachments(Long activityId);
    /**
     * èŽ·å¾—æ´»åŠ¨è¯¦æƒ…å›¾ç‰‡é™„ä»¶åˆ—è¡¨ï¼ˆæœ€å¤š 5 å¼ ï¼ŒæŒ‰ä¸Šä¼ å…ˆåŽæŽ’序)
     *
     * @param activityId æ´»åŠ¨ç¼–å·
     * @return é™„件(含访问地址)
     */
    List<SystemStorageBlobRespVO> getActivityDetailAttachments(Long activityId);
    /**
     * èŽ·å¾—å›¾ç‰‡é€šçŸ¥æ¨¡æ¿çš„å›¾ç‰‡é™„ä»¶åˆ—è¡¨ï¼ˆæœ€å¤š 9 å¼ ï¼‰
     *
     * @param activityId æ´»åŠ¨ç¼–å·
     * @return é™„件(含访问地址)
     */
    List<SystemStorageBlobRespVO> getActivityImageAttachments(Long activityId);
    /**
     * èŽ·å¾—é™„ä»¶é€šçŸ¥æ¨¡æ¿çš„é™„ä»¶åˆ—è¡¨ï¼ˆæœ€å¤š 5 ä¸ªï¼‰
     *
     * @param activityId æ´»åŠ¨ç¼–å·
     * @return é™„件(含访问地址)
     */
    List<SystemStorageBlobRespVO> getActivityFileAttachments(Long activityId);
    /**
     * èŽ·å¾—æ´»åŠ¨è‡ªå®šä¹‰é‡‡é›†å­—æ®µ
     *
     * @param activityId æ´»åŠ¨ç¼–å·
     * @return å­—段列表(按排序号升序)
     */
    List<WxCpActivityFieldDO> getActivityFields(Long activityId);
    /**
     * èŽ·å¾—æŸæ¡æŠ¥åè®°å½•çš„è‡ªå®šä¹‰é‡‡é›†å€¼
     *
     * @param signupId æŠ¥åè®°å½•编号
     * @return å­—段值列表
     */
    List<WxCpActivitySignupValueDO> getSignupValues(Long signupId);
    /**
     * å‘布活动(草稿 -&gt; å·²å‘布)
     *
     * @param id æ´»åŠ¨ç¼–å·
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/activity/WxCpActivityServiceImpl.java
@@ -7,13 +7,20 @@
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageAttachmentListReqVO;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageAttachmentSaveReqVO;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageBlobRespVO;
import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageBlobDO;
import cn.iocoder.yudao.module.system.service.storage.SystemStorageAttachmentService;
import cn.iocoder.yudao.module.system.service.storage.SystemStorageBlobService;
import cn.iocoder.yudao.module.wxcp.controller.admin.activity.vo.*;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivityDO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivityFieldDO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivitySignupDO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivitySignupValueDO;
import cn.iocoder.yudao.module.wxcp.dal.mysql.activity.WxCpActivityFieldMapper;
import cn.iocoder.yudao.module.wxcp.dal.mysql.activity.WxCpActivityMapper;
import cn.iocoder.yudao.module.wxcp.dal.mysql.activity.WxCpActivitySignupMapper;
import cn.iocoder.yudao.module.wxcp.dal.mysql.activity.WxCpActivitySignupValueMapper;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpActivityStatusEnum;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpActivityTypeEnum;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpPushScopeEnum;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpSignupStatusEnum;
import cn.iocoder.yudao.module.wxcp.controller.admin.vo.WxCpPushReqVO;
@@ -28,14 +35,19 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.io.File;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.wxcp.enums.ErrorCodeConstants.*;
@@ -53,6 +65,11 @@
    private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
    /**
     * ä¼ä¸šå¾®ä¿¡æ–‡æœ¬æ¶ˆæ¯çº¦ 2048 å­—节,中文按 3 å­—节算,这里按字符数留余量
     */
    private static final int MAX_PUSH_CONTENT_LENGTH = 600;
    /**
     * æŠ¥åé™„件的关联记录类型,对应 system_storage_attachment.record_type
     */
    public static final String SIGNUP_RECORD_TYPE = "wxcp_activity_signup";
@@ -63,17 +80,51 @@
    public static final String ACTIVITY_RECORD_TYPE = "wxcp_activity";
    /**
     * æ´»åŠ¨è¯¦æƒ…å›¾ç‰‡çš„å…³è”è®°å½•ç±»åž‹ï¼Œå¯¹åº” system_storage_attachment.record_type
     *
     * ä¸Žå°é¢åˆ†å¼€å­˜ï¼šå°é¢åªå…è®¸ 1 å¼ ã€æŠ¥åé¡µé¡¶éƒ¨è£å‰ªå±•示;
     * è¯¦æƒ…图可以多张、报名页按原图比例完整展示(二维码/海报这类不能裁的图放这里)。
     */
    public static final String ACTIVITY_DETAIL_RECORD_TYPE = "wxcp_activity_detail";
    /**
     * å›¾ç‰‡é€šçŸ¥æ¨¡æ¿çš„图片关联记录类型
     */
    public static final String ACTIVITY_IMAGE_RECORD_TYPE = "wxcp_activity_image";
    /**
     * é™„件通知模板的附件关联记录类型
     */
    public static final String ACTIVITY_FILE_RECORD_TYPE = "wxcp_activity_file";
    /**
     * é™„件用途,与其它模块保持一致
     */
    private static final String ATTACHMENT_APPLICATION = "file";
    /**
     * ä¼ä¸šå¾®ä¿¡æ–‡ä»¶æ¶ˆæ¯ä¸Šé™ 20MB
     */
    private static final long MAX_FILE_SIZE = 20L * 1024L * 1024L;
    /**
     * é»˜è®¤è¯„价最高星级
     */
    private static final int DEFAULT_RATING_MAX = 5;
    @Resource
    private WxCpActivityMapper wxcpActivityMapper;
    @Resource
    private WxCpActivitySignupMapper wxcpSignupMapper;
    @Resource
    private WxCpActivityFieldMapper activityFieldMapper;
    @Resource
    private WxCpActivitySignupValueMapper signupValueMapper;
    @Resource
    private SystemStorageAttachmentService storageAttachmentService;
    @Resource
    private SystemStorageBlobService storageBlobService;
    @Resource
    private WxCpMessagePushService messagePushService;
@@ -90,6 +141,23 @@
        activity.setStatus(WxCpActivityStatusEnum.DRAFT.getStatus());
        activity.setSignupCount(0);
        activity.setPushStatus(0);
        // æ¨¡æ¿ç±»åž‹ï¼šé»˜è®¤ã€Œæ•°æ®é‡‡é›†/评价」,与改造前的报名活动行为保持一致
        if (activity.getActivityType() == null) {
            activity.setActivityType(WxCpActivityTypeEnum.FORM.getType());
        }
        if (activity.getRatingMax() == null || activity.getRatingMax() <= 0) {
            activity.setRatingMax(DEFAULT_RATING_MAX);
        }
        if (activity.getRatingEnabled() == null) {
            activity.setRatingEnabled(Boolean.FALSE);
        }
        // æŠ¥åä¸Šé™åªå¯¹æ•°æ®é‡‡é›†æ¨¡æ¿æœ‰æ„ä¹‰ï¼›å…¶å®ƒæ¨¡æ¿ä¸ä¼ æ—¶æŒ‰ä¸é™å¤„理,避免接口强校验
        if (activity.getSignupLimit() == null) {
            activity.setSignupLimit(0);
        }
        if (StrUtil.isBlank(activity.getButtonText())) {
            activity.setButtonText(defaultButtonText(activity.getActivityType()));
        }
        // æŽ¨é€èŒƒå›´é€‰å¡«ï¼Œä¸ä¼ é»˜è®¤å…¨å‘˜ï¼›æŽ¨é€æ—¶è¿˜èƒ½åœ¨å¼¹çª—里改
        if (activity.getPushScope() == null) {
            activity.setPushScope(WxCpPushScopeEnum.ALL.getScope());
@@ -98,6 +166,21 @@
        // å°é¢èµ° system çš„附件中间表,业务表不落文件地址字段
        if (CollUtil.isNotEmpty(createReqVO.getBlobIds())) {
            bindActivityAttachments(activity.getId(), createReqVO.getBlobIds());
        }
        // è¯¦æƒ…图片同样走附件中间表,与封面分开存,互不影响
        if (CollUtil.isNotEmpty(createReqVO.getDetailBlobIds())) {
            bindActivityDetailAttachments(activity.getId(), createReqVO.getDetailBlobIds());
        }
        // å›¾ç‰‡é€šçŸ¥ / é™„件通知的素材也走附件中间表,各自独立 recordType
        if (CollUtil.isNotEmpty(createReqVO.getImageBlobIds())) {
            bindAttachments(ACTIVITY_IMAGE_RECORD_TYPE, activity.getId(), createReqVO.getImageBlobIds());
        }
        if (CollUtil.isNotEmpty(createReqVO.getFileBlobIds())) {
            bindAttachments(ACTIVITY_FILE_RECORD_TYPE, activity.getId(), createReqVO.getFileBlobIds());
        }
        // æ•°æ®é‡‡é›†æ¨¡æ¿çš„自定义字段
        if (CollUtil.isNotEmpty(createReqVO.getFieldList())) {
            saveActivityFields(activity.getId(), createReqVO.getFieldList());
        }
        return activity.getId();
    }
@@ -122,6 +205,23 @@
        if (updateReqVO.getBlobIds() != null) {
            bindActivityAttachments(activity.getId(), updateReqVO.getBlobIds());
        }
        // è¯¦æƒ…图片同理:null = ä¸æ”¹åŠ¨ï¼Œç©ºæ•°ç»„ = æ¸…空
        if (updateReqVO.getDetailBlobIds() != null) {
            bindActivityDetailAttachments(activity.getId(), updateReqVO.getDetailBlobIds());
        }
        if (updateReqVO.getImageBlobIds() != null) {
            bindAttachments(ACTIVITY_IMAGE_RECORD_TYPE, activity.getId(), updateReqVO.getImageBlobIds());
        }
        if (updateReqVO.getFileBlobIds() != null) {
            bindAttachments(ACTIVITY_FILE_RECORD_TYPE, activity.getId(), updateReqVO.getFileBlobIds());
        }
        // å­—段列表:null = ä¸æ”¹åŠ¨ï¼Œç©ºæ•°ç»„ = æ¸…空
        if (updateReqVO.getFieldList() != null) {
            activityFieldMapper.deleteByActivityId(activity.getId());
            if (CollUtil.isNotEmpty(updateReqVO.getFieldList())) {
                saveActivityFields(activity.getId(), updateReqVO.getFieldList());
            }
        }
    }
    @Override
@@ -129,8 +229,13 @@
    public void deleteActivity(Long id) {
        validateActivityExists(id);
        wxcpActivityMapper.deleteById(id);
        // æ´»åŠ¨åˆ äº†ï¼Œå°é¢é™„ä»¶å…³ç³»ä¸€å¹¶æ¸…æŽ‰ï¼Œé¿å…æ®‹ç•™æˆå­¤å„¿é™„ä»¶
        // æ´»åŠ¨åˆ äº†ï¼Œæ‰€æœ‰é™„ä»¶å…³ç³»ä¸Žé‡‡é›†å­—æ®µä¸€å¹¶æ¸…æŽ‰ï¼Œé¿å…æ®‹ç•™æˆå­¤å„¿æ•°æ®
        storageAttachmentService.deleteAttachmentsByRecordTypeAndRecordId(ACTIVITY_RECORD_TYPE, id);
        storageAttachmentService.deleteAttachmentsByRecordTypeAndRecordId(ACTIVITY_DETAIL_RECORD_TYPE, id);
        storageAttachmentService.deleteAttachmentsByRecordTypeAndRecordId(ACTIVITY_IMAGE_RECORD_TYPE, id);
        storageAttachmentService.deleteAttachmentsByRecordTypeAndRecordId(ACTIVITY_FILE_RECORD_TYPE, id);
        activityFieldMapper.deleteByActivityId(id);
        signupValueMapper.deleteByActivityId(id);
    }
    @Override
@@ -148,6 +253,69 @@
        return listAttachments(ACTIVITY_RECORD_TYPE, activityId);
    }
    @Override
    public List<SystemStorageBlobRespVO> getActivityDetailAttachments(Long activityId) {
        List<SystemStorageBlobRespVO> attachments = listAttachments(ACTIVITY_DETAIL_RECORD_TYPE, activityId);
        // å¤šå¼ å›¾å¿…须保证展示顺序稳定:底层是 selectByIds,不承诺返回顺序,
        // è¿™é‡ŒæŒ‰ blobId å‡åºï¼ˆå³ä¸Šä¼ å…ˆåŽï¼‰æŽ’一遍,避免同一批图每次刷新换位置。
        return attachments.stream()
                .sorted(Comparator.comparing(SystemStorageBlobRespVO::getId,
                        Comparator.nullsLast(Comparator.naturalOrder())))
                .toList();
    }
    @Override
    public List<SystemStorageBlobRespVO> getActivityImageAttachments(Long activityId) {
        return listAttachments(ACTIVITY_IMAGE_RECORD_TYPE, activityId);
    }
    @Override
    public List<SystemStorageBlobRespVO> getActivityFileAttachments(Long activityId) {
        return listAttachments(ACTIVITY_FILE_RECORD_TYPE, activityId);
    }
    @Override
    public List<WxCpActivityFieldDO> getActivityFields(Long activityId) {
        return activityFieldMapper.selectListByActivityId(activityId);
    }
    @Override
    public List<WxCpActivitySignupValueDO> getSignupValues(Long signupId) {
        return signupValueMapper.selectListBySignupId(signupId);
    }
    /**
     * ä¿å­˜æ´»åŠ¨è‡ªå®šä¹‰é‡‡é›†å­—æ®µ
     *
     * å…ˆæ•´ä½“清空再插入,保证前端提交的顺序与内容就是最终结果;
     * å·²äº§ç”Ÿçš„æŠ¥åå€¼åšå­—段名/类型快照,不会因为这里重建字段而丢失含义。
     */
    private void saveActivityFields(Long activityId, List<WxCpActivityFieldSaveReqVO> fieldList) {
        int index = 1;
        for (WxCpActivityFieldSaveReqVO field : fieldList) {
            if (field == null || StrUtil.isBlank(field.getFieldLabel())) {
                throw exception(ACTIVITY_FIELD_LABEL_REQUIRED, index);
            }
            String fieldType = StrUtil.blankToDefault(field.getFieldType(), WxCpActivityFieldDO.TYPE_TEXT);
            boolean needOptions = WxCpActivityFieldDO.TYPE_SELECT.equals(fieldType)
                    || WxCpActivityFieldDO.TYPE_RADIO.equals(fieldType)
                    || WxCpActivityFieldDO.TYPE_CHECKBOX.equals(fieldType);
            if (needOptions && StrUtil.isBlank(field.getFieldOptions())) {
                throw exception(ACTIVITY_FIELD_OPTIONS_REQUIRED, field.getFieldLabel());
            }
            activityFieldMapper.insert(WxCpActivityFieldDO.builder()
                    .activityId(activityId)
                    .fieldLabel(StrUtil.trim(field.getFieldLabel()))
                    .fieldType(fieldType)
                    .fieldOptions(StrUtil.trim(field.getFieldOptions()))
                    .required(Boolean.TRUE.equals(field.getRequired()))
                    .placeholder(StrUtil.trim(field.getPlaceholder()))
                    .sortNo(field.getSortNo() != null ? field.getSortNo() : index)
                    .build());
            index++;
        }
    }
    /**
     * ç»‘定活动封面附件
     *
@@ -159,9 +327,20 @@
     * é‚£ä¸ªæ–¹æ³•会连 blob ä¸€èµ·åˆ æŽ‰ï¼ŒåŒä¸€ä¸ªæ–‡ä»¶é‡æ–°ç»‘定时会因为文件已不存在而查不出来。
     */
    private void bindActivityAttachments(Long activityId, List<Long> blobIds) {
        bindAttachments(ACTIVITY_RECORD_TYPE, activityId, blobIds);
    }
    /**
     * ç»‘定活动详情图片附件(record_type = wxcp_activity_detail),规则同封面
     */
    private void bindActivityDetailAttachments(Long activityId, List<Long> blobIds) {
        bindAttachments(ACTIVITY_DETAIL_RECORD_TYPE, activityId, blobIds);
    }
    private void bindAttachments(String recordType, Long activityId, List<Long> blobIds) {
        SystemStorageAttachmentSaveReqVO bindReqVO = new SystemStorageAttachmentSaveReqVO();
        bindReqVO.setApplication(ATTACHMENT_APPLICATION);
        bindReqVO.setRecordType(ACTIVITY_RECORD_TYPE);
        bindReqVO.setRecordType(recordType);
        bindReqVO.setRecordId(activityId);
        bindReqVO.setBlobItems(toBlobItems(blobIds));
        storageAttachmentService.bindAttachments(bindReqVO);
@@ -235,10 +414,28 @@
            scope = WxCpPushScopeEnum.ALL.getScope();
        }
        // æ–‡æœ¬å¡ç‰‡æ¶ˆæ¯ï¼šæŒ‰é’®ç›´è¾¾æŠ¥åé¡µï¼Œç‚¹æ¶ˆæ¯æˆ–扫码都能报名
        WxCpPushResultVO result = messagePushService.pushCard(
                activity.getTitle(), buildPushDescription(activity),
                buildSignupUrl(activity.getId()), "立即报名", scope, target);
        // æŒ‰æ´»åŠ¨æ¨¡æ¿ç±»åž‹é€‰æ‹©æŽ¨é€å½¢æ€
        Integer activityType = activity.getActivityType() != null
                ? activity.getActivityType() : WxCpActivityTypeEnum.FORM.getType();
        WxCpPushResultVO result;
        if (Objects.equals(activityType, WxCpActivityTypeEnum.TEXT.getType())) {
            result = messagePushService.pushText(buildTextContent(activity), scope, target);
        } else if (Objects.equals(activityType, WxCpActivityTypeEnum.IMAGE.getType())) {
            result = pushImages(activity, scope, target);
        } else if (Objects.equals(activityType, WxCpActivityTypeEnum.FILE.getType())) {
            result = pushFiles(activity, scope, target);
        } else if (Objects.equals(activityType, WxCpActivityTypeEnum.MESSAGE.getType())) {
            String url = tryBuildSignupUrl(activity.getId());
            result = StrUtil.isBlank(url)
                    ? messagePushService.pushText(buildTextContent(activity), scope, target)
                    : messagePushService.pushCard(activity.getTitle(), buildMessageDescription(activity),
                    url, pushButtonText(activity), scope, target);
        } else {
            // æ•°æ®é‡‡é›†/评价:卡片直达采集页,点消息或扫码都能填写
            result = messagePushService.pushCard(
                    activity.getTitle(), buildPushDescription(activity),
                    buildSignupUrl(activity.getId()), pushButtonText(activity), scope, target);
        }
        if (Boolean.TRUE.equals(result.getSuccess())) {
            wxcpActivityMapper.updateById(WxCpActivityDO.builder()
@@ -280,6 +477,150 @@
        return sb.toString();
    }
    /**
     * çº¯æ–‡æœ¬/图片/附件模板的正文:标题 + æ—¶é—´åœ°ç‚¹ + æ´»åŠ¨è¯¦æƒ…ï¼Œè¿‡é•¿æˆªæ–­é¿å…æ•´æ¡æ¶ˆæ¯å‘é€å¤±è´¥
     */
    private String buildTextContent(WxCpActivityDO activity) {
        StringBuilder body = new StringBuilder();
        if (activity.getStartTime() != null) {
            body.append("活动时间:").append(activity.getStartTime().format(TIME_FMT));
            if (activity.getEndTime() != null) {
                body.append(" ~ ").append(activity.getEndTime().format(TIME_FMT));
            }
            body.append("\n");
        }
        if (StrUtil.isNotBlank(activity.getLocation())) {
            body.append("活动地点:").append(activity.getLocation()).append("\n");
        }
        if (StrUtil.isNotBlank(activity.getContent())) {
            body.append(activity.getContent());
        }
        String content = "【" + activity.getTitle() + "】\n" + body.toString().trim();
        if (content.length() > MAX_PUSH_CONTENT_LENGTH) {
            content = content.substring(0, MAX_PUSH_CONTENT_LENGTH)
                    + "\n……(内容过长已截断,请到系统内查看完整内容)";
        }
        return content;
    }
    /**
     * å•条消息模板的卡片正文:活动摘要 + å¼•导点击
     */
    private String buildMessageDescription(WxCpActivityDO activity) {
        String body = buildTextContent(activity);
        // textcard çš„ description çº¦ 512 å­—节,留出余量,避免整条卡片发送失败
        if (body.length() > 300) {
            body = body.substring(0, 300) + "……";
        }
        return body + "\n点击下方按钮查看详情";
    }
    private String pushButtonText(WxCpActivityDO activity) {
        return StrUtil.blankToDefault(activity.getButtonText(),
                defaultButtonText(activity.getActivityType()));
    }
    private String defaultButtonText(Integer activityType) {
        if (Objects.equals(activityType, WxCpActivityTypeEnum.MESSAGE.getType())) {
            return "查看详情";
        }
        return "立即报名";
    }
    /**
     * æ‹¼è£…活动页地址;未配置外网前缀时返回 null,由调用方降级为文本推送
     */
    private String tryBuildSignupUrl(Long id) {
        try {
            return buildSignupUrl(id);
        } catch (Exception e) {
            log.warn("[tryBuildSignupUrl][未配置活动页外网地址,降级为文本推送 id={}]", id);
            return null;
        }
    }
    /**
     * å›¾ç‰‡é€šçŸ¥ï¼šå…ˆå‘文字说明,再逐张发送图片
     */
    private WxCpPushResultVO pushImages(WxCpActivityDO activity, Integer scope, String target) {
        List<SystemStorageBlobRespVO> images = getActivityImageAttachments(activity.getId());
        if (CollUtil.isEmpty(images)) {
            throw exception(ACTIVITY_IMAGE_REQUIRED);
        }
        List<WxCpPushResultVO> results = new ArrayList<>();
        // æœ‰æ­£æ–‡å°±å…ˆå‘一条文字说明;文字失败不影响后续图片发送
        if (StrUtil.isNotBlank(activity.getContent())) {
            results.add(messagePushService.pushText(buildTextContent(activity), scope, target));
        }
        for (SystemStorageBlobRespVO image : images) {
            SystemStorageBlobDO blob = storageBlobService.getStorageBlob(image.getId());
            if (blob == null) {
                throw exception(ACTIVITY_FILE_NOT_EXISTS);
            }
            if (StrUtil.isNotBlank(blob.getContentType())
                    && !StrUtil.startWithIgnoreCase(blob.getContentType(), "image/")) {
                throw exception(ACTIVITY_IMAGE_ONLY);
            }
            File file = storageBlobService.getPublicFile(blob.getUidFilename(), blob.getResourceKey());
            if (file == null || !file.exists()) {
                throw exception(ACTIVITY_FILE_NOT_EXISTS);
            }
            results.add(messagePushService.pushImage(file, scope, target));
        }
        return aggregatePushResults(results, "图片");
    }
    /**
     * é™„件通知:先发文字说明,再逐个发送文件
     */
    private WxCpPushResultVO pushFiles(WxCpActivityDO activity, Integer scope, String target) {
        List<SystemStorageBlobRespVO> files = getActivityFileAttachments(activity.getId());
        if (CollUtil.isEmpty(files)) {
            throw exception(ACTIVITY_FILE_REQUIRED);
        }
        List<WxCpPushResultVO> results = new ArrayList<>();
        if (StrUtil.isNotBlank(activity.getContent())) {
            results.add(messagePushService.pushText(buildTextContent(activity), scope, target));
        }
        for (SystemStorageBlobRespVO item : files) {
            SystemStorageBlobDO blob = storageBlobService.getStorageBlob(item.getId());
            if (blob == null) {
                throw exception(ACTIVITY_FILE_NOT_EXISTS);
            }
            if (blob.getByteSize() != null && blob.getByteSize() > MAX_FILE_SIZE) {
                throw exception(ACTIVITY_FILE_TOO_LARGE);
            }
            File file = storageBlobService.getPublicFile(blob.getUidFilename(), blob.getResourceKey());
            if (file == null || !file.exists()) {
                throw exception(ACTIVITY_FILE_NOT_EXISTS);
            }
            results.add(messagePushService.pushFile(file, blob.getOriginalFilename(), null, scope, target));
        }
        return aggregatePushResults(results, "附件");
    }
    /**
     * å¤šæ¡æ¶ˆæ¯åˆå¹¶ç»“果:任一条失败即视为整体失败,并把失败原因汇总给前端
     */
    private WxCpPushResultVO aggregatePushResults(List<WxCpPushResultVO> results, String label) {
        if (CollUtil.isEmpty(results)) {
            return new WxCpPushResultVO(false, null, label + "推送内容为空", null, null);
        }
        List<WxCpPushResultVO> failures = results.stream()
                .filter(r -> !Boolean.TRUE.equals(r.getSuccess()))
                .toList();
        if (failures.isEmpty()) {
            return new WxCpPushResultVO(true, 0, "推送成功", null, null);
        }
        String message = failures.stream()
                .map(WxCpPushResultVO::getMessage)
                .filter(StrUtil::isNotBlank)
                .distinct()
                .collect(Collectors.joining(";"));
        return new WxCpPushResultVO(false, failures.get(0).getErrCode(),
                label + "推送失败:" + message, null, null);
    }
    @Override
    public byte[] generateSignupQrCode(Long id, int size) {
        validateActivityExists(id);
@@ -311,6 +652,12 @@
        if (!Objects.equals(activity.getStatus(), WxCpActivityStatusEnum.PUBLISHED.getStatus())) {
            throw exception(SIGNUP_ACTIVITY_NOT_PUBLISHED);
        }
        // åªæœ‰ã€Œæ•°æ®é‡‡é›†/评价」模板有表单,其它模板只做消息推送
        Integer activityType = activity.getActivityType() != null
                ? activity.getActivityType() : WxCpActivityTypeEnum.FORM.getType();
        if (!Objects.equals(activityType, WxCpActivityTypeEnum.FORM.getType())) {
            throw exception(SIGNUP_NOT_SUPPORTED);
        }
        // 2. æ ¡éªŒæŠ¥åæ—¶é—´çª—口
        LocalDateTime now = LocalDateTime.now();
@@ -326,6 +673,12 @@
                && activity.getSignupCount() != null && activity.getSignupCount() >= activity.getSignupLimit()) {
            throw exception(SIGNUP_FULL);
        }
        // 3.1 æ ¡éªŒè‡ªå®šä¹‰é‡‡é›†å­—段与评价,避免把空值写进库
        List<WxCpActivityFieldDO> fields = getActivityFields(activity.getId());
        List<WxCpActivitySignupValueDO> fieldValues =
                buildSignupValues(activity.getId(), fields, reqVO.getFieldValues());
        validateRating(activity, reqVO.getRatingScore());
        // 4. åŽ»é‡ï¼šæœ‰ä¼ä¸šå¾®ä¿¡è´¦å·æŒ‰è´¦å·æŸ¥ï¼›æ²¡æœ‰è´¦å·ä½†æœ‰æ‰‹æœºå·åˆ™æŒ‰æ‰‹æœºå·æŸ¥ï¼›
        //    ä¸¤è€…都没有时无法判定身份,不做去重——否则空手机号会互相误判成同一人
@@ -348,13 +701,16 @@
                    .mobile(mobile)
                    .deptName(reqVO.getDeptName())
                    .content(reqVO.getContent())
                    .ratingScore(reqVO.getRatingScore())
                    .remark(reqVO.getRemark())
                    .status(WxCpSignupStatusEnum.SIGNED_UP.getStatus())
                    .signupTime(now)
                    .checkInTime(null)
                    .build());
            // é‡æ–°æŠ¥åæ—¶æ—§é™„件作废,直接重新绑定(bindAttachments ä¼šå…ˆæ¸…旧关联,且不会删掉文件本身)
            // é‡æ–°æŠ¥åæ—¶æ—§é™„ä»¶/采集值作废,按本次提交覆盖
            bindSignupAttachments(exists.getId(), reqVO.getBlobIds());
            signupValueMapper.deleteBySignupId(exists.getId());
            insertSignupValues(fieldValues, exists.getId());
            incrementSignupCount(activity.getId());
            return exists.getId();
        }
@@ -365,12 +721,14 @@
        // å§“名、手机号改为选填:统一存空串而不是 null,避免后续按手机号查重时空值语义混乱
        signup.setName(StrUtil.trimToEmpty(reqVO.getName()));
        signup.setMobile(mobile);
        signup.setRatingScore(reqVO.getRatingScore());
        signup.setStatus(WxCpSignupStatusEnum.SIGNED_UP.getStatus());
        signup.setSignupTime(now);
        wxcpSignupMapper.insert(signup);
        // 6. ç»‘定报名人上传的图片/附件
        // 6. ç»‘定报名人上传的图片/附件与采集字段值
        bindSignupAttachments(signup.getId(), reqVO.getBlobIds());
        insertSignupValues(fieldValues, signup.getId());
        // 7. ç”¨ SQL è‡ªå¢žï¼Œé¿å…å¹¶å‘下的读改写丢失更新
        incrementSignupCount(activity.getId());
@@ -398,6 +756,65 @@
        return listAttachments(SIGNUP_RECORD_TYPE, signupId);
    }
    /**
     * ç»„装报名采集值:按字段定义逐项校验必填,没填的字段不落行
     */
    private List<WxCpActivitySignupValueDO> buildSignupValues(Long activityId, List<WxCpActivityFieldDO> fields,
                                                               List<WxCpSignupValueReqVO> reqValues) {
        if (CollUtil.isEmpty(fields)) {
            return Collections.emptyList();
        }
        Map<Long, String> valueMap = CollUtil.isEmpty(reqValues) ? Collections.emptyMap()
                : reqValues.stream()
                .filter(v -> v != null && v.getFieldId() != null)
                .collect(Collectors.toMap(WxCpSignupValueReqVO::getFieldId,
                        v -> StrUtil.trimToEmpty(v.getFieldValue()), (a, b) -> b));
        List<WxCpActivitySignupValueDO> result = new ArrayList<>();
        for (WxCpActivityFieldDO field : fields) {
            String value = StrUtil.trimToEmpty(valueMap.get(field.getId()));
            if (Boolean.TRUE.equals(field.getRequired()) && StrUtil.isBlank(value)) {
                throw exception(SIGNUP_FIELD_REQUIRED, field.getFieldLabel());
            }
            if (StrUtil.isBlank(value)) {
                continue;
            }
            result.add(WxCpActivitySignupValueDO.builder()
                    .activityId(activityId)
                    .fieldId(field.getId())
                    .fieldLabel(field.getFieldLabel())
                    .fieldType(field.getFieldType())
                    .fieldValue(value)
                    .sortNo(field.getSortNo())
                    .build());
        }
        return result;
    }
    private void insertSignupValues(List<WxCpActivitySignupValueDO> values, Long signupId) {
        if (CollUtil.isEmpty(values)) {
            return;
        }
        values.forEach(value -> value.setSignupId(signupId));
        signupValueMapper.insertBatch(values);
    }
    /**
     * å¼€å¯è¯„价时校验星级范围
     */
    private void validateRating(WxCpActivityDO activity, Integer score) {
        if (!Boolean.TRUE.equals(activity.getRatingEnabled())) {
            return;
        }
        if (score == null) {
            throw exception(SIGNUP_RATING_REQUIRED);
        }
        int max = activity.getRatingMax() != null && activity.getRatingMax() > 0
                ? activity.getRatingMax() : DEFAULT_RATING_MAX;
        if (score < 1 || score > max) {
            throw exception(SIGNUP_RATING_INVALID, max);
        }
    }
    private void incrementSignupCount(Long activityId) {
        wxcpActivityMapper.update(null, new LambdaUpdateWrapper<WxCpActivityDO>()
                .setSql("signup_count = signup_count + 1")
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/member/WxCpMemberServiceImpl.java
@@ -4,6 +4,7 @@
import cn.iocoder.yudao.module.wxcp.controller.admin.member.vo.WxCpMemberRespVO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.common.error.WxErrorException;
import me.chanjar.weixin.cp.api.WxCpService;
import me.chanjar.weixin.cp.bean.WxCpDepart;
import me.chanjar.weixin.cp.bean.WxCpUser;
@@ -73,7 +74,8 @@
            users = wxCpService.getUserService().listSimpleByDepartment(deptId, child, null);
        } catch (Exception e) {
            log.error("[getMemberList][获取企业微信成员失败 deptId={} fetchChild={}]", deptId, child, e);
            throw exception(WXCP_API_ERROR);
            throwWxCpApiError(e);
            return List.of();
        }
        // éƒ¨é—¨åæ˜ å°„,用于把成员的部门ID翻译成可读名称
@@ -94,10 +96,22 @@
            return departs != null ? departs : List.of();
        } catch (Exception e) {
            log.error("[listDeparts][获取企业微信部门失败]", e);
            throw exception(WXCP_API_ERROR);
            throwWxCpApiError(e);
            return List.of();
        }
    }
    /**
     * æŠŠä¼ä¸šå¾®ä¿¡æŽ¥å£å¼‚常转成带 errcode/errmsg çš„可读提示,便于定位 IP ç™½åå•、Secret ç­‰é—®é¢˜
     */
    private void throwWxCpApiError(Exception e) {
        if (e instanceof WxErrorException wxError && wxError.getError() != null) {
            throw exception(WXCP_API_ERROR,
                    wxError.getError().getErrorCode(), wxError.getError().getErrorMsg());
        }
        throw exception(WXCP_API_ERROR, "unknown", e.getMessage());
    }
    private WxCpDepartmentRespVO toDeptVO(WxCpDepart depart) {
        WxCpDepartmentRespVO vo = new WxCpDepartmentRespVO();
        vo.setId(depart.getId());
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/notice/WxCpNoticeServiceImpl.java
@@ -11,12 +11,16 @@
import cn.iocoder.yudao.module.wxcp.dal.mysql.notice.WxCpNoticeMapper;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpPushScopeEnum;
import cn.iocoder.yudao.module.wxcp.enums.notice.WxCpNoticeStatusEnum;
import cn.iocoder.yudao.module.wxcp.enums.notice.WxCpNoticeTypeEnum;
import cn.iocoder.yudao.module.wxcp.service.push.WxCpMessagePushService;
import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageBlobDO;
import cn.iocoder.yudao.module.system.service.storage.SystemStorageBlobService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.io.File;
import java.time.LocalDateTime;
import java.util.Objects;
@@ -43,10 +47,18 @@
    private WxCpNoticeMapper wxcpNoticeMapper;
    @Resource
    private WxCpMessagePushService messagePushService;
    @Resource
    private SystemStorageBlobService storageBlobService;
    /** ä¼ä¸šå¾®ä¿¡æ–‡ä»¶æ¶ˆæ¯ä¸Šé™ 20MB */
    private static final long MAX_FILE_SIZE = 20L * 1024L * 1024L;
    @Override
    public Long createNotice(WxCpNoticeSaveReqVO createReqVO) {
        WxCpNoticeDO notice = BeanUtils.toBean(createReqVO, WxCpNoticeDO.class);
        if (notice.getNoticeType() == null) {
            notice.setNoticeType(WxCpNoticeTypeEnum.TEXT.getType());
        }
        notice.setStatus(WxCpNoticeStatusEnum.DRAFT.getStatus());
        notice.setPushStatus(0);
        // æŽ¨é€èŒƒå›´é€‰å¡«ï¼Œä¸ä¼ é»˜è®¤å…¨å‘˜ï¼›æŽ¨é€æ—¶è¿˜èƒ½åœ¨å¼¹çª—里改
@@ -121,9 +133,45 @@
            scope = WxCpPushScopeEnum.ALL.getScope();
        }
        // é€šçŸ¥ç”¨çº¯æ–‡æœ¬æŽ¨é€ï¼šæ²¡æœ‰å¯è·³è½¬çš„详情页,而 textcard ç±»åž‹å¼ºåˆ¶è¦æ±‚ url
        WxCpPushResultVO result = messagePushService.pushText(
                buildPushContent(notice), scope, target);
        // æŽ¨é€æ—¶å…è®¸é‡æ–°é€‰æ‹©æ¨¡æ¿ç±»åž‹ï¼›æœªé€‰æ‹©åˆ™æ²¿ç”¨é€šçŸ¥ä¸Šä¿å­˜çš„æ¨¡æ¿
        Integer noticeType = pushReqVO.getNoticeType() != null
                ? pushReqVO.getNoticeType()
                : (notice.getNoticeType() != null ? notice.getNoticeType() : WxCpNoticeTypeEnum.TEXT.getType());
        String summary = StrUtil.blankToDefault(pushReqVO.getSummary(), notice.getSummary());
        String linkUrl = StrUtil.blankToDefault(pushReqVO.getLinkUrl(), notice.getLinkUrl());
        String linkBtnText = StrUtil.blankToDefault(pushReqVO.getLinkBtnText(), notice.getLinkBtnText());
        String coverUrl = StrUtil.blankToDefault(pushReqVO.getCoverUrl(), notice.getCoverUrl());
        Long fileBlobId = pushReqVO.getFileBlobId() != null ? pushReqVO.getFileBlobId() : notice.getFileBlobId();
        String fileName = StrUtil.blankToDefault(pushReqVO.getFileName(), notice.getFileName());
        // æŒ‰æœ¬æ¬¡é€‰æ‹©çš„æ¨¡æ¿ç±»åž‹æ ¡éªŒå¿…填项
        if (Objects.equals(noticeType, WxCpNoticeTypeEnum.TEXT.getType())
                && StrUtil.isBlank(notice.getContent())) {
            throw exception(NOTICE_CONTENT_REQUIRED);
        }
        if ((Objects.equals(noticeType, WxCpNoticeTypeEnum.CARD.getType())
                || Objects.equals(noticeType, WxCpNoticeTypeEnum.NEWS.getType()))
                && StrUtil.isBlank(linkUrl)) {
            throw exception(NOTICE_LINK_URL_REQUIRED);
        }
        if (Objects.equals(noticeType, WxCpNoticeTypeEnum.FILE.getType()) && fileBlobId == null) {
            throw exception(NOTICE_FILE_REQUIRED);
        }
        WxCpPushResultVO result;
        if (Objects.equals(noticeType, WxCpNoticeTypeEnum.CARD.getType())) {
            result = messagePushService.pushCard(notice.getTitle(),
                    StrUtil.blankToDefault(summary, notice.getContent()),
                    linkUrl, StrUtil.blankToDefault(linkBtnText, "去填报"), scope, target);
        } else if (Objects.equals(noticeType, WxCpNoticeTypeEnum.FILE.getType())) {
            result = pushFileNotice(fileBlobId, fileName, notice, scope, target);
        } else if (Objects.equals(noticeType, WxCpNoticeTypeEnum.NEWS.getType())) {
            result = messagePushService.pushNews(notice.getTitle(),
                    StrUtil.blankToDefault(summary, notice.getContent()),
                    linkUrl, coverUrl, scope, target);
        } else {
            result = messagePushService.pushText(buildPushContent(notice), scope, target);
        }
        if (Boolean.TRUE.equals(result.getSuccess())) {
            wxcpNoticeMapper.updateById(WxCpNoticeDO.builder()
@@ -138,6 +186,50 @@
    }
    /**
     * æ–‡ä»¶é€šçŸ¥ï¼šä»Žç³»ç»Ÿé™„件中心取文件,交给推送服务上传企业微信并发送
     */
    private WxCpPushResultVO pushFileNotice(Long fileBlobId, String fileName, WxCpNoticeDO notice,
                                            Integer scope, String target) {
        if (fileBlobId == null) {
            throw exception(NOTICE_FILE_REQUIRED);
        }
        SystemStorageBlobDO blob = storageBlobService.getStorageBlob(fileBlobId);
        if (blob == null) {
            throw exception(NOTICE_FILE_NOT_EXISTS);
        }
        if (blob.getByteSize() != null && blob.getByteSize() > MAX_FILE_SIZE) {
            throw exception(NOTICE_FILE_TOO_LARGE);
        }
        File file = storageBlobService.getPublicFile(blob.getUidFilename(), blob.getResourceKey());
        if (file == null || !file.exists()) {
            throw exception(NOTICE_FILE_NOT_EXISTS);
        }
        String actualFileName = StrUtil.blankToDefault(fileName, blob.getOriginalFilename());
        return messagePushService.pushFile(file, actualFileName, buildPushContent(notice), scope, target);
    }
    /**
     * æŒ‰æ¨¡æ¿ç±»åž‹æ ¡éªŒå¿…填项
     */
    private void validateNoticeByType(WxCpNoticeSaveReqVO reqVO) {
        Integer type = reqVO.getNoticeType() != null
                ? reqVO.getNoticeType() : WxCpNoticeTypeEnum.TEXT.getType();
        if (Objects.equals(type, WxCpNoticeTypeEnum.TEXT.getType())
                && StrUtil.isBlank(reqVO.getContent())) {
            throw exception(NOTICE_CONTENT_REQUIRED);
        }
        if ((Objects.equals(type, WxCpNoticeTypeEnum.CARD.getType())
                || Objects.equals(type, WxCpNoticeTypeEnum.NEWS.getType()))
                && StrUtil.isBlank(reqVO.getLinkUrl())) {
            throw exception(NOTICE_LINK_URL_REQUIRED);
        }
        if (Objects.equals(type, WxCpNoticeTypeEnum.FILE.getType())
                && reqVO.getFileBlobId() == null) {
            throw exception(NOTICE_FILE_REQUIRED);
        }
    }
    /**
     * æ‹¼è£…推送正文:标题加粗样式用不了,改用【】包裹;过长则截断并提示
     */
    private String buildPushContent(WxCpNoticeDO notice) {
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/push/WxCpMessagePushService.java
@@ -35,4 +35,41 @@
     */
    WxCpPushResultVO pushText(String content, Integer scope, String target);
    /**
     * æŽ¨é€å›¾æ–‡æ¶ˆæ¯ï¼ˆnews)
     *
     * @param title       æ ‡é¢˜
     * @param description æ‘˜è¦
     * @param url         ç‚¹å‡»è·³è½¬åœ°å€
     * @param picUrl      å°é¢å›¾åœ°å€ï¼ˆé€‰å¡«ï¼‰
     * @param scope       æŽ¨é€èŒƒå›´
     * @param target      æŽ¨é€ç›®æ ‡
     * @return æŽ¨é€ç»“æžœ
     */
    WxCpPushResultVO pushNews(String title, String description, String url, String picUrl,
                              Integer scope, String target);
    /**
     * æŽ¨é€æ–‡ä»¶æ¶ˆæ¯ï¼šå…ˆæŠŠæ–‡ä»¶ä¸Šä¼ åˆ°ä¼ä¸šå¾®ä¿¡æ¢å– mediaId,再发送文件
     *
     * @param file        æ–‡ä»¶
     * @param fileName    æ–‡ä»¶åï¼ˆä»…用于日志)
     * @param textContent æ–‡ä»¶å‰çš„说明文本,可为空
     * @param scope       æŽ¨é€èŒƒå›´
     * @param target      æŽ¨é€ç›®æ ‡
     * @return æŽ¨é€ç»“æžœ
     */
    WxCpPushResultVO pushFile(java.io.File file, String fileName, String textContent,
                              Integer scope, String target);
    /**
     * æŽ¨é€å›¾ç‰‡æ¶ˆæ¯ï¼šå…ˆæŠŠå›¾ç‰‡ä¸Šä¼ åˆ°ä¼ä¸šå¾®ä¿¡æ¢å– mediaId,再发送图片
     *
     * @param file   å›¾ç‰‡æ–‡ä»¶
     * @param scope  æŽ¨é€èŒƒå›´
     * @param target æŽ¨é€ç›®æ ‡
     * @return æŽ¨é€ç»“æžœ
     */
    WxCpPushResultVO pushImage(java.io.File file, Integer scope, String target);
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/push/WxCpMessagePushServiceImpl.java
@@ -7,6 +7,8 @@
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.cp.api.WxCpService;
import me.chanjar.weixin.common.bean.result.WxMediaUploadResult;
import me.chanjar.weixin.cp.bean.article.NewArticle;
import me.chanjar.weixin.cp.bean.message.WxCpMessage;
import me.chanjar.weixin.cp.bean.message.WxCpMessageSendResult;
import org.springframework.stereotype.Service;
@@ -58,6 +60,77 @@
        return doSend(message, scope, target, "text");
    }
    @Override
    public WxCpPushResultVO pushNews(String title, String description, String url, String picUrl,
                                     Integer scope, String target) {
        if (StrUtil.isBlank(url)) {
            throw new IllegalArgumentException("推送图文消息时 url ä¸èƒ½ä¸ºç©º");
        }
        NewArticle article = NewArticle.builder()
                .title(title)
                .description(StrUtil.blankToDefault(description, ""))
                .url(url)
                .picUrl(picUrl)
                .build();
        WxCpMessage message = WxCpMessage.NEWS()
                .agentId(agentId())
                .addArticle(article)
                .build();
        return doSend(message, scope, target, "news");
    }
    @Override
    public WxCpPushResultVO pushFile(java.io.File file, String fileName, String textContent,
                                     Integer scope, String target) {
        if (file == null || !file.exists()) {
            return new WxCpPushResultVO(false, null, "文件不存在,无法推送", null, null);
        }
        try {
            // 1. ä¸Šä¼ æ–‡ä»¶åˆ°ä¼ä¸šå¾®ä¿¡ï¼Œæ¢å– mediaId
            WxMediaUploadResult upload = wxCpService.getMediaService().upload("file", file);
            if (upload == null || StrUtil.isBlank(upload.getMediaId())) {
                return new WxCpPushResultVO(false, null, "文件上传企业微信失败", null, null);
            }
            // 2. å…ˆå‘送说明文本,再发送文件;文本失败不阻塞文件发送
            if (StrUtil.isNotBlank(textContent)) {
                pushText(textContent, scope, target);
            }
            // 3. å‘送文件消息
            WxCpMessage message = WxCpMessage.FILE()
                    .agentId(agentId())
                    .mediaId(upload.getMediaId())
                    .build();
            WxCpPushResultVO result = doSend(message, scope, target, "file");
            log.info("[pushFile][文件推送完成 fileName={} success={}]", fileName, result.getSuccess());
            return result;
        } catch (Exception e) {
            log.error("[pushFile][文件上传或推送异常 fileName={}]", fileName, e);
            return new WxCpPushResultVO(false, null, "文件推送异常:" + e.getMessage(), null, null);
        }
    }
    @Override
    public WxCpPushResultVO pushImage(java.io.File file, Integer scope, String target) {
        if (file == null || !file.exists()) {
            return new WxCpPushResultVO(false, null, "图片文件不存在,无法推送", null, null);
        }
        try {
            // ä¼ä¸šå¾®ä¿¡å›¾ç‰‡æ¶ˆæ¯è¦æ±‚先用 image ç±»åž‹ä¸Šä¼ æ¢å– mediaId
            WxMediaUploadResult upload = wxCpService.getMediaService().upload("image", file);
            if (upload == null || StrUtil.isBlank(upload.getMediaId())) {
                return new WxCpPushResultVO(false, null, "图片上传企业微信失败", null, null);
            }
            WxCpMessage message = WxCpMessage.IMAGE()
                    .agentId(agentId())
                    .mediaId(upload.getMediaId())
                    .build();
            return doSend(message, scope, target, "image");
        } catch (Exception e) {
            log.error("[pushImage][图片上传或推送异常 file={}]", file.getName(), e);
            return new WxCpPushResultVO(false, null, "图片推送异常:" + e.getMessage(), null, null);
        }
    }
    /**
     * ç»Ÿä¸€å¤„理接收者解析、发送与错误包装
     */
yudao-server/pom.xml
@@ -206,6 +206,27 @@
            <version>${revision}</version>
        </dependency>
        <!-- èµ„产管理模块 -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-asset</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- ç”¨è½¦ç®¡ç†æ¨¡å— -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-vehicle</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- é‡‡è´­ç®¡ç†æ¨¡å— -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-purchase</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- spring boot é…ç½®æ‰€éœ€ä¾èµ– -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
yudao-server/src/main/resources/application-local.yaml
@@ -65,16 +65,16 @@
      primary: master
      datasource:
        master:
          url: jdbc:mysql://localhost:3306/mom-jssm?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X è¿žæŽ¥çš„示例
          url: jdbc:mysql://36.213.150.237:10000/mom-jssm?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # ç›´è¿žæµ‹è¯•服务器 MySQL(宿主机 10000 -> å®¹å™¨ 3306),不再依赖本地 VMware è™šæ‹Ÿæœº
          username: root
          password: 123456
          password: xd@123456..
  # Redis é…ç½®ã€‚Redisson é»˜è®¤çš„配置足够使用,一般不需要进行调优
  data:
    redis:
      host: localhost # åœ°å€
      port: 6379 # ç«¯å£
      database: 5 # æ•°æ®åº“索引
      host: 36.213.150.237 # åœ°å€ï¼ˆæµ‹è¯•服务器 Redis,宿主机 9999 -> å®¹å™¨ 6379,无密码)
      port: 9999 # ç«¯å£
      database: 8 # æ•°æ®åº“索引。服务器上 db5 å·²è¢« mom-chdl å ç”¨ã€db3 è¢« mom-jssm çº¿ä¸Šå®žä¾‹å ç”¨ï¼Œæœ¬åœ°ç”¨ç©ºé—²çš„ db8 é¿å…ä¸²æ•°æ®
#      password: sugaozhixing.cn # å¯†ç ï¼Œå»ºè®®ç”Ÿäº§çŽ¯å¢ƒå¼€å¯
--- #################### å®šæ—¶ä»»åŠ¡ç›¸å…³é…ç½® ####################
@@ -192,7 +192,7 @@
  storage: # æ–‡ä»¶ä¸Šä¼ é…ç½®
    path: D:/uploads # æ–‡ä»¶å­˜å‚¨æ ¹ç›®å½•(按实际服务器路径配置)
    url-prefix: /admin-api/system/storage-blob # URL路径前缀
    domain: http://192.168.1.6:48080 # åŸŸåå‰ç¼€ï¼ˆæŒ‰å®žé™…域名配置)
    domain: ${STORAGE_DOMAIN:http://127.0.0.1:48080} # æ–‡ä»¶è®¿é—®åŸŸåå‰ç¼€ï¼›æœ¬åœ°é»˜è®¤æœ¬æœºï¼Œéƒ¨ç½²æ—¶ç”¨ STORAGE_DOMAIN è¦†ç›–
    expired: 120 # token过期时间(分钟)
    use-limit: 10 # token使用次数上限
    jwt-secret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG # JWT密钥(至少32字符)
@@ -220,11 +220,11 @@
      render-dpi: 200 # æ‰«æé¡µè½¬å›¾çš„ DPI,低于 200 ä¸­æ–‡å­—形会糊
      max-xlsx-rows: 300 # Excel å•表最多读取的行数
  database-backup: # æ•°æ®åº“备份配置
    host: localhost # å¤‡ä»½ç›®æ ‡ MySQL ä¸»æœºï¼Œç•™ç©ºè‡ªåŠ¨ä»Žæ•°æ®æº JDBC URL è§£æž
    port: 3306 # å¤‡ä»½ç›®æ ‡ MySQL ç«¯å£ï¼Œç•™ç©ºè‡ªåŠ¨ä»Žæ•°æ®æº JDBC URL è§£æž
    database: ruoyi-vue-pro # å¤‡ä»½ç›®æ ‡æ•°æ®åº“,留空自动从数据源 JDBC URL è§£æž
    host: 36.213.150.237 # å¤‡ä»½ç›®æ ‡ MySQL ä¸»æœºï¼Œç•™ç©ºè‡ªåŠ¨ä»Žæ•°æ®æº JDBC URL è§£æž
    port: 10000 # å¤‡ä»½ç›®æ ‡ MySQL ç«¯å£ï¼Œç•™ç©ºè‡ªåŠ¨ä»Žæ•°æ®æº JDBC URL è§£æž
    database: mom-jssm # å¤‡ä»½ç›®æ ‡æ•°æ®åº“,留空自动从数据源 JDBC URL è§£æž
    username: root # å¤‡ä»½è´¦å·ï¼Œç•™ç©ºä½¿ç”¨æ•°æ®æºè´¦å·
    password: '123456' # å¤‡ä»½å¯†ç ï¼Œç•™ç©ºä½¿ç”¨æ•°æ®æºå¯†ç 
    password: 'xd@123456..' # å¤‡ä»½å¯†ç ï¼Œç•™ç©ºä½¿ç”¨æ•°æ®æºå¯†ç 
    command: # mysqldump å‘½ä»¤ï¼Œç•™ç©ºè‡ªåŠ¨æ£€æµ‹éƒ¨ç½²çŽ¯å¢ƒï¼ˆæœ¬æœº mysqldump æˆ– Docker MySQL å®¹å™¨ï¼‰ï¼›å¯æ‰‹åŠ¨æŒ‡å®šï¼Œå¦‚ï¼šdocker exec mysql8 mysqldump
--- #################### ä¼ä¸šå¾®ä¿¡é…ç½® ####################