From 09cf3017e19fcf8dd17ee0993f2cc989f27a9fd9 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sat, 19 Sep 2026 10:11:41 +0800 Subject: [PATCH 1/3] feat(oa): add office automation --- .../common/util/date/LocalDateTimeUtils.java | 60 + .../common/util/object/PageUtils.java | 15 +- .../util/date/LocalDateTimeUtilsTest.java | 55 + .../common/util/object/PageUtilsTest.java | 68 + .../mybatis/core/mapper/BaseMapperX.java | 7 +- .../HrmAttendanceStatisticsServiceImpl.java | 2 +- yudao-module-oa/pom.xml | 81 ++ .../OaAnnouncementController.java | 214 +++ .../vo/OaAnnouncementPageReqVO.java | 39 + .../announcement/vo/OaAnnouncementRespVO.java | 61 + .../vo/OaAnnouncementSaveReqVO.java | 45 + .../attendance/OaAttendanceController.java | 260 ++++ .../vo/OaAttendanceMonthReportReqVO.java | 27 + .../vo/OaAttendanceMonthReportRespVO.java | 48 + .../attendance/vo/OaAttendancePageReqVO.java | 36 + .../attendance/vo/OaAttendanceRespVO.java | 47 + .../vo/OaAttendanceUpdateReqVO.java | 27 + .../vo/OaAttendanceWeekReportReqVO.java | 22 + .../vo/OaAttendanceWeekReportRespVO.java | 59 + .../contact/OaContactCategoryController.java | 88 ++ .../admin/contact/OaContactController.java | 234 +++ .../contact/vo/OaContactHandleReqVO.java | 18 + .../admin/contact/vo/OaContactPageReqVO.java | 28 + .../admin/contact/vo/OaContactRespVO.java | 112 ++ .../admin/contact/vo/OaContactSaveReqVO.java | 60 + .../admin/contact/vo/OaContactShareReqVO.java | 22 + .../vo/category/OaContactCategoryRespVO.java | 24 + .../category/OaContactCategorySaveReqVO.java | 25 + .../discussion/OaDiscussionController.java | 223 +++ .../OaDiscussionLikeController.java | 43 + .../OaDiscussionReplyController.java | 133 ++ .../OaDiscussionVoteController.java | 35 + .../vo/discussion/OaDiscussionPageReqVO.java | 34 + .../vo/discussion/OaDiscussionRespVO.java | 65 + .../vo/discussion/OaDiscussionSaveReqVO.java | 69 + .../vo/like/OaDiscussionLikeReqVO.java | 28 + .../reply/OaDiscussionReplyCreateReqVO.java | 36 + .../vo/reply/OaDiscussionReplyPageReqVO.java | 21 + .../vo/reply/OaDiscussionReplyRespVO.java | 52 + .../vo/vote/OaDiscussionVoteReqVO.java | 22 + .../vo/vote/OaVoteOptionRespVO.java | 33 + .../vo/vote/OaVoteOptionSaveReqVO.java | 26 + .../admin/file/OaFileFavoriteController.java | 43 + .../admin/file/OaFileNodeController.java | 116 ++ .../file/OaFilePermissionController.java | 57 + .../file/vo/node/OaFileNodeCopyReqVO.java | 19 + .../file/vo/node/OaFileNodeMoveReqVO.java | 19 + .../file/vo/node/OaFileNodePageReqVO.java | 42 + .../file/vo/node/OaFileNodeRenameReqVO.java | 20 + .../admin/file/vo/node/OaFileNodeRespVO.java | 54 + .../file/vo/node/OaFileNodeSaveReqVO.java | 49 + .../file/vo/node/OaFileStorageRespVO.java | 25 + .../vo/permission/OaFilePermissionRespVO.java | 33 + .../permission/OaFilePermissionSaveReqVO.java | 42 + .../admin/leave/OaLeaveApplyController.java | 122 ++ .../admin/leave/vo/OaLeaveApplyPageReqVO.java | 19 + .../admin/leave/vo/OaLeaveApplyRespVO.java | 54 + .../admin/leave/vo/OaLeaveApplySaveReqVO.java | 59 + .../leave/vo/OaLeaveApplySubmitReqVO.java | 21 + .../admin/mail/OaMailAccountController.java | 132 ++ .../admin/mail/OaMailFolderController.java | 37 + .../admin/mail/OaMailMessageController.java | 131 ++ .../admin/mail/OaMailProviderController.java | 83 ++ .../mail/vo/account/OaMailAccountRespVO.java | 31 + .../vo/account/OaMailAccountSaveReqVO.java | 58 + .../mail/vo/message/OaMailFolderRespVO.java | 19 + .../vo/message/OaMailMessagePageReqVO.java | 50 + .../mail/vo/message/OaMailMessageRespVO.java | 76 + .../vo/message/OaMailMessageSaveReqVO.java | 56 + .../vo/provider/OaMailProviderRespVO.java | 25 + .../vo/provider/OaMailProviderSaveReqVO.java | 74 + .../OaMeetingRoomBookingController.java | 184 +++ .../meetingroom/OaMeetingRoomController.java | 130 ++ .../OaMeetingRoomBookingPageReqVO.java | 54 + .../booking/OaMeetingRoomBookingRespVO.java | 88 ++ .../OaMeetingRoomBookingSaveReqVO.java | 61 + .../OaMeetingRoomBookingScheduleReqVO.java | 30 + .../vo/room/OaMeetingRoomPageReqVO.java | 28 + .../vo/room/OaMeetingRoomRespVO.java | 70 + .../vo/room/OaMeetingRoomSaveReqVO.java | 83 ++ .../admin/note/OaNoteCategoryController.java | 90 ++ .../admin/note/OaNoteController.java | 188 +++ .../note/vo/OaNoteFavoriteUpdateReqVO.java | 19 + .../admin/note/vo/OaNotePageReqVO.java | 43 + .../admin/note/vo/OaNoteRespVO.java | 55 + .../admin/note/vo/OaNoteSaveReqVO.java | 48 + .../admin/note/vo/OaNoteShareUpdateReqVO.java | 21 + .../vo/category/OaNoteCategoryRespVO.java | 24 + .../vo/category/OaNoteCategorySaveReqVO.java | 25 + .../OaOfficialDocReceiveController.java | 177 +++ .../OaOfficialDocSendController.java | 166 +++ .../OaOfficialDocTemplateController.java | 85 ++ .../OaOfficialDocReceivePageReqVO.java | 22 + .../receive/OaOfficialDocReceiveRespVO.java | 99 ++ .../OaOfficialDocReceiveSaveReqVO.java | 80 ++ .../vo/send/OaOfficialDocSendPageReqVO.java | 22 + .../vo/send/OaOfficialDocSendRespVO.java | 96 ++ .../vo/send/OaOfficialDocSendSaveReqVO.java | 84 ++ .../OaOfficialDocTemplatePageReqVO.java | 19 + .../template/OaOfficialDocTemplateRespVO.java | 48 + .../OaOfficialDocTemplateSaveReqVO.java | 60 + .../overtime/OaOvertimeApplyController.java | 121 ++ .../overtime/vo/OaOvertimeApplyPageReqVO.java | 19 + .../overtime/vo/OaOvertimeApplyRespVO.java | 51 + .../overtime/vo/OaOvertimeApplySaveReqVO.java | 53 + .../vo/OaOvertimeApplySubmitReqVO.java | 21 + .../admin/plan/OaPlanController.java | 197 +++ .../admin/plan/vo/OaPlanCommentReqVO.java | 22 + .../admin/plan/vo/OaPlanPageReqVO.java | 39 + .../admin/plan/vo/OaPlanReportPageReqVO.java | 36 + .../admin/plan/vo/OaPlanReportRespVO.java | 52 + .../admin/plan/vo/OaPlanRespVO.java | 61 + .../admin/plan/vo/OaPlanSaveReqVO.java | 75 + .../regular/OaRegularApplyController.java | 121 ++ .../regular/vo/OaRegularApplyPageReqVO.java | 19 + .../regular/vo/OaRegularApplyRespVO.java | 62 + .../regular/vo/OaRegularApplySaveReqVO.java | 73 + .../regular/vo/OaRegularApplySubmitReqVO.java | 21 + .../OaReimbursementController.java | 121 ++ .../vo/OaReimbursementPageReqVO.java | 19 + .../vo/OaReimbursementRespVO.java | 62 + .../vo/OaReimbursementSaveReqVO.java | 90 ++ .../vo/OaReimbursementSubmitReqVO.java | 21 + .../admin/resign/OaResignApplyController.java | 121 ++ .../resign/vo/OaResignApplyPageReqVO.java | 19 + .../admin/resign/vo/OaResignApplyRespVO.java | 50 + .../resign/vo/OaResignApplySaveReqVO.java | 46 + .../resign/vo/OaResignApplySubmitReqVO.java | 21 + .../admin/schedule/OaScheduleController.java | 174 +++ .../schedule/vo/OaSchedulePageReqVO.java | 48 + .../admin/schedule/vo/OaScheduleRespVO.java | 76 + .../schedule/vo/OaScheduleSaveReqVO.java | 64 + .../admin/seal/OaSealApplyController.java | 161 +++ .../admin/seal/OaSealController.java | 122 ++ .../admin/seal/vo/OaSealPageReqVO.java | 51 + .../admin/seal/vo/OaSealRespVO.java | 69 + .../admin/seal/vo/OaSealSaveReqVO.java | 68 + .../seal/vo/apply/OaSealApplyPageReqVO.java | 53 + .../seal/vo/apply/OaSealApplyRespVO.java | 112 ++ .../seal/vo/apply/OaSealApplySaveReqVO.java | 82 ++ .../admin/supply/OaSupplyApplyController.java | 143 ++ .../admin/supply/OaSupplyIssueController.java | 104 ++ .../admin/supply/OaSupplyItemController.java | 107 ++ .../vo/apply/OaSupplyApplyPageReqVO.java | 33 + .../supply/vo/apply/OaSupplyApplyRespVO.java | 66 + .../vo/apply/OaSupplyApplySaveReqVO.java | 67 + .../vo/issue/OaSupplyIssuePageReqVO.java | 39 + .../supply/vo/issue/OaSupplyIssueReqVO.java | 24 + .../supply/vo/issue/OaSupplyIssueRespVO.java | 75 + .../supply/vo/issue/OaSupplyReturnReqVO.java | 24 + .../supply/vo/item/OaSupplyItemPageReqVO.java | 28 + .../supply/vo/item/OaSupplyItemRespVO.java | 68 + .../supply/vo/item/OaSupplyItemSaveReqVO.java | 84 ++ .../vo/item/OaSupplyItemStockInReqVO.java | 21 + .../admin/task/OaTaskController.java | 240 ++++ .../admin/task/vo/OaTaskFeedbackReqVO.java | 31 + .../admin/task/vo/OaTaskLogRespVO.java | 30 + .../admin/task/vo/OaTaskPageReqVO.java | 42 + .../admin/task/vo/OaTaskRankingRespVO.java | 19 + .../admin/task/vo/OaTaskReceiverRespVO.java | 30 + .../admin/task/vo/OaTaskRespVO.java | 70 + .../admin/task/vo/OaTaskSaveReqVO.java | 75 + .../admin/travel/OaTravelApplyController.java | 146 ++ .../OaTravelReimbursementController.java | 143 ++ .../vo/apply/OaTravelApplyPageReqVO.java | 33 + .../travel/vo/apply/OaTravelApplyRespVO.java | 72 + .../vo/apply/OaTravelApplySaveReqVO.java | 93 ++ .../vo/apply/OaTravelApplySubmitReqVO.java | 21 + .../OaTravelReimbursementPageReqVO.java | 33 + .../OaTravelReimbursementRespVO.java | 75 + .../OaTravelReimbursementSaveReqVO.java | 85 ++ .../OaTravelReimbursementSubmitReqVO.java | 21 + .../vehicle/OaVehicleApplyController.java | 155 ++ .../admin/vehicle/OaVehicleController.java | 114 ++ .../vehicle/OaVehicleReturnController.java | 148 ++ .../admin/vehicle/vo/OaVehiclePageReqVO.java | 54 + .../admin/vehicle/vo/OaVehicleRespVO.java | 67 + .../admin/vehicle/vo/OaVehicleSaveReqVO.java | 82 ++ .../vo/apply/OaVehicleApplyPageReqVO.java | 42 + .../vo/apply/OaVehicleApplyRespVO.java | 74 + .../vo/apply/OaVehicleApplySaveReqVO.java | 56 + .../returning/OaVehicleReturnPageReqVO.java | 43 + .../vo/returning/OaVehicleReturnRespVO.java | 79 + .../returning/OaVehicleReturnSaveReqVO.java | 57 + .../workreport/OaWorkReportController.java | 171 +++ .../workreport/vo/OaWorkReportPageReqVO.java | 51 + .../workreport/vo/OaWorkReportRespVO.java | 77 + .../workreport/vo/OaWorkReportSaveReqVO.java | 126 ++ .../vo/OaWorkReportStatisticsReqVO.java | 54 + .../vo/OaWorkReportStatisticsRespVO.java | 88 ++ .../module/oa/controller/package-info.java | 5 + .../announcement/OaAnnouncementDO.java | 60 + .../OaAnnouncementReceiverDO.java | 46 + .../dataobject/attendance/OaAttendanceDO.java | 64 + .../contact/OaContactCategoryDO.java | 44 + .../dal/dataobject/contact/OaContactDO.java | 78 + .../dataobject/contact/OaContactShareDO.java | 52 + .../dataobject/discussion/OaDiscussionDO.java | 78 + .../discussion/OaDiscussionLikeDO.java | 48 + .../discussion/OaDiscussionReplyDO.java | 63 + .../dataobject/discussion/OaVoteOptionDO.java | 47 + .../dataobject/discussion/OaVoteRecordDO.java | 48 + .../dal/dataobject/file/OaFileFavoriteDO.java | 42 + .../oa/dal/dataobject/file/OaFileNodeDO.java | 90 ++ .../dataobject/file/OaFilePermissionDO.java | 65 + .../dal/dataobject/leave/OaLeaveApplyDO.java | 88 ++ .../dal/dataobject/mail/OaMailAccountDO.java | 63 + .../dal/dataobject/mail/OaMailFolderDO.java | 55 + .../dal/dataobject/mail/OaMailMessageDO.java | 119 ++ .../dal/dataobject/mail/OaMailProviderDO.java | 77 + .../meetingroom/OaMeetingRoomBookingDO.java | 130 ++ .../meetingroom/OaMeetingRoomDO.java | 114 ++ .../dal/dataobject/note/OaNoteCategoryDO.java | 44 + .../oa/dal/dataobject/note/OaNoteDO.java | 70 + .../dal/dataobject/note/OaNoteReceiverDO.java | 42 + .../officialdoc/OaOfficialDocReceiveDO.java | 154 ++ .../officialdoc/OaOfficialDocSendDO.java | 152 ++ .../officialdoc/OaOfficialDocTemplateDO.java | 77 + .../overtime/OaOvertimeApplyDO.java | 81 ++ .../oa/dal/dataobject/plan/OaPlanDO.java | 81 ++ .../dataobject/regular/OaRegularApplyDO.java | 96 ++ .../reimbursement/OaReimbursementDO.java | 140 ++ .../dataobject/resign/OaResignApplyDO.java | 83 ++ .../dal/dataobject/schedule/OaScheduleDO.java | 69 + .../schedule/OaScheduleParticipantDO.java | 50 + .../oa/dal/dataobject/seal/OaSealApplyDO.java | 181 +++ .../oa/dal/dataobject/seal/OaSealDO.java | 105 ++ .../dataobject/supply/OaSupplyApplyDO.java | 87 ++ .../supply/OaSupplyApplyItemDO.java | 104 ++ .../dal/dataobject/supply/OaSupplyItemDO.java | 98 ++ .../oa/dal/dataobject/task/OaTaskDO.java | 79 + .../oa/dal/dataobject/task/OaTaskLogDO.java | 53 + .../dal/dataobject/task/OaTaskReceiverDO.java | 49 + .../dataobject/travel/OaTravelApplyDO.java | 144 ++ .../travel/OaTravelReimbursementDO.java | 146 ++ .../dataobject/vehicle/OaVehicleApplyDO.java | 117 ++ .../dal/dataobject/vehicle/OaVehicleDO.java | 103 ++ .../dataobject/vehicle/OaVehicleReturnDO.java | 111 ++ .../dataobject/workreport/OaWorkReportDO.java | 131 ++ .../announcement/OaAnnouncementMapper.java | 56 + .../OaAnnouncementReceiverMapper.java | 42 + .../mysql/attendance/OaAttendanceMapper.java | 59 + .../contact/OaContactCategoryMapper.java | 29 + .../oa/dal/mysql/contact/OaContactMapper.java | 66 + .../mysql/contact/OaContactShareMapper.java | 92 ++ .../discussion/OaDiscussionLikeMapper.java | 43 + .../mysql/discussion/OaDiscussionMapper.java | 33 + .../discussion/OaDiscussionReplyMapper.java | 40 + .../mysql/discussion/OaVoteOptionMapper.java | 30 + .../mysql/discussion/OaVoteRecordMapper.java | 32 + .../dal/mysql/file/OaFileFavoriteMapper.java | 40 + .../oa/dal/mysql/file/OaFileNodeMapper.java | 114 ++ .../mysql/file/OaFilePermissionMapper.java | 64 + .../dal/mysql/leave/OaLeaveApplyMapper.java | 48 + .../dal/mysql/mail/OaMailAccountMapper.java | 43 + .../oa/dal/mysql/mail/OaMailFolderMapper.java | 23 + .../dal/mysql/mail/OaMailMessageMapper.java | 58 + .../dal/mysql/mail/OaMailProviderMapper.java | 24 + .../OaMeetingRoomBookingMapper.java | 83 ++ .../meetingroom/OaMeetingRoomMapper.java | 47 + .../dal/mysql/note/OaNoteCategoryMapper.java | 29 + .../oa/dal/mysql/note/OaNoteMapper.java | 56 + .../dal/mysql/note/OaNoteReceiverMapper.java | 49 + .../OaOfficialDocReceiveMapper.java | 36 + .../officialdoc/OaOfficialDocSendMapper.java | 40 + .../OaOfficialDocTemplateMapper.java | 30 + .../mysql/overtime/OaOvertimeApplyMapper.java | 26 + .../module/oa/dal/mysql/package-info.java | 9 + .../oa/dal/mysql/plan/OaPlanMapper.java | 46 + .../mysql/regular/OaRegularApplyMapper.java | 26 + .../reimbursement/OaReimbursementMapper.java | 26 + .../dal/mysql/resign/OaResignApplyMapper.java | 26 + .../dal/mysql/schedule/OaScheduleMapper.java | 61 + .../schedule/OaScheduleParticipantMapper.java | 48 + .../oa/dal/mysql/seal/OaSealApplyMapper.java | 61 + .../oa/dal/mysql/seal/OaSealMapper.java | 38 + .../mysql/supply/OaSupplyApplyItemMapper.java | 80 ++ .../dal/mysql/supply/OaSupplyApplyMapper.java | 42 + .../dal/mysql/supply/OaSupplyItemMapper.java | 43 + .../oa/dal/mysql/task/OaTaskLogMapper.java | 29 + .../oa/dal/mysql/task/OaTaskMapper.java | 63 + .../dal/mysql/task/OaTaskReceiverMapper.java | 80 ++ .../dal/mysql/travel/OaTravelApplyMapper.java | 61 + .../travel/OaTravelReimbursementMapper.java | 34 + .../mysql/vehicle/OaVehicleApplyMapper.java | 66 + .../oa/dal/mysql/vehicle/OaVehicleMapper.java | 37 + .../mysql/vehicle/OaVehicleReturnMapper.java | 41 + .../mysql/workreport/OaWorkReportMapper.java | 59 + .../oa/dal/redis/RedisKeyConstants.java | 18 + .../module/oa/dal/redis/no/OaNoRedisDAO.java | 91 ++ .../module/oa/enums/BpmModelConstants.java | 77 + .../module/oa/enums/DictTypeConstants.java | 42 + .../module/oa/enums/ErrorCodeConstants.java | 201 +++ .../oa/enums/MessageTemplateConstants.java | 15 + .../announcement/OaAnnouncementTypeEnum.java | 39 + .../attendance/OaAttendanceStatusEnum.java | 41 + .../attendance/OaAttendanceTypeEnum.java | 40 + .../discussion/OaDiscussionTypeEnum.java | 39 + .../oa/enums/file/OaFileCategoryEnum.java | 84 ++ .../oa/enums/file/OaFileNodeStatusEnum.java | 49 + .../oa/enums/file/OaFileNodeTypeEnum.java | 49 + .../enums/file/OaFilePermissionLevelEnum.java | 56 + .../module/oa/enums/file/OaFileScopeEnum.java | 40 + .../oa/enums/file/OaFileSubjectTypeEnum.java | 49 + .../oa/enums/leave/OaLeaveTypeEnum.java | 52 + .../oa/enums/mail/OaMailCapabilityEnum.java | 39 + .../oa/enums/mail/OaMailComposeModeEnum.java | 41 + .../oa/enums/mail/OaMailFolderKeyEnum.java | 39 + .../oa/enums/mail/OaMailFolderTypeEnum.java | 53 + .../OaMeetingRoomBookingScopeEnum.java | 43 + .../OaMeetingRoomReminderTypeEnum.java | 56 + .../meetingroom/OaMeetingRoomStatusEnum.java | 50 + .../meetingroom/OaMeetingRoomTypeEnum.java | 51 + .../OaMeetingRoomUseStatusEnum.java | 51 + .../module/oa/enums/note/OaNoteTypeEnum.java | 39 + .../OaOfficialDocHandleStatusEnum.java | 45 + .../OaOfficialDocReceiveTypeEnum.java | 49 + .../oa/enums/plan/OaPlanStatusEnum.java | 45 + .../module/oa/enums/plan/OaPlanTypeEnum.java | 45 + .../oa/enums/schedule/OaPriorityEnum.java | 39 + .../oa/enums/schedule/OaScheduleTypeEnum.java | 38 + .../oa/enums/seal/OaSealApplyTypeEnum.java | 46 + .../oa/enums/seal/OaSealStatusEnum.java | 44 + .../oa/enums/seal/OaSealUseModeEnum.java | 43 + .../oa/enums/seal/OaSealUseStatusEnum.java | 46 + .../supply/OaSupplyApplyItemStatusEnum.java | 41 + .../oa/enums/supply/OaSupplyCategoryEnum.java | 54 + .../enums/supply/OaSupplyManageTypeEnum.java | 44 + .../oa/enums/supply/OaSupplyUseTypeEnum.java | 43 + .../oa/enums/task/OaTaskStatusEnum.java | 50 + .../module/oa/enums/task/OaTaskTypeEnum.java | 38 + .../vehicle/OaVehicleReturnStatusEnum.java | 45 + .../oa/enums/vehicle/OaVehicleStatusEnum.java | 44 + .../workreport/OaWorkReportStatusEnum.java | 49 + .../workreport/OaWorkReportTypeEnum.java | 78 + .../oa/framework/config/OaProperties.java | 110 ++ .../module/oa/framework/package-info.java | 6 + .../OaMeetingRoomBookingReminderJob.java | 30 + .../yudao/module/oa/job/package-info.java | 4 + .../job/schedule/OaScheduleReminderJob.java | 28 + .../iocoder/yudao/module/oa/package-info.java | 10 + .../announcement/OaAnnouncementService.java | 117 ++ .../OaAnnouncementServiceImpl.java | 235 +++ .../attendance/OaAttendanceService.java | 110 ++ .../attendance/OaAttendanceServiceImpl.java | 373 +++++ .../contact/OaContactCategoryService.java | 88 ++ .../contact/OaContactCategoryServiceImpl.java | 117 ++ .../oa/service/contact/OaContactService.java | 143 ++ .../service/contact/OaContactServiceImpl.java | 271 ++++ .../discussion/OaDiscussionLikeService.java | 76 + .../OaDiscussionLikeServiceImpl.java | 110 ++ .../discussion/OaDiscussionReplyService.java | 74 + .../OaDiscussionReplyServiceImpl.java | 197 +++ .../discussion/OaDiscussionService.java | 80 ++ .../discussion/OaDiscussionServiceImpl.java | 203 +++ .../discussion/OaDiscussionVoteService.java | 57 + .../OaDiscussionVoteServiceImpl.java | 123 ++ .../service/file/OaFileFavoriteService.java | 55 + .../file/OaFileFavoriteServiceImpl.java | 72 + .../oa/service/file/OaFileNodeService.java | 137 ++ .../service/file/OaFileNodeServiceImpl.java | 709 +++++++++ .../service/file/OaFilePermissionService.java | 66 + .../file/OaFilePermissionServiceImpl.java | 121 ++ .../oa/service/leave/OaLeaveApplyService.java | 78 + .../leave/OaLeaveApplyServiceImpl.java | 194 +++ .../listener/OaLeaveApplyStatusListener.java | 31 + .../oa/service/mail/OaMailAccountService.java | 102 ++ .../mail/OaMailAccountServiceImpl.java | 200 +++ .../oa/service/mail/OaMailFolderService.java | 53 + .../service/mail/OaMailFolderServiceImpl.java | 62 + .../oa/service/mail/OaMailMessageClient.java | 216 +++ .../service/mail/OaMailMessageClientImpl.java | 668 +++++++++ .../oa/service/mail/OaMailMessageService.java | 118 ++ .../mail/OaMailMessageServiceImpl.java | 678 +++++++++ .../service/mail/OaMailProviderService.java | 56 + .../mail/OaMailProviderServiceImpl.java | 80 ++ .../OaMeetingRoomBookingService.java | 133 ++ .../OaMeetingRoomBookingServiceImpl.java | 445 ++++++ .../meetingroom/OaMeetingRoomService.java | 96 ++ .../meetingroom/OaMeetingRoomServiceImpl.java | 145 ++ .../OaMeetingRoomBookingStatusListener.java | 30 + .../service/note/OaNoteCategoryService.java | 89 ++ .../note/OaNoteCategoryServiceImpl.java | 115 ++ .../module/oa/service/note/OaNoteService.java | 126 ++ .../oa/service/note/OaNoteServiceImpl.java | 251 ++++ .../OaOfficialDocReceiveService.java | 97 ++ .../OaOfficialDocReceiveServiceImpl.java | 288 ++++ .../officialdoc/OaOfficialDocSendService.java | 118 ++ .../OaOfficialDocSendServiceImpl.java | 266 ++++ .../OaOfficialDocTemplateService.java | 69 + .../OaOfficialDocTemplateServiceImpl.java | 109 ++ .../OaOfficialDocReceiveStatusListener.java | 31 + .../OaOfficialDocSendStatusListener.java | 31 + .../overtime/OaOvertimeApplyService.java | 65 + .../overtime/OaOvertimeApplyServiceImpl.java | 146 ++ .../OaOvertimeApplyStatusListener.java | 31 + .../module/oa/service/plan/OaPlanService.java | 81 ++ .../oa/service/plan/OaPlanServiceImpl.java | 145 ++ .../regular/OaRegularApplyService.java | 65 + .../regular/OaRegularApplyServiceImpl.java | 145 ++ .../OaRegularApplyStatusListener.java | 31 + .../reimbursement/OaReimbursementService.java | 65 + .../OaReimbursementServiceImpl.java | 166 +++ .../OaReimbursementStatusListener.java | 31 + .../service/resign/OaResignApplyService.java | 65 + .../resign/OaResignApplyServiceImpl.java | 149 ++ .../listener/OaResignApplyStatusListener.java | 31 + .../service/schedule/OaScheduleService.java | 125 ++ .../schedule/OaScheduleServiceImpl.java | 319 +++++ .../oa/service/seal/OaSealApplyService.java | 90 ++ .../service/seal/OaSealApplyServiceImpl.java | 242 ++++ .../module/oa/service/seal/OaSealService.java | 53 + .../oa/service/seal/OaSealServiceImpl.java | 120 ++ .../listener/OaSealApplyStatusListener.java | 31 + .../service/supply/OaSupplyApplyService.java | 131 ++ .../supply/OaSupplyApplyServiceImpl.java | 286 ++++ .../service/supply/OaSupplyIssueService.java | 39 + .../supply/OaSupplyIssueServiceImpl.java | 136 ++ .../service/supply/OaSupplyItemService.java | 90 ++ .../supply/OaSupplyItemServiceImpl.java | 153 ++ .../listener/OaSupplyApplyStatusListener.java | 32 + .../module/oa/service/task/OaTaskService.java | 133 ++ .../oa/service/task/OaTaskServiceImpl.java | 277 ++++ .../service/travel/OaTravelApplyService.java | 138 ++ .../travel/OaTravelApplyServiceImpl.java | 257 ++++ .../travel/OaTravelReimbursementService.java | 81 ++ .../OaTravelReimbursementServiceImpl.java | 243 ++++ .../listener/OaTravelApplyStatusListener.java | 31 + .../OaTravelReimbursementStatusListener.java | 31 + .../vehicle/OaVehicleApplyService.java | 133 ++ .../vehicle/OaVehicleApplyServiceImpl.java | 263 ++++ .../vehicle/OaVehicleReturnService.java | 83 ++ .../vehicle/OaVehicleReturnServiceImpl.java | 254 ++++ .../oa/service/vehicle/OaVehicleService.java | 53 + .../service/vehicle/OaVehicleServiceImpl.java | 109 ++ .../OaVehicleApplyStatusListener.java | 32 + .../OaVehicleReturnStatusListener.java | 32 + .../workreport/OaWorkReportService.java | 86 ++ .../workreport/OaWorkReportServiceImpl.java | 358 +++++ .../main/resources/bpm/oa_leave_apply.json | 49 + .../bpm/oa_meeting_room_booking.json | 49 + .../bpm/oa_official_doc_receive.json | 49 + .../resources/bpm/oa_official_doc_send.json | 49 + .../main/resources/bpm/oa_overtime_apply.json | 49 + .../main/resources/bpm/oa_regular_apply.json | 49 + .../main/resources/bpm/oa_reimbursement.json | 49 + .../main/resources/bpm/oa_resign_apply.json | 49 + .../src/main/resources/bpm/oa_seal_apply.json | 49 + .../main/resources/bpm/oa_supply_apply.json | 49 + .../main/resources/bpm/oa_travel_apply.json | 49 + .../bpm/oa_travel_reimbursement.json | 49 + .../main/resources/bpm/oa_vehicle_apply.json | 49 + .../main/resources/bpm/oa_vehicle_return.json | 49 + .../OaAnnouncementServiceImplTest.java | 533 +++++++ .../OaAttendanceServiceImplTest.java | 537 +++++++ .../OaContactCategoryServiceImplTest.java | 114 ++ .../contact/OaContactServiceImplTest.java | 614 ++++++++ .../OaDiscussionLikeServiceImplTest.java | 117 ++ .../OaDiscussionReplyServiceImplTest.java | 123 ++ .../OaDiscussionServiceImplTest.java | 74 + .../OaDiscussionVoteServiceImplTest.java | 129 ++ .../file/OaFileFavoriteServiceImplTest.java | 132 ++ .../file/OaFileNodeServiceImplTest.java | 1272 +++++++++++++++++ .../file/OaFilePermissionServiceImplTest.java | 201 +++ .../leave/OaLeaveApplyServiceImplTest.java | 413 ++++++ .../mail/OaMailAccountServiceImplTest.java | 253 ++++ .../mail/OaMailMessageServiceImplTest.java | 528 +++++++ .../OaMeetingRoomBookingServiceImplTest.java | 634 ++++++++ .../OaMeetingRoomServiceImplTest.java | 209 +++ .../note/OaNoteCategoryServiceImplTest.java | 108 ++ .../service/note/OaNoteServiceImplTest.java | 547 +++++++ .../OaOfficialDocReceiveServiceImplTest.java | 739 ++++++++++ .../OaOfficialDocSendServiceImplTest.java | 443 ++++++ .../OaOfficialDocTemplateServiceImplTest.java | 178 +++ .../OaOvertimeApplyServiceImplTest.java | 280 ++++ .../service/plan/OaPlanServiceImplTest.java | 342 +++++ .../OaRegularApplyServiceImplTest.java | 279 ++++ .../OaReimbursementServiceImplTest.java | 307 ++++ .../resign/OaResignApplyServiceImplTest.java | 246 ++++ .../schedule/OaScheduleServiceImplTest.java | 693 +++++++++ .../seal/OaSealApplyServiceImplTest.java | 638 +++++++++ .../service/seal/OaSealServiceImplTest.java | 242 ++++ .../supply/OaSupplyApplyServiceImplTest.java | 454 ++++++ .../supply/OaSupplyIssueServiceImplTest.java | 230 +++ .../supply/OaSupplyItemServiceImplTest.java | 242 ++++ .../service/task/OaTaskServiceImplTest.java | 591 ++++++++ .../travel/OaTravelApplyServiceImplTest.java | 631 ++++++++ .../OaTravelReimbursementServiceImplTest.java | 602 ++++++++ .../OaVehicleApplyServiceImplTest.java | 511 +++++++ .../OaVehicleReturnServiceImplTest.java | 560 ++++++++ .../vehicle/OaVehicleServiceImplTest.java | 217 +++ .../OaWorkReportServiceImplTest.java | 558 ++++++++ .../test/resources/application-unit-test.yaml | 36 + .../src/test/resources/sql/clean.sql | 57 + .../src/test/resources/sql/create_tables.sql | 879 ++++++++++++ .../pm/workitem/PmsWorkItemServiceImpl.java | 5 +- .../module/system/api/user/AdminUserApi.java | 13 +- .../system/api/user/AdminUserApiImpl.java | 12 + .../system/service/user/AdminUserService.java | 8 + .../service/user/AdminUserServiceImpl.java | 12 + .../user/AdminUserServiceImplTest.java | 29 + 501 files changed, 53138 insertions(+), 17 deletions(-) create mode 100644 yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/object/PageUtilsTest.java create mode 100644 yudao-module-oa/pom.xml create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/OaAnnouncementController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/vo/OaAnnouncementPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/vo/OaAnnouncementRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/vo/OaAnnouncementSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/OaAttendanceController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceMonthReportReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceMonthReportRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendancePageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceUpdateReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceWeekReportReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceWeekReportRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/OaContactCategoryController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/OaContactController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactHandleReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactShareReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/category/OaContactCategoryRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/category/OaContactCategorySaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionLikeController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionReplyController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionVoteController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/discussion/OaDiscussionPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/discussion/OaDiscussionRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/discussion/OaDiscussionSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/like/OaDiscussionLikeReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/reply/OaDiscussionReplyCreateReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/reply/OaDiscussionReplyPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/reply/OaDiscussionReplyRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/vote/OaDiscussionVoteReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/vote/OaVoteOptionRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/vote/OaVoteOptionSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/OaFileFavoriteController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/OaFileNodeController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/OaFilePermissionController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeCopyReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeMoveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodePageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeRenameReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileStorageRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/permission/OaFilePermissionRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/permission/OaFilePermissionSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/OaLeaveApplyController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplyPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplyRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplySaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplySubmitReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/OaMailAccountController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/OaMailFolderController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/OaMailMessageController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/OaMailProviderController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/account/OaMailAccountRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/account/OaMailAccountSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/message/OaMailFolderRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/message/OaMailMessagePageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/message/OaMailMessageRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/message/OaMailMessageSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/provider/OaMailProviderRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/provider/OaMailProviderSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/OaMeetingRoomBookingController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/OaMeetingRoomController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/booking/OaMeetingRoomBookingPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/booking/OaMeetingRoomBookingRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/booking/OaMeetingRoomBookingSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/booking/OaMeetingRoomBookingScheduleReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/room/OaMeetingRoomPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/room/OaMeetingRoomRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/room/OaMeetingRoomSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/OaNoteCategoryController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/OaNoteController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/OaNoteFavoriteUpdateReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/OaNotePageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/OaNoteRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/OaNoteSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/OaNoteShareUpdateReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/category/OaNoteCategoryRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/category/OaNoteCategorySaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/OaOfficialDocReceiveController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/OaOfficialDocSendController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/OaOfficialDocTemplateController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/receive/OaOfficialDocReceivePageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/receive/OaOfficialDocReceiveRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/receive/OaOfficialDocReceiveSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/send/OaOfficialDocSendPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/send/OaOfficialDocSendRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/send/OaOfficialDocSendSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/template/OaOfficialDocTemplatePageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/template/OaOfficialDocTemplateRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/template/OaOfficialDocTemplateSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/overtime/OaOvertimeApplyController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/overtime/vo/OaOvertimeApplyPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/overtime/vo/OaOvertimeApplyRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/overtime/vo/OaOvertimeApplySaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/overtime/vo/OaOvertimeApplySubmitReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/OaPlanController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanCommentReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanReportPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanReportRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/regular/OaRegularApplyController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/regular/vo/OaRegularApplyPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/regular/vo/OaRegularApplyRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/regular/vo/OaRegularApplySaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/regular/vo/OaRegularApplySubmitReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/reimbursement/OaReimbursementController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/reimbursement/vo/OaReimbursementPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/reimbursement/vo/OaReimbursementRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/reimbursement/vo/OaReimbursementSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/reimbursement/vo/OaReimbursementSubmitReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/resign/OaResignApplyController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/resign/vo/OaResignApplyPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/resign/vo/OaResignApplyRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/resign/vo/OaResignApplySaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/resign/vo/OaResignApplySubmitReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/schedule/OaScheduleController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/schedule/vo/OaSchedulePageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/schedule/vo/OaScheduleRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/schedule/vo/OaScheduleSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/OaSealApplyController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/OaSealController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/OaSealPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/OaSealRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/OaSealSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/apply/OaSealApplyPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/apply/OaSealApplyRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/apply/OaSealApplySaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/OaSupplyApplyController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/OaSupplyIssueController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/OaSupplyItemController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/apply/OaSupplyApplyPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/apply/OaSupplyApplyRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/apply/OaSupplyApplySaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/issue/OaSupplyIssuePageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/issue/OaSupplyIssueReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/issue/OaSupplyIssueRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/issue/OaSupplyReturnReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/item/OaSupplyItemPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/item/OaSupplyItemRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/item/OaSupplyItemSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/item/OaSupplyItemStockInReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/OaTaskController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskFeedbackReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskLogRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskRankingRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskReceiverRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/OaTravelApplyController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/OaTravelReimbursementController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/apply/OaTravelApplyPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/apply/OaTravelApplyRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/apply/OaTravelApplySaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/apply/OaTravelApplySubmitReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/reimbursement/OaTravelReimbursementPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/reimbursement/OaTravelReimbursementRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/reimbursement/OaTravelReimbursementSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/reimbursement/OaTravelReimbursementSubmitReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/OaVehicleApplyController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/OaVehicleController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/OaVehicleReturnController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/OaVehiclePageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/OaVehicleRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/OaVehicleSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/apply/OaVehicleApplyPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/apply/OaVehicleApplyRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/apply/OaVehicleApplySaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/returning/OaVehicleReturnPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/returning/OaVehicleReturnRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/returning/OaVehicleReturnSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/OaWorkReportController.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/vo/OaWorkReportPageReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/vo/OaWorkReportRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/vo/OaWorkReportSaveReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/vo/OaWorkReportStatisticsReqVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/vo/OaWorkReportStatisticsRespVO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/package-info.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/announcement/OaAnnouncementDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/announcement/OaAnnouncementReceiverDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/attendance/OaAttendanceDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/contact/OaContactCategoryDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/contact/OaContactDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/contact/OaContactShareDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/discussion/OaDiscussionDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/discussion/OaDiscussionLikeDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/discussion/OaDiscussionReplyDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/discussion/OaVoteOptionDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/discussion/OaVoteRecordDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/file/OaFileFavoriteDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/file/OaFileNodeDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/file/OaFilePermissionDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/leave/OaLeaveApplyDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/mail/OaMailAccountDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/mail/OaMailFolderDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/mail/OaMailMessageDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/mail/OaMailProviderDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/meetingroom/OaMeetingRoomBookingDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/meetingroom/OaMeetingRoomDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/note/OaNoteCategoryDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/note/OaNoteDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/note/OaNoteReceiverDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/officialdoc/OaOfficialDocReceiveDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/officialdoc/OaOfficialDocSendDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/officialdoc/OaOfficialDocTemplateDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/overtime/OaOvertimeApplyDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/plan/OaPlanDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/regular/OaRegularApplyDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/reimbursement/OaReimbursementDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/resign/OaResignApplyDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/schedule/OaScheduleDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/schedule/OaScheduleParticipantDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/seal/OaSealApplyDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/seal/OaSealDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/supply/OaSupplyApplyDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/supply/OaSupplyApplyItemDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/supply/OaSupplyItemDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/task/OaTaskDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/task/OaTaskLogDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/task/OaTaskReceiverDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/travel/OaTravelApplyDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/travel/OaTravelReimbursementDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/vehicle/OaVehicleApplyDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/vehicle/OaVehicleDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/vehicle/OaVehicleReturnDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/workreport/OaWorkReportDO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/announcement/OaAnnouncementMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/announcement/OaAnnouncementReceiverMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/attendance/OaAttendanceMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/contact/OaContactCategoryMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/contact/OaContactMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/contact/OaContactShareMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/discussion/OaDiscussionLikeMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/discussion/OaDiscussionMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/discussion/OaDiscussionReplyMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/discussion/OaVoteOptionMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/discussion/OaVoteRecordMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/file/OaFileFavoriteMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/file/OaFileNodeMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/file/OaFilePermissionMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/leave/OaLeaveApplyMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/mail/OaMailAccountMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/mail/OaMailFolderMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/mail/OaMailMessageMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/mail/OaMailProviderMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/meetingroom/OaMeetingRoomBookingMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/meetingroom/OaMeetingRoomMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/note/OaNoteCategoryMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/note/OaNoteMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/note/OaNoteReceiverMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/officialdoc/OaOfficialDocReceiveMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/officialdoc/OaOfficialDocSendMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/officialdoc/OaOfficialDocTemplateMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/overtime/OaOvertimeApplyMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/package-info.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/plan/OaPlanMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/regular/OaRegularApplyMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/reimbursement/OaReimbursementMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/resign/OaResignApplyMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/schedule/OaScheduleMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/schedule/OaScheduleParticipantMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/seal/OaSealApplyMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/seal/OaSealMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/supply/OaSupplyApplyItemMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/supply/OaSupplyApplyMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/supply/OaSupplyItemMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/task/OaTaskLogMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/task/OaTaskMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/task/OaTaskReceiverMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/travel/OaTravelApplyMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/travel/OaTravelReimbursementMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/vehicle/OaVehicleApplyMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/vehicle/OaVehicleMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/vehicle/OaVehicleReturnMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/workreport/OaWorkReportMapper.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/redis/RedisKeyConstants.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/redis/no/OaNoRedisDAO.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/BpmModelConstants.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/DictTypeConstants.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/ErrorCodeConstants.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/MessageTemplateConstants.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/announcement/OaAnnouncementTypeEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/attendance/OaAttendanceStatusEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/attendance/OaAttendanceTypeEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/discussion/OaDiscussionTypeEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFileCategoryEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFileNodeStatusEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFileNodeTypeEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFilePermissionLevelEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFileScopeEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFileSubjectTypeEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/leave/OaLeaveTypeEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/mail/OaMailCapabilityEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/mail/OaMailComposeModeEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/mail/OaMailFolderKeyEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/mail/OaMailFolderTypeEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/meetingroom/OaMeetingRoomBookingScopeEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/meetingroom/OaMeetingRoomReminderTypeEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/meetingroom/OaMeetingRoomStatusEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/meetingroom/OaMeetingRoomTypeEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/meetingroom/OaMeetingRoomUseStatusEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/note/OaNoteTypeEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/officialdoc/OaOfficialDocHandleStatusEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/officialdoc/OaOfficialDocReceiveTypeEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/plan/OaPlanStatusEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/plan/OaPlanTypeEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/schedule/OaPriorityEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/schedule/OaScheduleTypeEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/seal/OaSealApplyTypeEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/seal/OaSealStatusEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/seal/OaSealUseModeEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/seal/OaSealUseStatusEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/supply/OaSupplyApplyItemStatusEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/supply/OaSupplyCategoryEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/supply/OaSupplyManageTypeEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/supply/OaSupplyUseTypeEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/task/OaTaskStatusEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/task/OaTaskTypeEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/vehicle/OaVehicleReturnStatusEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/vehicle/OaVehicleStatusEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/workreport/OaWorkReportStatusEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/workreport/OaWorkReportTypeEnum.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/framework/config/OaProperties.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/framework/package-info.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingReminderJob.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/package-info.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/schedule/OaScheduleReminderJob.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/package-info.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/announcement/OaAnnouncementService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/announcement/OaAnnouncementServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/attendance/OaAttendanceService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/attendance/OaAttendanceServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/contact/OaContactCategoryService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/contact/OaContactCategoryServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/contact/OaContactService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/contact/OaContactServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionLikeService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionLikeServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionReplyService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionReplyServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionVoteService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionVoteServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFileFavoriteService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFileFavoriteServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFileNodeService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFileNodeServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFilePermissionService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFilePermissionServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/leave/OaLeaveApplyService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/leave/OaLeaveApplyServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/leave/listener/OaLeaveApplyStatusListener.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailAccountService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailAccountServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailFolderService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailFolderServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailMessageClient.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailMessageClientImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailMessageService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailMessageServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailProviderService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailProviderServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomBookingService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomBookingServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/meetingroom/listener/OaMeetingRoomBookingStatusListener.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/note/OaNoteCategoryService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/note/OaNoteCategoryServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/note/OaNoteService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/note/OaNoteServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocReceiveService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocReceiveServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocSendService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocSendServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocTemplateService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocTemplateServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/listener/OaOfficialDocReceiveStatusListener.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/listener/OaOfficialDocSendStatusListener.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/overtime/OaOvertimeApplyService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/overtime/OaOvertimeApplyServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/overtime/listener/OaOvertimeApplyStatusListener.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/plan/OaPlanService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/plan/OaPlanServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/regular/OaRegularApplyService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/regular/OaRegularApplyServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/regular/listener/OaRegularApplyStatusListener.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/reimbursement/OaReimbursementService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/reimbursement/OaReimbursementServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/reimbursement/listener/OaReimbursementStatusListener.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/resign/OaResignApplyService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/resign/OaResignApplyServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/resign/listener/OaResignApplyStatusListener.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/schedule/OaScheduleService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/schedule/OaScheduleServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/seal/OaSealApplyService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/seal/OaSealApplyServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/seal/OaSealService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/seal/OaSealServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/seal/listener/OaSealApplyStatusListener.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyApplyService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyApplyServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyIssueService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyIssueServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyItemService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyItemServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/listener/OaSupplyApplyStatusListener.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/task/OaTaskService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/task/OaTaskServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelApplyService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelApplyServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelReimbursementService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelReimbursementServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/listener/OaTravelApplyStatusListener.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/listener/OaTravelReimbursementStatusListener.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleApplyService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleApplyServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleReturnService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleReturnServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleServiceImpl.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/listener/OaVehicleApplyStatusListener.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/listener/OaVehicleReturnStatusListener.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/workreport/OaWorkReportService.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/workreport/OaWorkReportServiceImpl.java create mode 100644 yudao-module-oa/src/main/resources/bpm/oa_leave_apply.json create mode 100644 yudao-module-oa/src/main/resources/bpm/oa_meeting_room_booking.json create mode 100644 yudao-module-oa/src/main/resources/bpm/oa_official_doc_receive.json create mode 100644 yudao-module-oa/src/main/resources/bpm/oa_official_doc_send.json create mode 100644 yudao-module-oa/src/main/resources/bpm/oa_overtime_apply.json create mode 100644 yudao-module-oa/src/main/resources/bpm/oa_regular_apply.json create mode 100644 yudao-module-oa/src/main/resources/bpm/oa_reimbursement.json create mode 100644 yudao-module-oa/src/main/resources/bpm/oa_resign_apply.json create mode 100644 yudao-module-oa/src/main/resources/bpm/oa_seal_apply.json create mode 100644 yudao-module-oa/src/main/resources/bpm/oa_supply_apply.json create mode 100644 yudao-module-oa/src/main/resources/bpm/oa_travel_apply.json create mode 100644 yudao-module-oa/src/main/resources/bpm/oa_travel_reimbursement.json create mode 100644 yudao-module-oa/src/main/resources/bpm/oa_vehicle_apply.json create mode 100644 yudao-module-oa/src/main/resources/bpm/oa_vehicle_return.json create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/announcement/OaAnnouncementServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/attendance/OaAttendanceServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/contact/OaContactCategoryServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/contact/OaContactServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionLikeServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionReplyServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionVoteServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/file/OaFileFavoriteServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/file/OaFileNodeServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/file/OaFilePermissionServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/leave/OaLeaveApplyServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/mail/OaMailAccountServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/mail/OaMailMessageServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomBookingServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/note/OaNoteCategoryServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/note/OaNoteServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocReceiveServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocSendServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocTemplateServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/overtime/OaOvertimeApplyServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/plan/OaPlanServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/regular/OaRegularApplyServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/reimbursement/OaReimbursementServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/resign/OaResignApplyServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/schedule/OaScheduleServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/seal/OaSealApplyServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/seal/OaSealServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyApplyServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyIssueServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyItemServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/task/OaTaskServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelApplyServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelReimbursementServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleApplyServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleReturnServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleServiceImplTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/workreport/OaWorkReportServiceImplTest.java create mode 100644 yudao-module-oa/src/test/resources/application-unit-test.yaml create mode 100644 yudao-module-oa/src/test/resources/sql/clean.sql create mode 100644 yudao-module-oa/src/test/resources/sql/create_tables.sql diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java index a78eda11a3..181a108027 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtils.java @@ -10,6 +10,8 @@ import cn.iocoder.yudao.framework.common.util.collection.ArrayUtils; import lombok.AllArgsConstructor; import lombok.Getter; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.sql.Timestamp; import java.time.*; import java.time.format.DateTimeFormatter; @@ -32,6 +34,54 @@ import static cn.hutool.core.date.DatePattern.*; */ public class LocalDateTimeUtils { + /** + * 按经过的时长计算天数,以 24 小时为一天,按指定精度四舍五入 + * + * @param beginTime 开始时间 + * @param endTime 结束时间 + * @param scale 保留的小数位数 + * @return 按毫秒精度计算的天数,时间缺失时返回 null + */ + public static BigDecimal getDaysBetween(LocalDateTime beginTime, LocalDateTime endTime, int scale) { + if (beginTime == null || endTime == null) { + return null; + } + return BigDecimal.valueOf(Duration.between(beginTime, endTime).toMillis()) + .divide(BigDecimal.valueOf(Duration.ofDays(1).toMillis()), scale, RoundingMode.HALF_UP); + } + + /** + * 按经过的时长计算天数,以 24 小时为一天,结果向上取整 + * + * 与 {@link #getDaysBetweenInclusive(LocalDateTime, LocalDateTime)} 不同,本方法不按自然日期计数。 + * 例如跨午夜的 2 小时为 1 天,恰好 24 小时为 1 天,相同时刻为 0 天。 + * + * @param beginTime 开始时间 + * @param endTime 结束时间 + * @return 按毫秒精度计算的天数,时间缺失时返回 null;反向区间按相同规则向上取整 + */ + public static Integer getDaysBetweenCeiling(LocalDateTime beginTime, LocalDateTime endTime) { + if (beginTime == null || endTime == null) { + return null; + } + return BigDecimal.valueOf(Duration.between(beginTime, endTime).toMillis()) + .divide(BigDecimal.valueOf(Duration.ofDays(1).toMillis()), 0, RoundingMode.CEILING).intValueExact(); + } + + /** + * 计算包含首尾日期的自然日数,不足一天按对应自然日期计算 + * + * @param beginTime 开始时间 + * @param endTime 结束时间 + * @return 自然日数,时间缺失时返回 null;结束日期早于开始日期时返回非正数 + */ + public static Integer getDaysBetweenInclusive(LocalDateTime beginTime, LocalDateTime endTime) { + if (beginTime == null || endTime == null) { + return null; + } + return Math.toIntExact(ChronoUnit.DAYS.between(beginTime.toLocalDate(), endTime.toLocalDate()) + 1); + } + /** * 空的 LocalDateTime 对象,主要用于 DB 唯一索引的默认值 */ @@ -543,6 +593,16 @@ public class LocalDateTimeUtils { return date == null ? null : date.atTime(LocalTime.MAX); } + /** + * 获得指定时间所在日期的结束时间 + * + * @param time 时间 + * @return 当日结束时间;时间为空时返回 {@code null} + */ + public static LocalDateTime getDayEndTime(LocalDateTime time) { + return time == null ? null : getDayEndTime(time.toLocalDate()); + } + /** * 获得自然日期范围对应的日期时间闭区间 * diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/object/PageUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/object/PageUtils.java index 472fff8be4..23c1ae3a0a 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/object/PageUtils.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/object/PageUtils.java @@ -28,14 +28,14 @@ public class PageUtils { } /** - * 对内存列表进行分页 + * 对内存列表分页,不改变列表顺序 * - * @param list 数据列表 - * @param pageParam 分页参数 + * @param pageParam 分页参数,支持 {@link PageParam#PAGE_SIZE_NONE} 返回全部数据 + * @param list 已排序的完整列表 * @param 数据类型 - * @return 分页结果 + * @return 分页结果,超出末页时保留总数并返回空列表 */ - public static PageResult buildPageResult(List list, PageParam pageParam) { + public static PageResult buildPageResult(PageParam pageParam, List list) { if (CollUtil.isEmpty(list)) { return PageResult.empty(); } @@ -43,8 +43,9 @@ public class PageUtils { if (PageParam.PAGE_SIZE_NONE.equals(pageParam.getPageSize())) { return new PageResult<>(list, total); } - int fromIndex = Math.toIntExact(Math.min(getStart(pageParam), total)); - int toIndex = Math.toIntExact(Math.min((long) fromIndex + pageParam.getPageSize(), total)); + long offset = (long) (pageParam.getPageNo() - 1) * pageParam.getPageSize(); + int fromIndex = (int) Math.min(offset, total); + int toIndex = (int) Math.min((long) fromIndex + pageParam.getPageSize(), total); return new PageResult<>(list.subList(fromIndex, toIndex), total); } diff --git a/yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtilsTest.java b/yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtilsTest.java index abb7a668ff..704044fdf7 100644 --- a/yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtilsTest.java +++ b/yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/date/LocalDateTimeUtilsTest.java @@ -4,6 +4,7 @@ import cn.hutool.core.collection.CollUtil; import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.TimeRange; import org.junit.jupiter.api.Test; +import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; @@ -25,6 +26,56 @@ import static org.junit.jupiter.api.Assertions.assertTrue; */ public class LocalDateTimeUtilsTest { + @Test + public void testGetDaysBetween() { + // 准备参数 + LocalDateTime beginTime = LocalDateTime.of(2026, 12, 31, 23, 0); + + // 调用,并断言:按时长四舍五入,跨日不改变计算规则 + assertEquals(new BigDecimal("0.0"), LocalDateTimeUtils.getDaysBetween(beginTime, beginTime, 1)); + assertEquals(new BigDecimal("0.5"), LocalDateTimeUtils.getDaysBetween(beginTime, beginTime.plusHours(12), 1)); + assertEquals(new BigDecimal("0.1"), LocalDateTimeUtils.getDaysBetween(beginTime, beginTime.plusMinutes(72), 1)); + assertEquals(new BigDecimal("0.0"), LocalDateTimeUtils.getDaysBetween(beginTime, beginTime.plusMinutes(71), 1)); + assertEquals(new BigDecimal("1.1"), LocalDateTimeUtils.getDaysBetween(beginTime, beginTime.plusHours(26), 1)); + assertEquals(new BigDecimal("-0.5"), LocalDateTimeUtils.getDaysBetween(beginTime, beginTime.minusHours(12), 1)); + assertEquals(new BigDecimal("0.50"), LocalDateTimeUtils.getDaysBetween(beginTime, beginTime.plusHours(12), 2)); + assertNull(LocalDateTimeUtils.getDaysBetween(null, beginTime, 1)); + assertNull(LocalDateTimeUtils.getDaysBetween(beginTime, null, 1)); + } + + @Test + public void testGetDaysBetweenCeiling() { + // 准备参数 + LocalDateTime beginTime = LocalDateTime.of(2026, 12, 31, 23, 0); + + // 调用,并断言:按时长取整,不因跨午夜额外增加一天 + assertEquals(0, LocalDateTimeUtils.getDaysBetweenCeiling(beginTime, beginTime)); + assertEquals(1, LocalDateTimeUtils.getDaysBetweenCeiling(beginTime, beginTime.plusHours(2))); + assertEquals(1, LocalDateTimeUtils.getDaysBetweenCeiling(beginTime, beginTime.plusDays(1))); + assertEquals(2, LocalDateTimeUtils.getDaysBetweenCeiling(beginTime, beginTime.plusDays(1).plusNanos(1_000_000))); + assertEquals(2, LocalDateTimeUtils.getDaysBetweenCeiling(beginTime, beginTime.plusHours(26))); + assertEquals(0, LocalDateTimeUtils.getDaysBetweenCeiling(beginTime, beginTime.minusHours(2))); + assertEquals(-1, LocalDateTimeUtils.getDaysBetweenCeiling(beginTime, beginTime.minusHours(26))); + assertNull(LocalDateTimeUtils.getDaysBetweenCeiling(null, beginTime)); + assertNull(LocalDateTimeUtils.getDaysBetweenCeiling(beginTime, null)); + } + + @Test + public void testGetDaysBetweenInclusive() { + // 准备参数 + LocalDateTime beginTime = LocalDateTime.of(2026, 12, 31, 23, 0); + LocalDateTime endTime = LocalDateTime.of(2027, 1, 2, 1, 0); + + // 调用,并断言:按自然日计算,包含首尾日期 + assertEquals(3, LocalDateTimeUtils.getDaysBetweenInclusive(beginTime, endTime)); + assertEquals(1, LocalDateTimeUtils.getDaysBetweenInclusive(beginTime, beginTime.minusHours(1))); + assertEquals(-1, LocalDateTimeUtils.getDaysBetweenInclusive(endTime, beginTime)); + assertNull(LocalDateTimeUtils.getDaysBetweenInclusive(null, endTime)); + assertNull(LocalDateTimeUtils.getDaysBetweenInclusive(beginTime, null)); + assertEquals(3, LocalDateTimeUtils.getDaysBetweenInclusive( + LocalDateTime.of(2024, 2, 28, 0, 0), LocalDateTime.of(2024, 3, 1, 0, 0))); + } + @Test public void testDateCompare() { // 准备参数 @@ -99,6 +150,10 @@ public class LocalDateTimeUtilsTest { assertNull(LocalDateTimeUtils.getDayBeginTime((LocalDateTime) null)); assertEquals(LocalDateTime.of(date, LocalTime.MAX), LocalDateTimeUtils.getDayEndTime(date)); + assertEquals(LocalDateTime.of(date, LocalTime.MAX), + LocalDateTimeUtils.getDayEndTime(time)); + assertNull(LocalDateTimeUtils.getDayEndTime((LocalDateTime) null)); + assertNull(LocalDateTimeUtils.getDayEndTime((LocalDate) null)); } @Test diff --git a/yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/object/PageUtilsTest.java b/yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/object/PageUtilsTest.java new file mode 100644 index 0000000000..172835e467 --- /dev/null +++ b/yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/object/PageUtilsTest.java @@ -0,0 +1,68 @@ +package cn.iocoder.yudao.framework.common.util.object; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link PageUtils} 的单元测试 + */ +public class PageUtilsTest { + + @Test + public void testBuildPageResult() { + // 准备参数 + PageParam pageParam = new PageParam(); + pageParam.setPageSize(2); + List list = Arrays.asList(1, 2, 3); + + // 调用并断言首页和末页 + PageResult result = PageUtils.buildPageResult(pageParam, list); + assertEquals(3L, result.getTotal()); + assertEquals(Arrays.asList(1, 2), result.getList()); + pageParam.setPageNo(2); + result = PageUtils.buildPageResult(pageParam, list); + assertEquals(3L, result.getTotal()); + assertEquals(Collections.singletonList(3), result.getList()); + assertEquals(Arrays.asList(1, 2, 3), list); + } + + @Test + public void testBuildPageResult_empty() { + PageParam pageParam = new PageParam(); + assertEquals(0L, PageUtils.buildPageResult(pageParam, Collections.emptyList()).getTotal()); + assertTrue(PageUtils.buildPageResult(pageParam, null).getList().isEmpty()); + } + + @Test + public void testBuildPageResult_pastLastPage() { + PageParam pageParam = new PageParam(); + pageParam.setPageNo(Integer.MAX_VALUE); + pageParam.setPageSize(200); + + PageResult result = PageUtils.buildPageResult(pageParam, Arrays.asList(1, 2, 3)); + + assertEquals(3L, result.getTotal()); + assertTrue(result.getList().isEmpty()); + } + + @Test + public void testBuildPageResult_noPagination() { + PageParam pageParam = new PageParam(); + pageParam.setPageNo(2); + pageParam.setPageSize(PageParam.PAGE_SIZE_NONE); + + PageResult result = PageUtils.buildPageResult(pageParam, Arrays.asList(1, 2, 3)); + + assertEquals(3L, result.getTotal()); + assertEquals(Arrays.asList(1, 2, 3), result.getList()); + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java index 79e95fecda..7dfb51846a 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java @@ -5,6 +5,7 @@ import cn.iocoder.yudao.framework.common.pojo.PageParam; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.pojo.SortablePageParam; import cn.iocoder.yudao.framework.common.pojo.SortingField; +import cn.iocoder.yudao.framework.common.util.object.PageUtils; import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils; import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils; import com.baomidou.mybatisplus.annotation.DbType; @@ -44,7 +45,7 @@ public interface BaseMapperX extends MPJBaseMapper { if (PageParam.PAGE_SIZE_NONE.equals(pageParam.getPageSize())) { MyBatisUtils.addOrder(queryWrapper, sortingFields); List list = selectList(queryWrapper); - return new PageResult<>(list, (long) list.size()); + return PageUtils.buildPageResult(pageParam, list); } // MyBatis Plus 查询 @@ -58,7 +59,7 @@ public interface BaseMapperX extends MPJBaseMapper { // 特殊:不分页,直接查询全部 if (PageParam.PAGE_SIZE_NONE.equals(pageParam.getPageSize())) { List list = selectJoinList(clazz, lambdaWrapper); - return new PageResult<>(list, (long) list.size()); + return PageUtils.buildPageResult(pageParam, list); } // MyBatis Plus Join 查询 @@ -81,7 +82,7 @@ public interface BaseMapperX extends MPJBaseMapper { // 特殊:不分页,直接查询全部 if (PageParam.PAGE_SIZE_NONE.equals(pageParam.getPageSize())) { List list = selectJoinList(clazz, lambdaWrapper); - return new PageResult<>(list, (long) list.size()); + return PageUtils.buildPageResult(pageParam, list); } // MyBatis Plus Join 查询 diff --git a/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/statistics/HrmAttendanceStatisticsServiceImpl.java b/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/statistics/HrmAttendanceStatisticsServiceImpl.java index d585b7cfd0..6331c57a31 100644 --- a/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/statistics/HrmAttendanceStatisticsServiceImpl.java +++ b/yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/statistics/HrmAttendanceStatisticsServiceImpl.java @@ -135,7 +135,7 @@ public class HrmAttendanceStatisticsServiceImpl implements HrmAttendanceStatisti buildMonthSummaryList(reqVO.getYear(), reqVO.getMonth(), employeeList); List filteredMonthSummaryList = filterList(monthSummaryList, record -> Objects.equals(record.getFullAttendance(), reqVO.getFullAttendance())); - return PageUtils.buildPageResult(filteredMonthSummaryList, reqVO); + return PageUtils.buildPageResult(reqVO, filteredMonthSummaryList); } @Override diff --git a/yudao-module-oa/pom.xml b/yudao-module-oa/pom.xml new file mode 100644 index 0000000000..6e291c6839 --- /dev/null +++ b/yudao-module-oa/pom.xml @@ -0,0 +1,81 @@ + + + + cn.iocoder.boot + yudao + ${revision} + + 4.0.0 + yudao-module-oa + jar + + ${project.artifactId} + + oa 包下,办公自动化(Office Automation),提供协同办公、行政管理和业务审批功能。 + 例如说:日程计划、企业云盘、请假报销、会议室、车辆、印章、公文等等 + + + + + cn.iocoder.boot + yudao-module-system + ${revision} + + + cn.iocoder.boot + yudao-module-infra + ${revision} + + + cn.iocoder.boot + yudao-module-bpm + ${revision} + + + + + cn.iocoder.boot + yudao-spring-boot-starter-web + + + cn.iocoder.boot + yudao-spring-boot-starter-security + + + + + cn.iocoder.boot + yudao-spring-boot-starter-mybatis + + + + + cn.iocoder.boot + yudao-spring-boot-starter-job + + + + + org.springframework.boot + spring-boot-starter-mail + + + org.jsoup + jsoup + + + com.belerweb + pinyin4j + + + + + cn.iocoder.boot + yudao-spring-boot-starter-test + test + + + + diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/OaAnnouncementController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/OaAnnouncementController.java new file mode 100644 index 0000000000..a531bcb06a --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/OaAnnouncementController.java @@ -0,0 +1,214 @@ +package cn.iocoder.yudao.module.oa.controller.admin.announcement; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.announcement.vo.OaAnnouncementPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.announcement.vo.OaAnnouncementRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.announcement.vo.OaAnnouncementSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.announcement.OaAnnouncementDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.announcement.OaAnnouncementReceiverDO; +import cn.iocoder.yudao.module.oa.service.announcement.OaAnnouncementService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - OA 公告") +@RestController +@RequestMapping("/oa/announcement") +@Validated +public class OaAnnouncementController { + + @Resource + private OaAnnouncementService announcementService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + // ==================== 我发布的 ==================== + + @PostMapping("/create") + @Operation(summary = "创建公告") + @PreAuthorize("@ss.hasPermission('oa:announcement:create')") + public CommonResult createAnnouncement(@Valid @RequestBody OaAnnouncementSaveReqVO createReqVO) { + return success(announcementService.createAnnouncement(createReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新公告") + @PreAuthorize("@ss.hasPermission('oa:announcement:update')") + public CommonResult updateAnnouncement(@Valid @RequestBody OaAnnouncementSaveReqVO updateReqVO) { + announcementService.updateAnnouncement(updateReqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除发布的公告") + @Parameter(name = "id", description = "公告编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:announcement:delete')") + public CommonResult deleteAnnouncement(@RequestParam("id") Long id) { + announcementService.deleteAnnouncement(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/published-page") + @Operation(summary = "获得我发布的公告分页") + @PreAuthorize("@ss.hasPermission('oa:announcement:query')") + public CommonResult> getPublishedAnnouncementPage( + @Valid OaAnnouncementPageReqVO pageReqVO) { + Long userId = getLoginUserId(); + PageResult pageResult = announcementService + .getPublishedAnnouncementPage(pageReqVO, userId); + return success(new PageResult<>(buildAnnouncementRespVOList(pageResult.getList(), userId), + pageResult.getTotal())); + } + + // ==================== 我收到的 ==================== + + @DeleteMapping("/delete-received") + @Operation(summary = "删除接收的公告") + @Parameter(name = "id", description = "公告编号", required = true, example = "1024") + public CommonResult deleteReceivedAnnouncement(@RequestParam("id") Long id) { + announcementService.deleteReceivedAnnouncement(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/received-page") + @Operation(summary = "获得接收的公告分页") + @PreAuthorize("@ss.hasPermission('oa:announcement:query')") + public CommonResult> getReceivedAnnouncementPage( + @Valid OaAnnouncementPageReqVO pageReqVO) { + Long userId = getLoginUserId(); + PageResult pageResult = announcementService + .getReceivedAnnouncementPage(pageReqVO, userId); + return success(new PageResult<>(buildAnnouncementRespVOList(pageResult.getList(), userId), + pageResult.getTotal())); + } + + @PutMapping("/update-read-status") + @Operation(summary = "标记公告为已读") + @Parameter(name = "id", description = "公告编号", required = true, example = "1024") + public CommonResult updateAnnouncementReadStatus(@RequestParam("id") Long id) { + announcementService.updateAnnouncementReadStatus(id, getLoginUserId()); + return success(true); + } + + @PostMapping("/forward") + @Operation(summary = "转发公告给下属") + @Parameter(name = "id", description = "公告编号", required = true, example = "1024") + public CommonResult forwardAnnouncement(@RequestParam("id") Long id) { + return success(announcementService.forwardAnnouncement(id, getLoginUserId())); + } + + // ==================== 公共详情 ==================== + + @GetMapping("/get") + @Operation(summary = "获得公告详情") + @Parameter(name = "id", description = "公告编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:announcement:query')") + public CommonResult getAnnouncement(@RequestParam("id") Long id) { + Long userId = getLoginUserId(); + OaAnnouncementDO announcement = announcementService.getAnnouncement(id, userId); + return success(buildAnnouncementRespVO(announcement, userId)); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接公告详情 + * + * @param announcement 公告 + * @param loginUserId 当前登录用户编号 + * @return 公告响应 + */ + private OaAnnouncementRespVO buildAnnouncementRespVO(OaAnnouncementDO announcement, Long loginUserId) { + if (announcement == null) { + return null; + } + return CollUtil.getFirst(buildAnnouncementRespVOList(Collections.singletonList(announcement), loginUserId)); + } + + /** + * 拼接公告响应列表 + * + * @param announcements 公告列表 + * @param loginUserId 当前登录用户编号 + * @return 公告响应列表 + */ + private List buildAnnouncementRespVOList(List announcements, + Long loginUserId) { + if (CollUtil.isEmpty(announcements)) { + return Collections.emptyList(); + } + // 1. 查询公告接收关系、发布人、接收人和发布人部门信息 + Map> receiverListMap = announcementService + .getAnnouncementReceiverListMap(convertSet(announcements, OaAnnouncementDO::getId)); + Collection userIds = new ArrayList<>(convertSet(announcements, + announcement -> NumberUtils.parseLong(announcement.getCreator()))); + receiverListMap.values().forEach(receivers -> userIds.addAll( + convertSet(receivers, OaAnnouncementReceiverDO::getReceiverUserId))); + Map userMap = adminUserApi.getUserMap(userIds); + Map deptMap = deptApi.getDeptMap(convertSet(userMap.values(), AdminUserRespDTO::getDeptId)); + Set subordinateUserIds = convertSet(adminUserApi.getUserListBySubordinate(loginUserId), AdminUserRespDTO::getId); + // 2. 拼接公告、发布人、接收人和当前接收状态 + return convertList(announcements, announcementDO -> { + OaAnnouncementRespVO announcement = BeanUtils.toBean(announcementDO, OaAnnouncementRespVO.class) + .setPublisherUserId(NumberUtils.parseLong(announcementDO.getCreator())); + MapUtils.findAndThen(userMap, announcement.getPublisherUserId(), publisher -> { + announcement.setPublisherUserName(publisher.getNickname()); + announcement.setPublisherDeptId(publisher.getDeptId()); + MapUtils.findAndThen(deptMap, publisher.getDeptId(), + dept -> announcement.setPublisherDeptName(dept.getName())); + }); + List receivers = receiverListMap.getOrDefault(announcement.getId(), + Collections.emptyList()); + List receiverUserIds = convertList(receivers, OaAnnouncementReceiverDO::getReceiverUserId); + announcement.setReceiverUserIds(receiverUserIds); + announcement.setReceiverUserNames(convertList(receiverUserIds, receiverUserId -> { + AdminUserRespDTO receiverUser = userMap.get(receiverUserId); + return receiverUser != null ? receiverUser.getNickname() : null; + })); + OaAnnouncementReceiverDO loginUserReceiver = CollUtil.findOne(receivers, + receiver -> receiver.getReceiverUserId().equals(loginUserId)); + if (loginUserReceiver != null) { + announcement.setReadStatus(loginUserReceiver.getReadStatus()); + } + announcement.setForwarded(CollUtil.containsAny(receiverUserIds, subordinateUserIds)); + return announcement; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/vo/OaAnnouncementPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/vo/OaAnnouncementPageReqVO.java new file mode 100644 index 0000000000..45f7d97aaa --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/vo/OaAnnouncementPageReqVO.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.oa.controller.admin.announcement.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.announcement.OaAnnouncementTypeEnum; +import cn.iocoder.yudao.module.oa.enums.schedule.OaPriorityEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - OA 公告分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaAnnouncementPageReqVO extends PageParam { + + @Schema(description = "公告标题,模糊匹配", example = "国庆节放假通知") + private String title; + + @Schema(description = "公告类型", example = "1") + @InEnum(value = OaAnnouncementTypeEnum.class, message = "公告类型必须是 {value}") + private Integer type; + + @Schema(description = "优先级", example = "1") + @InEnum(value = OaPriorityEnum.class, message = "优先级必须是 {value}") + private Integer priority; + + @Schema(description = "是否已读", example = "false") + private Boolean readStatus; + + @Schema(description = "发布时间范围", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/vo/OaAnnouncementRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/vo/OaAnnouncementRespVO.java new file mode 100644 index 0000000000..9896d1d7ee --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/vo/OaAnnouncementRespVO.java @@ -0,0 +1,61 @@ +package cn.iocoder.yudao.module.oa.controller.admin.announcement.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - OA 公告 Response VO") +@Data +public class OaAnnouncementRespVO { + + @Schema(description = "公告编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "发布人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long publisherUserId; + + @Schema(description = "发布人用户昵称", example = "芋道源码") + private String publisherUserName; + + @Schema(description = "发布人部门编号", example = "1") + private Long publisherDeptId; + + @Schema(description = "发布人部门名称", example = "研发部") + private String publisherDeptName; + + @Schema(description = "公告类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer type; + + @Schema(description = "优先级", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer priority; + + @Schema(description = "公告标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "国庆节放假通知") + private String title; + + @Schema(description = "公告内容", example = "请各部门提前安排好假期值班工作。") + private String content; + + @Schema(description = "相关链接", example = "https://example.com/file.pdf") + private String url; + + @Schema(description = "是否置顶", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean top; + + @Schema(description = "接收人用户编号列表", example = "[1, 2]") + private List receiverUserIds; + + @Schema(description = "接收人用户昵称列表", example = "[\"张三\", \"李四\"]") + private List receiverUserNames; + + @Schema(description = "当前接收人是否已读", example = "false") + private Boolean readStatus; + + @Schema(description = "当前接收人是否已转发给下属", example = "false") + private Boolean forwarded; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/vo/OaAnnouncementSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/vo/OaAnnouncementSaveReqVO.java new file mode 100644 index 0000000000..75c494361c --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/announcement/vo/OaAnnouncementSaveReqVO.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.oa.controller.admin.announcement.vo; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.announcement.OaAnnouncementTypeEnum; +import cn.iocoder.yudao.module.oa.enums.schedule.OaPriorityEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - OA 公告新增/修改 Request VO") +@Data +public class OaAnnouncementSaveReqVO { + + @Schema(description = "公告编号", example = "1024") + private Long id; + + @Schema(description = "公告类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "公告类型不能为空") + @InEnum(value = OaAnnouncementTypeEnum.class, message = "公告类型必须是 {value}") + private Integer type; + + @Schema(description = "优先级", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "优先级不能为空") + @InEnum(value = OaPriorityEnum.class, message = "优先级必须是 {value}") + private Integer priority; + + @Schema(description = "公告标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "国庆节放假通知") + @NotBlank(message = "公告标题不能为空") + @Size(max = 255, message = "公告标题长度不能超过 255 个字符") + private String title; + + @Schema(description = "公告内容", example = "请各部门提前安排好假期值班工作。") + private String content; + + @Schema(description = "相关链接", example = "https://www.iocoder.cn") + @Size(max = 512, message = "相关链接长度不能超过 512 个字符") + private String url; + + @Schema(description = "是否置顶", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + @NotNull(message = "是否置顶不能为空") + private Boolean top; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/OaAttendanceController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/OaAttendanceController.java new file mode 100644 index 0000000000..8335ed4c54 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/OaAttendanceController.java @@ -0,0 +1,260 @@ +package cn.iocoder.yudao.module.oa.controller.admin.attendance; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceMonthReportReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceMonthReportRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendancePageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceUpdateReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceWeekReportReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceWeekReportRespVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.attendance.OaAttendanceDO; +import cn.iocoder.yudao.module.oa.service.attendance.OaAttendanceService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.servlet.ServletUtils.getClientIP; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - OA 考勤") +@RestController +@RequestMapping("/oa/attendance") +@Validated +public class OaAttendanceController { + + @Resource + private OaAttendanceService attendanceService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + // ==================== 我的考勤 ==================== + + @PostMapping("/clock") + @Operation(summary = "执行当前用户打卡") + public CommonResult clockAttendance() { + return success(attendanceService.clockAttendance(getLoginUserId(), getClientIP())); + } + + @GetMapping("/my-page") + @Operation(summary = "获得我的考勤分页列表") + public CommonResult> getMyAttendancePage( + @Valid OaAttendancePageReqVO pageReqVO) { + PageResult pageResult = attendanceService.getMyAttendancePage( + pageReqVO, getLoginUserId()); + return success(new PageResult<>(buildAttendanceRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/my-today-list") + @Operation(summary = "获得我的今日考勤列表") + public CommonResult> getMyTodayAttendanceList() { + return success(buildAttendanceRespVOList( + attendanceService.getTodayAttendanceList(getLoginUserId()))); + } + + // ==================== 考勤管理 ==================== + + @PutMapping("/update") + @Operation(summary = "更新考勤记录") + @PreAuthorize("@ss.hasPermission('oa:attendance:update')") + public CommonResult updateAttendance(@Valid @RequestBody OaAttendanceUpdateReqVO updateReqVO) { + attendanceService.updateAttendance(updateReqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除考勤记录") + @Parameter(name = "id", description = "考勤记录编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:attendance:delete')") + public CommonResult deleteAttendance(@RequestParam("id") Long id) { + attendanceService.deleteAttendance(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得考勤详情") + @Parameter(name = "id", description = "考勤记录编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:attendance:query')") + public CommonResult getAttendance(@RequestParam("id") Long id) { + OaAttendanceDO attendance = attendanceService.getAttendance(id, getLoginUserId()); + return success(buildAttendanceRespVO(attendance)); + } + + @GetMapping("/page") + @Operation(summary = "获得考勤分页列表") + @PreAuthorize("@ss.hasPermission('oa:attendance:query')") + public CommonResult> getAttendancePage( + @Valid OaAttendancePageReqVO pageReqVO) { + PageResult pageResult = attendanceService.getAttendancePage(pageReqVO, getLoginUserId()); + return success(new PageResult<>(buildAttendanceRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + // ==================== 考勤报表 ==================== + + @GetMapping("/week-report") + @Operation(summary = "获得考勤周报") + @PreAuthorize("@ss.hasPermission('oa:attendance:query')") + public CommonResult> getAttendanceWeekReport( + @Valid OaAttendanceWeekReportReqVO reqVO) { + return success(buildAttendanceWeekReportRespVOList( + attendanceService.getAttendanceWeekReport(reqVO, getLoginUserId()))); + } + + @GetMapping("/month-report") + @Operation(summary = "获得考勤月报") + @PreAuthorize("@ss.hasPermission('oa:attendance:query')") + public CommonResult> getAttendanceMonthReport( + @Valid OaAttendanceMonthReportReqVO reqVO) { + return success(buildAttendanceMonthReportRespVOList( + attendanceService.getAttendanceMonthReport(reqVO, getLoginUserId()))); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接考勤详情 + * + * @param attendance 考勤记录 + * @return 考勤响应 + */ + private OaAttendanceRespVO buildAttendanceRespVO(OaAttendanceDO attendance) { + if (attendance == null) { + return null; + } + return CollUtil.getFirst(buildAttendanceRespVOList(Collections.singletonList(attendance))); + } + + /** + * 拼接考勤响应列表 + * + * @param attendances 考勤记录列表 + * @return 考勤响应列表 + */ + private List buildAttendanceRespVOList(List attendances) { + if (CollUtil.isEmpty(attendances)) { + return Collections.emptyList(); + } + Map userMap = adminUserApi.getUserMap( + convertSet(attendances, OaAttendanceDO::getUserId)); + Map deptMap = deptApi.getDeptMap( + convertSet(userMap.values(), AdminUserRespDTO::getDeptId)); + return BeanUtils.toBean(attendances, OaAttendanceRespVO.class, attendance -> + fillAttendanceUserInfo(attendance, userMap, deptMap)); + } + + /** + * 拼接考勤周报响应列表 + * + * @param reports 考勤周报列表 + * @return 包含用户和部门信息的考勤周报列表 + */ + private List buildAttendanceWeekReportRespVOList( + List reports) { + if (CollUtil.isEmpty(reports)) { + return Collections.emptyList(); + } + Map userMap = adminUserApi.getUserMap( + convertSet(reports, OaAttendanceWeekReportRespVO::getUserId)); + Map deptMap = deptApi.getDeptMap( + convertSet(userMap.values(), AdminUserRespDTO::getDeptId)); + reports.forEach(report -> fillAttendanceUserInfo(report, userMap, deptMap)); + return reports; + } + + /** + * 拼接考勤月报响应列表 + * + * @param reports 考勤月报列表 + * @return 包含用户和部门信息的考勤月报列表 + */ + private List buildAttendanceMonthReportRespVOList( + List reports) { + if (CollUtil.isEmpty(reports)) { + return Collections.emptyList(); + } + Map userMap = adminUserApi.getUserMap( + convertSet(reports, OaAttendanceMonthReportRespVO::getUserId)); + Map deptMap = deptApi.getDeptMap( + convertSet(userMap.values(), AdminUserRespDTO::getDeptId)); + reports.forEach(report -> fillAttendanceUserInfo(report, userMap, deptMap)); + return reports; + } + + /** + * 拼接考勤响应的用户和部门信息 + * + * @param attendance 考勤响应 + * @param userMap 用户 Map + * @param deptMap 部门 Map + */ + private void fillAttendanceUserInfo(OaAttendanceRespVO attendance, + Map userMap, + Map deptMap) { + MapUtils.findAndThen(userMap, attendance.getUserId(), user -> { + attendance.setUserName(user.getNickname()).setDeptId(user.getDeptId()); + MapUtils.findAndThen(deptMap, user.getDeptId(), dept -> attendance.setDeptName(dept.getName())); + }); + } + + /** + * 拼接考勤周报响应的用户和部门信息 + * + * @param report 考勤周报响应 + * @param userMap 用户 Map + * @param deptMap 部门 Map + */ + private void fillAttendanceUserInfo(OaAttendanceWeekReportRespVO report, + Map userMap, + Map deptMap) { + MapUtils.findAndThen(userMap, report.getUserId(), user -> { + report.setUserName(user.getNickname()).setDeptId(user.getDeptId()); + MapUtils.findAndThen(deptMap, user.getDeptId(), dept -> report.setDeptName(dept.getName())); + }); + } + + /** + * 拼接考勤月报响应的用户和部门信息 + * + * @param report 考勤月报响应 + * @param userMap 用户 Map + * @param deptMap 部门 Map + */ + private void fillAttendanceUserInfo(OaAttendanceMonthReportRespVO report, + Map userMap, + Map deptMap) { + MapUtils.findAndThen(userMap, report.getUserId(), user -> { + report.setUserName(user.getNickname()).setDeptId(user.getDeptId()); + MapUtils.findAndThen(deptMap, user.getDeptId(), dept -> report.setDeptName(dept.getName())); + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceMonthReportReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceMonthReportReqVO.java new file mode 100644 index 0000000000..0d76246d18 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceMonthReportReqVO.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.oa.controller.admin.attendance.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - OA 考勤月报 Request VO") +@Data +public class OaAttendanceMonthReportReqVO { + + @Schema(description = "年份", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026") + @NotNull(message = "年份不能为空") + @Min(value = 2000, message = "年份不能小于 2000") + private Integer year; + + @Schema(description = "月份", requiredMode = Schema.RequiredMode.REQUIRED, example = "9") + @NotNull(message = "月份不能为空") + @Min(value = 1, message = "月份不能小于 1") + @Max(value = 12, message = "月份不能大于 12") + private Integer month; + + @Schema(description = "用户编号", example = "1") + private Long userId; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceMonthReportRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceMonthReportRespVO.java new file mode 100644 index 0000000000..15152680af --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceMonthReportRespVO.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.oa.controller.admin.attendance.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.experimental.Accessors; + +@Schema(description = "管理后台 - OA 考勤月报 Response VO") +@Data +@Accessors(chain = true) +public class OaAttendanceMonthReportRespVO { + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long userId; + + @Schema(description = "用户昵称", example = "芋道") + private String userName; + + @Schema(description = "部门编号", example = "100") + private Long deptId; + + @Schema(description = "部门名称", example = "研发部") + private String deptName; + + @Schema(description = "上班打卡次数", requiredMode = Schema.RequiredMode.REQUIRED, example = "20") + private Integer clockInCount; + + @Schema(description = "下班打卡次数", requiredMode = Schema.RequiredMode.REQUIRED, example = "19") + private Integer clockOutCount; + + @Schema(description = "正常次数", requiredMode = Schema.RequiredMode.REQUIRED, example = "36") + private Integer normalCount; + + @Schema(description = "迟到次数", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Integer lateCount; + + @Schema(description = "早退次数", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer earlyCount; + + @Schema(description = "请假天数,审批通过且开始时间在当月的申请天数合计", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Integer leaveDays; + + @Schema(description = "出差天数,审批通过且开始时间在当月的申请天数合计", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer travelDays; + + @Schema(description = "旷工天数", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer absentDays; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendancePageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendancePageReqVO.java new file mode 100644 index 0000000000..5b3c65bfaa --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendancePageReqVO.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.oa.controller.admin.attendance.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.attendance.OaAttendanceStatusEnum; +import cn.iocoder.yudao.module.oa.enums.attendance.OaAttendanceTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - OA 考勤分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaAttendancePageReqVO extends PageParam { + + @Schema(description = "用户编号", example = "1") + private Long userId; + + @Schema(description = "考勤类型", example = "1") + @InEnum(value = OaAttendanceTypeEnum.class, message = "考勤类型必须是 {value}") + private Integer type; + + @Schema(description = "考勤状态", example = "1") + @InEnum(value = OaAttendanceStatusEnum.class, message = "考勤状态必须是 {value}") + private Integer status; + + @Schema(description = "考勤时间范围", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] attendanceTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceRespVO.java new file mode 100644 index 0000000000..35c5cea492 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceRespVO.java @@ -0,0 +1,47 @@ +package cn.iocoder.yudao.module.oa.controller.admin.attendance.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - OA 考勤 Response VO") +@Data +@Accessors(chain = true) +public class OaAttendanceRespVO { + + @Schema(description = "考勤记录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long userId; + + @Schema(description = "用户昵称", example = "芋道") + private String userName; + + @Schema(description = "部门编号", example = "100") + private Long deptId; + + @Schema(description = "部门名称", example = "研发部") + private String deptName; + + @Schema(description = "考勤类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer type; + + @Schema(description = "考勤状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer status; + + @Schema(description = "考勤时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789380000000") + private LocalDateTime attendanceTime; + + @Schema(description = "考勤 IP", example = "127.0.0.1") + private String attendanceIp; + + @Schema(description = "考勤备注", example = "正常打卡") + private String remark; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceUpdateReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceUpdateReqVO.java new file mode 100644 index 0000000000..534c093c2e --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceUpdateReqVO.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.oa.controller.admin.attendance.vo; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.attendance.OaAttendanceStatusEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - OA 考勤修改 Request VO") +@Data +public class OaAttendanceUpdateReqVO { + + @Schema(description = "考勤记录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "考勤记录编号不能为空") + private Long id; + + @Schema(description = "考勤状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "考勤状态不能为空") + @InEnum(value = OaAttendanceStatusEnum.class, message = "考勤状态必须是 {value}") + private Integer status; + + @Schema(description = "考勤备注", example = "因交通拥堵迟到") + @Size(max = 500, message = "考勤备注不能超过 500 个字符") + private String remark; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceWeekReportReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceWeekReportReqVO.java new file mode 100644 index 0000000000..6ea5ecfae4 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceWeekReportReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.oa.controller.admin.attendance.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; + +@Schema(description = "管理后台 - OA 考勤周报 Request VO") +@Data +public class OaAttendanceWeekReportReqVO { + + @Schema(description = "周开始日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-09-07") + @NotNull(message = "周开始日期不能为空") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + private LocalDate startDate; + + @Schema(description = "用户编号", example = "1") + private Long userId; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceWeekReportRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceWeekReportRespVO.java new file mode 100644 index 0000000000..85ed5d6603 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/attendance/vo/OaAttendanceWeekReportRespVO.java @@ -0,0 +1,59 @@ +package cn.iocoder.yudao.module.oa.controller.admin.attendance.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - OA 考勤周报 Response VO") +@Data +@Accessors(chain = true) +public class OaAttendanceWeekReportRespVO { + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long userId; + + @Schema(description = "用户昵称", example = "芋道") + private String userName; + + @Schema(description = "部门编号", example = "100") + private Long deptId; + + @Schema(description = "部门名称", example = "研发部") + private String deptName; + + @Schema(description = "每日考勤列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List dailyAttendances; + + @Schema(description = "管理后台 - OA 每日考勤 Response VO") + @Data + @Accessors(chain = true) + public static class DailyAttendance { + + @Schema(description = "日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-09-07") + private LocalDate date; + + @Schema(description = "上班考勤记录编号", example = "1024") + private Long clockInId; + + @Schema(description = "上班打卡时间", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime clockInTime; + + @Schema(description = "上班打卡状态", example = "1") + private Integer clockInStatus; + + @Schema(description = "下班考勤记录编号", example = "1025") + private Long clockOutId; + + @Schema(description = "下班打卡时间", type = "integer", format = "int64", example = "1789380000000") + private LocalDateTime clockOutTime; + + @Schema(description = "下班打卡状态", example = "1") + private Integer clockOutStatus; + + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/OaContactCategoryController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/OaContactCategoryController.java new file mode 100644 index 0000000000..e7ba296aad --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/OaContactCategoryController.java @@ -0,0 +1,88 @@ +package cn.iocoder.yudao.module.oa.controller.admin.contact; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.contact.vo.category.OaContactCategoryRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.contact.vo.category.OaContactCategorySaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.contact.OaContactCategoryDO; +import cn.iocoder.yudao.module.oa.service.contact.OaContactCategoryService; +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.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - OA 联系人分类") +@RestController +@RequestMapping("/oa/contact-category") +@Validated +public class OaContactCategoryController { + + @Resource + private OaContactCategoryService contactCategoryService; + + @PostMapping("/create") + @Operation(summary = "创建联系人分类") + public CommonResult createContactCategory( + @Valid @RequestBody OaContactCategorySaveReqVO createReqVO) { + return success(contactCategoryService.createContactCategory(createReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新联系人分类") + public CommonResult updateContactCategory( + @Valid @RequestBody OaContactCategorySaveReqVO updateReqVO) { + contactCategoryService.updateContactCategory(updateReqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除联系人分类") + @Parameter(name = "id", description = "分类编号", required = true, example = "1024") + public CommonResult deleteContactCategory(@RequestParam("id") Long id) { + contactCategoryService.deleteContactCategory(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得联系人分类") + @Parameter(name = "id", description = "分类编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:contact:query')") + public CommonResult getContactCategory(@RequestParam("id") Long id) { + OaContactCategoryDO category = contactCategoryService.getContactCategory(id, getLoginUserId()); + return success(BeanUtils.toBean(category, OaContactCategoryRespVO.class)); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得分类精简列表") + public CommonResult> getSimpleContactCategoryList() { + List categories = contactCategoryService.getContactCategoryList(getLoginUserId()); + return success(convertList(categories, category -> new OaContactCategoryRespVO() + .setId(category.getId()).setName(category.getName()))); + } + + @GetMapping("/list") + @Operation(summary = "获得联系人分类列表") + @PreAuthorize("@ss.hasPermission('oa:contact:query')") + public CommonResult> getContactCategoryList() { + List categories = contactCategoryService.getContactCategoryList(getLoginUserId()); + return success(BeanUtils.toBean(categories, OaContactCategoryRespVO.class)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/OaContactController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/OaContactController.java new file mode 100644 index 0000000000..3d206f3190 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/OaContactController.java @@ -0,0 +1,234 @@ +package cn.iocoder.yudao.module.oa.controller.admin.contact; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +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.oa.controller.admin.contact.vo.OaContactHandleReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.contact.vo.OaContactPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.contact.vo.OaContactRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.contact.vo.OaContactSaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.contact.vo.OaContactShareReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.contact.OaContactDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.contact.OaContactCategoryDO; +import cn.iocoder.yudao.module.oa.service.contact.OaContactCategoryService; +import cn.iocoder.yudao.module.oa.dal.dataobject.contact.OaContactShareDO; +import cn.iocoder.yudao.module.oa.service.contact.OaContactService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertListByFlatMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.filterList; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - OA 通讯录") +@RestController +@RequestMapping("/oa/contact") +@Validated +public class OaContactController { + + @Resource + private OaContactService contactService; + @Resource + private OaContactCategoryService contactCategoryService; + + @Resource + private AdminUserApi adminUserApi; + + // ==================== 我的联系人 ==================== + + @PostMapping("/create") + @Operation(summary = "创建外部联系人") + @PreAuthorize("@ss.hasPermission('oa:contact:create')") + public CommonResult createContact(@Valid @RequestBody OaContactSaveReqVO createReqVO) { + return success(contactService.createContact(createReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新外部联系人") + @PreAuthorize("@ss.hasPermission('oa:contact:update')") + public CommonResult updateContact(@Valid @RequestBody OaContactSaveReqVO updateReqVO) { + contactService.updateContact(updateReqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除外部联系人") + @Parameter(name = "id", description = "联系人编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:contact:delete')") + public CommonResult deleteContact(@RequestParam("id") Long id) { + contactService.deleteContact(id, getLoginUserId()); + return success(true); + } + + @PostMapping("/share") + @Operation(summary = "共享外部联系人") + public CommonResult shareContact(@Valid @RequestBody OaContactShareReqVO shareReqVO) { + contactService.shareContact(shareReqVO.getContactId(), shareReqVO.getUserIds(), getLoginUserId()); + return success(true); + } + + // ==================== 共享与我 ==================== + + @DeleteMapping("/delete-received") + @Operation(summary = "删除接收到的共享联系人") + @Parameter(name = "contactId", description = "联系人编号", required = true, example = "1024") + public CommonResult deleteReceivedContact(@RequestParam("contactId") Long contactId) { + contactService.deleteReceivedContact(contactId, getLoginUserId()); + return success(true); + } + + @PutMapping("/handle-share") + @Operation(summary = "处理外部联系人共享") + public CommonResult handleContactShare(@Valid @RequestBody OaContactHandleReqVO handleReqVO) { + contactService.handleContactShare(handleReqVO.getContactId(), handleReqVO.getCategoryId(), getLoginUserId()); + return success(true); + } + + // ==================== 公共查询 ==================== + + @GetMapping("/get") + @Operation(summary = "获得外部联系人详情") + @Parameter(name = "id", description = "联系人编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:contact:query')") + public CommonResult getContact(@RequestParam("id") Long id) { + OaContactDO contact = contactService.getContact(id, getLoginUserId()); + return success(buildContactRespVO(contact)); + } + + @GetMapping("/my-page") + @Operation(summary = "获得我的联系人分页") + @PreAuthorize("@ss.hasPermission('oa:contact:query')") + public CommonResult> getMyContactPage(@Valid OaContactPageReqVO pageReqVO) { + PageResult pageResult = contactService.getMyContactPage(pageReqVO, getLoginUserId()); + return success(new PageResult<>(buildContactRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/received-page") + @Operation(summary = "获得共享给我的联系人分页") + @PreAuthorize("@ss.hasPermission('oa:contact:query')") + public CommonResult> getReceivedContactPage(@Valid OaContactPageReqVO pageReqVO) { + PageResult pageResult = contactService.getReceivedContactPage(pageReqVO, getLoginUserId()); + return success(new PageResult<>(buildContactRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/shared-page") + @Operation(summary = "获得我共享的联系人分页") + @PreAuthorize("@ss.hasPermission('oa:contact:query')") + public CommonResult> getSharedContactPage(@Valid OaContactPageReqVO pageReqVO) { + PageResult pageResult = contactService.getSharedContactPage(pageReqVO, getLoginUserId()); + List contacts = contactService.getContactList(convertSet(pageResult.getList(), OaContactShareDO::getContactId)); + // 拼接 VO + Map contactMap = convertMap(buildContactRespVOList(contacts), OaContactRespVO::getId); + return success(new PageResult<>(convertList(pageResult.getList(), share -> { + OaContactRespVO contact = BeanUtils.toBean(contactMap.get(share.getContactId()), OaContactRespVO.class); + contact.setShare(CollUtil.findOne(contact.getShares(), item -> ObjectUtil.equal(item.getId(), share.getId()))); + return contact; + }), pageResult.getTotal())); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接联系人详情 + * + * @param contact 联系人 + * @return 联系人响应 + */ + private OaContactRespVO buildContactRespVO(OaContactDO contact) { + if (contact == null) { + return null; + } + return CollUtil.getFirst(buildContactRespVOList(Collections.singletonList(contact))); + } + + /** + * 拼接外部联系人响应列表 + * + * @param contacts 联系人列表 + * @return 联系人响应列表 + */ + private List buildContactRespVOList(List contacts) { + if (CollUtil.isEmpty(contacts)) { + return Collections.emptyList(); + } + // 1.1 批量查询共享关系 + Map> contactShareListMap = contactService.getContactShareListMap( + convertSet(contacts, OaContactDO::getId)); + List shares = convertListByFlatMap(contactShareListMap.values(), List::stream); + // 1.2 批量查询创建人和接收人信息 + Set userIds = convertSet(contacts, contact -> NumberUtils.parseLong(contact.getCreator())); + userIds.addAll(convertSet(shares, OaContactShareDO::getUserId)); + userIds.addAll(convertSet(shares, share -> NumberUtils.parseLong(share.getCreator()))); + Map userMap = adminUserApi.getUserMap(userIds); + // 1.3 批量查询创建人和接收人的分类 + Set categoryIds = convertSet(contacts, OaContactDO::getCategoryId); + categoryIds.addAll(convertSet(shares, OaContactShareDO::getCategoryId)); + Map categoryMap = contactCategoryService.getContactCategoryMap(categoryIds); + Long loginUserId = getLoginUserId(); + // 2. 拼接展示字段,接收人仅能查看自己的共享关系 + return convertList(contacts, contactDO -> { + OaContactRespVO contact = BeanUtils.toBean(contactDO, OaContactRespVO.class) + .setOwnerUserId(NumberUtils.parseLong(contactDO.getCreator())); + MapUtils.findAndThen(categoryMap, contact.getCategoryId(), + category -> contact.setCategoryName(category.getName())); + MapUtils.findAndThen(userMap, contact.getOwnerUserId(), + user -> contact.setOwnerUserName(user.getNickname())); + List contactShares = contactShareListMap.getOrDefault(contact.getId(), + Collections.emptyList()); + // 仅展示本人发出的共享,不将初始化的本人关联计入共享人数 + List visibleContactShares = filterList(contactShares, + share -> ObjectUtil.equal(NumberUtils.parseLong(share.getCreator()), loginUserId) + && ObjectUtil.notEqual(share.getUserId(), loginUserId)); + contact.setShares(convertList(visibleContactShares, share -> { + OaContactRespVO.Share shareVO = BeanUtils.toBean(share, OaContactRespVO.Share.class); + MapUtils.findAndThen(categoryMap, share.getCategoryId(), + category -> shareVO.setCategoryName(category.getName())); + MapUtils.findAndThen(userMap, NumberUtils.parseLong(share.getCreator()), + user -> shareVO.setCreatorName(user.getNickname())); + MapUtils.findAndThen(userMap, share.getUserId(), + user -> shareVO.setUserName(user.getNickname()).setUserAvatar(user.getAvatar())); + return shareVO; + })); + OaContactShareDO currentUserShare = CollUtil.findOne(contactShares, + share -> share.getUserId().equals(loginUserId)); + if (currentUserShare != null) { + MapUtils.findAndThen(userMap, NumberUtils.parseLong(currentUserShare.getCreator()), + user -> contact.setSharerName(user.getNickname())); + contact.setHandleStatus(currentUserShare.getHandleStatus()) + .setSharedCategoryId(currentUserShare.getCategoryId()); + MapUtils.findAndThen(categoryMap, currentUserShare.getCategoryId(), + category -> contact.setSharedCategoryName(category.getName())); + } + return contact; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactHandleReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactHandleReqVO.java new file mode 100644 index 0000000000..8dad5259e1 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactHandleReqVO.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.oa.controller.admin.contact.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - OA 联系人共享处理 Request VO") +@Data +public class OaContactHandleReqVO { + + @Schema(description = "联系人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "联系人编号不能为空") + private Long contactId; + + @Schema(description = "接收人分类编号", example = "1024") + private Long categoryId; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactPageReqVO.java new file mode 100644 index 0000000000..5737b4c3e4 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactPageReqVO.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.oa.controller.admin.contact.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Pattern; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Schema(description = "管理后台 - OA 外部联系人分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaContactPageReqVO extends PageParam { + + @Schema(description = "姓名、拼音、手机或公司名称关键字", example = "张三") + private String keyword; + + @Schema(description = "分类编号", example = "1024") + private Long categoryId; + + @Schema(description = "是否已处理", example = "false") + private Boolean handleStatus; + + @Schema(description = "姓名拼音首字母,空字符串表示全部", example = "A") + @Pattern(regexp = "^[A-Z]?$", message = "姓名拼音首字母必须为 A-Z") + private String alphabet; + + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactRespVO.java new file mode 100644 index 0000000000..288292728d --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactRespVO.java @@ -0,0 +1,112 @@ +package cn.iocoder.yudao.module.oa.controller.admin.contact.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - OA 外部联系人 Response VO") +@Data +public class OaContactRespVO { + + @Schema(description = "联系人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "创建人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long ownerUserId; + + @Schema(description = "创建人用户昵称", example = "芋道源码") + private String ownerUserName; + + @Schema(description = "分类编号", example = "1024") + private Long categoryId; + + @Schema(description = "分类名称", example = "客户") + private String categoryName; + + @Schema(description = "姓名", requiredMode = Schema.RequiredMode.REQUIRED, example = "张三") + private String name; + + @Schema(description = "姓名拼音", example = "zhangsan") + private String pinyin; + + @Schema(description = "性别", example = "1") + private Integer sex; + + @Schema(description = "手机号码", example = "13800138000") + private String mobile; + + @Schema(description = "邮箱", example = "yudao@example.com") + private String email; + + @Schema(description = "地址", example = "上海市浦东新区世纪大道 100 号") + private String address; + + @Schema(description = "公司名称", example = "芋道科技有限公司") + private String companyName; + + @Schema(description = "公司电话", example = "021-12345678") + private String companyPhone; + + @Schema(description = "头像地址", example = "https://example.com/image.png") + private String avatar; + + @Schema(description = "备注", example = "请提前联系行政部确认") + private String remark; + + @Schema(description = "共享记录列表") + private List shares; + + @Schema(description = "当前行的共享关系,仅我共享的列表返回") + private Share share; + + @Schema(description = "分享给当前用户的共享人昵称", example = "芋道源码") + private String sharerName; + + @Schema(description = "当前接收人的处理状态", example = "false") + private Boolean handleStatus; + + @Schema(description = "当前接收人的分类编号", example = "1024") + private Long sharedCategoryId; + + @Schema(description = "当前接收人的分类名称", example = "客户联系人") + private String sharedCategoryName; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + + @Schema(description = "管理后台 - OA 联系人共享 Response VO") + @Data + public static class Share { + + @Schema(description = "共享记录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "共享接收人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long userId; + + @Schema(description = "实际共享人昵称", example = "张三") + private String creatorName; + + @Schema(description = "共享接收人用户昵称", example = "芋道源码") + private String userName; + + @Schema(description = "共享接收人用户头像", example = "https://example.com/avatar.png") + private String userAvatar; + + @Schema(description = "分类编号", example = "1024") + private Long categoryId; + + @Schema(description = "接收人的分类名称", example = "客户") + private String categoryName; + + @Schema(description = "处理状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean handleStatus; + + @Schema(description = "共享时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "1789347600000") + private LocalDateTime createTime; + + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactSaveReqVO.java new file mode 100644 index 0000000000..9f4079235f --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactSaveReqVO.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.oa.controller.admin.contact.vo; + +import cn.iocoder.yudao.framework.common.validation.Mobile; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - OA 外部联系人新增/修改 Request VO") +@Data +public class OaContactSaveReqVO { + + @Schema(description = "联系人编号", example = "1024") + private Long id; + + @Schema(description = "分类编号", example = "1024") + private Long categoryId; + + @Schema(description = "姓名", requiredMode = Schema.RequiredMode.REQUIRED, example = "张三") + @NotBlank(message = "姓名不能为空") + @Size(max = 50, message = "姓名长度不能超过 50 个字符") + private String name; + + @Schema(description = "性别", example = "1") + private Integer sex; + + @Schema(description = "手机号码", requiredMode = Schema.RequiredMode.REQUIRED, example = "13800138000") + @NotBlank(message = "手机号码不能为空") + @Mobile + @Size(max = 20, message = "手机号码长度不能超过 20 个字符") + private String mobile; + + @Schema(description = "邮箱", requiredMode = Schema.RequiredMode.REQUIRED, example = "user@example.com") + @NotBlank(message = "邮箱不能为空") + @Email(message = "邮箱格式不正确") + @Size(max = 100, message = "邮箱长度不能超过 100 个字符") + private String email; + + @Schema(description = "地址", example = "上海市浦东新区世纪大道 100 号") + @Size(max = 255, message = "地址长度不能超过 255 个字符") + private String address; + + @Schema(description = "公司名称", example = "芋道科技有限公司") + @Size(max = 100, message = "公司名称长度不能超过 100 个字符") + private String companyName; + + @Schema(description = "公司电话", example = "021-12345678") + @Size(max = 30, message = "公司电话长度不能超过 30 个字符") + private String companyPhone; + + @Schema(description = "头像地址", example = "https://example.com/image.png") + @Size(max = 512, message = "头像地址长度不能超过 512 个字符") + private String avatar; + + @Schema(description = "备注", example = "请提前联系行政部确认") + @Size(max = 500, message = "备注长度不能超过 500 个字符") + private String remark; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactShareReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactShareReqVO.java new file mode 100644 index 0000000000..c763d63aa4 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/OaContactShareReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.oa.controller.admin.contact.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - OA 联系人共享 Request VO") +@Data +public class OaContactShareReqVO { + + @Schema(description = "联系人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "联系人编号不能为空") + private Long contactId; + + @Schema(description = "共享接收人用户编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1, 2]") + @NotEmpty(message = "共享接收人不能为空") + private List<@NotNull(message = "共享接收人编号不能为空") Long> userIds; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/category/OaContactCategoryRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/category/OaContactCategoryRespVO.java new file mode 100644 index 0000000000..471f785953 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/category/OaContactCategoryRespVO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.oa.controller.admin.contact.vo.category; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - OA 联系人分类 Response VO") +@Data +public class OaContactCategoryRespVO { + + @Schema(description = "分类编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "分类名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "客户") + private String name; + + @Schema(description = "显示排序", example = "10") + private Integer sort; + + @Schema(description = "创建时间", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/category/OaContactCategorySaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/category/OaContactCategorySaveReqVO.java new file mode 100644 index 0000000000..f1ba3319dc --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/contact/vo/category/OaContactCategorySaveReqVO.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.oa.controller.admin.contact.vo.category; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - OA 联系人分类新增/修改 Request VO") +@Data +public class OaContactCategorySaveReqVO { + + @Schema(description = "分类编号", example = "1024") + private Long id; + + @Schema(description = "分类名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "客户") + @NotBlank(message = "分类名称不能为空") + @Size(max = 50, message = "分类名称长度不能超过 50 个字符") + private String name; + + @Schema(description = "显示排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + @NotNull(message = "显示排序不能为空") + private Integer sort; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionController.java new file mode 100644 index 0000000000..00bdf9d1b7 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionController.java @@ -0,0 +1,223 @@ +package cn.iocoder.yudao.module.oa.controller.admin.discussion; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.discussion.OaDiscussionPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.discussion.OaDiscussionRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.discussion.OaDiscussionSaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.vote.OaVoteOptionRespVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionLikeDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionReplyDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaVoteOptionDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaVoteRecordDO; +import cn.iocoder.yudao.module.oa.service.discussion.OaDiscussionService; +import cn.iocoder.yudao.module.oa.service.discussion.OaDiscussionReplyService; +import cn.iocoder.yudao.module.oa.service.discussion.OaDiscussionLikeService; +import cn.iocoder.yudao.module.oa.service.discussion.OaDiscussionVoteService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; + +@Tag(name = "管理后台 - OA 讨论") +@RestController +@RequestMapping("/oa/discussion") +@Validated +public class OaDiscussionController { + + @Resource + private OaDiscussionService discussionService; + @Resource + private AdminUserApi adminUserApi; + @Resource + private OaDiscussionLikeService discussionLikeService; + @Resource + private OaDiscussionReplyService discussionReplyService; + @Resource + private OaDiscussionVoteService discussionVoteService; + + @PostMapping("/create") + @Operation(summary = "创建讨论") + @PreAuthorize("@ss.hasPermission('oa:discussion:create')") + public CommonResult createDiscussion(@Valid @RequestBody OaDiscussionSaveReqVO createReqVO) { + return success(discussionService.createDiscussion(createReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新讨论") + @PreAuthorize("@ss.hasPermission('oa:discussion:update')") + public CommonResult updateDiscussion(@Valid @RequestBody OaDiscussionSaveReqVO updateReqVO) { + discussionService.updateDiscussion(updateReqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除讨论") + @Parameter(name = "id", description = "讨论编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:discussion:delete')") + public CommonResult deleteDiscussion(@RequestParam("id") Long id) { + discussionService.deleteDiscussion(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得讨论详情") + @Parameter(name = "visit", description = "是否记录本次访问,默认不记录", example = "true") + @Parameter(name = "id", description = "讨论编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:discussion:query')") + public CommonResult getDiscussion(@RequestParam("id") Long id, + @RequestParam(value = "visit", defaultValue = "false") Boolean visit) { + // 1.1 查询讨论 + OaDiscussionDO discussion = discussionService.validateDiscussionExists(id); + // 1.2 按需记录本次访问 + if (Boolean.TRUE.equals(visit)) { + discussionService.increaseDiscussionVisitCount(id); + discussion.setVisitCount(discussion.getVisitCount() + 1); + } + + // 2. 拼接讨论详情 + return success(buildDiscussionRespVO(discussion)); + } + + @GetMapping("/page") + @Operation(summary = "获得讨论分页") + @PreAuthorize("@ss.hasPermission('oa:discussion:query')") + public CommonResult> getDiscussionPage( + @Valid OaDiscussionPageReqVO pageReqVO) { + PageResult pageResult = discussionService.getDiscussionPage(pageReqVO); + return success(new PageResult<>(buildDiscussionRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/manage-page") + @Operation(summary = "获得管理范围内的讨论分页") + @PreAuthorize("@ss.hasPermission('oa:discussion:query')") + public CommonResult> getDiscussionManagePage( + @Valid OaDiscussionPageReqVO pageReqVO) { + PageResult pageResult = discussionService.getDiscussionManagePage(pageReqVO, getLoginUserId()); + return success(new PageResult<>(buildDiscussionRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接讨论详情 + * + * @param discussion 讨论 + * @return 讨论响应 + */ + private OaDiscussionRespVO buildDiscussionRespVO(OaDiscussionDO discussion) { + if (discussion == null) { + return null; + } + return CollUtil.getFirst(buildDiscussionRespVOList(Collections.singletonList(discussion))); + } + + /** + * 拼接讨论响应列表 + * + * @param discussions 讨论列表 + * @return 讨论响应列表 + */ + private List buildDiscussionRespVOList(List discussions) { + if (CollUtil.isEmpty(discussions)) { + return Collections.emptyList(); + } + // 1.1 批量查询回复和点赞 + Set discussionIds = convertSet(discussions, OaDiscussionDO::getId); + Map> replyListMap = convertMultiMap( + discussionReplyService.getDiscussionReplyList(discussionIds), OaDiscussionReplyDO::getDiscussionId); + List discussionLikes = discussionLikeService.getDiscussionLikeList(discussionIds); + Map> likeListMap = convertMultiMap(discussionLikes, + OaDiscussionLikeDO::getDiscussionId); + // 1.2 批量查询投票选项和记录 + Map> voteOptionListMap = convertMultiMap( + discussionVoteService.getVoteOptionList(discussionIds), OaVoteOptionDO::getDiscussionId); + List voteRecords = discussionVoteService.getVoteRecordList(discussionIds); + Map> voteRecordListMap = convertMultiMap(voteRecords, + OaVoteRecordDO::getDiscussionId); + // 1.3 批量查询发布人、点赞人和投票人 + Set userIds = convertSet(discussions, OaDiscussionDO::getUserId); + userIds.addAll(convertSet(discussionLikes, OaDiscussionLikeDO::getUserId)); + userIds.addAll(convertSet(voteRecords, OaVoteRecordDO::getUserId)); + Map userMap = adminUserApi.getUserMap(userIds); + Long loginUserId = getLoginUserId(); + // 2. 转换并拼接讨论展示字段 + return BeanUtils.toBean(discussions, OaDiscussionRespVO.class, discussion -> { + findAndThen(userMap, discussion.getUserId(), user -> discussion.setUserName(user.getNickname())); + discussion.setReplyCount(CollUtil.size(replyListMap.get(discussion.getId()))); + List likes = likeListMap.getOrDefault(discussion.getId(), Collections.emptyList()); + discussion.setLikeCount(likes.size()); + discussion.setLiked(CollUtil.findOne(likes, like -> like.getUserId().equals(loginUserId)) != null); + discussion.setLikeUserNames(convertList(likes, + like -> getUserNickname(userMap, like.getUserId()))); + discussion.setVoteOptions(buildVoteOptionRespVOList( + voteOptionListMap.getOrDefault(discussion.getId(), Collections.emptyList()), + voteRecordListMap.getOrDefault(discussion.getId(), Collections.emptyList()), userMap, loginUserId)); + }); + } + + /** + * 拼接投票选项响应列表 + * + * @param voteOptions 投票选项列表 + * @param voteRecords 投票记录列表 + * @param userMap 用户 Map + * @param loginUserId 当前登录用户编号 + * @return 投票选项响应列表 + */ + private List buildVoteOptionRespVOList(List voteOptions, + List voteRecords, + Map userMap, + Long loginUserId) { + Map> optionVoteRecordListMap = convertMultiMap(voteRecords, + OaVoteRecordDO::getOptionId); + return BeanUtils.toBean(voteOptions, OaVoteOptionRespVO.class, voteOption -> { + voteOption.setVoteCount(0).setVoted(false).setVoterUserNames(Collections.emptyList()); + findAndThen(optionVoteRecordListMap, voteOption.getId(), records -> + voteOption.setVoteCount(records.size()) + .setVoted(CollUtil.findOne(records, record -> record.getUserId().equals(loginUserId)) != null) + .setVoterUserNames(convertList(records, record -> getUserNickname(userMap, record.getUserId())))); + }); + } + + /** + * 获得用户昵称 + * + * @param userMap 用户 Map + * @param userId 用户编号 + * @return 用户昵称 + */ + private static String getUserNickname(Map userMap, Long userId) { + AdminUserRespDTO user = userMap.get(userId); + return user != null ? user.getNickname() : null; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionLikeController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionLikeController.java new file mode 100644 index 0000000000..8f36a7a51f --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionLikeController.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.oa.controller.admin.discussion; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.like.OaDiscussionLikeReqVO; +import cn.iocoder.yudao.module.oa.service.discussion.OaDiscussionLikeService; +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.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +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 static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - OA 讨论点赞") +@RestController +@RequestMapping("/oa/discussion-like") +@Validated +public class OaDiscussionLikeController { + + @Resource + private OaDiscussionLikeService discussionLikeService; + + @PostMapping("/create") + @Operation(summary = "点赞讨论或主回复") + public CommonResult createDiscussionLike(@Valid @RequestBody OaDiscussionLikeReqVO reqVO) { + discussionLikeService.createDiscussionLike(reqVO.getDiscussionId(), reqVO.getReplyId(), getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "取消点赞讨论或主回复") + public CommonResult deleteDiscussionLike(@Valid OaDiscussionLikeReqVO reqVO) { + discussionLikeService.deleteDiscussionLike(reqVO.getDiscussionId(), reqVO.getReplyId(), getLoginUserId()); + return success(true); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionReplyController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionReplyController.java new file mode 100644 index 0000000000..6164e31c55 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionReplyController.java @@ -0,0 +1,133 @@ +package cn.iocoder.yudao.module.oa.controller.admin.discussion; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +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.oa.controller.admin.discussion.vo.reply.OaDiscussionReplyPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.reply.OaDiscussionReplyRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.reply.OaDiscussionReplyCreateReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionLikeDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionReplyDO; +import cn.iocoder.yudao.module.oa.service.discussion.OaDiscussionReplyService; +import cn.iocoder.yudao.module.oa.service.discussion.OaDiscussionLikeService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSetByFlatMap; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - OA 讨论回复") +@RestController +@RequestMapping("/oa/discussion-reply") +@Validated +public class OaDiscussionReplyController { + + @Resource + private OaDiscussionReplyService discussionReplyService; + @Resource + private AdminUserApi adminUserApi; + @Resource + private OaDiscussionLikeService discussionLikeService; + + @PostMapping("/create") + @Operation(summary = "创建讨论回复") + public CommonResult createDiscussionReply( + @Valid @RequestBody OaDiscussionReplyCreateReqVO createReqVO) { + return success(discussionReplyService.createDiscussionReply(createReqVO, getLoginUserId())); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除讨论回复") + @Parameter(name = "id", description = "回复编号", required = true, example = "1024") + public CommonResult deleteDiscussionReply(@RequestParam("id") Long id) { + discussionReplyService.deleteDiscussionReply(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得讨论回复分页") + @PreAuthorize("@ss.hasPermission('oa:discussion:query')") + public CommonResult> getDiscussionReplyPage( + @Valid OaDiscussionReplyPageReqVO pageReqVO) { + PageResult pageResult = discussionReplyService.getDiscussionReplyPage(pageReqVO); + // 1. 批量查询当前页楼层内的子回复 + List replies = new ArrayList<>(pageResult.getList()); + replies.addAll(discussionReplyService.getDiscussionReplyDescendantList(pageResult.getList())); + + // 2. 批量拼接展示字段,再按照父回复关系组织楼层 + List replyVOs = buildDiscussionReplyRespVOList(replies); + Map replyMap = convertMap(replyVOs, OaDiscussionReplyRespVO::getId); + replyVOs.forEach(reply -> reply.setChildren(new ArrayList<>())); + replyVOs.forEach(reply -> findAndThen(replyMap, reply.getParentId(), + parent -> parent.getChildren().add(reply))); + return success(new PageResult<>(convertList(pageResult.getList(), + reply -> replyMap.get(reply.getId())), pageResult.getTotal())); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接讨论回复响应列表 + * + * @param replies 讨论回复列表 + * @return 讨论回复响应列表 + */ + private List buildDiscussionReplyRespVOList(List replies) { + if (CollUtil.isEmpty(replies)) { + return Collections.emptyList(); + } + // 1.1 批量查询回复点赞 + Map> replyLikeListMap = discussionLikeService.getReplyLikeListMap( + convertSet(replies, OaDiscussionReplyDO::getId, + reply -> ObjectUtil.equal(reply.getParentId(), OaDiscussionReplyDO.PARENT_ID_ROOT))); + // 1.2 批量查询回复人、被回复人和点赞人 + Set userIds = convertSetByFlatMap(replies, + reply -> Stream.of(reply.getUserId(), reply.getReplyUserId())); + userIds.addAll(convertSetByFlatMap(replyLikeListMap.values(), + likes -> likes.stream().map(OaDiscussionLikeDO::getUserId))); + Map userMap = adminUserApi.getUserMap(userIds); + Long loginUserId = getLoginUserId(); + + // 2. 转换并拼接回复展示字段 + return BeanUtils.toBean(replies, OaDiscussionReplyRespVO.class, reply -> { + findAndThen(userMap, reply.getUserId(), user -> reply.setUserName(user.getNickname())); + findAndThen(userMap, reply.getReplyUserId(), user -> reply.setReplyUserName(user.getNickname())); + List likes = replyLikeListMap.getOrDefault(reply.getId(), Collections.emptyList()); + reply.setLikeCount(likes.size()) + .setLiked(CollUtil.findOne(likes, like -> like.getUserId().equals(loginUserId)) != null) + .setLikeUserNames(convertList(likes, like -> { + AdminUserRespDTO user = userMap.get(like.getUserId()); + return user != null ? user.getNickname() : null; + })); + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionVoteController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionVoteController.java new file mode 100644 index 0000000000..bb3bbbdeea --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/OaDiscussionVoteController.java @@ -0,0 +1,35 @@ +package cn.iocoder.yudao.module.oa.controller.admin.discussion; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.vote.OaDiscussionVoteReqVO; +import cn.iocoder.yudao.module.oa.service.discussion.OaDiscussionVoteService; +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.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 static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - OA 讨论投票") +@RestController +@RequestMapping("/oa/discussion-vote") +@Validated +public class OaDiscussionVoteController { + + @Resource + private OaDiscussionVoteService discussionVoteService; + + @PostMapping("/create") + @Operation(summary = "参与讨论投票") + public CommonResult voteDiscussion(@Valid @RequestBody OaDiscussionVoteReqVO voteReqVO) { + discussionVoteService.voteDiscussion(voteReqVO.getDiscussionId(), voteReqVO.getOptionIds(), getLoginUserId()); + return success(true); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/discussion/OaDiscussionPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/discussion/OaDiscussionPageReqVO.java new file mode 100644 index 0000000000..78d0f6a76d --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/discussion/OaDiscussionPageReqVO.java @@ -0,0 +1,34 @@ +package cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.discussion; + +import cn.iocoder.yudao.framework.common.pojo.SortablePageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.discussion.OaDiscussionTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - OA 讨论分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaDiscussionPageReqVO extends SortablePageParam { + + @Schema(description = "标题", example = "产品方案讨论") + private String title; + + @Schema(description = "讨论类型", example = "1") + @InEnum(value = OaDiscussionTypeEnum.class, message = "讨论类型必须是 {value}") + private Integer type; + + @Schema(description = "发布人用户编号", example = "1") + private Long userId; + + @Schema(description = "创建时间范围", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/discussion/OaDiscussionRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/discussion/OaDiscussionRespVO.java new file mode 100644 index 0000000000..b591fe2289 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/discussion/OaDiscussionRespVO.java @@ -0,0 +1,65 @@ +package cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.discussion; + +import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.vote.OaVoteOptionRespVO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - OA 讨论 Response VO") +@Data +public class OaDiscussionRespVO { + + @Schema(description = "讨论编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "发布人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long userId; + + @Schema(description = "发布人用户昵称", example = "芋道源码") + private String userName; + + @Schema(description = "讨论类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer type; + + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "下周团队会议时间征集") + private String title; + + @Schema(description = "内容", example = "请大家反馈下周团队会议的时间安排。") + private String content; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + private List fileUrls; + + @Schema(description = "访问次数", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + private Integer visitCount; + + @Schema(description = "回复数", requiredMode = Schema.RequiredMode.REQUIRED, example = "5") + private Integer replyCount; + + @Schema(description = "点赞数", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer likeCount; + + @Schema(description = "当前用户是否已点赞", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean liked; + + @Schema(description = "点赞人用户昵称列表", example = "[\"张三\", \"李四\"]") + private List likeUserNames; + + @Schema(description = "投票是否允许多选", example = "false") + private Boolean voteMultiple; + + @Schema(description = "投票开始时间", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime voteStartTime; + + @Schema(description = "投票结束时间", type = "integer", format = "int64", example = "1789380000000") + private LocalDateTime voteEndTime; + + @Schema(description = "投票选项列表") + private List voteOptions; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/discussion/OaDiscussionSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/discussion/OaDiscussionSaveReqVO.java new file mode 100644 index 0000000000..1c31bc679c --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/discussion/OaDiscussionSaveReqVO.java @@ -0,0 +1,69 @@ +package cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.discussion; + +import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.vote.OaVoteOptionSaveReqVO; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.discussion.OaDiscussionTypeEnum; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - OA 讨论新增/修改 Request VO") +@Data +public class OaDiscussionSaveReqVO { + + @Schema(description = "讨论编号", example = "1024") + private Long id; + + @Schema(description = "讨论类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "讨论类型不能为空") + @InEnum(value = OaDiscussionTypeEnum.class, message = "讨论类型必须是 {value}") + private Integer type; + + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "下周团队会议时间征集") + @NotBlank(message = "标题不能为空") + @Size(max = 255, message = "标题长度不能超过 255 个字符") + private String title; + + @Schema(description = "内容", example = "请大家反馈下周团队会议的时间安排。") + private String content; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + private List<@NotBlank(message = "附件地址不能为空") @Size(max = 512, message = "附件地址长度不能超过 512 个字符") String> fileUrls; + + @Schema(description = "投票是否允许多选", example = "false") + private Boolean voteMultiple; + + @Schema(description = "投票开始时间", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime voteStartTime; + + @Schema(description = "投票结束时间", type = "integer", format = "int64", example = "1789380000000") + private LocalDateTime voteEndTime; + + @Schema(description = "投票选项列表") + @Valid + private List<@NotNull(message = "投票选项不能为空") OaVoteOptionSaveReqVO> voteOptions; + + @JsonIgnore + @AssertTrue(message = "投票至少需要两个选项,并设置正确的开始和结束时间") + @Schema(hidden = true) + public boolean isVoteConfigValid() { + if (ObjectUtil.notEqual(OaDiscussionTypeEnum.VOTE.getType(), type)) { + return true; + } + if (voteMultiple == null || voteStartTime == null || voteEndTime == null) { + return false; + } + return voteEndTime.isAfter(voteStartTime) && CollUtil.size(voteOptions) >= 2; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/like/OaDiscussionLikeReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/like/OaDiscussionLikeReqVO.java new file mode 100644 index 0000000000..2985fd893f --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/like/OaDiscussionLikeReqVO.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.like; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import lombok.Data; + +@Schema(description = "管理后台 - OA 讨论点赞 Request VO") +@Data +public class OaDiscussionLikeReqVO { + + @Schema(description = "讨论编号,与回复编号必须且只能填写一个", example = "1024") + private Long discussionId; + + @Schema(description = "回复编号,与讨论编号必须且只能填写一个", example = "2048") + private Long replyId; + + @JsonIgnore + @AssertTrue(message = "讨论编号和回复编号必须且只能填写一个") + @Schema(hidden = true) + public boolean isTargetValid() { + if (discussionId != null) { + return replyId == null; + } + return replyId != null; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/reply/OaDiscussionReplyCreateReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/reply/OaDiscussionReplyCreateReqVO.java new file mode 100644 index 0000000000..559fcd14e2 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/reply/OaDiscussionReplyCreateReqVO.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.reply; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.AssertTrue; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Data; + +@Schema(description = "管理后台 - OA 讨论回复新增 Request VO") +@Data +public class OaDiscussionReplyCreateReqVO { + + @Schema(description = "讨论编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "讨论编号不能为空") + private Long discussionId; + + @Schema(description = "父回复编号", example = "0") + private Long parentId; + + @Schema(description = "回复内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "已收到,将按时完成。") + @NotBlank(message = "回复内容不能为空") + private String content; + + /** + * 楼内评论对齐源端 255 字限制,根回复使用 TEXT 正文。 + * + * @return 评论长度是否合法 + */ + @AssertTrue(message = "楼内评论不能超过 255 个字符") + @JsonIgnore + public boolean isCommentLengthValid() { + return parentId == null || parentId == 0L || content == null || content.length() <= 255; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/reply/OaDiscussionReplyPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/reply/OaDiscussionReplyPageReqVO.java new file mode 100644 index 0000000000..d238d075c4 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/reply/OaDiscussionReplyPageReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.reply; + +import cn.iocoder.yudao.framework.common.pojo.SortablePageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Schema(description = "管理后台 - OA 讨论回复分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaDiscussionReplyPageReqVO extends SortablePageParam { + + @Schema(description = "讨论编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "讨论编号不能为空") + private Long discussionId; + + @Schema(description = "回复人用户编号", example = "1") + private Long userId; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/reply/OaDiscussionReplyRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/reply/OaDiscussionReplyRespVO.java new file mode 100644 index 0000000000..7c735ae174 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/reply/OaDiscussionReplyRespVO.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.reply; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - OA 讨论回复 Response VO") +@Data +public class OaDiscussionReplyRespVO { + + @Schema(description = "回复编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "讨论编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long discussionId; + + @Schema(description = "回复人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long userId; + + @Schema(description = "回复人用户昵称", example = "芋道源码") + private String userName; + + @Schema(description = "父回复编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Long parentId; + + @Schema(description = "被回复人用户编号", example = "2") + private Long replyUserId; + + @Schema(description = "被回复人用户昵称", example = "管理员") + private String replyUserName; + + @Schema(description = "回复内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "已收到,将按时完成。") + private String content; + + @Schema(description = "点赞数", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + private Integer likeCount; + + @Schema(description = "当前用户是否已点赞", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean liked; + + @Schema(description = "点赞人用户昵称列表", example = "[\"张三\", \"李四\"]") + private List likeUserNames; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + + @Schema(description = "楼层内的子回复") + private List children; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/vote/OaDiscussionVoteReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/vote/OaDiscussionVoteReqVO.java new file mode 100644 index 0000000000..c6f9970664 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/vote/OaDiscussionVoteReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.vote; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - OA 讨论投票 Request VO") +@Data +public class OaDiscussionVoteReqVO { + + @Schema(description = "讨论编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "讨论编号不能为空") + private Long discussionId; + + @Schema(description = "投票选项编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1, 2]") + @NotEmpty(message = "投票选项不能为空") + private List<@NotNull(message = "投票选项编号不能为空") Long> optionIds; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/vote/OaVoteOptionRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/vote/OaVoteOptionRespVO.java new file mode 100644 index 0000000000..dafa10bf39 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/vote/OaVoteOptionRespVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.vote; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - OA 投票选项 Response VO") +@Data +public class OaVoteOptionRespVO { + + @Schema(description = "投票选项编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "选项标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "周五下午") + private String title; + + @Schema(description = "选项颜色", example = "#409EFF") + private String color; + + @Schema(description = "显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + private Integer sort; + + @Schema(description = "投票数", requiredMode = Schema.RequiredMode.REQUIRED, example = "5") + private Integer voteCount; + + @Schema(description = "当前用户是否已选择", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean voted; + + @Schema(description = "投票人用户昵称列表", example = "[\"张三\", \"李四\"]") + private List voterUserNames; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/vote/OaVoteOptionSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/vote/OaVoteOptionSaveReqVO.java new file mode 100644 index 0000000000..986503663d --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/discussion/vo/vote/OaVoteOptionSaveReqVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.vote; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - OA 投票选项新增/修改 Request VO") +@Data +public class OaVoteOptionSaveReqVO { + + @Schema(description = "选项标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "周五下午") + @NotBlank(message = "投票选项标题不能为空") + @Size(max = 200, message = "投票选项标题长度不能超过 200 个字符") + private String title; + + @Schema(description = "选项颜色", example = "#409EFF") + @Size(max = 20, message = "投票选项颜色长度不能超过 20 个字符") + private String color; + + @Schema(description = "显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + @NotNull(message = "显示顺序不能为空") + private Integer sort; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/OaFileFavoriteController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/OaFileFavoriteController.java new file mode 100644 index 0000000000..13261b3504 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/OaFileFavoriteController.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.oa.controller.admin.file; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.oa.service.file.OaFileFavoriteService; +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 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; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 云盘收藏") +@RestController +@RequestMapping("/oa/file-favorite") +@Validated +public class OaFileFavoriteController { + + @Resource + private OaFileFavoriteService fileFavoriteService; + + @PostMapping("/create") + @Operation(summary = "收藏文件") + @Parameter(name = "nodeId", description = "节点编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:file:query')") + public CommonResult createFileFavorite(@RequestParam("nodeId") Long nodeId) { + fileFavoriteService.createFileFavorite(nodeId, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "取消收藏文件") + @Parameter(name = "nodeId", description = "节点编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:file:query')") + public CommonResult deleteFileFavorite(@RequestParam("nodeId") Long nodeId) { + fileFavoriteService.deleteFileFavorite(nodeId, getLoginUserId()); + return success(true); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/OaFileNodeController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/OaFileNodeController.java new file mode 100644 index 0000000000..8acb165617 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/OaFileNodeController.java @@ -0,0 +1,116 @@ +package cn.iocoder.yudao.module.oa.controller.admin.file; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.file.vo.node.*; +import cn.iocoder.yudao.module.oa.service.file.OaFileNodeService; +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; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 云盘文件") +@RestController +@RequestMapping("/oa/file-node") +@Validated +public class OaFileNodeController { + + @Resource + private OaFileNodeService fileNodeService; + + @GetMapping("/get-storage") + @Operation(summary = "获得本人云盘空间") + @PreAuthorize("@ss.hasPermission('oa:file:query')") + public CommonResult getFileStorage() { + return success(fileNodeService.getFileStorage(getLoginUserId())); + } + + @GetMapping("/page") + @Operation(summary = "获得云盘文件分页") + @PreAuthorize("@ss.hasPermission('oa:file:query')") + public CommonResult> getFileNodePage(@Valid OaFileNodePageReqVO pageReqVO) { + return success(fileNodeService.getFileNodePage(pageReqVO, getLoginUserId())); + } + + @GetMapping("/directory-list") + @Operation(summary = "获得本人可用目录列表") + @PreAuthorize("@ss.hasPermission('oa:file:query')") + public CommonResult> getFileDirectoryList() { + return success(fileNodeService.getFileDirectoryList(getLoginUserId())); + } + + @PostMapping("/create") + @Operation(summary = "创建云盘文件或目录") + @PreAuthorize("@ss.hasPermission('oa:file:create')") + public CommonResult createFileNode(@Valid @RequestBody OaFileNodeSaveReqVO reqVO) { + return success(fileNodeService.createFileNode(reqVO, getLoginUserId())); + } + + @PutMapping("/update-name") + @Operation(summary = "重命名云盘文件") + @PreAuthorize("@ss.hasPermission('oa:file:update')") + public CommonResult updateFileNodeName(@Valid @RequestBody OaFileNodeRenameReqVO reqVO) { + fileNodeService.updateFileNodeName(reqVO, getLoginUserId()); + return success(true); + } + + @PutMapping("/update-parent") + @Operation(summary = "移动云盘文件") + @PreAuthorize("@ss.hasPermission('oa:file:update')") + public CommonResult updateFileNodeParent(@Valid @RequestBody OaFileNodeMoveReqVO reqVO) { + fileNodeService.updateFileNodeParent(reqVO, getLoginUserId()); + return success(true); + } + + @PostMapping("/copy") + @Operation(summary = "复制云盘文件或目录") + @PreAuthorize("@ss.hasPermission('oa:file:create')") + public CommonResult copyFileNode(@Valid @RequestBody OaFileNodeCopyReqVO reqVO) { + return success(fileNodeService.copyFileNode(reqVO, getLoginUserId())); + } + + @PutMapping("/recycle") + @Operation(summary = "移入回收站") + @Parameter(name = "id", description = "节点编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:file:delete')") + public CommonResult recycleFileNode(@RequestParam("id") Long id) { + fileNodeService.recycleFileNode(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/restore") + @Operation(summary = "恢复回收站文件") + @Parameter(name = "id", description = "节点编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:file:delete')") + public CommonResult restoreFileNode(@RequestParam("id") Long id) { + fileNodeService.restoreFileNode(id, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "彻底删除回收站文件") + @Parameter(name = "id", description = "节点编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:file:delete')") + public CommonResult deleteFileNode(@RequestParam("id") Long id) { + fileNodeService.deleteFileNode(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得文件详情") + @Parameter(name = "id", description = "节点编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:file:query')") + public CommonResult getFileNode(@RequestParam("id") Long id) { + return success(fileNodeService.getFileNode(id, getLoginUserId())); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/OaFilePermissionController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/OaFilePermissionController.java new file mode 100644 index 0000000000..b9ee5a8fdc --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/OaFilePermissionController.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.oa.controller.admin.file; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.file.vo.permission.OaFilePermissionRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.file.vo.permission.OaFilePermissionSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.file.OaFilePermissionDO; +import cn.iocoder.yudao.module.oa.service.file.OaFilePermissionService; +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; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 云盘共享权限") +@RestController +@RequestMapping("/oa/file-permission") +@Validated +public class OaFilePermissionController { + + @Resource + private OaFilePermissionService filePermissionService; + + @GetMapping("/list") + @Operation(summary = "获得文件共享权限") + @Parameter(name = "nodeId", description = "节点编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:file:share')") + public CommonResult> getFilePermissionList(@RequestParam("nodeId") Long nodeId) { + List permissions = filePermissionService.getFilePermissionList(nodeId, getLoginUserId()); + return success(BeanUtils.toBean(permissions, OaFilePermissionRespVO.class)); + } + + @PostMapping("/save") + @Operation(summary = "保存文件共享权限") + @PreAuthorize("@ss.hasPermission('oa:file:share')") + public CommonResult saveFilePermission(@Valid @RequestBody OaFilePermissionSaveReqVO reqVO) { + return success(filePermissionService.saveFilePermission(reqVO, getLoginUserId())); + } + + @DeleteMapping("/delete") + @Operation(summary = "取消文件共享权限") + @Parameter(name = "id", description = "权限编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:file:share')") + public CommonResult deleteFilePermission(@RequestParam("id") Long id) { + filePermissionService.deleteFilePermission(id, getLoginUserId()); + return success(true); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeCopyReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeCopyReqVO.java new file mode 100644 index 0000000000..212d82a9ef --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeCopyReqVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.oa.controller.admin.file.vo.node; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - 云盘复制 Request VO") +@Data +public class OaFileNodeCopyReqVO { + + @Schema(description = "节点编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "节点编号不能为空") + private Long id; + + @Schema(description = "目标目录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "目标目录编号不能为空") + private Long parentId; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeMoveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeMoveReqVO.java new file mode 100644 index 0000000000..eda52f3904 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeMoveReqVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.oa.controller.admin.file.vo.node; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - 云盘移动 Request VO") +@Data +public class OaFileNodeMoveReqVO { + + @Schema(description = "节点编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "节点编号不能为空") + private Long id; + + @Schema(description = "目标目录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "目标目录编号不能为空") + private Long parentId; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodePageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodePageReqVO.java new file mode 100644 index 0000000000..94ab7eac6c --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodePageReqVO.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.oa.controller.admin.file.vo.node; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.file.OaFileCategoryEnum; +import cn.iocoder.yudao.module.oa.enums.file.OaFileScopeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 云盘文件分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaFileNodePageReqVO extends PageParam { + + @Schema(description = "列表范围:my、shared、favorite、recycle", requiredMode = Schema.RequiredMode.REQUIRED, example = "my") + @NotBlank(message = "列表范围不能为空") + @InEnum(value = OaFileScopeEnum.class, message = "列表范围必须是 {value}") + private String scope; + + @Schema(description = "父目录编号,不传时查询范围内全部文件", example = "1024") + private Long parentId; + + @Schema(description = "名称", example = "办公用品盘点结果.pdf") + @Size(max = 255, message = "名称长度不能超过 255 个字符") + private String name; + + @Schema(description = "文件分类", example = "1") + @InEnum(value = OaFileCategoryEnum.class, message = "文件分类必须是 {value}") + private Integer category; + + @Schema(description = "创建时间范围", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeRenameReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeRenameReqVO.java new file mode 100644 index 0000000000..abce8c0cd3 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeRenameReqVO.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.oa.controller.admin.file.vo.node; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; + +@Schema(description = "管理后台 - 云盘重命名 Request VO") +@Data +public class OaFileNodeRenameReqVO { + + @Schema(description = "节点编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "节点编号不能为空") + private Long id; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "办公用品盘点结果.pdf") + @NotBlank(message = "名称不能为空") + @Size(max = 255, message = "名称长度不能超过 255 个字符") + private String name; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeRespVO.java new file mode 100644 index 0000000000..c7829ab132 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeRespVO.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.oa.controller.admin.file.vo.node; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 云盘文件 Response VO") +@Data +public class OaFileNodeRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "父目录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long parentId; + + @Schema(description = "节点类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer type; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "办公用品盘点结果.pdf") + private String name; + + @Schema(description = "临时文件地址,仅详情中具有下载权限时返回", example = "https://example.com/file.pdf") + private String url; + + @Schema(description = "扩展名", example = "pdf") + private String extension; + + @Schema(description = "文件分类", example = "1") + private Integer category; + + @Schema(description = "大小,单位字节", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long size; + + @Schema(description = "节点状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + + @Schema(description = "创建人", example = "1024") + private String creator; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime updateTime; + + @Schema(description = "当前用户权限级别", example = "1") + private Integer level; + + @Schema(description = "是否收藏", example = "false") + private Boolean favorite; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeSaveReqVO.java new file mode 100644 index 0000000000..db75607fbb --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileNodeSaveReqVO.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.oa.controller.admin.file.vo.node; + +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.file.OaFileNodeTypeEnum; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; + +@Schema(description = "管理后台 - 云盘文件新增 Request VO") +@Data +public class OaFileNodeSaveReqVO { + + @Schema(description = "父目录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "父目录编号不能为空") + private Long parentId; + + @Schema(description = "节点类型:0 文件夹、1 文件", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "节点类型不能为空") + @InEnum(value = OaFileNodeTypeEnum.class, message = "节点类型必须是 {value}") + private Integer type; + + @Schema(description = "节点名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "办公用品盘点结果.pdf") + @NotBlank(message = "节点名称不能为空") + @Size(max = 255, message = "节点名称长度不能超过 255 个字符") + private String name; + + @Schema(description = "上传文件地址,文件夹不传", example = "https://example.com/file.pdf") + @Size(max = 2048, message = "文件地址长度不能超过 2048 个字符") + private String url; + + @Schema(description = "文件大小,单位字节,文件夹不传", example = "1024") + private Long size; + + @AssertTrue(message = "文件地址不能为空") + @JsonIgnore + public boolean isUrlValid() { + return ObjUtil.notEqual(type, OaFileNodeTypeEnum.FILE.getType()) || StrUtil.isNotBlank(url); + } + + @AssertTrue(message = "文件大小不能为空且不能小于 0") + @JsonIgnore + public boolean isSizeValid() { + return ObjUtil.notEqual(type, OaFileNodeTypeEnum.FILE.getType()) || (size != null && size >= 0); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileStorageRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileStorageRespVO.java new file mode 100644 index 0000000000..7fb485f55d --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/node/OaFileStorageRespVO.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.oa.controller.admin.file.vo.node; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - 云盘空间 Response VO") +@Data +public class OaFileStorageRespVO { + + @Schema(description = "已用容量,单位字节", example = "1024") + private Long usedSize; + + @Schema(description = "总容量,单位字节", example = "1073741824") + private Long totalSize; + + @Schema(description = "本人正常文件数量,不含目录和回收站", example = "1") + private Long fileCount; + + @Schema(description = "本人有效共享节点数量,按文件或目录去重", example = "1") + private Long sharedCount; + + @Schema(description = "收到的共享入口数量,不重复统计子节点", example = "1") + private Long receivedCount; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/permission/OaFilePermissionRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/permission/OaFilePermissionRespVO.java new file mode 100644 index 0000000000..520009cea0 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/permission/OaFilePermissionRespVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.oa.controller.admin.file.vo.permission; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 云盘共享权限 Response VO") +@Data +public class OaFilePermissionRespVO { + + @Schema(description = "权限编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "节点编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long nodeId; + + @Schema(description = "主体类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer subjectType; + + @Schema(description = "主体编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long subjectId; + + @Schema(description = "权限级别", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer level; + + @Schema(description = "是否继承", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean inherit; + + @Schema(description = "到期时间", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime expireTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/permission/OaFilePermissionSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/permission/OaFilePermissionSaveReqVO.java new file mode 100644 index 0000000000..c1ce3fef04 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/file/vo/permission/OaFilePermissionSaveReqVO.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.oa.controller.admin.file.vo.permission; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.file.OaFilePermissionLevelEnum; +import cn.iocoder.yudao.module.oa.enums.file.OaFileSubjectTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 云盘共享权限 Request VO") +@Data +public class OaFilePermissionSaveReqVO { + + @Schema(description = "节点编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "节点编号不能为空") + private Long nodeId; + + @Schema(description = "共享主体", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "共享主体类型不能为空") + @InEnum(value = OaFileSubjectTypeEnum.class, message = "共享主体类型必须是 {value}") + private Integer subjectType; + + @Schema(description = "主体编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "主体编号不能为空") + private Long subjectId; + + @Schema(description = "权限", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "权限级别不能为空") + @InEnum(value = OaFilePermissionLevelEnum.class, message = "权限级别必须是 {value}") + private Integer level; + + @Schema(description = "是否继承到子目录及文件", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + @NotNull(message = "是否继承不能为空") + private Boolean inherit; + + @Schema(description = "到期时间", type = "integer", format = "int64", example = "1789347600000") + @Future(message = "到期时间必须晚于当前时间") + private LocalDateTime expireTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/OaLeaveApplyController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/OaLeaveApplyController.java new file mode 100644 index 0000000000..3fd7699846 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/OaLeaveApplyController.java @@ -0,0 +1,122 @@ +package cn.iocoder.yudao.module.oa.controller.admin.leave; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.leave.vo.OaLeaveApplySaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.leave.vo.OaLeaveApplySubmitReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.leave.vo.OaLeaveApplyPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.leave.vo.OaLeaveApplyRespVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.leave.OaLeaveApplyDO; +import cn.iocoder.yudao.module.oa.service.leave.OaLeaveApplyService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 请假申请") +@RestController +@RequestMapping("/oa/leave-apply") +@Validated +public class OaLeaveApplyController { + + @Resource + private OaLeaveApplyService leaveApplyService; + + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/create") + @Operation(summary = "创建请假申请草稿") + @PreAuthorize("@ss.hasPermission('oa:leave-apply:create')") + public CommonResult createLeaveApply(@Valid @RequestBody OaLeaveApplySaveReqVO createReqVO) { + return success(leaveApplyService.createLeaveApply(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新请假申请草稿") + @PreAuthorize("@ss.hasPermission('oa:leave-apply:create')") + public CommonResult updateLeaveApply(@Valid @RequestBody OaLeaveApplySaveReqVO saveReqVO) { + leaveApplyService.updateLeaveApply(saveReqVO, getLoginUserId()); + return success(true); + } + + @PostMapping("/submit") + @Operation(summary = "提交请假申请") + @PreAuthorize("@ss.hasPermission('oa:leave-apply:create')") + public CommonResult submitLeaveApply(@Valid @RequestBody OaLeaveApplySubmitReqVO submitReqVO) { + leaveApplyService.submitLeaveApply(submitReqVO, getLoginUserId()); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得本人请假申请分页") + @PreAuthorize("@ss.hasPermission('oa:leave-apply:query')") + public CommonResult> getLeaveApplyPage(@Valid OaLeaveApplyPageReqVO pageReqVO) { + PageResult page = leaveApplyService.getLeaveApplyPage(getLoginUserId(), pageReqVO); + return success(new PageResult<>(buildLeaveApplyRespVOList(page.getList()), page.getTotal())); + } + + @GetMapping("/get") + @Operation(summary = "获得请假申请详情") + @Parameter(name = "id", description = "申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:leave-apply:query')") + public CommonResult getLeaveApply(@RequestParam("id") Long id) { + OaLeaveApplyDO application = leaveApplyService.getLeaveApply(id); + return success(buildLeaveApplyRespVO(application)); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接请假申请详情 + * + * @param application 申请 + * @return 请假申请响应 + */ + private OaLeaveApplyRespVO buildLeaveApplyRespVO(OaLeaveApplyDO application) { + if (application == null) { + return null; + } + return CollUtil.getFirst(buildLeaveApplyRespVOList(Collections.singletonList(application))); + } + + /** + * 批量拼接申请人展示信息 + * + * @param applications 申请列表 + * @return 响应列表 + */ + private List buildLeaveApplyRespVOList(List applications) { + if (CollUtil.isEmpty(applications)) { + return Collections.emptyList(); + } + // 1. 批量查询申请人 + Map users = adminUserApi.getUserMap( + convertSet(applications, application -> Long.valueOf(application.getCreator()))); + // 2. 转换并拼接 VO + return convertList(applications, application -> { + OaLeaveApplyRespVO vo = BeanUtils.toBean(application, OaLeaveApplyRespVO.class); + MapUtils.findAndThen(users, Long.valueOf(application.getCreator()), user -> vo.setCreatorName(user.getNickname())); + return vo; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplyPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplyPageReqVO.java new file mode 100644 index 0000000000..875af8eb2d --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplyPageReqVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.oa.controller.admin.leave.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 OaLeaveApplyPageReqVO extends PageParam { + + @Schema(description = "标题", example = "个人事务请假申请") + private String title; + + @Schema(description = "审批状态", example = "-1") + private Integer status; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplyRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplyRespVO.java new file mode 100644 index 0000000000..9a523cb6e6 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplyRespVO.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.oa.controller.admin.leave.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 请假申请 Response VO") +@Data +public class OaLeaveApplyRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "标题", example = "个人事务请假申请") + private String title; + + @Schema(description = "紧急程度", example = "1") + private Integer urgency; + + @Schema(description = "请假类型", example = "1") + private Integer type; + + @Schema(description = "开始时间", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime startTime; + + @Schema(description = "结束时间", type = "integer", format = "int64", example = "1789380000000") + private LocalDateTime endTime; + + @Schema(description = "天数", example = "1") + private Integer days; + + @Schema(description = "申请原因", example = "处理个人事务") + private String reason; + + @Schema(description = "审批状态", example = "-1") + private Integer status; + + @Schema(description = "流程实例编号", example = "1024") + private String processInstanceId; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + private List fileUrls; + + @Schema(description = "申请人编号", example = "1024") + private String creator; + + @Schema(description = "申请人昵称", example = "张三") + private String creatorName; + + @Schema(description = "申请时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplySaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplySaveReqVO.java new file mode 100644 index 0000000000..e832790fce --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplySaveReqVO.java @@ -0,0 +1,59 @@ +package cn.iocoder.yudao.module.oa.controller.admin.leave.vo; + +import cn.iocoder.yudao.framework.dict.validation.InDict; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 请假申请新增/修改 Request VO") +@Data +public class OaLeaveApplySaveReqVO { + + @Schema(description = "申请编号", example = "1024") + private Long id; + + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "个人事务请假申请") + @NotBlank(message = "标题不能为空") + @Size(max = 255, message = "标题不能超过 255 个字符") + private String title; + + @Schema(description = "紧急程度", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "紧急程度不能为空") + @InDict(type = DictTypeConstants.APPLY_URGENCY, message = "紧急程度必须是 {value}") + private Integer urgency; + + @Schema(description = "请假类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "请假类型不能为空") + @InDict(type = DictTypeConstants.LEAVE_TYPE, message = "请假类型必须是 {value}") + private Integer type; + + @Schema(description = "开始时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + @NotNull(message = "开始时间不能为空") + private LocalDateTime startTime; + + @Schema(description = "结束时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789380000000") + @NotNull(message = "结束时间不能为空") + private LocalDateTime endTime; + + @Schema(description = "申请原因", requiredMode = Schema.RequiredMode.REQUIRED, example = "处理个人事务") + @NotBlank(message = "申请原因不能为空") + @Size(max = 5000, message = "申请原因不能超过 5000 个字符") + private String reason; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + @Size(max = 5, message = "附件不能超过 5 个") + private List<@NotBlank(message = "附件地址不能为空") @Size(max = 1024, message = "附件地址不能超过 1024 个字符") String> fileUrls; + + @AssertTrue(message = "结束时间不能早于开始时间") + @JsonIgnore + public boolean isTimeRangeValid() { + // 缺失值交由字段上的必填校验处理 + return startTime == null || endTime == null || !endTime.isBefore(startTime); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplySubmitReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplySubmitReqVO.java new file mode 100644 index 0000000000..04205a278a --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/leave/vo/OaLeaveApplySubmitReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.oa.controller.admin.leave.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Schema(description = "管理后台 - 请假申请提交 Request VO") +@Data +public class OaLeaveApplySubmitReqVO { + + @Schema(description = "申请编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "申请编号不能为空") + private Long id; + + @Schema(description = "发起人自选审批人") + private Map> startUserSelectAssignees; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/OaMailAccountController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/OaMailAccountController.java new file mode 100644 index 0000000000..e1baab86cf --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/OaMailAccountController.java @@ -0,0 +1,132 @@ +package cn.iocoder.yudao.module.oa.controller.admin.mail; + +import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.module.oa.controller.admin.mail.vo.account.*; +import cn.iocoder.yudao.module.oa.service.mail.OaMailAccountService; +import cn.iocoder.yudao.module.oa.dal.dataobject.mail.OaMailAccountDO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +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 java.util.Set; +import cn.hutool.core.util.StrUtil; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +@Tag(name = "管理后台 - 企业邮箱账号") +@RestController +@RequestMapping("/oa/mail-account") +@Validated +public class OaMailAccountController { + + @Resource + private OaMailAccountService mailAccountService; + + @Resource + private AdminUserApi adminUserApi; + + @GetMapping("/list") + @Operation(summary = "获得本人邮箱账号列表") + @Parameter(name = "status", description = "状态,未传时查询全部状态") + @PreAuthorize("@ss.hasPermission('oa:mail-account:query')") + public CommonResult> getMailAccountList(@RequestParam(value = "status", required = false) Integer status) { + List accounts = mailAccountService.getMailAccountListByUserIdAndStatus(getLoginUserId(), status); + return success(BeanUtils.toBean(accounts, OaMailAccountRespVO.class)); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得当前租户邮箱地址精简列表") + public CommonResult> getSimpleMailAccountList() { + // 1.1 查询邮箱及归属人,批量获取用户展示信息 + List accounts = mailAccountService.getMailAccountListByStatus(CommonStatusEnum.ENABLE.getStatus()); + Map users = adminUserApi.getUserMap( + convertSet(accounts, account -> NumberUtils.parseLong(account.getCreator()))); + // 1.2 全员选择器只返回邮箱和姓名,不暴露登录名及连接配置 + List result = convertList(accounts, account -> { + OaMailAccountRespVO respVO = new OaMailAccountRespVO().setMail(account.getMail()); + MapUtils.findAndThen(users, NumberUtils.parseLong(account.getCreator()), + user -> respVO.setUserName(user.getNickname())); + return respVO; + }); + + // 2. 补充已配置邮箱的启用用户 + Set mails = convertSet(result, account -> account.getMail().toLowerCase(java.util.Locale.ROOT)); + List systemUsers = adminUserApi.getUserListByStatus(CommonStatusEnum.ENABLE.getStatus()); + for (AdminUserRespDTO user : systemUsers) { + if (StrUtil.isNotBlank(user.getEmail()) && mails.add(user.getEmail().toLowerCase(java.util.Locale.ROOT))) { + result.add(new OaMailAccountRespVO().setMail(user.getEmail()).setUserName(user.getNickname())); + } + } + return success(result); + } + + @GetMapping("/get") + @Operation(summary = "获得本人邮箱账号") + @Parameter(name = "id", description = "邮箱账号编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:mail-account:query')") + public CommonResult getMailAccount(@RequestParam("id") Long id) { + OaMailAccountDO account = mailAccountService.validateMailAccount(id, getLoginUserId()); + return success(BeanUtils.toBean(account, OaMailAccountRespVO.class)); + } + + @PostMapping("/create") + @ApiAccessLog(requestEnable = false) // 特殊:password 密码,避免日志记录 + @Operation(summary = "绑定本人邮箱账号") + @PreAuthorize("@ss.hasPermission('oa:mail-account:create')") + public CommonResult createMailAccount(@Valid @RequestBody OaMailAccountSaveReqVO reqVO) { + return success(mailAccountService.createMailAccount(reqVO, getLoginUserId())); + } + + @PutMapping("/update") + @ApiAccessLog(requestEnable = false) // 特殊:password 密码,避免日志记录 + @Operation(summary = "更新本人邮箱账号") + @PreAuthorize("@ss.hasPermission('oa:mail-account:update')") + public CommonResult updateMailAccount(@Valid @RequestBody OaMailAccountSaveReqVO reqVO) { + mailAccountService.updateMailAccount(reqVO, getLoginUserId()); + return success(true); + } + + @PutMapping("/update-default") + @Operation(summary = "设置本人默认发件账号") + @Parameter(name = "id", description = "邮箱账号编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:mail-account:update')") + public CommonResult updateMailAccountDefault(@RequestParam("id") Long id) { + mailAccountService.updateMailAccountDefault(id, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "移除本人邮箱绑定") + @Parameter(name = "id", description = "邮箱账号编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:mail-account:delete')") + public CommonResult deleteMailAccount(@RequestParam("id") Long id) { + mailAccountService.deleteMailAccount(id, getLoginUserId()); + return success(true); + } + + @PostMapping("/test-connection") + @Operation(summary = "测试本人邮箱连接") + @Parameter(name = "id", description = "邮箱账号编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:mail-account:query')") + public CommonResult> testMailAccountConnection(@RequestParam("id") Long id) { + return success(mailAccountService.testMailAccountConnection(id, getLoginUserId())); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/OaMailFolderController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/OaMailFolderController.java new file mode 100644 index 0000000000..7138fce3fa --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/OaMailFolderController.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.oa.controller.admin.mail; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.oa.controller.admin.mail.vo.message.OaMailFolderRespVO; +import cn.iocoder.yudao.module.oa.service.mail.OaMailMessageService; +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 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; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 企业邮箱文件夹") +@RestController +@RequestMapping("/oa/mail-folder") +@Validated +public class OaMailFolderController { + + @Resource + private OaMailMessageService mailMessageService; + + @GetMapping("/list") + @Operation(summary = "获得邮箱文件夹列表") + @Parameter(name = "accountId", description = "邮箱账号编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:mail-account:query')") + public CommonResult> getMailFolderList( + @RequestParam Long accountId) { + return success(mailMessageService.getMailFolderList(accountId, getLoginUserId())); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/OaMailMessageController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/OaMailMessageController.java new file mode 100644 index 0000000000..5859a7d0a4 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/OaMailMessageController.java @@ -0,0 +1,131 @@ +package cn.iocoder.yudao.module.oa.controller.admin.mail; + +import cn.iocoder.yudao.framework.common.pojo.*; +import cn.iocoder.yudao.module.oa.controller.admin.mail.vo.message.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.mail.*; +import cn.iocoder.yudao.module.oa.service.mail.OaMailMessageService; +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 cn.hutool.core.lang.Pair; +import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; +import java.util.List; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.enums.mail.OaMailComposeModeEnum; +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; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 企业邮箱邮件") +@RestController +@RequestMapping("/oa/mail-message") +@Validated +@PreAuthorize("@ss.hasPermission('oa:mail-account:query')") +public class OaMailMessageController { + + @Resource + private OaMailMessageService mailMessageService; + + @GetMapping("/compose") + @Operation(summary = "获得写信预填数据") + @Parameter(name = "id", description = "邮件编号", required = true, example = "1024") + @Parameter(name = "mode", description = "写信方式", required = true) + public CommonResult getMailMessageCompose(@RequestParam Long id, + @RequestParam @InEnum(value = OaMailComposeModeEnum.class, message = "写信方式必须是 {value}") String mode) { + return success(mailMessageService.getMailMessageCompose(id, mode, getLoginUserId())); + } + + @PostMapping("/sync") + @Operation(summary = "同步本人邮箱索引") + @Parameter(name = "accountId", description = "邮箱账号编号", required = true, example = "1024") + public CommonResult syncMailMessageList(@RequestParam Long accountId) { + return success(mailMessageService.syncMailMessageList(accountId, getLoginUserId())); + } + + @GetMapping("/page") + @Operation(summary = "获得邮件索引分页") + public CommonResult> getMailMessagePage(@Valid OaMailMessagePageReqVO reqVO) { + PageResult pageResult = mailMessageService.getMailMessagePage(reqVO, getLoginUserId()); + return success(BeanUtils.toBean(pageResult, OaMailMessageRespVO.class)); + } + + @GetMapping("/get") + @Operation(summary = "获得邮件详情") + @Parameter(name = "id", description = "邮件编号", required = true, example = "1024") + public CommonResult getMailMessage(@RequestParam Long id) { + OaMailMessageDO message = mailMessageService.getMailMessage(id, getLoginUserId()); + return success(BeanUtils.toBean(message, OaMailMessageRespVO.class) + .setAttachments(BeanUtils.toBean(message.getAttachments(), OaMailMessageRespVO.Attachment.class))); + } + + @PutMapping("/update-read") + @Operation(summary = "更新邮件已读状态") + @Parameter(name = "id", description = "邮件编号", required = true, example = "1024") + @Parameter(name = "readStatus", description = "是否已读", required = true) + public CommonResult updateMailMessageRead(@RequestParam Long id, + @RequestParam Boolean readStatus) { + mailMessageService.updateMailMessageRead(id, readStatus, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除邮件") + @Parameter(name = "id", description = "邮件编号", required = true, example = "1024") + public CommonResult deleteMailMessage(@RequestParam Long id) { + mailMessageService.deleteMailMessage(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/restore") + @Operation(summary = "恢复邮件到收件箱") + @Parameter(name = "id", description = "邮件编号", required = true, example = "1024") + public CommonResult restoreMailMessage(@RequestParam Long id) { + mailMessageService.restoreMailMessage(id, getLoginUserId()); + return success(true); + } + + @PostMapping(value = "/save-draft", consumes = "application/json") + @Operation(summary = "保存草稿") + public CommonResult saveMailMessageDraft(@Valid @RequestBody OaMailMessageSaveReqVO reqVO) { + return success(mailMessageService.saveMailMessageDraft(reqVO, getLoginUserId())); + } + + @PostMapping(value = "/send", consumes = "application/json") + @Operation(summary = "发送邮件") + public CommonResult sendMailMessage(@Valid @RequestBody OaMailMessageSaveReqVO reqVO) { + return success(mailMessageService.sendMailMessage(reqVO, getLoginUserId())); + } + + @PostMapping(value = "/save-draft", consumes = "multipart/form-data") + @Operation(summary = "保存带附件的草稿") + public CommonResult saveMailMessageDraftWithFiles(@Valid @RequestPart("data") OaMailMessageSaveReqVO reqVO, + @RequestPart(value = "files", required = false) List files) { + return success(mailMessageService.saveMailMessageDraft(reqVO.setFiles(files), getLoginUserId())); + } + + @PostMapping(value = "/send", consumes = "multipart/form-data") + @Operation(summary = "发送带附件的邮件") + public CommonResult sendMailMessageWithFiles(@Valid @RequestPart("data") OaMailMessageSaveReqVO reqVO, + @RequestPart(value = "files", required = false) List files) { + return success(mailMessageService.sendMailMessage(reqVO.setFiles(files), getLoginUserId())); + } + + @GetMapping("/attachment") + @Operation(summary = "下载本人邮件附件") + public void getMailMessageAttachment(@RequestParam Long id, @RequestParam String part, + HttpServletResponse response) throws IOException { + Pair attachment = mailMessageService.getMailMessageAttachment(id, part, getLoginUserId()); + ServletUtils.writeAttachment(response, attachment.getKey(), attachment.getValue()); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/OaMailProviderController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/OaMailProviderController.java new file mode 100644 index 0000000000..385b486cff --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/OaMailProviderController.java @@ -0,0 +1,83 @@ +package cn.iocoder.yudao.module.oa.controller.admin.mail; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.mail.vo.provider.OaMailProviderRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.mail.vo.provider.OaMailProviderSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.mail.OaMailProviderDO; +import cn.iocoder.yudao.module.oa.service.mail.OaMailProviderService; +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 cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +@Tag(name = "管理后台 - 企业邮箱服务配置") +@RestController +@RequestMapping("/oa/mail-provider") +@Validated +public class OaMailProviderController { + + @Resource + private OaMailProviderService mailProviderService; + + @GetMapping("/list") + @Operation(summary = "获得邮箱服务配置列表") + @Parameter(name = "status", description = "状态,未传时查询全部状态") + @PreAuthorize("@ss.hasPermission('oa:mail-provider:query')") + public CommonResult> getMailProviderList(@RequestParam(value = "status", required = false) Integer status) { + List providers = mailProviderService.getMailProviderList(status); + return success(BeanUtils.toBean(providers, OaMailProviderRespVO.class)); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得邮箱服务配置精简列表") + public CommonResult> getSimpleMailProviderList() { + List providers = mailProviderService.getMailProviderList(CommonStatusEnum.ENABLE.getStatus()); + return success(convertList(providers, provider -> new OaMailProviderRespVO() + .setId(provider.getId()).setName(provider.getName()).setStatus(provider.getStatus()))); + } + + @GetMapping("/get") + @Operation(summary = "获得邮箱服务配置") + @Parameter(name = "id", description = "服务配置编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:mail-provider:query')") + public CommonResult getMailProvider(@RequestParam("id") Long id) { + OaMailProviderDO provider = mailProviderService.validateMailProviderExists(id); + return success(BeanUtils.toBean(provider, OaMailProviderRespVO.class)); + } + + @PostMapping("/create") + @Operation(summary = "创建邮箱服务配置") + @PreAuthorize("@ss.hasPermission('oa:mail-provider:create')") + public CommonResult createMailProvider(@Valid @RequestBody OaMailProviderSaveReqVO reqVO) { + return success(mailProviderService.createMailProvider(reqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新邮箱服务配置") + @PreAuthorize("@ss.hasPermission('oa:mail-provider:update')") + public CommonResult updateMailProvider(@Valid @RequestBody OaMailProviderSaveReqVO reqVO) { + mailProviderService.updateMailProvider(reqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除邮箱服务配置") + @Parameter(name = "id", description = "服务配置编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:mail-provider:delete')") + public CommonResult deleteMailProvider(@RequestParam("id") Long id) { + mailProviderService.deleteMailProvider(id); + return success(true); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/account/OaMailAccountRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/account/OaMailAccountRespVO.java new file mode 100644 index 0000000000..127212766d --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/account/OaMailAccountRespVO.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.oa.controller.admin.mail.vo.account; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "管理后台 - 企业邮箱账号 Response VO") +public class OaMailAccountRespVO { + + @Schema(description = "账号编号", example = "1024") + private Long id; + + @Schema(description = "服务配置编号", example = "1024") + private Long providerId; + + @Schema(description = "邮箱地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "user@example.com") + private String mail; + + @Schema(description = "归属人姓名", example = "张三") + private String userName; + + @Schema(description = "登录用户名", example = "user@example.com") + private String username; + + @Schema(description = "是否默认发件账号", example = "false") + private Boolean defaultStatus; + + @Schema(description = "状态", example = "0") + private Integer status; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/account/OaMailAccountSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/account/OaMailAccountSaveReqVO.java new file mode 100644 index 0000000000..70c093bc60 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/account/OaMailAccountSaveReqVO.java @@ -0,0 +1,58 @@ +package cn.iocoder.yudao.module.oa.controller.admin.mail.vo.account; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.ToString; + +@Data +@Schema(description = "管理后台 - 企业邮箱账号新增/修改 Request VO") +public class OaMailAccountSaveReqVO { + + @Schema(description = "账号编号", example = "1024") + private Long id; + + @Schema(description = "服务配置编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "服务配置编号不能为空") + private Long providerId; + + @Schema(description = "邮箱地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "user@example.com") + @NotBlank(message = "邮箱地址不能为空") + @Email(message = "邮箱地址格式不正确") + @Size(max = 255, message = "邮箱地址长度不能超过 {max} 个字符") + private String mail; + + @Schema(description = "登录用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "user@example.com") + @NotBlank(message = "登录用户名不能为空") + @Size(max = 255, message = "登录用户名长度不能超过 {max} 个字符") + private String username; + + @Schema(description = "密码或授权码,新增时必填,修改时留空表示不变", accessMode = Schema.AccessMode.WRITE_ONLY) + @ToString.Exclude + @Size(max = 4096, message = "密码或授权码长度不能超过 {max} 个字符") + private String password; + + @Schema(description = "是否默认发件账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + @NotNull(message = "是否默认发件账号不能为空") + private Boolean defaultStatus; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + @InEnum(value = CommonStatusEnum.class, message = "状态必须是 {value}") + private Integer status; + + /** + * 校验默认邮箱账号的启用状态 + * + * @return 是否符合默认账号规则 + */ + @AssertTrue(message = "默认邮箱账号必须启用") + @JsonIgnore + public boolean isDefaultStatusValid() { + return !Boolean.TRUE.equals(defaultStatus) || CommonStatusEnum.ENABLE.getStatus().equals(status); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/message/OaMailFolderRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/message/OaMailFolderRespVO.java new file mode 100644 index 0000000000..cafedd97ea --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/message/OaMailFolderRespVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.oa.controller.admin.mail.vo.message; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - 邮件目录 Response VO") +@Data +public class OaMailFolderRespVO { + + @Schema(description = "目录查询标识", example = "INBOX") + private String key; + + @Schema(description = "显示名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "收件箱") + private String name; + + @Schema(description = "未读数量", example = "1") + private Long unreadCount; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/message/OaMailMessagePageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/message/OaMailMessagePageReqVO.java new file mode 100644 index 0000000000..65e16b8fd2 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/message/OaMailMessagePageReqVO.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.oa.controller.admin.mail.vo.message; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.oa.enums.mail.OaMailFolderKeyEnum; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Schema(description = "管理后台 - 邮件分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaMailMessagePageReqVO extends PageParam { + + @Schema(description = "邮箱账号编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "邮箱账号编号不能为空") + private Long accountId; + + @Schema(description = "文件夹类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "INBOX") + @NotBlank(message = "文件夹标识不能为空") + private String folderKey; + + @Schema(description = "主题或发件人", example = "项目评审") + @Size(max = 200, message = "搜索关键字不能超过 200 个字符") + private String keyword; + + @Schema(description = "是否已读", example = "false") + private Boolean readStatus; + + @Schema(description = "是否有附件", example = "false") + private Boolean hasAttach; + + /** + * 校验文件夹入口 + * + * @return 是否为标准入口或合法的自定义目录编号 + */ + @AssertTrue(message = "文件夹标识不正确") + @JsonIgnore + public boolean isFolderKeyValid() { + if (StrUtil.isBlank(folderKey) || ArrayUtil.contains(OaMailFolderKeyEnum.ARRAYS, folderKey)) { + return true; // 空值由 NotBlank 校验 + } + return folderKey.matches("[1-9][0-9]{0,17}"); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/message/OaMailMessageRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/message/OaMailMessageRespVO.java new file mode 100644 index 0000000000..529896049f --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/message/OaMailMessageRespVO.java @@ -0,0 +1,76 @@ +package cn.iocoder.yudao.module.oa.controller.admin.mail.vo.message; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 企业邮箱邮件 Response VO") +@Data +public class OaMailMessageRespVO { + + @Schema(description = "邮件编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "邮箱账号编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long accountId; + + @Schema(description = "文件夹编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long folderId; + + @Schema(description = "远端邮件 UID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001") + private Long uid; + + @Schema(description = "远端 UID 有效期", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long uidValidity; + + @Schema(description = "邮件主题", requiredMode = Schema.RequiredMode.REQUIRED, example = "项目评审会议通知") + private String subject; + + @Schema(description = "发件人", example = "sender@example.com") + private String sender; + + @Schema(description = "收件人", example = "[\"recipient@example.com\"]") + private List recipients; + + @Schema(description = "抄送人", example = "[\"cc@example.com\"]") + private List ccs; + + @Schema(description = "回复地址", example = "[\"reply@example.com\"]") + private List replyTos; + + @Schema(description = "接收时间", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime receiveTime; + + @Schema(description = "是否已读", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean readStatus; + + @Schema(description = "是否有附件", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean hasAttach; + + @Schema(description = "邮件大小(字节)", example = "1024") + private Integer size; + + @Schema(description = "安全 HTML 正文", example = "

请查收本周工作安排。

") + private String content; + + @Schema(description = "附件信息") + private List attachments; + + @Schema(description = "管理后台 - 邮件附件 Response VO") + @Data + public static class Attachment { + + @Schema(description = "MIME 部件路径", example = "1.2") + private String part; + + @Schema(description = "附件名称", example = "办公用品盘点结果.pdf") + private String name; + + @Schema(description = "附件大小(字节)", example = "1024") + private Integer size; + + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/message/OaMailMessageSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/message/OaMailMessageSaveReqVO.java new file mode 100644 index 0000000000..feaab529ac --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/message/OaMailMessageSaveReqVO.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.oa.controller.admin.mail.vo.message; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; +import com.fasterxml.jackson.annotation.JsonIgnore; +import org.springframework.web.multipart.MultipartFile; +import cn.iocoder.yudao.module.oa.dal.dataobject.mail.OaMailMessageDO; +import java.util.List; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.mail.OaMailComposeModeEnum; + +@Schema(description = "管理后台 - 写信及草稿 Request VO") +@Data +public class OaMailMessageSaveReqVO { + + @Schema(description = "邮箱账号编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "邮箱账号编号不能为空") + private Long accountId; + + @Schema(description = "原草稿编号", example = "1024") + private Long draftId; + + @Schema(description = "收件人", example = "[\"recipient@example.com\"]") + private List<@NotBlank(message = "收件人地址不能为空") String> recipients; + + @Schema(description = "抄送人", example = "[\"cc@example.com\"]") + private List<@NotBlank(message = "抄送人地址不能为空") String> ccs; + + @Schema(description = "主题", example = "项目评审会议通知") + @Size(max = 65535, message = "邮件主题不能超过 65535 个字符") + private String subject; + + @Schema(description = "正文 HTML", example = "

请查收本周工作安排。

") + @Size(max = 500000, message = "正文 HTML长度不能超过 {max} 个字符") + private String content; + + @Schema(description = "回复或转发的原邮件编号", example = "1024") + private Long sourceId; + + @Schema(description = "写信方式", requiredMode = Schema.RequiredMode.REQUIRED, example = "new") + @InEnum(value = OaMailComposeModeEnum.class, message = "写信方式必须是 {value}") + @NotBlank(message = "写信方式不能为空") + private String mode; + + @Schema(description = "保留的原邮件附件路径;空列表表示移除全部原附件") + private List attachmentParts; + + @Schema(description = "原邮件附件,仅用于写信回显", accessMode = Schema.AccessMode.READ_ONLY) + private List attachments; + + @JsonIgnore + @Schema(hidden = true) + private List files; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/provider/OaMailProviderRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/provider/OaMailProviderRespVO.java new file mode 100644 index 0000000000..d37abe76c5 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/provider/OaMailProviderRespVO.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.oa.controller.admin.mail.vo.provider; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - 邮箱服务配置 Response VO") +@Data +public class OaMailProviderRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "企业邮箱") + private String name; + + @Schema(description = "IMAP 连接配置") + private OaMailProviderSaveReqVO.ConnectionConfig imap; + + @Schema(description = "SMTP 连接配置") + private OaMailProviderSaveReqVO.ConnectionConfig smtp; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/provider/OaMailProviderSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/provider/OaMailProviderSaveReqVO.java new file mode 100644 index 0000000000..1e1dcc6446 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/mail/vo/provider/OaMailProviderSaveReqVO.java @@ -0,0 +1,74 @@ +package cn.iocoder.yudao.module.oa.controller.admin.mail.vo.provider; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import lombok.Data; +import com.fasterxml.jackson.annotation.JsonIgnore; + +@Data +@Schema(description = "管理后台 - 企业邮箱服务配置新增/修改 Request VO") +public class OaMailProviderSaveReqVO { + + @Schema(description = "服务配置编号", example = "1024") + private Long id; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "企业邮箱") + @NotBlank(message = "名称不能为空") + @Size(max = 255, message = "名称长度不能超过 {max} 个字符") + private String name; + + @Schema(description = "IMAP 连接配置", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "IMAP 连接配置不能为空") + @Valid + private ConnectionConfig imap; + + @Schema(description = "SMTP 连接配置", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "SMTP 连接配置不能为空") + @Valid + private ConnectionConfig smtp; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + @InEnum(value = CommonStatusEnum.class, message = "状态必须是 {value}") + private Integer status; + + @Schema(description = "管理后台 - 邮箱连接配置 Request VO") + @Data + public static class ConnectionConfig { + + @Schema(description = "服务器域名", requiredMode = Schema.RequiredMode.REQUIRED, example = "imap.example.com") + @NotBlank(message = "服务器域名不能为空") + @Size(max = 253, message = "服务器域名长度不能超过 253 个字符") + private String host; + + @Schema(description = "服务器端口", requiredMode = Schema.RequiredMode.REQUIRED, example = "993") + @NotNull(message = "服务器端口不能为空") + @Min(value = 1, message = "服务器端口不能小于 1") + @Max(value = 65535, message = "服务器端口不能大于 65535") + private Integer port; + + @Schema(description = "是否开启 SSL", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + @NotNull(message = "是否开启 SSL 不能为空") + private Boolean sslEnable; + + @Schema(description = "是否开启 STARTTLS", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + @NotNull(message = "是否开启 STARTTLS 不能为空") + private Boolean starttlsEnable; + + /** + * 校验邮箱连接加密方式 + * + * @return 是否仅启用一种加密方式,空值由字段必填注解校验 + */ + @AssertTrue(message = "请选择 SSL 或 STARTTLS,且不能同时开启") + @JsonIgnore + public boolean isEncryptionValid() { + return sslEnable == null || !sslEnable.equals(starttlsEnable); + } + + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/OaMeetingRoomBookingController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/OaMeetingRoomBookingController.java new file mode 100644 index 0000000000..a74fabeefa --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/OaMeetingRoomBookingController.java @@ -0,0 +1,184 @@ +package cn.iocoder.yudao.module.oa.controller.admin.meetingroom; + +import cn.iocoder.yudao.module.oa.controller.admin.meetingroom.vo.booking.*; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.NumberUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.dal.dataobject.meetingroom.*; +import cn.iocoder.yudao.module.oa.service.meetingroom.*; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 会议室预定") +@RestController +@RequestMapping("/oa/meeting-room-booking") +@Validated +public class OaMeetingRoomBookingController { + + @Resource + private OaMeetingRoomBookingService meetingRoomBookingService; + @Resource + private OaMeetingRoomService meetingRoomService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + @PostMapping("/create") + @Operation(summary = "创建会议室预定草稿") + @PreAuthorize("@ss.hasPermission('oa:meeting-room-booking:create')") + public CommonResult createMeetingRoomBooking(@Valid @RequestBody OaMeetingRoomBookingSaveReqVO reqVO) { + return success(meetingRoomBookingService.createMeetingRoomBooking(reqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新会议室预定草稿") + @PreAuthorize("@ss.hasPermission('oa:meeting-room-booking:update')") + public CommonResult updateMeetingRoomBooking(@Valid @RequestBody OaMeetingRoomBookingSaveReqVO reqVO) { + meetingRoomBookingService.updateMeetingRoomBooking(reqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除会议室预定草稿") + @Parameter(name = "id", description = "预定编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:meeting-room-booking:delete')") + public CommonResult deleteMeetingRoomBooking(@RequestParam("id") Long id) { + meetingRoomBookingService.deleteMeetingRoomBooking(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得会议室预定详情") + @Parameter(name = "id", description = "预定编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:meeting-room-booking:query')") + public CommonResult getMeetingRoomBooking(@RequestParam("id") Long id) { + OaMeetingRoomBookingDO booking = meetingRoomBookingService.getMeetingRoomBooking(id); + return success(buildMeetingRoomBookingRespVO(booking)); + } + + @GetMapping("/page") + @Operation(summary = "获得本人的会议室预定分页") + @PreAuthorize("@ss.hasPermission('oa:meeting-room-booking:query')") + public CommonResult> getMeetingRoomBookingPage(@Valid OaMeetingRoomBookingPageReqVO reqVO) { + PageResult pageResult = meetingRoomBookingService.getMeetingRoomBookingPage(getLoginUserId(), reqVO); + return success(new PageResult<>(buildMeetingRoomBookingRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + @PutMapping("/submit") + @Operation(summary = "提交会议室预定") + @Parameter(name = "id", description = "预定编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:meeting-room-booking:create')") + public CommonResult submitMeetingRoomBooking(@RequestParam("id") Long id) { + meetingRoomBookingService.submitMeetingRoomBooking(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/cancel") + @Operation(summary = "取消会议室预定") + @Parameter(name = "id", description = "预定编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:meeting-room-booking:update')") + public CommonResult cancelMeetingRoomBooking(@RequestParam("id") Long id) { + meetingRoomBookingService.cancelMeetingRoomBooking(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/start") + @Operation(summary = "开始使用会议室预定") + @Parameter(name = "id", description = "预定编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:meeting-room-booking:update')") + public CommonResult startMeetingRoomBooking(@RequestParam("id") Long id) { + meetingRoomBookingService.startMeetingRoomBooking(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/finish") + @Operation(summary = "完成使用会议室预定") + @Parameter(name = "id", description = "预定编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:meeting-room-booking:update')") + public CommonResult finishMeetingRoomBooking(@RequestParam("id") Long id) { + meetingRoomBookingService.finishMeetingRoomBooking(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/schedule") + @Operation(summary = "获得会议室日程") + @PreAuthorize("@ss.hasAnyPermissions('oa:meeting-room:query', 'oa:meeting-room-booking:query')") + public CommonResult> getMeetingRoomBookingListBySchedule(@Valid OaMeetingRoomBookingScheduleReqVO reqVO) { + List bookings = meetingRoomBookingService.getMeetingRoomBookingListBySchedule(reqVO); + return success(buildMeetingRoomBookingRespVOList(bookings)); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接会议室预定详情 + * + * @param booking 会议室预定 + * @return 会议室预定响应 + */ + private OaMeetingRoomBookingRespVO buildMeetingRoomBookingRespVO(OaMeetingRoomBookingDO booking) { + if (booking == null) { + return null; + } + return CollUtil.getFirst(buildMeetingRoomBookingRespVOList(Collections.singletonList(booking))); + } + + /** + * 构建会议室预定响应列表 + * + * @param bookings 会议室预定列表 + * @return 响应列表 + */ + private List buildMeetingRoomBookingRespVOList(List bookings) { + if (CollUtil.isEmpty(bookings)) { + return Collections.emptyList(); + } + // 1.1 批量查询申请人、主持人和参会人 + Set userIds = convertSet(bookings, OaMeetingRoomBookingDO::getModeratorUserId); + userIds.addAll(convertSet(bookings, booking -> Long.valueOf(booking.getCreator()), + booking -> NumberUtil.isLong(booking.getCreator()))); + userIds.addAll(convertSetByFlatMap(bookings, OaMeetingRoomBookingDO::getAttendeeUserIds, Collection::stream)); + Map userMap = adminUserApi.getUserMap(userIds); + // 1.2 批量查询房间及申请部门 + Map roomMap = meetingRoomService.getMeetingRoomMap( + convertSet(bookings, OaMeetingRoomBookingDO::getRoomId)); + Map deptMap = deptApi.getDeptMap(convertSet(bookings, OaMeetingRoomBookingDO::getDeptId)); + // 2. 转换并拼接展示字段 + return convertList(bookings, booking -> { + OaMeetingRoomBookingRespVO vo = BeanUtils.toBean(booking, OaMeetingRoomBookingRespVO.class); + MapUtils.findAndThen(roomMap, booking.getRoomId(), room -> + vo.setRoomName(room.getName()).setRoomLocation(room.getLocation()).setRoomType(room.getType())); + MapUtils.findAndThen(userMap, booking.getModeratorUserId(), user -> vo.setModeratorName(user.getNickname())); + if (NumberUtil.isLong(booking.getCreator())) { + MapUtils.findAndThen(userMap, Long.valueOf(booking.getCreator()), user -> vo.setCreatorName(user.getNickname())); + } + MapUtils.findAndThen(deptMap, booking.getDeptId(), dept -> vo.setDeptName(dept.getName())); + vo.setAttendeeNames(convertList(booking.getAttendeeUserIds(), userId -> + userMap.containsKey(userId) ? userMap.get(userId).getNickname() : userId.toString())); + return vo; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/OaMeetingRoomController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/OaMeetingRoomController.java new file mode 100644 index 0000000000..9eab6c47e7 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/OaMeetingRoomController.java @@ -0,0 +1,130 @@ +package cn.iocoder.yudao.module.oa.controller.admin.meetingroom; + +import cn.iocoder.yudao.module.oa.controller.admin.meetingroom.vo.room.*; +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.dal.dataobject.meetingroom.*; +import cn.iocoder.yudao.module.oa.service.meetingroom.*; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 会议室") +@RestController +@RequestMapping("/oa/meeting-room") +@Validated +public class OaMeetingRoomController { + + @Resource + private OaMeetingRoomService meetingRoomService; + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/create") + @Operation(summary = "创建会议室") + @PreAuthorize("@ss.hasPermission('oa:meeting-room:create')") + public CommonResult createMeetingRoom(@Valid @RequestBody OaMeetingRoomSaveReqVO reqVO) { + return success(meetingRoomService.createMeetingRoom(reqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新会议室") + @PreAuthorize("@ss.hasPermission('oa:meeting-room:update')") + public CommonResult updateMeetingRoom(@Valid @RequestBody OaMeetingRoomSaveReqVO reqVO) { + meetingRoomService.updateMeetingRoom(reqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除会议室") + @Parameter(name = "id", description = "会议室编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:meeting-room:delete')") + public CommonResult deleteMeetingRoom(@RequestParam("id") Long id) { + meetingRoomService.deleteMeetingRoom(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得会议室详情") + @Parameter(name = "id", description = "会议室编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:meeting-room:query')") + public CommonResult getMeetingRoom(@RequestParam("id") Long id) { + OaMeetingRoomDO room = meetingRoomService.validateMeetingRoomExists(id); + return success(buildMeetingRoomRespVO(room)); + } + + @GetMapping("/page") + @Operation(summary = "获得会议室分页") + @PreAuthorize("@ss.hasPermission('oa:meeting-room:query')") + public CommonResult> getMeetingRoomPage(@Valid OaMeetingRoomPageReqVO reqVO) { + PageResult pageResult = meetingRoomService.getMeetingRoomPage(reqVO); + return success(new PageResult<>(buildMeetingRoomRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/bookable-page") + @Operation(summary = "获得可预定会议室分页") + public CommonResult> getMeetingRoomPageByBookingUserId(@Valid OaMeetingRoomPageReqVO reqVO) { + PageResult pageResult = meetingRoomService.getMeetingRoomPageByBookingUserId(reqVO, getLoginUserId()); + List list = buildMeetingRoomRespVOList(pageResult.getList()); + return success(new PageResult<>(convertList(list, room -> new OaMeetingRoomRespVO() + .setId(room.getId()).setName(room.getName()).setLocation(room.getLocation()) + .setType(room.getType()).setStatus(room.getStatus()).setSeatCount(room.getSeatCount()) + .setManagerName(room.getManagerName())), pageResult.getTotal())); + } + + // ==================== 拼接 VO ==================== + + /** + * 构建会议室响应 + * + * @param room 会议室 + * @return 会议室响应 + */ + private OaMeetingRoomRespVO buildMeetingRoomRespVO(OaMeetingRoomDO room) { + AdminUserRespDTO user = adminUserApi.getUser(room.getManagerUserId()); + OaMeetingRoomRespVO respVO = BeanUtils.toBean(room, OaMeetingRoomRespVO.class); + if (user != null) { + respVO.setManagerName(user.getNickname()).setManagerPhone(user.getMobile()); + } + return respVO; + } + + /** + * 构建会议室响应列表 + * + * @param rooms 会议室列表 + * @return 响应列表 + */ + private List buildMeetingRoomRespVOList(List rooms) { + if (CollUtil.isEmpty(rooms)) { + return Collections.emptyList(); + } + // 1. 批量查询管理员 + Map userMap = adminUserApi.getUserMap(convertSet(rooms, OaMeetingRoomDO::getManagerUserId)); + // 2. 转换并拼接管理员姓名、电话 + return convertList(rooms, room -> { + OaMeetingRoomRespVO vo = BeanUtils.toBean(room, OaMeetingRoomRespVO.class); + MapUtils.findAndThen(userMap, room.getManagerUserId(), user -> + vo.setManagerName(user.getNickname()).setManagerPhone(user.getMobile())); + return vo; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/booking/OaMeetingRoomBookingPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/booking/OaMeetingRoomBookingPageReqVO.java new file mode 100644 index 0000000000..71486d1047 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/booking/OaMeetingRoomBookingPageReqVO.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.oa.controller.admin.meetingroom.vo.booking; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 会议室预定分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaMeetingRoomBookingPageReqVO extends PageParam { + + @Schema(description = "单据编号", example = "HY20260914000001") + private String no; + + @Schema(description = "会议室名称", example = "101 项目讨论室") + private String roomName; + + @Schema(description = "会议主题", example = "产品需求评审") + private String title; + + @Schema(description = "主持人姓名", example = "芋艿") + private String moderatorName; + + @Schema(description = "申请部门编号", example = "100") + private Long deptId; + + @Schema(description = "审批状态", example = "2") + private Integer status; + + @Schema(description = "使用状态", example = "0") + private Integer useStatus; + + @Schema(description = "会议开始时间", example = "[\"2026-09-14 00:00:00\",\"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] startTime; + + @Schema(description = "会议结束时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] endTime; + + @Schema(description = "创建人编号", example = "1") + private String creator; + + @Schema(description = "创建时间", example = "[\"2026-09-14 00:00:00\",\"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/booking/OaMeetingRoomBookingRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/booking/OaMeetingRoomBookingRespVO.java new file mode 100644 index 0000000000..49e4cf33e1 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/booking/OaMeetingRoomBookingRespVO.java @@ -0,0 +1,88 @@ +package cn.iocoder.yudao.module.oa.controller.admin.meetingroom.vo.booking; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 会议室预定 Response VO") +@Data +public class OaMeetingRoomBookingRespVO { + + @Schema(description = "编号", example = "1024", requiredMode = Schema.RequiredMode.REQUIRED) + private Long id; + + @Schema(description = "会议室编号", example = "1024", requiredMode = Schema.RequiredMode.REQUIRED) + private Long roomId; + + @Schema(description = "会议主题", example = "产品需求评审", requiredMode = Schema.RequiredMode.REQUIRED) + private String title; + + @Schema(description = "会议开始时间", example = "1789351200000", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64") + private LocalDateTime startTime; + + @Schema(description = "会议结束时间", example = "1789354800000", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64") + private LocalDateTime endTime; + + @Schema(description = "主持人用户编号", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) + private Long moderatorUserId; + + @Schema(description = "参会人用户编号列表", example = "[1,2]") + private List attendeeUserIds; + + @Schema(description = "会议提醒", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer reminderType; + + @Schema(description = "会议说明", example = "讨论产品需求和排期") + private String description; + + @Schema(description = "申请备注", example = "请提前安排会议室") + private String remark; + + @Schema(description = "附件地址列表", example = "[\"https://www.iocoder.cn/meeting.pdf\"]") + private List fileUrls; + + @Schema(description = "申请单号", example = "HY20260914000001", requiredMode = Schema.RequiredMode.REQUIRED) + private String no; + + @Schema(description = "会议室名称", example = "101 项目讨论室") + private String roomName; + + @Schema(description = "位置", example = "办公楼一层 101") + private String roomLocation; + + @Schema(description = "会议室类型", example = "1") + private Integer roomType; + + @Schema(description = "主持人姓名", example = "芋艿") + private String moderatorName; + + @Schema(description = "参会人姓名", example = "[\"芋艿\",\"小王\"]") + private List attendeeNames; + + @Schema(description = "申请人", example = "芋艿") + private String creatorName; + + @Schema(description = "申请部门编号", example = "100") + private Long deptId; + + @Schema(description = "部门名称", example = "研发部门") + private String deptName; + + @Schema(description = "审批状态", example = "2", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer status; + + @Schema(description = "使用状态", example = "0", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer useStatus; + + @Schema(description = "提交时是否需要审批", example = "false") + private Boolean needApproval; + + @Schema(description = "流程实例编号", example = "process-1024") + private String processInstanceId; + + @Schema(description = "创建时间", example = "1789347600000", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64") + private LocalDateTime createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/booking/OaMeetingRoomBookingSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/booking/OaMeetingRoomBookingSaveReqVO.java new file mode 100644 index 0000000000..7a52a2bd3c --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/booking/OaMeetingRoomBookingSaveReqVO.java @@ -0,0 +1,61 @@ +package cn.iocoder.yudao.module.oa.controller.admin.meetingroom.vo.booking; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.meetingroom.*; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 会议室预定新增/修改 Request VO") +@Data +public class OaMeetingRoomBookingSaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "会议室编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "会议室不能为空") + private Long roomId; + + @Schema(description = "会议主题", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品需求评审") + @NotBlank(message = "会议主题不能为空") + @Size(max = 200, message = "会议主题长度不能超过 {max} 个字符") + private String title; + + @Schema(description = "会议开始时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "1789351200000", type = "integer", format = "int64") + @NotNull(message = "开始时间不能为空") + private LocalDateTime startTime; + + @Schema(description = "会议结束时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "1789354800000", type = "integer", format = "int64") + @NotNull(message = "结束时间不能为空") + private LocalDateTime endTime; + + @Schema(description = "主持人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "主持人不能为空") + private Long moderatorUserId; + + @Schema(description = "参会人用户编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1,2]") + @NotNull(message = "参会人不能为空") + private List<@NotNull(message = "参会人不能为空") Long> attendeeUserIds; + + @Schema(description = "会议提醒", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "会议提醒不能为空") + @InEnum(value = OaMeetingRoomReminderTypeEnum.class, message = "会议提醒不正确") + private Integer reminderType; + + @Schema(description = "会议说明", example = "讨论产品需求和排期") + @Size(max = 500, message = "会议说明长度不能超过 {max} 个字符") + private String description; + + @Schema(description = "申请备注", example = "请提前安排会议室") + @Size(max = 500, message = "申请备注长度不能超过 {max} 个字符") + private String remark; + + @Schema(description = "附件地址列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"https://www.iocoder.cn/meeting.pdf\"]") + @NotNull(message = "附件地址列表不能为空") + private List<@NotBlank(message = "附件地址列表不能为空") String> fileUrls; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/booking/OaMeetingRoomBookingScheduleReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/booking/OaMeetingRoomBookingScheduleReqVO.java new file mode 100644 index 0000000000..175e133fe1 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/booking/OaMeetingRoomBookingScheduleReqVO.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.oa.controller.admin.meetingroom.vo.booking; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 会议室占用时段 Request VO") +@Data +public class OaMeetingRoomBookingScheduleReqVO { + + @Schema(description = "会议室编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "会议室编号不能为空") + private Long roomId; + + @Schema(description = "查询开始时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-09-14 10:00:00") + @NotNull(message = "查询开始时间不能为空") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime startTime; + + @Schema(description = "查询结束时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-09-14 11:00:00") + @NotNull(message = "查询结束时间不能为空") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime endTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/room/OaMeetingRoomPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/room/OaMeetingRoomPageReqVO.java new file mode 100644 index 0000000000..75cc5a7bbf --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/room/OaMeetingRoomPageReqVO.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.oa.controller.admin.meetingroom.vo.room; + +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 OaMeetingRoomPageReqVO extends PageParam { + + @Schema(description = "会议室名称", example = "101 项目讨论室") + private String name; + + @Schema(description = "位置", example = "办公楼一层 101") + private String location; + + @Schema(description = "类型", example = "1") + private Integer type; + + @Schema(description = "可用状态", example = "0") + private Integer status; + + @Schema(description = "负责人姓名", example = "芋艿") + private String managerName; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/room/OaMeetingRoomRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/room/OaMeetingRoomRespVO.java new file mode 100644 index 0000000000..31fb9473f0 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/room/OaMeetingRoomRespVO.java @@ -0,0 +1,70 @@ +package cn.iocoder.yudao.module.oa.controller.admin.meetingroom.vo.room; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 会议室 Response VO") +@Data +public class OaMeetingRoomRespVO { + + @Schema(description = "编号", example = "1024", requiredMode = Schema.RequiredMode.REQUIRED) + private Long id; + + @Schema(description = "会议室名称", example = "101 项目讨论室", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @Schema(description = "位置", example = "办公楼一层 101", requiredMode = Schema.RequiredMode.REQUIRED) + private String location; + + @Schema(description = "会议室类型", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer type; + + @Schema(description = "负责人用户编号", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) + private Long managerUserId; + + @Schema(description = "可用状态", example = "0", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer status; + + @Schema(description = "会议室图片", example = "https://www.iocoder.cn/meeting-room.jpg") + private String picUrl; + + @Schema(description = "坐席数", example = "8") + private Integer seatCount; + + @Schema(description = "设备列表", example = "[1,2]") + private List equipments; + + @Schema(description = "允许预定", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean allowBooking; + + @Schema(description = "预定需审批", example = "false", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean needApproval; + + @Schema(description = "可预定范围", example = "0", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer bookingScope; + + @Schema(description = "可预定成员用户编号列表", example = "[1,2]") + private List bookingUserIds; + + @Schema(description = "显示顺序", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer sort; + + @Schema(description = "备注", example = "请提前安排会议室") + private String remark; + + @Schema(description = "附件地址列表", example = "[\"https://www.iocoder.cn/meeting.pdf\"]") + private List fileUrls; + + @Schema(description = "负责人姓名", example = "芋艿") + private String managerName; + + @Schema(description = "负责人联系方式", example = "13800138000") + private String managerPhone; + + @Schema(description = "创建时间", example = "1789347600000", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64") + private LocalDateTime createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/room/OaMeetingRoomSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/room/OaMeetingRoomSaveReqVO.java new file mode 100644 index 0000000000..67d31171d5 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/meetingroom/vo/room/OaMeetingRoomSaveReqVO.java @@ -0,0 +1,83 @@ +package cn.iocoder.yudao.module.oa.controller.admin.meetingroom.vo.room; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.meetingroom.*; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - 会议室新增/修改 Request VO") +@Data +public class OaMeetingRoomSaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "会议室名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "101 项目讨论室") + @NotBlank(message = "会议室名称不能为空") + @Size(max = 100, message = "会议室名称长度不能超过 {max} 个字符") + private String name; + + @Schema(description = "位置", requiredMode = Schema.RequiredMode.REQUIRED, example = "办公楼一层 101") + @NotBlank(message = "位置不能为空") + @Size(max = 255, message = "位置长度不能超过 {max} 个字符") + private String location; + + @Schema(description = "会议室类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "类型不能为空") + @InEnum(value = OaMeetingRoomTypeEnum.class, message = "会议室类型不正确") + private Integer type; + + @Schema(description = "负责人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "负责人不能为空") + private Long managerUserId; + + @Schema(description = "可用状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "可用状态不能为空") + @InEnum(value = OaMeetingRoomStatusEnum.class, message = "可用状态不正确") + private Integer status; + + @Schema(description = "会议室图片", example = "https://www.iocoder.cn/meeting-room.jpg") + @Size(max = 1024, message = "会议室图片长度不能超过 {max} 个字符") + private String picUrl; + + @Schema(description = "坐席数", example = "8") + @Min(value = 1, message = "坐席数不能小于 {value}") + private Integer seatCount; + + @Schema(description = "设备列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1,2]") + @NotNull(message = "设备列表不能为空") + private List<@NotNull(message = "设备列表不能为空") Integer> equipments; + + @Schema(description = "允许预定", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + @NotNull(message = "允许预定不能为空") + private Boolean allowBooking; + + @Schema(description = "预定需审批", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + @NotNull(message = "预定需审批不能为空") + private Boolean needApproval; + + @Schema(description = "可预定范围", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "可预定范围不能为空") + @InEnum(value = OaMeetingRoomBookingScopeEnum.class, message = "可预定范围不正确") + private Integer bookingScope; + + @Schema(description = "可预定成员用户编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1,2]") + @NotNull(message = "可预定成员不能为空") + private List<@NotNull(message = "可预定成员不能为空") Long> bookingUserIds; + + @Schema(description = "显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "显示顺序不能为空") + private Integer sort; + + @Schema(description = "备注", example = "请提前安排会议室") + @Size(max = 200, message = "备注长度不能超过 {max} 个字符") + private String remark; + + @Schema(description = "附件地址列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"https://www.iocoder.cn/meeting.pdf\"]") + @NotNull(message = "附件地址列表不能为空") + private List<@NotBlank(message = "附件地址列表不能为空") String> fileUrls; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/OaNoteCategoryController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/OaNoteCategoryController.java new file mode 100644 index 0000000000..ee59df1687 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/OaNoteCategoryController.java @@ -0,0 +1,90 @@ +package cn.iocoder.yudao.module.oa.controller.admin.note; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.note.vo.category.OaNoteCategoryRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.note.vo.category.OaNoteCategorySaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.note.OaNoteCategoryDO; +import cn.iocoder.yudao.module.oa.service.note.OaNoteCategoryService; +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.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - OA 笔记目录") +@RestController +@RequestMapping("/oa/note-category") +@Validated +public class OaNoteCategoryController { + + @Resource + private OaNoteCategoryService noteCategoryService; + + // ==================== 目录维护 ==================== + + @PostMapping("/create") + @Operation(summary = "创建笔记目录") + public CommonResult createNoteCategory(@Valid @RequestBody OaNoteCategorySaveReqVO createReqVO) { + return success(noteCategoryService.createNoteCategory(createReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新笔记目录") + public CommonResult updateNoteCategory(@Valid @RequestBody OaNoteCategorySaveReqVO updateReqVO) { + noteCategoryService.updateNoteCategory(updateReqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除笔记目录") + @Parameter(name = "id", description = "目录编号", required = true, example = "1024") + public CommonResult deleteNoteCategory(@RequestParam("id") Long id) { + noteCategoryService.deleteNoteCategory(id, getLoginUserId()); + return success(true); + } + + // ==================== 目录查询 ==================== + + @GetMapping("/get") + @Operation(summary = "获得笔记目录") + @Parameter(name = "id", description = "目录编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:note:query')") + public CommonResult getNoteCategory(@RequestParam("id") Long id) { + OaNoteCategoryDO category = noteCategoryService.getNoteCategory(id, getLoginUserId()); + return success(BeanUtils.toBean(category, OaNoteCategoryRespVO.class)); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得笔记目录精简列表") + public CommonResult> getSimpleNoteCategoryList() { + List categories = noteCategoryService.getNoteCategoryList(getLoginUserId()); + return success(convertList(categories, category -> new OaNoteCategoryRespVO() + .setId(category.getId()).setName(category.getName()))); + } + + @GetMapping("/list") + @Operation(summary = "获得笔记目录列表") + @PreAuthorize("@ss.hasPermission('oa:note:query')") + public CommonResult> getNoteCategoryList() { + List categories = noteCategoryService.getNoteCategoryList(getLoginUserId()); + return success(BeanUtils.toBean(categories, OaNoteCategoryRespVO.class)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/OaNoteController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/OaNoteController.java new file mode 100644 index 0000000000..f5cc264022 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/OaNoteController.java @@ -0,0 +1,188 @@ +package cn.iocoder.yudao.module.oa.controller.admin.note; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +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.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.module.oa.controller.admin.note.vo.OaNoteFavoriteUpdateReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.note.vo.OaNotePageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.note.vo.OaNoteRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.note.vo.OaNoteSaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.note.vo.OaNoteShareUpdateReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.note.OaNoteCategoryDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.note.OaNoteDO; +import cn.iocoder.yudao.module.oa.service.note.OaNoteCategoryService; +import cn.iocoder.yudao.module.oa.service.note.OaNoteService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.filterList; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - OA 笔记") +@RestController +@RequestMapping("/oa/note") +@Validated +public class OaNoteController { + + @Resource + private OaNoteService noteService; + @Resource + private OaNoteCategoryService noteCategoryService; + @Resource + private AdminUserApi adminUserApi; + + // ==================== 我的笔记 ==================== + + @PostMapping("/create") + @Operation(summary = "创建笔记") + @PreAuthorize("@ss.hasPermission('oa:note:create')") + public CommonResult createNote(@Valid @RequestBody OaNoteSaveReqVO createReqVO) { + return success(noteService.createNote(createReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新笔记") + @PreAuthorize("@ss.hasPermission('oa:note:update')") + public CommonResult updateNote(@Valid @RequestBody OaNoteSaveReqVO updateReqVO) { + noteService.updateNote(updateReqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除笔记") + @Parameter(name = "id", description = "笔记编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:note:delete')") + public CommonResult deleteNote(@RequestParam("id") Long id) { + noteService.deleteNote(id, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete-received") + @Operation(summary = "移除收到的共享笔记") + @Parameter(name = "id", description = "笔记编号", required = true, example = "1024") + public CommonResult deleteReceivedNote(@RequestParam("id") Long id) { + noteService.deleteReceivedNote(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/update-favorite") + @Operation(summary = "更新笔记收藏状态") + public CommonResult updateNoteFavorite(@Valid @RequestBody OaNoteFavoriteUpdateReqVO updateReqVO) { + noteService.updateNoteFavorite(updateReqVO.getId(), updateReqVO.getFavorite(), getLoginUserId()); + return success(true); + } + + @PutMapping("/update-share") + @Operation(summary = "更新笔记共享接收人") + @PreAuthorize("@ss.hasPermission('oa:note:update')") + public CommonResult updateNoteShare(@Valid @RequestBody OaNoteShareUpdateReqVO updateReqVO) { + noteService.updateNoteShare(updateReqVO.getId(), updateReqVO.getReceiverUserIds(), getLoginUserId()); + return success(true); + } + + // ==================== 公共查询(我的笔记、共享给我) ==================== + + @GetMapping("/get") + @Operation(summary = "获得笔记详情") + @Parameter(name = "id", description = "笔记编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:note:query')") + public CommonResult getNote(@RequestParam("id") Long id) { + OaNoteDO note = noteService.getNote(id, getLoginUserId()); + return success(buildNoteRespVO(note)); + } + + @GetMapping("/my-page") + @Operation(summary = "获得我的笔记分页") + @PreAuthorize("@ss.hasPermission('oa:note:query')") + public CommonResult> getMyNotePage(@Valid OaNotePageReqVO pageReqVO) { + PageResult pageResult = noteService.getMyNotePage(pageReqVO, getLoginUserId()); + return success(new PageResult<>(buildNoteRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/received-page") + @Operation(summary = "获得共享给我的笔记分页") + @PreAuthorize("@ss.hasPermission('oa:note:query')") + public CommonResult> getReceivedNotePage(@Valid OaNotePageReqVO pageReqVO) { + PageResult pageResult = noteService.getReceivedNotePage(pageReqVO, getLoginUserId()); + return success(new PageResult<>(buildNoteRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接笔记详情 + * + * @param note 笔记 + * @return 笔记响应 + */ + private OaNoteRespVO buildNoteRespVO(OaNoteDO note) { + if (note == null) { + return null; + } + return CollUtil.getFirst(buildNoteRespVOList(Collections.singletonList(note))); + } + + /** + * 拼接笔记响应列表 + * + * @param notes 笔记列表 + * @return 笔记响应列表 + */ + private List buildNoteRespVOList(List notes) { + if (CollUtil.isEmpty(notes)) { + return Collections.emptyList(); + } + // 1. 查询接收关系、用户及目录信息 + Map> receiverUserIdListMap = noteService.getNoteReceiverUserIdListMap( + convertSet(notes, OaNoteDO::getId)); + Set userIds = convertSet(notes, note -> NumberUtils.parseLong(note.getCreator())); + receiverUserIdListMap.values().forEach(userIds::addAll); + Map userMap = adminUserApi.getUserMap(userIds); + Map categoryMap = noteCategoryService.getNoteCategoryMap(convertSet(notes, OaNoteDO::getCategoryId)); + // 2. 转换笔记并拼接展示字段 + return convertList(notes, noteDO -> { + OaNoteRespVO note = BeanUtils.toBean(noteDO, OaNoteRespVO.class) + .setCreatorUserId(NumberUtils.parseLong(noteDO.getCreator())); + MapUtils.findAndThen(userMap, note.getCreatorUserId(), user -> note.setCreatorUserName(user.getNickname())); + MapUtils.findAndThen(categoryMap, note.getCategoryId(), category -> note.setCategoryName(category.getName())); + // 持有关系包含创建人,共享名单仅展示其他接收人 + List receiverUserIds = filterList(receiverUserIdListMap.getOrDefault(note.getId(), Collections.emptyList()), + userId -> ObjectUtil.notEqual(userId, note.getCreatorUserId())); + note.setReceiverUserIds(receiverUserIds); + note.setReceiverUserNames(convertList(receiverUserIds, receiverUserId -> { + AdminUserRespDTO receiverUser = userMap.get(receiverUserId); + return receiverUser != null ? receiverUser.getNickname() : null; + })); + return note; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/OaNoteFavoriteUpdateReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/OaNoteFavoriteUpdateReqVO.java new file mode 100644 index 0000000000..8fe6d3fbd5 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/OaNoteFavoriteUpdateReqVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.oa.controller.admin.note.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - OA 笔记收藏状态修改 Request VO") +@Data +public class OaNoteFavoriteUpdateReqVO { + + @Schema(description = "笔记编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "笔记编号不能为空") + private Long id; + + @Schema(description = "是否收藏", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + @NotNull(message = "收藏状态不能为空") + private Boolean favorite; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/OaNotePageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/OaNotePageReqVO.java new file mode 100644 index 0000000000..8813ff6038 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/OaNotePageReqVO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.oa.controller.admin.note.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.note.OaNoteTypeEnum; +import cn.iocoder.yudao.module.oa.enums.schedule.OaPriorityEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - OA 笔记分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaNotePageReqVO extends PageParam { + + @Schema(description = "标题", example = "会议纪要") + private String title; + + @Schema(description = "目录编号", example = "1024") + private Long categoryId; + + @Schema(description = "笔记类型", example = "1") + @InEnum(value = OaNoteTypeEnum.class, message = "笔记类型必须是 {value}") + private Integer type; + + @Schema(description = "优先级", example = "1") + @InEnum(value = OaPriorityEnum.class, message = "优先级必须是 {value}") + private Integer priority; + + @Schema(description = "是否收藏", example = "false") + private Boolean favorite; + + @Schema(description = "创建时间范围", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/OaNoteRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/OaNoteRespVO.java new file mode 100644 index 0000000000..303eff6978 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/OaNoteRespVO.java @@ -0,0 +1,55 @@ +package cn.iocoder.yudao.module.oa.controller.admin.note.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - OA 笔记 Response VO") +@Data +public class OaNoteRespVO { + + @Schema(description = "笔记编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "创建人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long creatorUserId; + + @Schema(description = "创建人用户昵称", example = "芋道源码") + private String creatorUserName; + + @Schema(description = "目录编号", example = "1024") + private Long categoryId; + + @Schema(description = "目录名称", example = "工作") + private String categoryName; + + @Schema(description = "笔记类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer type; + + @Schema(description = "优先级", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer priority; + + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "项目会议纪要") + private String title; + + @Schema(description = "内容", example = "记录本周会议结论与待办事项。") + private String content; + + @Schema(description = "是否收藏", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean favorite; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + private List fileUrls; + + @Schema(description = "接收人用户编号列表", example = "[1, 2]") + private List receiverUserIds; + + @Schema(description = "接收人用户昵称列表", example = "[\"张三\", \"李四\"]") + private List receiverUserNames; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/OaNoteSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/OaNoteSaveReqVO.java new file mode 100644 index 0000000000..8826418828 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/OaNoteSaveReqVO.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.oa.controller.admin.note.vo; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.note.OaNoteTypeEnum; +import cn.iocoder.yudao.module.oa.enums.schedule.OaPriorityEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - OA 笔记新增/修改 Request VO") +@Data +public class OaNoteSaveReqVO { + + @Schema(description = "笔记编号", example = "1024") + private Long id; + + @Schema(description = "目录编号", example = "1024") + private Long categoryId; + + @Schema(description = "笔记类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "笔记类型不能为空") + @InEnum(value = OaNoteTypeEnum.class, message = "笔记类型必须是 {value}") + private Integer type; + + @Schema(description = "优先级", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "优先级不能为空") + @InEnum(value = OaPriorityEnum.class, message = "优先级必须是 {value}") + @Max(value = 2, message = "笔记优先级只能为一般或重要") + private Integer priority; + + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "项目会议纪要") + @NotBlank(message = "标题不能为空") + @Size(max = 255, message = "标题长度不能超过 255 个字符") + private String title; + + @Schema(description = "内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "记录本周会议结论与待办事项。") + @NotBlank(message = "笔记内容不能为空") + private String content; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + private List<@NotBlank(message = "附件地址不能为空") @Size(max = 512, message = "附件地址长度不能超过 512 个字符") String> fileUrls; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/OaNoteShareUpdateReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/OaNoteShareUpdateReqVO.java new file mode 100644 index 0000000000..d7378e651d --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/OaNoteShareUpdateReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.oa.controller.admin.note.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - OA 笔记共享修改 Request VO") +@Data +public class OaNoteShareUpdateReqVO { + + @Schema(description = "笔记编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "笔记编号不能为空") + private Long id; + + @Schema(description = "接收人用户编号列表,空列表表示取消全部共享", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1, 2]") + @NotNull(message = "接收人列表不能为空") + private List<@NotNull(message = "接收人编号不能为空") Long> receiverUserIds; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/category/OaNoteCategoryRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/category/OaNoteCategoryRespVO.java new file mode 100644 index 0000000000..461750ff43 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/category/OaNoteCategoryRespVO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.oa.controller.admin.note.vo.category; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - OA 笔记目录 Response VO") +@Data +public class OaNoteCategoryRespVO { + + @Schema(description = "目录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "目录名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "工作") + private String name; + + @Schema(description = "显示顺序", example = "10") + private Integer sort; + + @Schema(description = "创建时间", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/category/OaNoteCategorySaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/category/OaNoteCategorySaveReqVO.java new file mode 100644 index 0000000000..6b1f5662cd --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/note/vo/category/OaNoteCategorySaveReqVO.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.oa.controller.admin.note.vo.category; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - OA 笔记目录新增/修改 Request VO") +@Data +public class OaNoteCategorySaveReqVO { + + @Schema(description = "目录编号", example = "1024") + private Long id; + + @Schema(description = "目录名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "工作") + @NotBlank(message = "目录名称不能为空") + @Size(max = 255, message = "目录名称长度不能超过 255 个字符") + private String name; + + @Schema(description = "显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + @NotNull(message = "显示顺序不能为空") + private Integer sort; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/OaOfficialDocReceiveController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/OaOfficialDocReceiveController.java new file mode 100644 index 0000000000..cbe210bfcd --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/OaOfficialDocReceiveController.java @@ -0,0 +1,177 @@ +package cn.iocoder.yudao.module.oa.controller.admin.officialdoc; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.receive.OaOfficialDocReceivePageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.receive.OaOfficialDocReceiveRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.receive.OaOfficialDocReceiveSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.officialdoc.OaOfficialDocReceiveDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.officialdoc.OaOfficialDocSendDO; +import cn.iocoder.yudao.module.oa.service.officialdoc.OaOfficialDocReceiveService; +import cn.iocoder.yudao.module.oa.service.officialdoc.OaOfficialDocSendService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSetByFlatMap; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 公文收文") +@RestController +@RequestMapping("/oa/officialdoc-receive") +@Validated +public class OaOfficialDocReceiveController { + + @Resource + private OaOfficialDocReceiveService officialDocReceiveService; + @Resource + private OaOfficialDocSendService officialDocSendService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + @PostMapping("/create") + @Operation(summary = "创建公文收文") + @PreAuthorize("@ss.hasPermission('oa:officialdoc-receive:create')") + public CommonResult createOfficialDocReceive(@Valid @RequestBody OaOfficialDocReceiveSaveReqVO reqVO) { + return success(officialDocReceiveService.createOfficialDocReceive(reqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新公文收文") + @PreAuthorize("@ss.hasPermission('oa:officialdoc-receive:update')") + public CommonResult updateOfficialDocReceive(@Valid @RequestBody OaOfficialDocReceiveSaveReqVO reqVO) { + officialDocReceiveService.updateOfficialDocReceive(reqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除公文收文") + @Parameter(name = "id", description = "收文编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:officialdoc-receive:delete')") + public CommonResult deleteOfficialDocReceive(@RequestParam("id") Long id) { + officialDocReceiveService.deleteOfficialDocReceive(id, getLoginUserId()); + return success(true); + } + + @PostMapping("/submit") + @Operation(summary = "提交公文收文审批") + @Parameter(name = "id", description = "收文编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:officialdoc-receive:update')") + public CommonResult submitOfficialDocReceive(@RequestParam("id") Long id) { + officialDocReceiveService.submitOfficialDocReceive(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/cancel") + @Operation(summary = "撤销公文收文审批") + @Parameter(name = "id", description = "收文编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:officialdoc-receive:update')") + public CommonResult cancelOfficialDocReceive(@RequestParam("id") Long id) { + officialDocReceiveService.cancelOfficialDocReceive(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/claim") + @Operation(summary = "签收公文收文") + @Parameter(name = "id", description = "收文编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:officialdoc-receive:update')") + public CommonResult claimOfficialDocReceive(@RequestParam("id") Long id) { + officialDocReceiveService.claimOfficialDocReceive(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得公文收文分页列表") + @PreAuthorize("@ss.hasPermission('oa:officialdoc-receive:query')") + public CommonResult> getOfficialDocReceivePage( + @Valid OaOfficialDocReceivePageReqVO pageReqVO) { + PageResult pageResult = officialDocReceiveService.getOfficialDocReceivePage(pageReqVO, getLoginUserId()); + return success(new PageResult<>(buildOfficialDocReceiveRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/get") + @Operation(summary = "获得公文收文详情") + @Parameter(name = "id", description = "收文编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:officialdoc-receive:query')") + public CommonResult getOfficialDocReceive(@RequestParam("id") Long id) { + OaOfficialDocReceiveDO receive = officialDocReceiveService.getOfficialDocReceive(id); + return success(buildOfficialDocReceiveRespVO(receive)); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接收文详情及关联发文信息 + * + * @param receive 收文 + * @return 收文详情 + */ + private OaOfficialDocReceiveRespVO buildOfficialDocReceiveRespVO(OaOfficialDocReceiveDO receive) { + if (receive == null) { + return null; + } + return CollUtil.getFirst(buildOfficialDocReceiveRespVOList(Collections.singletonList(receive))); + } + + /** + * 拼接公文收文的用户、部门名称及关联发文信息 + * + * @param receives 公文收文列表 + * @return 公文收文响应列表 + */ + private List buildOfficialDocReceiveRespVOList(List receives) { + if (CollUtil.isEmpty(receives)) { + return Collections.emptyList(); + } + // 1.1 批量查询关联发文,手工收文无需查询 + Map sendMap = officialDocSendService.getOfficialDocSendMap( + convertSet(receives, OaOfficialDocReceiveDO::getSendId)); + // 1.2 批量查询主办人及签发人 + Map userMap = adminUserApi.getUserMap(convertSetByFlatMap(receives, receive -> { + OaOfficialDocSendDO send = sendMap.get(receive.getSendId()); + return Stream.of(receive.getHandlerUserId(), send != null ? send.getSignerUserId() : null); + })); + // 1.3 批量查询收发文部门 + Map deptMap = deptApi.getDeptMap(convertSetByFlatMap(receives, receive -> { + OaOfficialDocSendDO send = sendMap.get(receive.getSendId()); + return Stream.of(receive.getReceiveDeptId(), send != null ? send.getSendDeptId() : null); + })); + // TODO DONE @AI:移除重复解释实现方式的注释,OA 同类说明同步精简。 + // 2. 转换并补充展示字段 + return convertList(receives, receive -> { + OaOfficialDocReceiveRespVO respVO = BeanUtils.toBean(receive, OaOfficialDocReceiveRespVO.class); + MapUtils.findAndThen(userMap, receive.getHandlerUserId(), user -> respVO.setHandlerName(user.getNickname())); + MapUtils.findAndThen(deptMap, receive.getReceiveDeptId(), dept -> respVO.setReceiveDeptName(dept.getName())); + MapUtils.findAndThen(sendMap, receive.getSendId(), send -> { + MapUtils.findAndThen(deptMap, send.getSendDeptId(), dept -> respVO.setSendDeptName(dept.getName())); + MapUtils.findAndThen(userMap, send.getSignerUserId(), user -> respVO.setSignerName(user.getNickname())); + respVO.setIssueTime(send.getIssueTime()).setDisclosureType(send.getDisclosureType()); + }); + return respVO; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/OaOfficialDocSendController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/OaOfficialDocSendController.java new file mode 100644 index 0000000000..aaebe2d07f --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/OaOfficialDocSendController.java @@ -0,0 +1,166 @@ +package cn.iocoder.yudao.module.oa.controller.admin.officialdoc; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.send.OaOfficialDocSendPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.send.OaOfficialDocSendRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.send.OaOfficialDocSendSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.officialdoc.OaOfficialDocSendDO; +import cn.iocoder.yudao.module.oa.service.officialdoc.OaOfficialDocSendService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 公文发文") +@RestController +@RequestMapping("/oa/officialdoc-send") +@Validated +public class OaOfficialDocSendController { + + @Resource + private OaOfficialDocSendService officialDocSendService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + @PostMapping("/create") + @Operation(summary = "创建公文发文") + @PreAuthorize("@ss.hasPermission('oa:officialdoc-send:create')") + public CommonResult createOfficialDocSend(@Valid @RequestBody OaOfficialDocSendSaveReqVO reqVO) { + return success(officialDocSendService.createOfficialDocSend(reqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新公文发文") + @PreAuthorize("@ss.hasPermission('oa:officialdoc-send:update')") + public CommonResult updateOfficialDocSend(@Valid @RequestBody OaOfficialDocSendSaveReqVO reqVO) { + officialDocSendService.updateOfficialDocSend(reqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除公文发文") + @Parameter(name = "id", description = "发文编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:officialdoc-send:delete')") + public CommonResult deleteOfficialDocSend(@RequestParam("id") Long id) { + officialDocSendService.deleteOfficialDocSend(id, getLoginUserId()); + return success(true); + } + + @PostMapping("/submit") + @Operation(summary = "提交公文发文审批") + @Parameter(name = "id", description = "发文编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:officialdoc-send:update')") + public CommonResult submitOfficialDocSend(@RequestParam("id") Long id) { + officialDocSendService.submitOfficialDocSend(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/cancel") + @Operation(summary = "撤销公文发文审批") + @Parameter(name = "id", description = "发文编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:officialdoc-send:update')") + public CommonResult cancelOfficialDocSend(@RequestParam("id") Long id) { + officialDocSendService.cancelOfficialDocSend(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得公文发文分页列表") + @PreAuthorize("@ss.hasPermission('oa:officialdoc-send:query')") + public CommonResult> getOfficialDocSendPage( + @Valid OaOfficialDocSendPageReqVO pageReqVO) { + PageResult pageResult = officialDocSendService.getOfficialDocSendPage(pageReqVO, getLoginUserId()); + return success(new PageResult<>(buildOfficialDocSendRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/get") + @Operation(summary = "获得公文发文详情") + @Parameter(name = "id", description = "发文编号", required = true, example = "1024") + @PreAuthorize("@ss.hasAnyPermissions('oa:officialdoc-send:query', 'oa:officialdoc-receive:query')") + public CommonResult getOfficialDocSend(@RequestParam("id") Long id) { + OaOfficialDocSendDO send = officialDocSendService.getOfficialDocSend(id); + return success(buildOfficialDocSendRespVO(send)); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接公文发文详情 + * + * @param send 公文发文 + * @return 公文发文响应 + */ + private OaOfficialDocSendRespVO buildOfficialDocSendRespVO(OaOfficialDocSendDO send) { + if (send == null) { + return null; + } + return CollUtil.getFirst(buildOfficialDocSendRespVOList(Collections.singletonList(send))); + } + + /** + * 拼接公文发文的用户、部门名称 + * + * @param sends 公文发文列表 + * @return 公文发文响应列表 + */ + private List buildOfficialDocSendRespVOList(List sends) { + if (CollUtil.isEmpty(sends)) { + return Collections.emptyList(); + } + // 1.1 收集发文、主送和抄送部门编号 + Set deptIds = convertSet(sends, OaOfficialDocSendDO::getSendDeptId); + sends.forEach(send -> { + if (CollUtil.isNotEmpty(send.getMainDeptIds())) { + deptIds.addAll(send.getMainDeptIds()); + } + if (CollUtil.isNotEmpty(send.getCopyDeptIds())) { + deptIds.addAll(send.getCopyDeptIds()); + } + }); + // 1.2 批量查询签发人和部门 + Map userMap = adminUserApi.getUserMap(convertSet(sends, OaOfficialDocSendDO::getSignerUserId)); + Map deptMap = deptApi.getDeptMap(deptIds); + + // 2. 转换并补充展示字段 + return convertList(sends, send -> { + OaOfficialDocSendRespVO respVO = BeanUtils.toBean(send, OaOfficialDocSendRespVO.class); + MapUtils.findAndThen(userMap, send.getSignerUserId(), user -> respVO.setSignerName(user.getNickname())); + MapUtils.findAndThen(deptMap, send.getSendDeptId(), dept -> respVO.setSendDeptName(dept.getName())); + respVO.setMainDeptNames(convertList(send.getMainDeptIds(), id -> { + DeptRespDTO dept = deptMap.get(id); + return dept != null ? dept.getName() : null; + })); + respVO.setCopyDeptNames(convertList(send.getCopyDeptIds(), id -> { + DeptRespDTO dept = deptMap.get(id); + return dept != null ? dept.getName() : null; + })); + return respVO; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/OaOfficialDocTemplateController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/OaOfficialDocTemplateController.java new file mode 100644 index 0000000000..223b5abca0 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/OaOfficialDocTemplateController.java @@ -0,0 +1,85 @@ +package cn.iocoder.yudao.module.oa.controller.admin.officialdoc; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +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.oa.controller.admin.officialdoc.vo.template.OaOfficialDocTemplatePageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.template.OaOfficialDocTemplateRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.template.OaOfficialDocTemplateSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.officialdoc.OaOfficialDocTemplateDO; +import cn.iocoder.yudao.module.oa.service.officialdoc.OaOfficialDocTemplateService; +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; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +@Tag(name = "管理后台 - 套红模板") +@RestController +@RequestMapping("/oa/officialdoc-template") +@Validated +public class OaOfficialDocTemplateController { + + @Resource + private OaOfficialDocTemplateService officialDocTemplateService; + + @PostMapping("/create") + @Operation(summary = "创建套红模板") + @PreAuthorize("@ss.hasPermission('oa:officialdoc-template:create')") + public CommonResult createOfficialDocTemplate(@Valid @RequestBody OaOfficialDocTemplateSaveReqVO reqVO) { + return success(officialDocTemplateService.createOfficialDocTemplate(reqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新套红模板") + @PreAuthorize("@ss.hasPermission('oa:officialdoc-template:update')") + public CommonResult updateOfficialDocTemplate(@Valid @RequestBody OaOfficialDocTemplateSaveReqVO reqVO) { + officialDocTemplateService.updateOfficialDocTemplate(reqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除套红模板") + @Parameter(name = "id", description = "模板编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:officialdoc-template:delete')") + public CommonResult deleteOfficialDocTemplate(@RequestParam("id") Long id) { + officialDocTemplateService.deleteOfficialDocTemplate(id); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得套红模板分页列表") + @PreAuthorize("@ss.hasPermission('oa:officialdoc-template:query')") + public CommonResult> getOfficialDocTemplatePage( + @Valid OaOfficialDocTemplatePageReqVO pageReqVO) { + PageResult pageResult = officialDocTemplateService.getOfficialDocTemplatePage(pageReqVO); + return success(BeanUtils.toBean(pageResult, OaOfficialDocTemplateRespVO.class)); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得套红模板精简列表") + public CommonResult> getSimpleOfficialDocTemplateList() { + List templates = officialDocTemplateService.getOfficialDocTemplateList(CommonStatusEnum.ENABLE.getStatus()); + return success(convertList(templates, template -> new OaOfficialDocTemplateRespVO() + .setId(template.getId()).setName(template.getName()))); + } + + @GetMapping("/get") + @Operation(summary = "获得套红模板详情") + @Parameter(name = "id", description = "模板编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:officialdoc-template:query')") + public CommonResult getOfficialDocTemplate(@RequestParam("id") Long id) { + OaOfficialDocTemplateDO template = officialDocTemplateService.getOfficialDocTemplate(id); + return success(BeanUtils.toBean(template, OaOfficialDocTemplateRespVO.class)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/receive/OaOfficialDocReceivePageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/receive/OaOfficialDocReceivePageReqVO.java new file mode 100644 index 0000000000..9ad66516dd --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/receive/OaOfficialDocReceivePageReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.receive; + +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 OaOfficialDocReceivePageReqVO extends PageParam { + + @Schema(description = "公文标题", example = "关于开展办公用品盘点的通知") + private String title; + + @Schema(description = "来文字号", example = "芋办〔2026〕1号") + private String documentNo; + + @Schema(description = "流程状态", example = "-1") + private Integer status; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/receive/OaOfficialDocReceiveRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/receive/OaOfficialDocReceiveRespVO.java new file mode 100644 index 0000000000..84b94d52b5 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/receive/OaOfficialDocReceiveRespVO.java @@ -0,0 +1,99 @@ +package cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.receive; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 公文收文 Response VO") +@Data +public class OaOfficialDocReceiveRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "来源发文编号", example = "1024") + private Long sendId; + + @Schema(description = "发文部门", example = "行政部") + private String sendDeptName; + + @Schema(description = "发文日期", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime issueTime; + + @Schema(description = "签发人", example = "张三") + private String signerName; + + @Schema(description = "公开类别", example = "0") + private Integer disclosureType; + + @Schema(description = "收文类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer receiveType; + + @Schema(description = "收文时间", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime receiveTime; + + @Schema(description = "收文部门编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long receiveDeptId; + + @Schema(description = "主办人用户编号", example = "1024") + private Long handlerUserId; + + @Schema(description = "领导批示", example = "请行政部牵头办理") + private String instruction; + + @Schema(description = "办理结果", example = "已完成盘点并归档") + private String result; + + @Schema(description = "办理期限", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime deadlineTime; + + @Schema(description = "内容摘要", example = "本周已完成办公用品盘点") + private String summary; + + @Schema(description = "备注", example = "请提前联系行政部确认") + private String remark; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + private List fileUrls; + + @Schema(description = "单据编号", example = "CC202609140001") + private String no; + + @Schema(description = "公文标题", example = "关于开展办公用品盘点的通知") + private String title; + + @Schema(description = "来文字号", example = "芋办〔2026〕1号") + private String documentNo; + + @Schema(description = "密级", example = "0") + private Integer secrecyLevel; + + @Schema(description = "紧急程度", example = "0") + private Integer urgencyLevel; + + @Schema(description = "正式公文地址", example = "https://example.com/file.pdf") + private String formalFileUrl; + + @Schema(description = "收文部门", example = "行政部") + private String receiveDeptName; + + @Schema(description = "主办人", example = "张三") + private String handlerName; + + @Schema(description = "审批状态", example = "-1") + private Integer status; + + @Schema(description = "办理状态", example = "0") + private Integer handleStatus; + + @Schema(description = "流程实例编号", example = "1024") + private String processInstanceId; + + @Schema(description = "创建人编号", example = "1024") + private String creator; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/receive/OaOfficialDocReceiveSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/receive/OaOfficialDocReceiveSaveReqVO.java new file mode 100644 index 0000000000..33ca2f824c --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/receive/OaOfficialDocReceiveSaveReqVO.java @@ -0,0 +1,80 @@ +package cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.receive; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import java.time.LocalDateTime; +import java.util.List; +import jakarta.validation.constraints.*; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.framework.dict.validation.InDict; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import cn.iocoder.yudao.module.oa.enums.officialdoc.OaOfficialDocReceiveTypeEnum; + +@Schema(description = "管理后台 - 公文收文新增/修改 Request VO") +@Data +public class OaOfficialDocReceiveSaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "公文标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "关于开展办公用品盘点的通知") + @NotBlank(message = "公文标题不能为空") + @Size(max = 255, message = "公文标题长度不能超过 {max} 个字符") + private String title; + + @Schema(description = "来文字号", example = "芋办〔2026〕1号") + @Size(max = 64, message = "来文字号长度不能超过 {max} 个字符") + private String documentNo; + + @Schema(description = "密级", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "密级不能为空") + @InDict(type = DictTypeConstants.OFFICIAL_DOC_SECRET_LEVEL, message = "密级必须是 {value}") + private Integer secrecyLevel; + + @Schema(description = "紧急程度", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "紧急程度不能为空") + @InDict(type = DictTypeConstants.OFFICIAL_DOC_URGENCY_LEVEL, message = "紧急程度必须是 {value}") + private Integer urgencyLevel; + + @Schema(description = "收文类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "收文类型不能为空") + @InEnum(value = OaOfficialDocReceiveTypeEnum.class, message = "收文类型必须是 {value}") + private Integer receiveType; + + @Schema(description = "收文时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + @NotNull(message = "收文时间不能为空") + private LocalDateTime receiveTime; + + @Schema(description = "收文部门编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "收文部门不能为空") + private Long receiveDeptId; + + @Schema(description = "主办人用户编号", example = "1024") + private Long handlerUserId; + + @Schema(description = "领导批示", example = "请行政部牵头办理") + @Size(max = 2000, message = "领导批示长度不能超过 {max} 个字符") + private String instruction; + + @Schema(description = "办理结果", example = "已完成盘点并归档") + @Size(max = 2000, message = "办理结果长度不能超过 {max} 个字符") + private String result; + + @Schema(description = "办理期限", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime deadlineTime; + + @Schema(description = "内容摘要", example = "本周已完成办公用品盘点") + private String summary; + + @Schema(description = "备注", example = "请提前联系行政部确认") + @Size(max = 500, message = "备注长度不能超过 {max} 个字符") + private String remark; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + private List<@NotBlank(message = "附件地址不能为空") String> fileUrls; + + @Schema(description = "正式公文地址", example = "https://example.com/file.pdf") + @Size(max = 2048, message = "正式公文地址长度不能超过 {max} 个字符") + private String formalFileUrl; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/send/OaOfficialDocSendPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/send/OaOfficialDocSendPageReqVO.java new file mode 100644 index 0000000000..fe15a9bd19 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/send/OaOfficialDocSendPageReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.send; + +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 OaOfficialDocSendPageReqVO extends PageParam { + + @Schema(description = "公文标题", example = "关于开展办公用品盘点的通知") + private String title; + + @Schema(description = "发文字号", example = "芋办〔2026〕1号") + private String documentNo; + + @Schema(description = "流程状态", example = "-1") + private Integer status; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/send/OaOfficialDocSendRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/send/OaOfficialDocSendRespVO.java new file mode 100644 index 0000000000..b178093a87 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/send/OaOfficialDocSendRespVO.java @@ -0,0 +1,96 @@ +package cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.send; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 公文发文 Response VO") +@Data +public class OaOfficialDocSendRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "套红模板编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long templateId; + + @Schema(description = "公文标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "关于开展办公用品盘点的通知") + private String title; + + @Schema(description = "字号", example = "芋办") + private String noPrefix; + + @Schema(description = "年份", example = "2026") + private Integer year; + + @Schema(description = "第几号文", example = "1") + private Integer sequence; + + @Schema(description = "密级", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer secrecyLevel; + + @Schema(description = "紧急程度", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer urgencyLevel; + + @Schema(description = "公开类别", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer disclosureType; + + @Schema(description = "发文日期", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime issueTime; + + @Schema(description = "发文部门编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long sendDeptId; + + @Schema(description = "主送部门编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1, 2]") + private List mainDeptIds; + + @Schema(description = "抄送部门编号列表", example = "[1, 2]") + private List copyDeptIds; + + @Schema(description = "公文正文", example = "请各部门于本周五前完成办公用品盘点并提交结果。") + private String content; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + private List fileUrls; + + @Schema(description = "正式公文地址", example = "https://example.com/file.pdf") + private String formalFileUrl; + + @Schema(description = "附注", example = "请按要求办理并反馈结果") + private String remark; + + @Schema(description = "单据编号", example = "CC202609140001") + private String no; + + @Schema(description = "公文文号", example = "芋办〔2026〕1号") + private String documentNo; + + @Schema(description = "签发人编号", example = "1024") + private Long signerUserId; + + @Schema(description = "签发人", example = "张三") + private String signerName; + + @Schema(description = "发文部门", example = "行政部") + private String sendDeptName; + + @Schema(description = "主送部门", example = "[\"行政部\"]") + private List mainDeptNames; + + @Schema(description = "抄送部门", example = "[\"财务部\"]") + private List copyDeptNames; + + @Schema(description = "审批状态", example = "-1") + private Integer status; + + @Schema(description = "流程实例编号", example = "1024") + private String processInstanceId; + + @Schema(description = "创建人编号", example = "1024") + private String creator; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/send/OaOfficialDocSendSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/send/OaOfficialDocSendSaveReqVO.java new file mode 100644 index 0000000000..774be701f1 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/send/OaOfficialDocSendSaveReqVO.java @@ -0,0 +1,84 @@ +package cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.send; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import java.time.LocalDateTime; +import java.util.List; +import jakarta.validation.constraints.*; +import cn.iocoder.yudao.framework.dict.validation.InDict; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; + +@Schema(description = "管理后台 - 公文发文新增/修改 Request VO") +@Data +public class OaOfficialDocSendSaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "套红模板编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "套红模板不能为空") + private Long templateId; + + @Schema(description = "公文标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "关于开展办公用品盘点的通知") + @NotBlank(message = "标题不能为空") + @Size(max = 255, message = "公文标题长度不能超过 {max} 个字符") + private String title; + + @Schema(description = "字号", example = "芋办") + @Size(max = 64, message = "字号长度不能超过 {max} 个字符") + private String noPrefix; + + @Schema(description = "年份", example = "2026") + @Min(value = 1, message = "年份不能小于 {value}") + @Max(value = 9999, message = "年份不能大于 {value}") + private Integer year; + + @Schema(description = "第几号文", example = "1") + @Min(value = 1, message = "第几号文不能小于 {value}") + private Integer sequence; + + @Schema(description = "密级", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "密级不能为空") + @InDict(type = DictTypeConstants.OFFICIAL_DOC_SECRET_LEVEL, message = "密级必须是 {value}") + private Integer secrecyLevel; + + @Schema(description = "紧急程度", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "紧急程度不能为空") + @InDict(type = DictTypeConstants.OFFICIAL_DOC_URGENCY_LEVEL, message = "紧急程度必须是 {value}") + private Integer urgencyLevel; + + @Schema(description = "公开类别", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "公开类别不能为空") + @InDict(type = DictTypeConstants.OFFICIAL_DOC_PUBLIC_CATEGORY, message = "公开类别必须是 {value}") + private Integer disclosureType; + + @Schema(description = "发文日期", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + @NotNull(message = "发文日期不能为空") + private LocalDateTime issueTime; + + @Schema(description = "发文部门编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "发文部门不能为空") + private Long sendDeptId; + + @Schema(description = "主送部门编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1, 2]") + @NotEmpty(message = "主送部门不能为空") + private List<@NotNull(message = "主送部门编号不能为空") Long> mainDeptIds; + + @Schema(description = "抄送部门编号列表", example = "[1, 2]") + private List<@NotNull(message = "抄送部门编号不能为空") Long> copyDeptIds; + + @Schema(description = "公文正文", example = "请各部门于本周五前完成办公用品盘点并提交结果。") + private String content; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + private List<@NotBlank(message = "附件地址不能为空") String> fileUrls; + + @Schema(description = "正式公文地址", example = "https://example.com/file.pdf") + @Size(max = 2048, message = "正式公文地址长度不能超过 {max} 个字符") + private String formalFileUrl; + + @Schema(description = "附注", example = "请按要求办理并反馈结果") + @Size(max = 500, message = "附注长度不能超过 {max} 个字符") + private String remark; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/template/OaOfficialDocTemplatePageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/template/OaOfficialDocTemplatePageReqVO.java new file mode 100644 index 0000000000..2883fb3667 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/template/OaOfficialDocTemplatePageReqVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.template; + +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 OaOfficialDocTemplatePageReqVO extends PageParam { + + @Schema(description = "模板名称", example = "公司行政发文模板") + private String name; + + @Schema(description = "状态", example = "0") + private Integer status; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/template/OaOfficialDocTemplateRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/template/OaOfficialDocTemplateRespVO.java new file mode 100644 index 0000000000..6fa089ae7b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/template/OaOfficialDocTemplateRespVO.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.template; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 套红模板 Response VO") +@Data +public class OaOfficialDocTemplateRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "模板名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "公司行政发文模板") + private String name; + + @Schema(description = "红头名称", example = "芋道科技有限公司文件") + private String authorityName; + + @Schema(description = "红头字号", example = "32") + private Integer fontSize; + + @Schema(description = "发文字号前缀", example = "芋办") + private String noPrefix; + + @Schema(description = "印章图片地址", example = "https://example.com/image.png") + private String sealPicUrl; + + @Schema(description = "分隔线类型", example = "0") + private Integer separatorType; + + @Schema(description = "状态", example = "0") + private Integer status; + + @Schema(description = "显示顺序", example = "1") + private Integer sort; + + @Schema(description = "备注", example = "请提前联系行政部确认") + private String remark; + + @Schema(description = "创建人编号", example = "1024") + private String creator; + + @Schema(description = "创建时间", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/template/OaOfficialDocTemplateSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/template/OaOfficialDocTemplateSaveReqVO.java new file mode 100644 index 0000000000..bff44fc326 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/officialdoc/vo/template/OaOfficialDocTemplateSaveReqVO.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.template; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.framework.dict.validation.InDict; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; + +@Schema(description = "管理后台 - 套红模板新增/修改 Request VO") +@Data +public class OaOfficialDocTemplateSaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "模板名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "公司行政发文模板") + @NotBlank(message = "模板名称不能为空") + @Size(max = 128, message = "模板名称长度不能超过 {max} 个字符") + private String name; + + @Schema(description = "红头名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋道科技有限公司文件") + @NotBlank(message = "红头名称不能为空") + @Size(max = 255, message = "红头名称长度不能超过 {max} 个字符") + private String authorityName; + + @Schema(description = "红头字号", requiredMode = Schema.RequiredMode.REQUIRED, example = "32") + @NotNull(message = "字号不能为空") + @Min(value = 18, message = "红头字号不能小于 {value}") + @Max(value = 72, message = "红头字号不能大于 {value}") + private Integer fontSize; + + @Schema(description = "发文字号前缀", example = "芋办") + @Size(max = 64, message = "发文字号前缀长度不能超过 {max} 个字符") + private String noPrefix; + + @Schema(description = "印章图片地址", example = "https://example.com/image.png") + @Size(max = 2048, message = "印章图片地址长度不能超过 {max} 个字符") + private String sealPicUrl; + + @Schema(description = "分隔线类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "分隔线类型不能为空") + @InDict(type = DictTypeConstants.OFFICIAL_DOC_SEPARATOR_TYPE, message = "分隔线类型必须是 {value}") + private Integer separatorType; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + @InEnum(value = CommonStatusEnum.class, message = "状态必须是 {value}") + private Integer status; + + @Schema(description = "显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "显示顺序不能为空") + private Integer sort; + + @Schema(description = "备注", example = "请提前联系行政部确认") + @Size(max = 500, message = "备注长度不能超过 {max} 个字符") + private String remark; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/overtime/OaOvertimeApplyController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/overtime/OaOvertimeApplyController.java new file mode 100644 index 0000000000..12297627c4 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/overtime/OaOvertimeApplyController.java @@ -0,0 +1,121 @@ +package cn.iocoder.yudao.module.oa.controller.admin.overtime; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.overtime.vo.OaOvertimeApplySaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.overtime.vo.OaOvertimeApplySubmitReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.overtime.vo.OaOvertimeApplyPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.overtime.vo.OaOvertimeApplyRespVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.overtime.OaOvertimeApplyDO; +import cn.iocoder.yudao.module.oa.service.overtime.OaOvertimeApplyService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 加班申请") +@RestController +@RequestMapping("/oa/overtime-apply") +@Validated +public class OaOvertimeApplyController { + + @Resource + private OaOvertimeApplyService overtimeApplyService; + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/create") + @Operation(summary = "创建加班申请草稿") + @PreAuthorize("@ss.hasPermission('oa:overtime-apply:create')") + public CommonResult createOvertimeApply(@Valid @RequestBody OaOvertimeApplySaveReqVO createReqVO) { + return success(overtimeApplyService.createOvertimeApply(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新加班申请草稿") + @PreAuthorize("@ss.hasPermission('oa:overtime-apply:create')") + public CommonResult updateOvertimeApply(@Valid @RequestBody OaOvertimeApplySaveReqVO saveReqVO) { + overtimeApplyService.updateOvertimeApply(saveReqVO, getLoginUserId()); + return success(true); + } + + @PostMapping("/submit") + @Operation(summary = "提交加班申请") + @PreAuthorize("@ss.hasPermission('oa:overtime-apply:create')") + public CommonResult submitOvertimeApply(@Valid @RequestBody OaOvertimeApplySubmitReqVO submitReqVO) { + overtimeApplyService.submitOvertimeApply(submitReqVO, getLoginUserId()); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得本人加班申请分页") + @PreAuthorize("@ss.hasPermission('oa:overtime-apply:query')") + public CommonResult> getOvertimeApplyPage(@Valid OaOvertimeApplyPageReqVO pageReqVO) { + PageResult page = overtimeApplyService.getOvertimeApplyPage(getLoginUserId(), pageReqVO); + return success(new PageResult<>(buildOvertimeApplyRespVOList(page.getList()), page.getTotal())); + } + + @GetMapping("/get") + @Operation(summary = "获得加班申请详情") + @Parameter(name = "id", description = "申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:overtime-apply:query')") + public CommonResult getOvertimeApply(@RequestParam("id") Long id) { + OaOvertimeApplyDO application = overtimeApplyService.getOvertimeApply(id); + return success(buildOvertimeApplyRespVO(application)); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接加班申请详情 + * + * @param application 申请 + * @return 加班申请响应 + */ + private OaOvertimeApplyRespVO buildOvertimeApplyRespVO(OaOvertimeApplyDO application) { + if (application == null) { + return null; + } + return CollUtil.getFirst(buildOvertimeApplyRespVOList(Collections.singletonList(application))); + } + + /** + * 批量拼接申请人展示信息 + * + * @param applications 申请列表 + * @return 响应列表 + */ + private List buildOvertimeApplyRespVOList(List applications) { + if (CollUtil.isEmpty(applications)) { + return Collections.emptyList(); + } + // 1. 批量查询申请人 + Map users = adminUserApi.getUserMap( + convertSet(applications, application -> Long.valueOf(application.getCreator()))); + // 2. 转换并拼接 VO + return convertList(applications, application -> { + OaOvertimeApplyRespVO vo = BeanUtils.toBean(application, OaOvertimeApplyRespVO.class); + MapUtils.findAndThen(users, Long.valueOf(application.getCreator()), user -> vo.setCreatorName(user.getNickname())); + return vo; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/overtime/vo/OaOvertimeApplyPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/overtime/vo/OaOvertimeApplyPageReqVO.java new file mode 100644 index 0000000000..ad3fb938ed --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/overtime/vo/OaOvertimeApplyPageReqVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.oa.controller.admin.overtime.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 OaOvertimeApplyPageReqVO extends PageParam { + + @Schema(description = "标题", example = "项目上线加班申请") + private String title; + + @Schema(description = "审批状态", example = "-1") + private Integer status; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/overtime/vo/OaOvertimeApplyRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/overtime/vo/OaOvertimeApplyRespVO.java new file mode 100644 index 0000000000..fe3b831ff9 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/overtime/vo/OaOvertimeApplyRespVO.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.oa.controller.admin.overtime.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 加班申请 Response VO") +@Data +public class OaOvertimeApplyRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "标题", example = "项目上线加班申请") + private String title; + + @Schema(description = "紧急程度", example = "1") + private Integer urgency; + + @Schema(description = "加班类型", example = "1") + private Integer type; + + @Schema(description = "开始时间", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime startTime; + + @Schema(description = "结束时间", type = "integer", format = "int64", example = "1789380000000") + private LocalDateTime endTime; + + @Schema(description = "天数", example = "1") + private BigDecimal days; + + @Schema(description = "申请原因", example = "完成项目上线前的联调工作") + private String reason; + + @Schema(description = "审批状态", example = "-1") + private Integer status; + + @Schema(description = "流程实例编号", example = "1024") + private String processInstanceId; + + @Schema(description = "申请人编号", example = "1024") + private String creator; + + @Schema(description = "申请人昵称", example = "张三") + private String creatorName; + + @Schema(description = "申请时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/overtime/vo/OaOvertimeApplySaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/overtime/vo/OaOvertimeApplySaveReqVO.java new file mode 100644 index 0000000000..4a0ac2d3df --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/overtime/vo/OaOvertimeApplySaveReqVO.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.oa.controller.admin.overtime.vo; + +import cn.iocoder.yudao.framework.dict.validation.InDict; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 加班申请新增/修改 Request VO") +@Data +public class OaOvertimeApplySaveReqVO { + + @Schema(description = "申请编号", example = "1024") + private Long id; + + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "项目上线加班申请") + @NotBlank(message = "标题不能为空") + @Size(max = 255, message = "标题不能超过 255 个字符") + private String title; + + @Schema(description = "紧急程度", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "紧急程度不能为空") + @InDict(type = DictTypeConstants.APPLY_URGENCY, message = "紧急程度必须是 {value}") + private Integer urgency; + + @Schema(description = "加班类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "加班类型不能为空") + @InDict(type = DictTypeConstants.OVERTIME_TYPE, message = "加班类型必须是 {value}") + private Integer type; + + @Schema(description = "开始时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + @NotNull(message = "开始时间不能为空") + private LocalDateTime startTime; + + @Schema(description = "结束时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789380000000") + @NotNull(message = "结束时间不能为空") + private LocalDateTime endTime; + + @Schema(description = "申请原因", requiredMode = Schema.RequiredMode.REQUIRED, example = "完成项目上线前的联调工作") + @NotBlank(message = "申请原因不能为空") + @Size(max = 5000, message = "申请原因不能超过 5000 个字符") + private String reason; + + @AssertTrue(message = "结束时间不能早于开始时间") + @JsonIgnore + public boolean isTimeRangeValid() { + return startTime == null || endTime == null || !endTime.isBefore(startTime); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/overtime/vo/OaOvertimeApplySubmitReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/overtime/vo/OaOvertimeApplySubmitReqVO.java new file mode 100644 index 0000000000..3c385637ff --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/overtime/vo/OaOvertimeApplySubmitReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.oa.controller.admin.overtime.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Schema(description = "管理后台 - 加班申请提交 Request VO") +@Data +public class OaOvertimeApplySubmitReqVO { + + @Schema(description = "申请编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "申请编号不能为空") + private Long id; + + @Schema(description = "发起人自选审批人") + private Map> startUserSelectAssignees; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/OaPlanController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/OaPlanController.java new file mode 100644 index 0000000000..bb835b71b8 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/OaPlanController.java @@ -0,0 +1,197 @@ +package cn.iocoder.yudao.module.oa.controller.admin.plan; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +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.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.common.util.object.PageUtils; +import cn.iocoder.yudao.module.oa.controller.admin.plan.vo.OaPlanCommentReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.plan.vo.OaPlanPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.plan.vo.OaPlanReportPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.plan.vo.OaPlanReportRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.plan.vo.OaPlanRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.plan.vo.OaPlanSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.plan.OaPlanDO; +import cn.iocoder.yudao.module.oa.service.plan.OaPlanService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +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.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - OA 工作计划") +@RestController +@RequestMapping("/oa/plan") +@Validated +public class OaPlanController { + + @Resource + private OaPlanService planService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + // ==================== 我的计划 ==================== + + @PostMapping("/create") + @Operation(summary = "创建工作计划") + @PreAuthorize("@ss.hasPermission('oa:plan:create')") + public CommonResult createPlan(@Valid @RequestBody OaPlanSaveReqVO createReqVO) { + return success(planService.createPlan(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新工作计划") + @PreAuthorize("@ss.hasPermission('oa:plan:update')") + public CommonResult updatePlan(@Valid @RequestBody OaPlanSaveReqVO updateReqVO) { + planService.updatePlan(updateReqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除工作计划") + @Parameter(name = "id", description = "计划编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:plan:delete')") + public CommonResult deletePlan(@RequestParam("id") Long id) { + planService.deletePlan(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得工作计划详情") + @Parameter(name = "id", description = "计划编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:plan:query')") + public CommonResult getPlan(@RequestParam("id") Long id) { + OaPlanDO plan = planService.getPlan(id, getLoginUserId()); + return success(buildPlanRespVO(plan)); + } + + @GetMapping("/page") + @Operation(summary = "获得工作计划分页") + @PreAuthorize("@ss.hasPermission('oa:plan:query')") + public CommonResult> getPlanPage(@Valid OaPlanPageReqVO pageReqVO) { + PageResult pageResult = planService.getPlanPage(pageReqVO, getLoginUserId()); + return success(new PageResult<>(buildPlanRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + // ==================== 计划报表 ==================== + + @GetMapping("/report-page") + @Operation(summary = "获得工作计划报表分页") + @PreAuthorize("@ss.hasPermission('oa:plan:query')") + public CommonResult> getPlanReportPage( + @Valid OaPlanReportPageReqVO pageReqVO) { + // 1.1 查询 System 管理范围内的用户 + List users = adminUserApi.getUserListBySubordinate(getLoginUserId()); + // 1.2 按成员姓名筛选,按用户编号保持分页稳定 + users = convertList(users, Function.identity(), user -> StrUtil.isBlank(pageReqVO.getUserName()) + || StrUtil.containsIgnoreCase(StrUtil.nullToEmpty(user.getNickname()), pageReqVO.getUserName())); + users.sort(Comparator.comparing(AdminUserRespDTO::getId)); + + // 2.1 对成员分页 + PageResult userPage = PageUtils.buildPageResult(pageReqVO, users); + List pageUsers = userPage.getList(); + // 2.2 查询分页成员在统计周期内的最新计划 + Map latestPlanMap = planService.getLatestPlanMap( + convertSet(pageUsers, AdminUserRespDTO::getId), pageReqVO.getType(), pageReqVO.getCreateTime()); + // 3. 拼接报表响应 + return success(new PageResult<>(buildPlanReportRespVOList(pageUsers, latestPlanMap), userPage.getTotal())); + } + + @PutMapping("/add-comment") + @Operation(summary = "点评工作计划") + @PreAuthorize("@ss.hasPermission('oa:plan:comment')") + public CommonResult addPlanComment(@Valid @RequestBody OaPlanCommentReqVO updateReqVO) { + planService.addPlanComment(updateReqVO.getId(), updateReqVO.getComment(), getLoginUserId()); + return success(true); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接工作计划响应 + * + * @param plan 工作计划 + * @return 工作计划响应 + */ + private OaPlanRespVO buildPlanRespVO(OaPlanDO plan) { + return CollUtil.getFirst(buildPlanRespVOList(Collections.singletonList(plan))); + } + + /** + * 拼接工作计划响应列表 + * + * @param plans 工作计划列表 + * @return 工作计划响应列表 + */ + private List buildPlanRespVOList(List plans) { + if (CollUtil.isEmpty(plans)) { + return Collections.emptyList(); + } + Map userMap = adminUserApi.getUserMap( + convertSet(plans, plan -> NumberUtils.parseLong(plan.getCreator()))); + Map deptMap = deptApi.getDeptMap( + convertSet(userMap.values(), AdminUserRespDTO::getDeptId)); + return convertList(plans, plan -> { + OaPlanRespVO respVO = BeanUtils.toBean(plan, OaPlanRespVO.class) + .setUserId(NumberUtils.parseLong(plan.getCreator())); + MapUtils.findAndThen(userMap, respVO.getUserId(), user -> { + respVO.setUserName(user.getNickname()).setDeptId(user.getDeptId()); + MapUtils.findAndThen(deptMap, user.getDeptId(), dept -> respVO.setDeptName(dept.getName())); + }); + return respVO; + }); + } + + /** + * 拼接工作计划报表响应列表 + * + * @param users 成员用户列表 + * @param latestPlanMap 最新计划 Map + * @return 工作计划报表响应列表 + */ + private List buildPlanReportRespVOList(List users, + Map latestPlanMap) { + Map deptMap = deptApi.getDeptMap( + convertSet(users, AdminUserRespDTO::getDeptId)); + return convertList(users, user -> { + OaPlanDO plan = latestPlanMap.get(user.getId()); + OaPlanReportRespVO report = plan == null ? new OaPlanReportRespVO() + : BeanUtils.toBean(plan, OaPlanReportRespVO.class).setPlanId(plan.getId()); + report.setUserId(user.getId()).setUserName(user.getNickname()).setDeptId(user.getDeptId()); + MapUtils.findAndThen(deptMap, user.getDeptId(), dept -> report.setDeptName(dept.getName())); + return report; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanCommentReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanCommentReqVO.java new file mode 100644 index 0000000000..8fffa63115 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanCommentReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.oa.controller.admin.plan.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - OA 工作计划点评 Request VO") +@Data +public class OaPlanCommentReqVO { + + @Schema(description = "计划编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "计划编号不能为空") + private Long id; + + @Schema(description = "计划点评", requiredMode = Schema.RequiredMode.REQUIRED, example = "计划执行及时,后续继续跟进") + @NotBlank(message = "计划点评不能为空") + @Size(max = 1000, message = "点评内容长度不能超过 {max} 个字符") + private String comment; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanPageReqVO.java new file mode 100644 index 0000000000..de35eb8007 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanPageReqVO.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.oa.controller.admin.plan.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.plan.OaPlanStatusEnum; +import cn.iocoder.yudao.module.oa.enums.plan.OaPlanTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - OA 工作计划分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaPlanPageReqVO extends PageParam { + + @Schema(description = "标题", example = "本周工作计划") + private String title; + + @Schema(description = "标签", example = "重点") + private String label; + + @Schema(description = "计划类型", example = "1") + @InEnum(value = OaPlanTypeEnum.class, message = "计划类型必须是 {value}") + private Integer type; + + @Schema(description = "计划状态", example = "1") + @InEnum(value = OaPlanStatusEnum.class, message = "计划状态必须是 {value}") + private Integer status; + + @Schema(description = "创建时间范围", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanReportPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanReportPageReqVO.java new file mode 100644 index 0000000000..e57047fa15 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanReportPageReqVO.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.oa.controller.admin.plan.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.plan.OaPlanTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - OA 工作计划报表分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaPlanReportPageReqVO extends PageParam { + + @Schema(description = "成员昵称", example = "芋道") + private String userName; + + @Schema(description = "计划类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "计划类型不能为空") + @InEnum(value = OaPlanTypeEnum.class, message = "计划类型必须是 {value}") + private Integer type; + + @Schema(description = "创建时间范围", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]") + @NotNull(message = "创建时间范围不能为空") + @Size(min = 2, max = 2, message = "创建时间范围格式不正确") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanReportRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanReportRespVO.java new file mode 100644 index 0000000000..e12e5c9d48 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanReportRespVO.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.oa.controller.admin.plan.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - OA 工作计划报表 Response VO") +@Data +public class OaPlanReportRespVO { + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long userId; + + @Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋道") + private String userName; + + @Schema(description = "部门编号", example = "1") + private Long deptId; + + @Schema(description = "部门名称", example = "研发部") + private String deptName; + + @Schema(description = "计划编号", example = "1024") + private Long planId; + + @Schema(description = "计划状态", example = "1") + private Integer status; + + @Schema(description = "标题", example = "本周工作计划") + private String title; + + @Schema(description = "标签", example = "本周重点") + private String label; + + @Schema(description = "计划内容", example = "下周完成各部门办公用品盘点差异核实,并按实际需求补充库存。") + private String content; + + @Schema(description = "计划总结", example = "已完成本周计划的全部任务") + private String summary; + + @Schema(description = "计划点评", example = "计划执行及时,后续继续跟进") + private String comment; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + private List fileUrls; + + @Schema(description = "创建时间", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanRespVO.java new file mode 100644 index 0000000000..df6c296710 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanRespVO.java @@ -0,0 +1,61 @@ +package cn.iocoder.yudao.module.oa.controller.admin.plan.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - OA 工作计划 Response VO") +@Data +public class OaPlanRespVO { + + @Schema(description = "计划编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long userId; + + @Schema(description = "用户昵称", example = "芋道") + private String userName; + + @Schema(description = "部门编号", example = "1") + private Long deptId; + + @Schema(description = "部门名称", example = "研发部") + private String deptName; + + @Schema(description = "计划类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer type; + + @Schema(description = "计划状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer status; + + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "本周工作计划") + private String title; + + @Schema(description = "标签", example = "本周重点") + private String label; + + @Schema(description = "计划内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "下周完成各部门办公用品盘点差异核实,并按实际需求补充库存。") + private String content; + + @Schema(description = "计划总结", example = "已完成本周计划的全部任务") + private String summary; + + @Schema(description = "计划点评", example = "计划执行及时,后续继续跟进") + private String comment; + + @Schema(description = "开始时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime startTime; + + @Schema(description = "结束时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789380000000") + private LocalDateTime endTime; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + private List fileUrls; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanSaveReqVO.java new file mode 100644 index 0000000000..5ee57e2ca4 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/plan/vo/OaPlanSaveReqVO.java @@ -0,0 +1,75 @@ +package cn.iocoder.yudao.module.oa.controller.admin.plan.vo; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.plan.OaPlanStatusEnum; +import cn.iocoder.yudao.module.oa.enums.plan.OaPlanTypeEnum; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - OA 工作计划新增/修改 Request VO") +@Data +public class OaPlanSaveReqVO { + + @Schema(description = "计划编号", example = "1024") + private Long id; + + @Schema(description = "计划类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "计划类型不能为空") + @InEnum(value = OaPlanTypeEnum.class, message = "计划类型必须是 {value}") + private Integer type; + + @Schema(description = "计划状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "计划状态不能为空") + @InEnum(value = OaPlanStatusEnum.class, message = "计划状态必须是 {value}") + private Integer status; + + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "本周工作计划") + @NotBlank(message = "标题不能为空") + @Size(max = 50, message = "标题长度不能超过 50 个字符") + private String title; + + @Schema(description = "标签", example = "重点") + @Size(max = 255, message = "标签长度不能超过 255 个字符") + private String label; + + @Schema(description = "计划内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "下周完成各部门办公用品盘点差异核实,并按实际需求补充库存。") + @NotBlank(message = "计划内容不能为空") + @Size(min = 20, message = "计划内容不能少于 20 个字符") + private String content; + + @Schema(description = "计划总结", example = "已完成本周计划的全部任务") + private String summary; + + @Schema(description = "开始时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + @NotNull(message = "开始时间不能为空") + private LocalDateTime startTime; + + @Schema(description = "结束时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789380000000") + @NotNull(message = "结束时间不能为空") + private LocalDateTime endTime; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + @Size(max = 1, message = "附件最多上传 1 个") + private List<@NotBlank(message = "附件地址不能为空") @Size(max = 512, message = "附件地址长度不能超过 512 个字符") String> fileUrls; + + @JsonIgnore + @AssertTrue(message = "计划结束时间必须晚于开始时间") + public boolean isTimeRangeValid() { + return startTime == null || endTime == null || startTime.isBefore(endTime); + } + + @JsonIgnore + @AssertTrue(message = "计划总结不能少于 20 个字符") + public boolean isSummaryValid() { + return summary == null || summary.isEmpty() || summary.trim().length() >= 20; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/regular/OaRegularApplyController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/regular/OaRegularApplyController.java new file mode 100644 index 0000000000..04d0ce2fb2 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/regular/OaRegularApplyController.java @@ -0,0 +1,121 @@ +package cn.iocoder.yudao.module.oa.controller.admin.regular; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.regular.vo.OaRegularApplySaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.regular.vo.OaRegularApplySubmitReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.regular.vo.OaRegularApplyPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.regular.vo.OaRegularApplyRespVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.regular.OaRegularApplyDO; +import cn.iocoder.yudao.module.oa.service.regular.OaRegularApplyService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 转正申请") +@RestController +@RequestMapping("/oa/regular-apply") +@Validated +public class OaRegularApplyController { + + @Resource + private OaRegularApplyService regularApplyService; + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/create") + @Operation(summary = "创建转正申请草稿") + @PreAuthorize("@ss.hasPermission('oa:regular-apply:create')") + public CommonResult createRegularApply(@Valid @RequestBody OaRegularApplySaveReqVO createReqVO) { + return success(regularApplyService.createRegularApply(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新转正申请草稿") + @PreAuthorize("@ss.hasPermission('oa:regular-apply:create')") + public CommonResult updateRegularApply(@Valid @RequestBody OaRegularApplySaveReqVO saveReqVO) { + regularApplyService.updateRegularApply(saveReqVO, getLoginUserId()); + return success(true); + } + + @PostMapping("/submit") + @Operation(summary = "提交转正申请") + @PreAuthorize("@ss.hasPermission('oa:regular-apply:create')") + public CommonResult submitRegularApply(@Valid @RequestBody OaRegularApplySubmitReqVO submitReqVO) { + regularApplyService.submitRegularApply(submitReqVO, getLoginUserId()); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得本人转正申请分页") + @PreAuthorize("@ss.hasPermission('oa:regular-apply:query')") + public CommonResult> getRegularApplyPage(@Valid OaRegularApplyPageReqVO pageReqVO) { + PageResult page = regularApplyService.getRegularApplyPage(getLoginUserId(), pageReqVO); + return success(new PageResult<>(buildRegularApplyRespVOList(page.getList()), page.getTotal())); + } + + @GetMapping("/get") + @Operation(summary = "获得转正申请详情") + @Parameter(name = "id", description = "申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:regular-apply:query')") + public CommonResult getRegularApply(@RequestParam("id") Long id) { + OaRegularApplyDO application = regularApplyService.getRegularApply(id); + return success(buildRegularApplyRespVO(application)); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接转正申请详情 + * + * @param application 申请 + * @return 转正申请响应 + */ + private OaRegularApplyRespVO buildRegularApplyRespVO(OaRegularApplyDO application) { + if (application == null) { + return null; + } + return CollUtil.getFirst(buildRegularApplyRespVOList(Collections.singletonList(application))); + } + + /** + * 批量拼接申请人展示信息 + * + * @param applications 申请列表 + * @return 响应列表 + */ + private List buildRegularApplyRespVOList(List applications) { + if (CollUtil.isEmpty(applications)) { + return Collections.emptyList(); + } + // 1. 批量查询申请人 + Map users = adminUserApi.getUserMap( + convertSet(applications, application -> Long.valueOf(application.getCreator()))); + // 2. 转换并拼接 VO + return convertList(applications, application -> { + OaRegularApplyRespVO vo = BeanUtils.toBean(application, OaRegularApplyRespVO.class); + MapUtils.findAndThen(users, Long.valueOf(application.getCreator()), user -> vo.setCreatorName(user.getNickname())); + return vo; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/regular/vo/OaRegularApplyPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/regular/vo/OaRegularApplyPageReqVO.java new file mode 100644 index 0000000000..95e5b5a2ac --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/regular/vo/OaRegularApplyPageReqVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.oa.controller.admin.regular.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 OaRegularApplyPageReqVO extends PageParam { + + @Schema(description = "标题", example = "试用期转正申请") + private String title; + + @Schema(description = "审批状态", example = "-1") + private Integer status; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/regular/vo/OaRegularApplyRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/regular/vo/OaRegularApplyRespVO.java new file mode 100644 index 0000000000..2b34955340 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/regular/vo/OaRegularApplyRespVO.java @@ -0,0 +1,62 @@ +package cn.iocoder.yudao.module.oa.controller.admin.regular.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 转正申请 Response VO") +@Data +public class OaRegularApplyRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "标题", example = "试用期转正申请") + private String title; + + @Schema(description = "紧急程度", example = "1") + private Integer urgency; + + @Schema(description = "开始时间", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime startTime; + + @Schema(description = "结束时间", type = "integer", format = "int64", example = "1789380000000") + private LocalDateTime endTime; + + @Schema(description = "天数", example = "1") + private Integer days; + + @Schema(description = "试用期心得", example = "已熟悉团队协作流程并完成安排的任务") + private String experience; + + @Schema(description = "岗位职责理解", example = "负责项目开发、交付及日常维护") + private String understanding; + + @Schema(description = "试用期成长", example = "能够独立完成需求开发与问题排查") + private String growth; + + @Schema(description = "目前不足", example = "跨部门沟通效率仍需提升") + private String deficiency; + + @Schema(description = "工作改进", example = "完善任务拆分并每日跟进进度") + private String improvement; + + @Schema(description = "产品意见建议", example = "建议简化报销填写流程") + private String suggestion; + + @Schema(description = "审批状态", example = "-1") + private Integer status; + + @Schema(description = "流程实例编号", example = "1024") + private String processInstanceId; + + @Schema(description = "申请人编号", example = "1024") + private String creator; + + @Schema(description = "申请人昵称", example = "张三") + private String creatorName; + + @Schema(description = "申请时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/regular/vo/OaRegularApplySaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/regular/vo/OaRegularApplySaveReqVO.java new file mode 100644 index 0000000000..b5f318bd0c --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/regular/vo/OaRegularApplySaveReqVO.java @@ -0,0 +1,73 @@ +package cn.iocoder.yudao.module.oa.controller.admin.regular.vo; + +import cn.iocoder.yudao.framework.dict.validation.InDict; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 转正申请新增/修改 Request VO") +@Data +public class OaRegularApplySaveReqVO { + + @Schema(description = "申请编号", example = "1024") + private Long id; + + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "试用期转正申请") + @NotBlank(message = "标题不能为空") + @Size(max = 255, message = "标题不能超过 255 个字符") + private String title; + + @Schema(description = "紧急程度", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "紧急程度不能为空") + @InDict(type = DictTypeConstants.APPLY_URGENCY, message = "紧急程度必须是 {value}") + private Integer urgency; + + @Schema(description = "开始时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + @NotNull(message = "开始时间不能为空") + private LocalDateTime startTime; + + @Schema(description = "结束时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789380000000") + @NotNull(message = "结束时间不能为空") + private LocalDateTime endTime; + + @Schema(description = "试用期心得", requiredMode = Schema.RequiredMode.REQUIRED, example = "已熟悉团队协作流程并完成安排的任务") + @NotBlank(message = "试用期心得不能为空") + @Size(max = 255, message = "试用期心得不能超过 255 个字符") + private String experience; + + @Schema(description = "岗位职责理解", requiredMode = Schema.RequiredMode.REQUIRED, example = "负责项目开发、交付及日常维护") + @NotBlank(message = "岗位职责理解不能为空") + @Size(max = 255, message = "岗位职责理解不能超过 255 个字符") + private String understanding; + + @Schema(description = "试用期成长", requiredMode = Schema.RequiredMode.REQUIRED, example = "能够独立完成需求开发与问题排查") + @NotBlank(message = "试用期成长不能为空") + @Size(max = 255, message = "试用期成长不能超过 255 个字符") + private String growth; + + @Schema(description = "目前不足", requiredMode = Schema.RequiredMode.REQUIRED, example = "跨部门沟通效率仍需提升") + @NotBlank(message = "目前不足不能为空") + @Size(max = 255, message = "目前不足不能超过 255 个字符") + private String deficiency; + + @Schema(description = "工作改进", requiredMode = Schema.RequiredMode.REQUIRED, example = "完善任务拆分并每日跟进进度") + @NotBlank(message = "工作改进不能为空") + @Size(max = 255, message = "工作改进不能超过 255 个字符") + private String improvement; + + @Schema(description = "产品意见建议", requiredMode = Schema.RequiredMode.REQUIRED, example = "建议简化报销填写流程") + @NotBlank(message = "产品意见建议不能为空") + @Size(max = 255, message = "产品意见建议不能超过 255 个字符") + private String suggestion; + + @AssertTrue(message = "结束时间不能早于开始时间") + @JsonIgnore + public boolean isTimeRangeValid() { + return startTime == null || endTime == null || !endTime.isBefore(startTime); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/regular/vo/OaRegularApplySubmitReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/regular/vo/OaRegularApplySubmitReqVO.java new file mode 100644 index 0000000000..0e58085611 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/regular/vo/OaRegularApplySubmitReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.oa.controller.admin.regular.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Schema(description = "管理后台 - 转正申请提交 Request VO") +@Data +public class OaRegularApplySubmitReqVO { + + @Schema(description = "申请编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "申请编号不能为空") + private Long id; + + @Schema(description = "发起人自选审批人") + private Map> startUserSelectAssignees; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/reimbursement/OaReimbursementController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/reimbursement/OaReimbursementController.java new file mode 100644 index 0000000000..539f00bcf4 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/reimbursement/OaReimbursementController.java @@ -0,0 +1,121 @@ +package cn.iocoder.yudao.module.oa.controller.admin.reimbursement; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.reimbursement.vo.OaReimbursementSaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.reimbursement.vo.OaReimbursementSubmitReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.reimbursement.vo.OaReimbursementPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.reimbursement.vo.OaReimbursementRespVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.reimbursement.OaReimbursementDO; +import cn.iocoder.yudao.module.oa.service.reimbursement.OaReimbursementService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 费用报销") +@RestController +@RequestMapping("/oa/reimbursement") +@Validated +public class OaReimbursementController { + + @Resource + private OaReimbursementService reimbursementService; + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/create") + @Operation(summary = "创建费用报销草稿") + @PreAuthorize("@ss.hasPermission('oa:reimbursement:create')") + public CommonResult createReimbursement(@Valid @RequestBody OaReimbursementSaveReqVO createReqVO) { + return success(reimbursementService.createReimbursement(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新费用报销草稿") + @PreAuthorize("@ss.hasPermission('oa:reimbursement:create')") + public CommonResult updateReimbursement(@Valid @RequestBody OaReimbursementSaveReqVO saveReqVO) { + reimbursementService.updateReimbursement(saveReqVO, getLoginUserId()); + return success(true); + } + + @PostMapping("/submit") + @Operation(summary = "提交费用报销") + @PreAuthorize("@ss.hasPermission('oa:reimbursement:create')") + public CommonResult submitReimbursement(@Valid @RequestBody OaReimbursementSubmitReqVO submitReqVO) { + reimbursementService.submitReimbursement(submitReqVO, getLoginUserId()); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得本人费用报销分页") + @PreAuthorize("@ss.hasPermission('oa:reimbursement:query')") + public CommonResult> getReimbursementPage(@Valid OaReimbursementPageReqVO pageReqVO) { + PageResult page = reimbursementService.getReimbursementPage(getLoginUserId(), pageReqVO); + return success(new PageResult<>(buildReimbursementRespVOList(page.getList()), page.getTotal())); + } + + @GetMapping("/get") + @Operation(summary = "获得费用报销详情") + @Parameter(name = "id", description = "申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:reimbursement:query')") + public CommonResult getReimbursement(@RequestParam("id") Long id) { + OaReimbursementDO application = reimbursementService.getReimbursement(id); + return success(buildReimbursementRespVO(application)); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接费用报销详情 + * + * @param application 申请 + * @return 费用报销响应 + */ + private OaReimbursementRespVO buildReimbursementRespVO(OaReimbursementDO application) { + if (application == null) { + return null; + } + return CollUtil.getFirst(buildReimbursementRespVOList(Collections.singletonList(application))); + } + + /** + * 批量拼接申请人展示信息 + * + * @param applications 申请列表 + * @return 响应列表 + */ + private List buildReimbursementRespVOList(List applications) { + if (CollUtil.isEmpty(applications)) { + return Collections.emptyList(); + } + // 1. 批量查询申请人 + Map users = adminUserApi.getUserMap( + convertSet(applications, application -> Long.valueOf(application.getCreator()))); + // 2. 转换并拼接 VO + return convertList(applications, application -> { + OaReimbursementRespVO vo = BeanUtils.toBean(application, OaReimbursementRespVO.class); + MapUtils.findAndThen(users, Long.valueOf(application.getCreator()), user -> vo.setCreatorName(user.getNickname())); + return vo; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/reimbursement/vo/OaReimbursementPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/reimbursement/vo/OaReimbursementPageReqVO.java new file mode 100644 index 0000000000..86e90a8143 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/reimbursement/vo/OaReimbursementPageReqVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.oa.controller.admin.reimbursement.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Schema(description = "管理后台 - 费用报销分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaReimbursementPageReqVO extends PageParam { + + @Schema(description = "标题", example = "客户项目交通费报销") + private String title; + + @Schema(description = "审批状态", example = "-1") + private Integer status; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/reimbursement/vo/OaReimbursementRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/reimbursement/vo/OaReimbursementRespVO.java new file mode 100644 index 0000000000..80b01511e4 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/reimbursement/vo/OaReimbursementRespVO.java @@ -0,0 +1,62 @@ +package cn.iocoder.yudao.module.oa.controller.admin.reimbursement.vo; + +import cn.iocoder.yudao.module.oa.dal.dataobject.reimbursement.OaReimbursementDO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 费用报销 Response VO") +@Data +public class OaReimbursementRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "标题", example = "客户项目交通费报销") + private String title; + + @Schema(description = "紧急程度", example = "1") + private Integer urgency; + + @Schema(description = "申请原因", example = "报销客户项目验收期间的交通费用") + private String reason; + + @Schema(description = "证明人用户编号", example = "1024") + private Long witnessUserId; + + @Schema(description = "相关客户", example = "芋道科技有限公司") + private String customerName; + + @Schema(description = "报销方式", example = "1") + private Integer paymentMethod; + + @Schema(description = "票据总数", example = "1") + private Integer invoiceCount; + + @Schema(description = "报销总金额", example = "100.00") + private BigDecimal totalPrice; + + @Schema(description = "审批状态", example = "-1") + private Integer status; + + @Schema(description = "流程实例编号", example = "1024") + private String processInstanceId; + + @Schema(description = "报销明细") + private List items; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + private List fileUrls; + + @Schema(description = "申请人编号", example = "1024") + private String creator; + + @Schema(description = "申请人昵称", example = "张三") + private String creatorName; + + @Schema(description = "申请时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/reimbursement/vo/OaReimbursementSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/reimbursement/vo/OaReimbursementSaveReqVO.java new file mode 100644 index 0000000000..cb5e52587b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/reimbursement/vo/OaReimbursementSaveReqVO.java @@ -0,0 +1,90 @@ +package cn.iocoder.yudao.module.oa.controller.admin.reimbursement.vo; + +import cn.iocoder.yudao.framework.dict.validation.InDict; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 费用报销新增/修改 Request VO") +@Data +public class OaReimbursementSaveReqVO { + + @Schema(description = "申请编号", example = "1024") + private Long id; + + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "客户项目交通费报销") + @NotBlank(message = "标题不能为空") + @Size(max = 255, message = "标题不能超过 255 个字符") + private String title; + + @Schema(description = "紧急程度", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "紧急程度不能为空") + @InDict(type = DictTypeConstants.APPLY_URGENCY, message = "紧急程度必须是 {value}") + private Integer urgency; + + @Schema(description = "证明人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "证明人不能为空") + private Long witnessUserId; + + @Schema(description = "相关客户", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋道科技有限公司") + @NotBlank(message = "相关客户不能为空") + @Size(max = 255, message = "相关客户不能超过 255 个字符") + private String customerName; + + @Schema(description = "报销方式", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "报销方式不能为空") + @InDict(type = DictTypeConstants.REIMBURSEMENT_PAYMENT_METHOD, message = "报销方式必须是 {value}") + private Integer paymentMethod; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + @Size(max = 5, message = "附件不能超过 5 个") + private List<@NotBlank(message = "附件地址不能为空") @Size(max = 1024, message = "附件地址不能超过 1024 个字符") String> fileUrls; + + @Schema(description = "申请原因", requiredMode = Schema.RequiredMode.REQUIRED, example = "报销客户项目验收期间的交通费用") + @NotBlank(message = "申请原因不能为空") + @Size(max = 5000, message = "申请原因不能超过 5000 个字符") + private String reason; + + @Schema(description = "报销明细", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "报销明细不能为空") + @Valid + private List<@NotNull(message = "报销明细不能为空") Item> items; + + + @Schema(description = "管理后台 - 报销明细 Request VO") + @Data + public static class Item { + + @Schema(description = "费用发生时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + @NotNull(message = "费用发生时间不能为空") + private LocalDateTime expenseTime; + + @Schema(description = "费用类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "费用类型不能为空") + @InDict(type = DictTypeConstants.EXPENSE_TYPE, message = "费用类型必须是 {value}") + private Integer expenseType; + + @Schema(description = "费用说明", requiredMode = Schema.RequiredMode.REQUIRED, example = "客户项目验收往返交通费") + @NotBlank(message = "费用说明不能为空") + private String description; + + @Schema(description = "票据张数", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "票据张数不能为空") + @Min(value = 0, message = "票据张数不能小于 0") + private Integer invoiceCount; + + @Schema(description = "报销金额", requiredMode = Schema.RequiredMode.REQUIRED, example = "100.00") + @NotNull(message = "报销金额不能为空") + @DecimalMin(value = "0", message = "报销金额不能小于 0") + @Digits(integer = 16, fraction = 2, message = "金额最多 16 位整数、2 位小数") + private BigDecimal price; + + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/reimbursement/vo/OaReimbursementSubmitReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/reimbursement/vo/OaReimbursementSubmitReqVO.java new file mode 100644 index 0000000000..7fc3cab239 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/reimbursement/vo/OaReimbursementSubmitReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.oa.controller.admin.reimbursement.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Schema(description = "管理后台 - 费用报销提交 Request VO") +@Data +public class OaReimbursementSubmitReqVO { + + @Schema(description = "申请编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "申请编号不能为空") + private Long id; + + @Schema(description = "发起人自选审批人") + private Map> startUserSelectAssignees; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/resign/OaResignApplyController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/resign/OaResignApplyController.java new file mode 100644 index 0000000000..819c85b59e --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/resign/OaResignApplyController.java @@ -0,0 +1,121 @@ +package cn.iocoder.yudao.module.oa.controller.admin.resign; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.resign.vo.OaResignApplySaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.resign.vo.OaResignApplySubmitReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.resign.vo.OaResignApplyPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.resign.vo.OaResignApplyRespVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.resign.OaResignApplyDO; +import cn.iocoder.yudao.module.oa.service.resign.OaResignApplyService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 离职申请") +@RestController +@RequestMapping("/oa/resign-apply") +@Validated +public class OaResignApplyController { + + @Resource + private OaResignApplyService resignApplyService; + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/create") + @Operation(summary = "创建离职申请草稿") + @PreAuthorize("@ss.hasPermission('oa:resign-apply:create')") + public CommonResult createResignApply(@Valid @RequestBody OaResignApplySaveReqVO createReqVO) { + return success(resignApplyService.createResignApply(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新离职申请草稿") + @PreAuthorize("@ss.hasPermission('oa:resign-apply:create')") + public CommonResult updateResignApply(@Valid @RequestBody OaResignApplySaveReqVO saveReqVO) { + resignApplyService.updateResignApply(saveReqVO, getLoginUserId()); + return success(true); + } + + @PostMapping("/submit") + @Operation(summary = "提交离职申请") + @PreAuthorize("@ss.hasPermission('oa:resign-apply:create')") + public CommonResult submitResignApply(@Valid @RequestBody OaResignApplySubmitReqVO submitReqVO) { + resignApplyService.submitResignApply(submitReqVO, getLoginUserId()); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得本人离职申请分页") + @PreAuthorize("@ss.hasPermission('oa:resign-apply:query')") + public CommonResult> getResignApplyPage(@Valid OaResignApplyPageReqVO pageReqVO) { + PageResult page = resignApplyService.getResignApplyPage(getLoginUserId(), pageReqVO); + return success(new PageResult<>(buildResignApplyRespVOList(page.getList()), page.getTotal())); + } + + @GetMapping("/get") + @Operation(summary = "获得离职申请详情") + @Parameter(name = "id", description = "申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:resign-apply:query')") + public CommonResult getResignApply(@RequestParam("id") Long id) { + OaResignApplyDO application = resignApplyService.getResignApply(id); + return success(buildResignApplyRespVO(application)); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接离职申请详情 + * + * @param application 离职申请 + * @return 离职申请响应 + */ + private OaResignApplyRespVO buildResignApplyRespVO(OaResignApplyDO application) { + if (application == null) { + return null; + } + return CollUtil.getFirst(buildResignApplyRespVOList(Collections.singletonList(application))); + } + + /** + * 批量拼接申请人展示信息 + * + * @param applications 申请列表 + * @return 响应列表 + */ + private List buildResignApplyRespVOList(List applications) { + if (CollUtil.isEmpty(applications)) { + return Collections.emptyList(); + } + // 1. 批量查询申请人 + Map users = adminUserApi.getUserMap( + convertSet(applications, application -> Long.valueOf(application.getCreator()))); + // 2. 转换并拼接 VO + return convertList(applications, application -> { + OaResignApplyRespVO vo = BeanUtils.toBean(application, OaResignApplyRespVO.class); + MapUtils.findAndThen(users, Long.valueOf(application.getCreator()), user -> vo.setCreatorName(user.getNickname())); + return vo; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/resign/vo/OaResignApplyPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/resign/vo/OaResignApplyPageReqVO.java new file mode 100644 index 0000000000..49ef86ec81 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/resign/vo/OaResignApplyPageReqVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.oa.controller.admin.resign.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 OaResignApplyPageReqVO extends PageParam { + + @Schema(description = "标题", example = "离职申请") + private String title; + + @Schema(description = "审批状态", example = "-1") + private Integer status; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/resign/vo/OaResignApplyRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/resign/vo/OaResignApplyRespVO.java new file mode 100644 index 0000000000..79dd0fe0a6 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/resign/vo/OaResignApplyRespVO.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.oa.controller.admin.resign.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 离职申请 Response VO") +@Data +public class OaResignApplyRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "标题", example = "离职申请") + private String title; + + @Schema(description = "紧急程度", example = "1") + private Integer urgency; + + @Schema(description = "申请原因", example = "因个人职业发展规划申请离职") + private String reason; + + @Schema(description = "工作交接人用户编号", example = "1024") + private Long handoverUserId; + + @Schema(description = "未完成事宜", example = "待移交客户资料与项目文档") + private String unfinishedWork; + + @Schema(description = "是否有费用报销未完成", example = "false") + private Boolean hasPendingReimbursement; + + @Schema(description = "申请人意见建议", example = "建议完善新员工培训资料") + private String suggestion; + + @Schema(description = "审批状态", example = "-1") + private Integer status; + + @Schema(description = "流程实例编号", example = "1024") + private String processInstanceId; + + @Schema(description = "申请人编号", example = "1024") + private String creator; + + @Schema(description = "申请人昵称", example = "张三") + private String creatorName; + + @Schema(description = "申请时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/resign/vo/OaResignApplySaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/resign/vo/OaResignApplySaveReqVO.java new file mode 100644 index 0000000000..9f60450b0a --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/resign/vo/OaResignApplySaveReqVO.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.oa.controller.admin.resign.vo; + +import cn.iocoder.yudao.framework.dict.validation.InDict; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - 离职申请新增/修改 Request VO") +@Data +public class OaResignApplySaveReqVO { + + @Schema(description = "申请编号", example = "1024") + private Long id; + + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "离职申请") + @NotBlank(message = "标题不能为空") + @Size(max = 255, message = "标题不能超过 255 个字符") + private String title; + + @Schema(description = "紧急程度", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "紧急程度不能为空") + @InDict(type = DictTypeConstants.APPLY_URGENCY, message = "紧急程度必须是 {value}") + private Integer urgency; + + @Schema(description = "工作交接人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "工作交接人不能为空") + private Long handoverUserId; + + @Schema(description = "未完成事宜", requiredMode = Schema.RequiredMode.REQUIRED, example = "待移交客户资料与项目文档") + @NotBlank(message = "未完成事宜不能为空") + private String unfinishedWork; + + @Schema(description = "申请原因", requiredMode = Schema.RequiredMode.REQUIRED, example = "因个人职业发展规划申请离职") + @NotBlank(message = "申请原因不能为空") + @Size(max = 5000, message = "申请原因不能超过 5000 个字符") + private String reason; + + @Schema(description = "是否有费用报销未完成", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + @NotNull(message = "是否有费用报销未完成不能为空") + private Boolean hasPendingReimbursement; + + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/resign/vo/OaResignApplySubmitReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/resign/vo/OaResignApplySubmitReqVO.java new file mode 100644 index 0000000000..2c39507077 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/resign/vo/OaResignApplySubmitReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.oa.controller.admin.resign.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Schema(description = "管理后台 - 离职申请提交 Request VO") +@Data +public class OaResignApplySubmitReqVO { + + @Schema(description = "申请编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "申请编号不能为空") + private Long id; + + @Schema(description = "发起人自选审批人") + private Map> startUserSelectAssignees; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/schedule/OaScheduleController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/schedule/OaScheduleController.java new file mode 100644 index 0000000000..1a5627d91f --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/schedule/OaScheduleController.java @@ -0,0 +1,174 @@ +package cn.iocoder.yudao.module.oa.controller.admin.schedule; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.module.oa.controller.admin.schedule.vo.OaSchedulePageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.schedule.vo.OaScheduleRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.schedule.vo.OaScheduleSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.schedule.OaScheduleDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.schedule.OaScheduleParticipantDO; +import cn.iocoder.yudao.module.oa.service.schedule.OaScheduleService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - OA 日程") +@RestController +@RequestMapping("/oa/schedule") +@Validated +public class OaScheduleController { + + @Resource + private OaScheduleService scheduleService; + + @Resource + private AdminUserApi adminUserApi; + + @Resource + private DeptApi deptApi; + + @PostMapping("/create") + @Operation(summary = "创建日程") + @PreAuthorize("@ss.hasPermission('oa:schedule:create')") + public CommonResult createSchedule(@Valid @RequestBody OaScheduleSaveReqVO createReqVO) { + return success(scheduleService.createSchedule(createReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新日程") + @PreAuthorize("@ss.hasPermission('oa:schedule:update')") + public CommonResult updateSchedule(@Valid @RequestBody OaScheduleSaveReqVO updateReqVO) { + scheduleService.updateSchedule(updateReqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除日程") + @Parameter(name = "id", description = "日程编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:schedule:delete')") + public CommonResult deleteSchedule(@RequestParam("id") Long id) { + scheduleService.deleteSchedule(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得日程详情") + @Parameter(name = "id", description = "日程编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:schedule:query')") + public CommonResult getSchedule(@RequestParam("id") Long id) { + // 1. 查询日程及参与人 + OaScheduleDO schedule = scheduleService.getSchedule(id, getLoginUserId()); + List participants = scheduleService.getScheduleParticipantList(id); + // 2. 转换详情并拼接参与人阅读信息 + Map userMap = adminUserApi.getUserMap( + convertSet(participants, OaScheduleParticipantDO::getUserId)); + OaScheduleRespVO respVO = CollUtil.getFirst(buildScheduleRespVOList(Collections.singletonList(schedule))); + respVO.setParticipants(convertList(participants, participant -> { + OaScheduleRespVO.Participant participantVO = BeanUtils.toBean(participant, OaScheduleRespVO.Participant.class); + MapUtils.findAndThen(userMap, participant.getUserId(), user -> participantVO.setUserName(user.getNickname())); + return participantVO; + })); + return success(respVO); + } + + @PutMapping("/update-read-status") + @Operation(summary = "标记本人已阅读日程") + @Parameter(name = "id", description = "日程编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:schedule:query')") + public CommonResult updateScheduleReadStatus(@RequestParam("id") Long id) { + scheduleService.updateScheduleReadStatus(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得日程分页") + @PreAuthorize("@ss.hasPermission('oa:schedule:query')") + public CommonResult> getSchedulePage(@Valid OaSchedulePageReqVO pageReqVO) { + PageResult pageResult = scheduleService.getSchedulePage(pageReqVO, getLoginUserId()); + return success(new PageResult<>(buildScheduleRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/my-page") + @Operation(summary = "获得我的日程分页") + @PreAuthorize("@ss.hasPermission('oa:schedule:query')") + public CommonResult> getMySchedulePage(@Valid OaSchedulePageReqVO pageReqVO) { + PageResult pageResult = scheduleService.getMySchedulePage(pageReqVO, getLoginUserId()); + return success(new PageResult<>(buildScheduleRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/received-page") + @Operation(summary = "获得共享给我的日程分页") + @PreAuthorize("@ss.hasPermission('oa:schedule:query')") + public CommonResult> getReceivedSchedulePage(@Valid OaSchedulePageReqVO pageReqVO) { + PageResult pageResult = scheduleService.getReceivedSchedulePage(pageReqVO, getLoginUserId()); + return success(new PageResult<>(buildScheduleRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接日程响应列表 + * + * @param schedules 日程列表 + * @return 日程响应列表 + */ + private List buildScheduleRespVOList(List schedules) { + if (CollUtil.isEmpty(schedules)) { + return Collections.emptyList(); + } + // 1.1 查询日程参与人 + Map> participantUserIdListMap = scheduleService.getScheduleParticipantUserIdListMap( + convertSet(schedules, OaScheduleDO::getId)); + // 1.2 批量查询创建人和参与人 + Collection userIds = convertSet(schedules, schedule -> NumberUtils.parseLong(schedule.getCreator())); + participantUserIdListMap.values().forEach(userIds::addAll); + Map userMap = adminUserApi.getUserMap(userIds); + // 1.3 批量查询部门 + Map deptMap = deptApi.getDeptMap(convertSet(userMap.values(), AdminUserRespDTO::getDeptId)); + // 2. 转换日程并拼接展示字段 + return convertList(schedules, scheduleDO -> { + OaScheduleRespVO schedule = BeanUtils.toBean(scheduleDO, OaScheduleRespVO.class); + MapUtils.findAndThen(userMap, NumberUtils.parseLong(schedule.getCreator()), user -> { + schedule.setCreatorName(user.getNickname()); + MapUtils.findAndThen(deptMap, user.getDeptId(), dept -> schedule.setCreatorDeptName(dept.getName())); + }); + schedule.setParticipantUserIds(Collections.emptyList()).setParticipantUserNames(Collections.emptyList()); + MapUtils.findAndThen(participantUserIdListMap, schedule.getId(), participantUserIds -> { + schedule.setParticipantUserIds(participantUserIds) + .setParticipantUserNames(convertList(convertList(participantUserIds, userMap::get), AdminUserRespDTO::getNickname)); + }); + return schedule; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/schedule/vo/OaSchedulePageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/schedule/vo/OaSchedulePageReqVO.java new file mode 100644 index 0000000000..7bb0e36518 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/schedule/vo/OaSchedulePageReqVO.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.oa.controller.admin.schedule.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.schedule.OaPriorityEnum; +import cn.iocoder.yudao.module.oa.enums.schedule.OaScheduleTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import jakarta.validation.constraints.Size; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - OA 日程分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaSchedulePageReqVO extends PageParam { + + @Schema(description = "是否包含我的日程", example = "true") + private Boolean includeMine; + + @Schema(description = "是否包含共享给我的日程", example = "true") + private Boolean includeReceived; + + @Schema(description = "标题", example = "周会") + private String title; + + @Schema(description = "日程类型", example = "1") + @InEnum(value = OaScheduleTypeEnum.class, message = "日程类型必须是 {value}") + private Integer type; + + @Schema(description = "优先级", example = "1") + @InEnum(value = OaPriorityEnum.class, message = "优先级必须是 {value}") + private Integer priority; + + @Schema(description = "开始时间范围", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] startTime; + + @Schema(description = "日历显示时间范围,查询与该范围相交的日程", example = "[\"2026-09-01 00:00:00\", \"2026-09-30 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + @Size(min = 2, max = 2) + private LocalDateTime[] overlapTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/schedule/vo/OaScheduleRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/schedule/vo/OaScheduleRespVO.java new file mode 100644 index 0000000000..8275ab5d01 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/schedule/vo/OaScheduleRespVO.java @@ -0,0 +1,76 @@ +package cn.iocoder.yudao.module.oa.controller.admin.schedule.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - OA 日程 Response VO") +@Data +public class OaScheduleRespVO { + + @Schema(description = "日程编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "创建人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private String creator; + + @Schema(description = "创建人用户昵称", example = "芋道源码") + private String creatorName; + + @Schema(description = "发布人部门名称", example = "研发部门") + private String creatorDeptName; + + @Schema(description = "日程类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer type; + + @Schema(description = "优先级", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer priority; + + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "项目评审会议") + private String title; + + @Schema(description = "描述", example = "下周完成盘点差异核实") + private String description; + + @Schema(description = "开始时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime startTime; + + @Schema(description = "结束时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789380000000") + private LocalDateTime endTime; + + @Schema(description = "是否提醒", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean remind; + + @Schema(description = "参与人用户编号列表", example = "[1, 2]") + private List participantUserIds; + + @Schema(description = "参与人用户昵称列表", example = "[\"张三\", \"李四\"]") + private List participantUserNames; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + + @Schema(description = "参与人阅读信息,仅详情返回") + private List participants; + + @Schema(description = "管理后台 - 日程参与人 Response VO") + @Data + public static class Participant { + + @Schema(description = "参与人用户编号", example = "1") + private Long userId; + + @Schema(description = "参与人用户昵称", example = "芋道源码") + private String userName; + + @Schema(description = "是否已读", example = "false") + private Boolean readStatus; + + @Schema(description = "首次阅读时间", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime readTime; + + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/schedule/vo/OaScheduleSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/schedule/vo/OaScheduleSaveReqVO.java new file mode 100644 index 0000000000..7a4ea6f57b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/schedule/vo/OaScheduleSaveReqVO.java @@ -0,0 +1,64 @@ +package cn.iocoder.yudao.module.oa.controller.admin.schedule.vo; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.schedule.OaPriorityEnum; +import cn.iocoder.yudao.module.oa.enums.schedule.OaScheduleTypeEnum; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - OA 日程新增/修改 Request VO") +@Data +public class OaScheduleSaveReqVO { + + @Schema(description = "日程编号", example = "1024") + private Long id; + + @Schema(description = "日程类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "日程类型不能为空") + @InEnum(value = OaScheduleTypeEnum.class, message = "日程类型必须是 {value}") + private Integer type; + + @Schema(description = "优先级", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "优先级不能为空") + @InEnum(value = OaPriorityEnum.class, message = "优先级必须是 {value}") + private Integer priority; + + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "周会") + @NotBlank(message = "标题不能为空") + @Size(max = 255, message = "标题长度不能超过 255 个字符") + private String title; + + @Schema(description = "描述", example = "下周完成盘点差异核实") + @Size(max = 1000, message = "描述长度不能超过 1000 个字符") + private String description; + + @Schema(description = "开始时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + @NotNull(message = "开始时间不能为空") + private LocalDateTime startTime; + + @Schema(description = "结束时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789380000000") + @NotNull(message = "结束时间不能为空") + private LocalDateTime endTime; + + @Schema(description = "是否提醒", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + @NotNull(message = "是否提醒不能为空") + private Boolean remind; + + @Schema(description = "参与人用户编号列表", example = "[1, 2]") + private List<@NotNull(message = "参与人编号不能为空") Long> participantUserIds; + + @JsonIgnore + @AssertTrue(message = "日程结束时间必须晚于开始时间") + public boolean isTimeRangeValid() { + return startTime == null || endTime == null || startTime.isBefore(endTime); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/OaSealApplyController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/OaSealApplyController.java new file mode 100644 index 0000000000..2998aab413 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/OaSealApplyController.java @@ -0,0 +1,161 @@ +package cn.iocoder.yudao.module.oa.controller.admin.seal; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.seal.vo.OaSealPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.seal.vo.OaSealRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.seal.vo.apply.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.seal.OaSealApplyDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.seal.OaSealDO; +import cn.iocoder.yudao.module.oa.service.seal.OaSealApplyService; +import cn.iocoder.yudao.module.oa.service.seal.OaSealService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.*; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 用印申请") +@RestController +@RequestMapping("/oa/seal-apply") +@Validated +public class OaSealApplyController { + + @Resource + private OaSealApplyService sealApplyService; + @Resource + private OaSealService sealService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + @GetMapping("/seal-page") + @Operation(summary = "获得可申请的印章分页") + @PreAuthorize("@ss.hasAnyPermissions('oa:seal-apply:create', 'oa:seal-apply:query')") + public CommonResult> getSealPage(@Valid OaSealPageReqVO pageReqVO) { + PageResult pageResult = sealService.getSealPage(pageReqVO); + return success(new PageResult<>(convertList(pageResult.getList(), seal -> new OaSealRespVO() + .setId(seal.getId()).setNo(seal.getNo()).setName(seal.getName())), pageResult.getTotal())); + } + + @PostMapping("/create") + @Operation(summary = "创建用印申请草稿") + @PreAuthorize("@ss.hasPermission('oa:seal-apply:create')") + public CommonResult createSealApply(@Valid @RequestBody OaSealApplySaveReqVO reqVO) { + return success(sealApplyService.createSealApply(reqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新用印申请草稿") + @PreAuthorize("@ss.hasPermission('oa:seal-apply:update')") + public CommonResult updateSealApply(@Valid @RequestBody OaSealApplySaveReqVO reqVO) { + sealApplyService.updateSealApply(reqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除用印申请草稿") + @Parameter(name = "id", description = "申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:seal-apply:delete')") + public CommonResult deleteSealApply(@RequestParam("id") Long id) { + sealApplyService.deleteSealApply(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/submit") + @Operation(summary = "提交用印申请") + @Parameter(name = "id", description = "申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:seal-apply:create')") + public CommonResult submitSealApply(@RequestParam("id") Long id) { + sealApplyService.submitSealApply(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/cancel") + @Operation(summary = "撤销用印申请") + @Parameter(name = "id", description = "申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:seal-apply:update')") + public CommonResult cancelSealApply(@RequestParam("id") Long id) { + sealApplyService.cancelSealApply(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得用印申请") + @Parameter(name = "id", description = "申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:seal-apply:query')") + public CommonResult getSealApply(@RequestParam("id") Long id) { + OaSealApplyDO apply = sealApplyService.getSealApply(id); + return success(buildSealApplyRespVO(apply)); + } + + @GetMapping("/page") + @Operation(summary = "获得本人的用印申请分页") + @PreAuthorize("@ss.hasPermission('oa:seal-apply:query')") + public CommonResult> getSealApplyPage( + @Valid OaSealApplyPageReqVO pageReqVO) { + PageResult pageResult = sealApplyService.getSealApplyPage(getLoginUserId(), pageReqVO); + return success(new PageResult<>(buildSealApplyRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接用印申请详情 + * + * @param apply 用印申请 + * @return 用印申请详情 + */ + private OaSealApplyRespVO buildSealApplyRespVO(OaSealApplyDO apply) { + if (apply == null) { + return null; + } + return CollUtil.getFirst(buildSealApplyRespVOList(Collections.singletonList(apply))); + } + + /** + * 拼接用印申请列表的用户和部门信息 + * + * @param applies 用印申请列表 + * @return 用印申请响应列表 + */ + private List buildSealApplyRespVOList(List applies) { + if (CollUtil.isEmpty(applies)) { + return Collections.emptyList(); + } + // 1. 批量查询申请人、申请部门和关联业务信息 + Map userMap = adminUserApi.getUserMap( + convertSetByFlatMap(applies, apply -> Stream.of(apply.getUserId(), apply.getKeeperUserId()))); + Map deptMap = deptApi.getDeptMap( + convertSetByFlatMap(applies, apply -> Stream.of(apply.getDeptId(), apply.getKeeperDeptId()))); + // 2. 转换并拼接展示字段 + return convertList(applies, item -> { + OaSealApplyRespVO vo = BeanUtils.toBean(item, OaSealApplyRespVO.class); + MapUtils.findAndThen(userMap, item.getUserId(), user -> vo.setUserName(user.getNickname())); + MapUtils.findAndThen(deptMap, item.getDeptId(), dept -> vo.setDeptName(dept.getName())); + MapUtils.findAndThen(userMap, item.getKeeperUserId(), user -> vo.setKeeperName(user.getNickname())); + MapUtils.findAndThen(deptMap, item.getKeeperDeptId(), dept -> vo.setKeeperDeptName(dept.getName())); + return vo; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/OaSealController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/OaSealController.java new file mode 100644 index 0000000000..849b192d7d --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/OaSealController.java @@ -0,0 +1,122 @@ +package cn.iocoder.yudao.module.oa.controller.admin.seal; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.seal.vo.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.seal.OaSealDO; +import cn.iocoder.yudao.module.oa.service.seal.OaSealService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.*; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; + +@Tag(name = "管理后台 - 印章") +@RestController +@RequestMapping("/oa/seal") +@Validated +public class OaSealController { + + @Resource + private OaSealService sealService; + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + @PostMapping("/create") + @Operation(summary = "创建印章") + @PreAuthorize("@ss.hasPermission('oa:seal:create')") + public CommonResult createSeal(@Valid @RequestBody OaSealSaveReqVO createReqVO) { + return success(sealService.createSeal(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新印章") + @PreAuthorize("@ss.hasPermission('oa:seal:update')") + public CommonResult updateSeal(@Valid @RequestBody OaSealSaveReqVO updateReqVO) { + sealService.updateSeal(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除印章") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:seal:delete')") + public CommonResult deleteSeal(@RequestParam("id") Long id) { + sealService.deleteSeal(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得印章") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:seal:query')") + public CommonResult getSeal(@RequestParam("id") Long id) { + OaSealDO seal = sealService.validateSealExists(id); + return success(buildSealRespVO(seal)); + } + + @GetMapping("/page") + @Operation(summary = "获得印章分页") + @PreAuthorize("@ss.hasPermission('oa:seal:query')") + public CommonResult> getSealPage(@Valid OaSealPageReqVO pageReqVO) { + PageResult pageResult = sealService.getSealPage(pageReqVO); + return success(new PageResult<>(buildSealRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接印章详情 + * + * @param seal 印章 + * @return 印章详情 + */ + private OaSealRespVO buildSealRespVO(OaSealDO seal) { + if (seal == null) { + return null; + } + return CollUtil.getFirst(buildSealRespVOList(Collections.singletonList(seal))); + } + + /** + * 拼接印章列表的保管人和部门信息 + * + * @param seals 印章列表 + * @return 印章响应列表 + */ + private List buildSealRespVOList(List seals) { + if (CollUtil.isEmpty(seals)) { + return Collections.emptyList(); + } + // 1. 批量查询保管人及部门 + Map userMap = adminUserApi.getUserMap(convertSet(seals, OaSealDO::getKeeperUserId)); + Map deptMap = deptApi.getDeptMap( + convertSetByFlatMap(seals, seal -> Stream.of(seal.getDeptId(), seal.getKeeperDeptId()))); + // 2. 转换台账并拼接展示字段 + return convertList(seals, seal -> { + OaSealRespVO respVO = BeanUtils.toBean(seal, OaSealRespVO.class); + MapUtils.findAndThen(deptMap, seal.getDeptId(), dept -> respVO.setDeptName(dept.getName())); + MapUtils.findAndThen(userMap, seal.getKeeperUserId(), user -> respVO.setKeeperName(user.getNickname())); + MapUtils.findAndThen(deptMap, seal.getKeeperDeptId(), dept -> respVO.setKeeperDeptName(dept.getName())); + return respVO; + }); + } +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/OaSealPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/OaSealPageReqVO.java new file mode 100644 index 0000000000..746f5e8fa1 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/OaSealPageReqVO.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.oa.controller.admin.seal.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 印章分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaSealPageReqVO extends PageParam { + + @Schema(description = "编号", example = "SEAL-001") + private String no; + + @Schema(description = "名称", example = "公司公章") + private String name; + + @Schema(description = "分类", example = "1") + private Integer category; + + @Schema(description = "类型", example = "1") + private Integer type; + + @Schema(description = "保管人编号", example = "1024") + private Long keeperUserId; + + @Schema(description = "所属部门编号", example = "1024") + private Long deptId; + + @Schema(description = "状态", example = "0") + private Integer status; + + @Schema(description = "购买时间范围", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] purchaseTime; + + @Schema(description = "启用时间范围", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] enableTime; + + @Schema(description = "停用时间范围", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] disableTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/OaSealRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/OaSealRespVO.java new file mode 100644 index 0000000000..6d2626d221 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/OaSealRespVO.java @@ -0,0 +1,69 @@ +package cn.iocoder.yudao.module.oa.controller.admin.seal.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 印章 Response VO") +@Data +public class OaSealRespVO { + + @Schema(description = "编号", example = "1024", requiredMode = Schema.RequiredMode.REQUIRED) + private Long id; + + @Schema(description = "所属部门编号", example = "103", requiredMode = Schema.RequiredMode.REQUIRED) + private Long deptId; + + @Schema(description = "所属部门", example = "研发部门") + private String deptName; + + @Schema(description = "印章编号", example = "SEAL-001", requiredMode = Schema.RequiredMode.REQUIRED) + private String no; + + @Schema(description = "印章名称", example = "公司公章", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @Schema(description = "印章类型", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer type; + + @Schema(description = "印章分类", example = "1") + private Integer category; + + @Schema(description = "保管人用户编号", example = "1024", requiredMode = Schema.RequiredMode.REQUIRED) + private Long keeperUserId; + + @Schema(description = "保管部门编号", example = "1024", requiredMode = Schema.RequiredMode.REQUIRED) + private Long keeperDeptId; + + @Schema(description = "保管人", example = "芋道") + private String keeperName; + + @Schema(description = "保管部门", example = "研发部门") + private String keeperDeptName; + + @Schema(description = "印章台账状态", example = "0", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer status; + + @Schema(description = "购买时间", example = "1789088400000", type = "integer", format = "int64") + private LocalDateTime purchaseTime; + + @Schema(description = "启用时间", example = "1789088400000", type = "integer", format = "int64") + private LocalDateTime enableTime; + + @Schema(description = "停用时间", example = "1789088400000", type = "integer", format = "int64") + private LocalDateTime disableTime; + + @Schema(description = "印章照片地址", example = "https://example.com/seal.png") + private String picUrl; + + @Schema(description = "显示顺序", example = "0", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer sort; + + @Schema(description = "备注", example = "合同签署完成后归档") + private String remark; + + @Schema(description = "创建时间", example = "1789088400000", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64") + private LocalDateTime createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/OaSealSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/OaSealSaveReqVO.java new file mode 100644 index 0000000000..21fd0a8a7c --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/OaSealSaveReqVO.java @@ -0,0 +1,68 @@ +package cn.iocoder.yudao.module.oa.controller.admin.seal.vo; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.seal.OaSealStatusEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 印章新增/修改 Request VO") +@Data +public class OaSealSaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "所属部门编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "103") + @NotNull(message = "所属部门不能为空") + private Long deptId; + + @Schema(description = "印章名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "公司公章") + @NotBlank(message = "印章名称不能为空") + @Size(max = 128, message = "印章名称长度不能超过 128 个字符") + private String name; + + @Schema(description = "印章类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "印章类型不能为空") + private Integer type; + + @Schema(description = "印章分类", example = "1") + private Integer category; + + @Schema(description = "保管人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "保管人用户编号不能为空") + private Long keeperUserId; + + @Schema(description = "保管部门编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "保管部门编号不能为空") + private Long keeperDeptId; + + @Schema(description = "印章状态", example = "0", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "印章状态不能为空") + @InEnum(value = OaSealStatusEnum.class, message = "印章状态必须是 {value}") + private Integer status; + + @Schema(description = "购买时间", example = "1789088400000", type = "integer", format = "int64") + private LocalDateTime purchaseTime; + + @Schema(description = "启用时间", example = "1789088400000", type = "integer", format = "int64") + private LocalDateTime enableTime; + + @Schema(description = "停用时间", example = "1789174800000", type = "integer", format = "int64") + private LocalDateTime disableTime; + + @Schema(description = "印章照片地址", example = "https://example.com/seal.png") + @Size(max = 2048, message = "印章照片地址长度不能超过 2048 个字符") + private String picUrl; + + @Schema(description = "显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "显示顺序不能为空") + private Integer sort; + + @Schema(description = "备注", example = "合同签署完成后归档") + @Size(max = 500, message = "备注长度不能超过 500 个字符") + private String remark; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/apply/OaSealApplyPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/apply/OaSealApplyPageReqVO.java new file mode 100644 index 0000000000..18bb0305b7 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/apply/OaSealApplyPageReqVO.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.oa.controller.admin.seal.vo.apply; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "管理后台 - 用印申请分页 Request VO") +public class OaSealApplyPageReqVO extends PageParam { + + @Schema(description = "申请单号", example = "SEAL-APPLY-001") + private String no; + + @Schema(description = "印章编号", example = "1024") + private Long sealId; + + @Schema(description = "印章名称", example = "公司公章") + private String sealName; + + @Schema(description = "申请部门编号", example = "100") + private Long deptId; + + @Schema(description = "创建时间范围") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + + @Schema(description = "预计用印时间范围", example = "[\"2026-09-11 09:00:00\", \"2026-09-11 18:00:00\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] expectedUseTime; + + @Schema(description = "审批状态", example = "-1") + private Integer status; + + @Schema(description = "用印状态", example = "0") + private Integer useStatus; + + @Schema(description = "用印类型", example = "1") + private Integer type; + + @Schema(description = "用印方式", example = "1") + private Integer mode; + + @Schema(description = "紧急", example = "false") + private Boolean urgent; +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/apply/OaSealApplyRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/apply/OaSealApplyRespVO.java new file mode 100644 index 0000000000..f704a402e2 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/apply/OaSealApplyRespVO.java @@ -0,0 +1,112 @@ +package cn.iocoder.yudao.module.oa.controller.admin.seal.vo.apply; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Schema(description = "管理后台 - 用印申请 Response VO") +public class OaSealApplyRespVO { + + @Schema(description = "编号", example = "1024", requiredMode = Schema.RequiredMode.REQUIRED) + private Long id; + + @Schema(description = "印章编号", example = "1024", requiredMode = Schema.RequiredMode.REQUIRED) + private Long sealId; + + @Schema(description = "用印事由", example = "合同签署用印", requiredMode = Schema.RequiredMode.REQUIRED) + private String reason; + + @Schema(description = "用印类型", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer type; + + @Schema(description = "用印方式", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer mode; + + @Schema(description = "文件标题", example = "采购合同") + private String documentTitle; + + @Schema(description = "文件类型", example = "合同") + private String documentType; + + @Schema(description = "文件份数", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer documentCount; + + @Schema(description = "合同金额", example = "10000.00") + private BigDecimal contractPrice; + + @Schema(description = "合同对方", example = "示例公司") + private String contractParty; + + @Schema(description = "预计用印时间", example = "1789088400000", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64") + private LocalDateTime expectedUseTime; + + @Schema(description = "预计归还时间", example = "1789120800000", type = "integer", format = "int64") + private LocalDateTime expectedReturnTime; + + @Schema(description = "是否紧急", example = "false", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean urgent; + + @Schema(description = "备注", example = "合同签署完成后归档") + private String remark; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/contract.pdf\"]") + private List fileUrls; + + @Schema(description = "申请单号", example = "SEAL-APPLY-001", requiredMode = Schema.RequiredMode.REQUIRED) + private String no; + + @Schema(description = "印章编号", example = "SEAL-001", requiredMode = Schema.RequiredMode.REQUIRED) + private String sealNo; + + @Schema(description = "印章名称", example = "公司公章", requiredMode = Schema.RequiredMode.REQUIRED) + private String sealName; + + @Schema(description = "印章类型", example = "1") + private Integer sealType; + + @Schema(description = "保管部门编号", example = "100") + private Long keeperDeptId; + + @Schema(description = "保管部门", example = "办公室") + private String keeperDeptName; + + @Schema(description = "申请人编号", example = "1024", requiredMode = Schema.RequiredMode.REQUIRED) + private Long userId; + + @Schema(description = "申请部门编号", example = "1024") + private Long deptId; + + @Schema(description = "申请人", example = "芋道") + private String userName; + + @Schema(description = "申请部门", example = "研发部门") + private String deptName; + + @Schema(description = "保管人编号", example = "1024", requiredMode = Schema.RequiredMode.REQUIRED) + private Long keeperUserId; + + @Schema(description = "保管人", example = "芋道") + private String keeperName; + + @Schema(description = "审批状态", example = "-1", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer status; + + @Schema(description = "用印状态", example = "0", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer useStatus; + + @Schema(description = "流程实例编号", example = "b3bb1b18-6001-11ef-9d05-0242ac120002") + private String processInstanceId; + + @Schema(description = "实际用印时间", example = "1789088400000", type = "integer", format = "int64") + private LocalDateTime actualUseTime; + + @Schema(description = "实际归还时间", example = "1789120800000", type = "integer", format = "int64") + private LocalDateTime actualReturnTime; + + @Schema(description = "创建时间", example = "1789088400000", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64") + private LocalDateTime createTime; +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/apply/OaSealApplySaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/apply/OaSealApplySaveReqVO.java new file mode 100644 index 0000000000..8f5fc767a1 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/seal/vo/apply/OaSealApplySaveReqVO.java @@ -0,0 +1,82 @@ +package cn.iocoder.yudao.module.oa.controller.admin.seal.vo.apply; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.seal.*; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Schema(description = "管理后台 - 用印申请新增/修改 Request VO") +public class OaSealApplySaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "印章编号", example = "1024", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "印章不能为空") + private Long sealId; + + @Schema(description = "用印事由", example = "合同签署用印", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "用印事由不能为空") + @Size(max = 500, message = "用印事由长度不能超过 {max} 个字符") + private String reason; + + @Schema(description = "用印类型", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "用印类型不能为空") + @InEnum(value = OaSealApplyTypeEnum.class, message = "用印类型必须是 {value}") + private Integer type; + + @Schema(description = "用印方式", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "用印方式不能为空") + @InEnum(value = OaSealUseModeEnum.class, message = "用印方式必须是 {value}") + private Integer mode; + + @Schema(description = "文件标题", example = "采购合同") + @Size(max = 255, message = "文件标题长度不能超过 {max} 个字符") + private String documentTitle; + + @Schema(description = "文件类型", example = "合同") + @Size(max = 64, message = "文件类型长度不能超过 {max} 个字符") + private String documentType; + + @Schema(description = "文件份数", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "文件份数不能为空") + @Min(value = 1, message = "文件份数不能小于 {value}") + private Integer documentCount; + + @Schema(description = "合同金额", example = "10000.00") + @DecimalMin(value = "0", message = "合同金额不能小于 {value}") + @Digits(integer = 16, fraction = 2, message = "合同金额整数位不能超过 {integer} 位,小数位不能超过 {fraction} 位") + private BigDecimal contractPrice; + + @Schema(description = "合同对方", example = "示例公司") + @Size(max = 255, message = "合同对方长度不能超过 {max} 个字符") + private String contractParty; + + @Schema(description = "预计用印时间", example = "1789088400000", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64") + @NotNull(message = "预计用印时间不能为空") + private LocalDateTime expectedUseTime; + + @Schema(description = "预计归还时间", example = "1789120800000", type = "integer", format = "int64") + private LocalDateTime expectedReturnTime; + + @Schema(description = "实际归还时间", example = "1789117200000", type = "integer", format = "int64") + private LocalDateTime actualReturnTime; + + @Schema(description = "是否紧急", example = "false", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "是否紧急不能为空") + private Boolean urgent; + + @Schema(description = "备注", example = "合同签署完成后归档") + @Size(max = 500, message = "备注长度不能超过 {max} 个字符") + private String remark; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/contract.pdf\"]") + @Size(max = 5, message = "附件地址列表数量不能超过 {max} 个") + private List<@NotBlank(message = "附件地址不能为空") @Size(max = 2048, message = "附件地址长度不能超过 {max} 个字符") String> fileUrls; +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/OaSupplyApplyController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/OaSupplyApplyController.java new file mode 100644 index 0000000000..93806cc005 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/OaSupplyApplyController.java @@ -0,0 +1,143 @@ +package cn.iocoder.yudao.module.oa.controller.admin.supply; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.apply.*; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.issue.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.supply.*; +import cn.iocoder.yudao.module.oa.service.supply.*; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 用品领用申请") +@RestController +@RequestMapping("/oa/supply-apply") +@Validated +public class OaSupplyApplyController { + + @Resource + private OaSupplyApplyService supplyApplyService; + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + @PostMapping("/create") + @Operation(summary = "创建用品领用申请") + @PreAuthorize("@ss.hasPermission('oa:supply-apply:create')") + public CommonResult createSupplyApply(@Valid @RequestBody OaSupplyApplySaveReqVO reqVO) { + return success(supplyApplyService.createSupplyApply(reqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新用品领用申请") + @PreAuthorize("@ss.hasPermission('oa:supply-apply:update')") + public CommonResult updateSupplyApply(@Valid @RequestBody OaSupplyApplySaveReqVO reqVO) { + supplyApplyService.updateSupplyApply(reqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除用品领用申请") + @Parameter(name = "id", description = "申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:supply-apply:delete')") + public CommonResult deleteSupplyApply(@RequestParam("id") Long id) { + supplyApplyService.deleteSupplyApply(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/submit") + @Operation(summary = "提交用品领用申请") + @Parameter(name = "id", description = "申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:supply-apply:create')") + public CommonResult submitSupplyApply(@RequestParam("id") Long id) { + supplyApplyService.submitSupplyApply(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/cancel") + @Operation(summary = "取消用品领用申请") + @Parameter(name = "id", description = "申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:supply-apply:update')") + public CommonResult cancelSupplyApply(@RequestParam("id") Long id) { + supplyApplyService.cancelSupplyApply(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得用品领用申请") + @Parameter(name = "id", description = "申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:supply-apply:query')") + public CommonResult getSupplyApply(@RequestParam("id") Long id) { + OaSupplyApplyDO apply = supplyApplyService.getSupplyApply(id); + return success(buildSupplyApplyRespVO(apply)); + } + + @GetMapping("/page") + @Operation(summary = "获得本人的用品领用申请分页") + @PreAuthorize("@ss.hasPermission('oa:supply-apply:query')") + public CommonResult> getSupplyApplyPage(@Valid OaSupplyApplyPageReqVO reqVO) { + PageResult page = supplyApplyService.getSupplyApplyPage(reqVO, getLoginUserId()); + return success(new PageResult<>(buildSupplyApplyRespVOList(page.getList()), page.getTotal())); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接用品领用申请详情 + * + * @param apply 领用申请 + * @return 领用申请详情 + */ + private OaSupplyApplyRespVO buildSupplyApplyRespVO(OaSupplyApplyDO apply) { + if (apply == null) { + return null; + } + List items = supplyApplyService.getSupplyApplyItemList(apply.getId()); + OaSupplyApplyRespVO respVO = CollUtil.getFirst(buildSupplyApplyRespVOList(Collections.singletonList(apply))); + respVO.setItems(BeanUtils.toBean(items, OaSupplyIssueRespVO.class)); + return respVO; + } + + /** + * 拼接申请人及申请部门 + * + * @param applies 申请列表 + * @return 申请响应列表 + */ + private List buildSupplyApplyRespVOList(List applies) { + if (CollUtil.isEmpty(applies)) { + return Collections.emptyList(); + } + // 1. 查询申请人及部门 + Map users = adminUserApi.getUserMap(convertSet(applies, apply -> Long.valueOf(apply.getCreator()))); + Map depts = deptApi.getDeptMap(convertSet(applies, OaSupplyApplyDO::getDeptId)); + // 2. 拼接展示字段 + return convertList(applies, apply -> { + OaSupplyApplyRespVO respVO = BeanUtils.toBean(apply, OaSupplyApplyRespVO.class); + MapUtils.findAndThen(users, Long.valueOf(apply.getCreator()), user -> respVO.setCreatorName(user.getNickname())); + MapUtils.findAndThen(depts, apply.getDeptId(), dept -> respVO.setDeptName(dept.getName())); + return respVO; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/OaSupplyIssueController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/OaSupplyIssueController.java new file mode 100644 index 0000000000..f05e9e3aaa --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/OaSupplyIssueController.java @@ -0,0 +1,104 @@ +package cn.iocoder.yudao.module.oa.controller.admin.supply; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.item.*; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.apply.*; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.issue.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.supply.*; +import cn.iocoder.yudao.module.oa.service.supply.*; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 领用发放") +@RestController +@RequestMapping("/oa/supply-issue") +@Validated +public class OaSupplyIssueController { + + @Resource + private OaSupplyApplyService supplyApplyService; + @Resource + private OaSupplyIssueService supplyIssueService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + @GetMapping("/page") + @Operation(summary = "获得领用发放分页") + @PreAuthorize("@ss.hasPermission('oa:supply-issue:query')") + public CommonResult> getSupplyIssuePage(@Valid OaSupplyIssuePageReqVO reqVO) { + PageResult page = supplyIssueService.getSupplyIssuePage(reqVO); + return success(new PageResult<>(buildSupplyApplyItemRespVOList(page.getList()), page.getTotal())); + } + + @PutMapping("/issue") + @Operation(summary = "发放用品") + @PreAuthorize("@ss.hasPermission('oa:supply-issue:issue')") + public CommonResult issueSupply(@Valid @RequestBody OaSupplyIssueReqVO reqVO) { + supplyIssueService.issueSupply(reqVO, getLoginUserId()); + return success(true); + } + + @PutMapping("/return") + @Operation(summary = "确认归还用品") + @PreAuthorize("@ss.hasPermission('oa:supply-issue:return')") + public CommonResult returnSupply(@Valid @RequestBody OaSupplyReturnReqVO reqVO) { + supplyIssueService.returnSupply(reqVO); + return success(true); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接领用发放展示字段 + * + * @param items 申请明细 + * @return 领用发放列表 + */ + private List buildSupplyApplyItemRespVOList(List items) { + if (CollUtil.isEmpty(items)) { + return Collections.emptyList(); + } + // 1.1 查询申请信息 + Map applies = supplyApplyService.getSupplyApplyMap(convertSet(items, OaSupplyApplyItemDO::getApplyId)); + // 1.2 查询申请人与发放人 + Set userIds = convertSet(applies.values(), apply -> Long.valueOf(apply.getCreator())); + userIds.addAll(convertSet(items, OaSupplyApplyItemDO::getIssueUserId)); + Map users = adminUserApi.getUserMap(userIds); + // 1.3 查询部门 + Map depts = deptApi.getDeptMap(convertSet(applies.values(), OaSupplyApplyDO::getDeptId)); + // 2. 拼接展示字段 + return convertList(items, item -> { + OaSupplyIssueRespVO respVO = BeanUtils.toBean(item, OaSupplyIssueRespVO.class); + MapUtils.findAndThen(applies, item.getApplyId(), apply -> { + respVO.setNo(apply.getNo()).setUseType(apply.getUseType()).setCreateTime(apply.getCreateTime()); + MapUtils.findAndThen(users, Long.valueOf(apply.getCreator()), user -> respVO.setCreatorName(user.getNickname())); + MapUtils.findAndThen(depts, apply.getDeptId(), dept -> respVO.setDeptName(dept.getName())); + }); + MapUtils.findAndThen(users, item.getIssueUserId(), user -> respVO.setIssueUserName(user.getNickname())); + return respVO; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/OaSupplyItemController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/OaSupplyItemController.java new file mode 100644 index 0000000000..9d5e8b47a7 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/OaSupplyItemController.java @@ -0,0 +1,107 @@ +package cn.iocoder.yudao.module.oa.controller.admin.supply; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +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.oa.controller.admin.supply.vo.item.*; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.apply.*; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.issue.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.supply.*; +import cn.iocoder.yudao.module.oa.service.supply.*; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 办公用品") +@RestController +@RequestMapping("/oa/supply-item") +@Validated +public class OaSupplyItemController { + + @Resource + private OaSupplyItemService supplyItemService; + @Resource + private DeptApi deptApi; + + @PostMapping("/create") + @Operation(summary = "创建办公用品") + @PreAuthorize("@ss.hasPermission('oa:supply-item:create')") + public CommonResult createSupplyItem(@Valid @RequestBody OaSupplyItemSaveReqVO reqVO) { + return success(supplyItemService.createSupplyItem(reqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新办公用品") + @PreAuthorize("@ss.hasPermission('oa:supply-item:update')") + public CommonResult updateSupplyItem(@Valid @RequestBody OaSupplyItemSaveReqVO reqVO) { + supplyItemService.updateSupplyItem(reqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除办公用品") + @Parameter(name = "id", description = "办公用品编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:supply-item:delete')") + public CommonResult deleteSupplyItem(@RequestParam("id") Long id) { + supplyItemService.deleteSupplyItem(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得办公用品") + @Parameter(name = "id", description = "办公用品编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:supply-item:query')") + public CommonResult getSupplyItem(@RequestParam("id") Long id) { + OaSupplyItemDO item = supplyItemService.getSupplyItem(id); + return success(BeanUtils.toBean(item, OaSupplyItemRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得办公用品分页") + @PreAuthorize("@ss.hasPermission('oa:supply-item:query')") + public CommonResult> getSupplyItemPage(@Valid OaSupplyItemPageReqVO reqVO) { + // 1. 查询物品及所属部门 + PageResult page = supplyItemService.getSupplyItemPage(reqVO); + Map depts = deptApi.getDeptMap(convertSet(page.getList(), OaSupplyItemDO::getDeptId)); + // 2. 拼接部门名称 + PageResult result = BeanUtils.toBean(page, OaSupplyItemRespVO.class); + result.getList().forEach(item -> { + DeptRespDTO dept = depts.get(item.getDeptId()); + if (dept != null) { + item.setDeptName(dept.getName()); + } + }); + return success(result); + } + + @GetMapping("/select-page") + @Operation(summary = "获得可领用的办公用品分页") + public CommonResult> getSupplyItemSelectPage(@Valid OaSupplyItemPageReqVO reqVO) { + reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus()); + PageResult page = supplyItemService.getSupplyItemPage(reqVO); + return success(BeanUtils.toBean(page, OaSupplyItemRespVO.class)); + } + + @PutMapping("/stock-in") + @Operation(summary = "办公用品入库") + @PreAuthorize("@ss.hasPermission('oa:supply-item:stock-in')") + public CommonResult stockInSupplyItem(@Valid @RequestBody OaSupplyItemStockInReqVO reqVO) { + supplyItemService.stockInSupplyItem(reqVO); + return success(true); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/apply/OaSupplyApplyPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/apply/OaSupplyApplyPageReqVO.java new file mode 100644 index 0000000000..0b7ca5f586 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/apply/OaSupplyApplyPageReqVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.oa.controller.admin.supply.vo.apply; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.module.oa.enums.supply.*; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 用品领用申请分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaSupplyApplyPageReqVO extends PageParam { + + @Schema(description = "申请单号", example = "YC202609140001") + private String no; + + @Schema(description = "单据状态", example = "-1") + private Integer status; + + @Schema(description = "申请部门编号", example = "1024") + private Long deptId; + + @Schema(description = "创建时间", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/apply/OaSupplyApplyRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/apply/OaSupplyApplyRespVO.java new file mode 100644 index 0000000000..9c0d80e727 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/apply/OaSupplyApplyRespVO.java @@ -0,0 +1,66 @@ +package cn.iocoder.yudao.module.oa.controller.admin.supply.vo.apply; + +import cn.iocoder.yudao.module.oa.enums.supply.*; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.issue.OaSupplyIssueRespVO; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 用品领用申请 Response VO") +@Data +public class OaSupplyApplyRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "领用日期", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime applyTime; + + @Schema(description = "使用类型", example = "1") + private Integer useType; + + @Schema(description = "领取方式", example = "1") + private Integer pickupMethod; + + @Schema(description = "申请事由", example = "领用部门日常办公用品") + private String reason; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + private List fileUrls; + + @Schema(description = "备注", example = "请提前联系行政部确认") + private String remark; + + @Schema(description = "申请单号", requiredMode = Schema.RequiredMode.REQUIRED, example = "YC202609140001") + private String no; + + @Schema(description = "申请人编号", example = "1024") + private String creator; + + @Schema(description = "申请人", example = "张三") + private String creatorName; + + @Schema(description = "申请部门编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long deptId; + + @Schema(description = "申请部门", example = "行政部") + private String deptName; + + @Schema(description = "单据状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "-1") + private Integer status; + + @Schema(description = "流程实例编号", example = "1024") + private String processInstanceId; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + + @Schema(description = "领用明细") + private List items; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/apply/OaSupplyApplySaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/apply/OaSupplyApplySaveReqVO.java new file mode 100644 index 0000000000..384240e499 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/apply/OaSupplyApplySaveReqVO.java @@ -0,0 +1,67 @@ +package cn.iocoder.yudao.module.oa.controller.admin.supply.vo.apply; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.supply.*; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 用品领用申请新增/修改 Request VO") +@Data +public class OaSupplyApplySaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "领用日期", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + @NotNull(message = "领用日期不能为空") + private LocalDateTime applyTime; + + @Schema(description = "使用类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "使用类型不能为空") + @InEnum(value = OaSupplyUseTypeEnum.class, message = "使用类型必须是 {value}") + private Integer useType; + + @Schema(description = "领取方式", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "领取方式不能为空") + @Min(value = 1, message = "领取方式不能小于 {value}") + private Integer pickupMethod; + + @Schema(description = "申请事由", requiredMode = Schema.RequiredMode.REQUIRED, example = "领用部门日常办公用品") + @NotBlank(message = "申请事由不能为空") + @Size(max = 500, message = "申请事由长度不能超过 {max} 个字符") + private String reason; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + @Size(max = 10, message = "附件地址列表数量不能超过 {max} 个") + private List<@NotBlank(message = "附件地址不能为空") String> fileUrls; + + @Schema(description = "备注", example = "请提前联系行政部确认") + @Size(max = 500, message = "备注长度不能超过 {max} 个字符") + private String remark; + + @Schema(description = "领用明细", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "领用明细不能为空") + @Valid + private List<@NotNull(message = "领用明细不能为空") Item> items; + + @Schema(description = "管理后台 - 用品申请明细 Request VO") + @Data + public static class Item { + + @Schema(description = "办公用品编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "办公用品不能为空") + private Long itemId; + + @Schema(description = "领用数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "领用数量不能为空") + @Min(value = 1, message = "领用数量不能小于 1") + private Integer applyQuantity; + + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/issue/OaSupplyIssuePageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/issue/OaSupplyIssuePageReqVO.java new file mode 100644 index 0000000000..1f9fc663b5 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/issue/OaSupplyIssuePageReqVO.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.oa.controller.admin.supply.vo.issue; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.module.oa.enums.supply.*; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 领用发放分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaSupplyIssuePageReqVO extends PageParam { + + @Schema(description = "物品名称", example = "A4 打印纸") + private String itemName; + + @Schema(description = "申请人", example = "张三") + private String creatorName; + + @Schema(description = "管理类型", example = "1") + private Integer manageType; + + @Schema(description = "使用类型", example = "1") + private Integer useType; + + @Schema(description = "明细状态", example = "0") + private Integer status; + + @Schema(description = "申请时间", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/issue/OaSupplyIssueReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/issue/OaSupplyIssueReqVO.java new file mode 100644 index 0000000000..e340c2aa84 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/issue/OaSupplyIssueReqVO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.oa.controller.admin.supply.vo.issue; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; + +@Schema(description = "管理后台 - 用品发放 Request VO") +@Data +public class OaSupplyIssueReqVO { + + @Schema(description = "明细编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "明细编号不能为空") + private Long id; + + @Schema(description = "实发数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "实发数量不能为空") + @Min(value = 1, message = "实发数量不能小于 {value}") + private Integer issuedQuantity; + + @Schema(description = "发放备注", example = "已按申请明细发放") + @Size(max = 500, message = "发放备注长度不能超过 {max} 个字符") + private String issueRemark; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/issue/OaSupplyIssueRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/issue/OaSupplyIssueRespVO.java new file mode 100644 index 0000000000..2318088c2a --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/issue/OaSupplyIssueRespVO.java @@ -0,0 +1,75 @@ +package cn.iocoder.yudao.module.oa.controller.admin.supply.vo.issue; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 用品领用发放 Response VO") +@Data +public class OaSupplyIssueRespVO { + + @Schema(description = "明细编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "申请编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long applyId; + + @Schema(description = "用品编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long itemId; + + @Schema(description = "物品名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "A4 打印纸") + private String itemName; + + @Schema(description = "规格型号", example = "A4 80g") + private String model; + + @Schema(description = "计量单位", example = "包") + private String unit; + + @Schema(description = "管理类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer manageType; + + @Schema(description = "申请数量", example = "1") + private Integer applyQuantity; + + @Schema(description = "实发数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer issuedQuantity; + + @Schema(description = "已归还数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer returnedQuantity; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "-1") + private Integer status; + + @Schema(description = "发放人编号", example = "1024") + private Long issueUserId; + + @Schema(description = "发放人", example = "张三") + private String issueUserName; + + @Schema(description = "发放时间", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime issueTime; + + @Schema(description = "发放备注", example = "已按申请明细发放") + private String issueRemark; + + @Schema(description = "归还备注", example = "物品完好,已归还入库") + private String returnRemark; + + @Schema(description = "申请单号", example = "YC202609140001") + private String no; + + @Schema(description = "申请人", example = "张三") + private String creatorName; + + @Schema(description = "申请部门", example = "行政部") + private String deptName; + + @Schema(description = "使用类型", example = "1") + private Integer useType; + + @Schema(description = "申请时间", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/issue/OaSupplyReturnReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/issue/OaSupplyReturnReqVO.java new file mode 100644 index 0000000000..81fd2605d2 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/issue/OaSupplyReturnReqVO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.oa.controller.admin.supply.vo.issue; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; + +@Schema(description = "管理后台 - 用品归还 Request VO") +@Data +public class OaSupplyReturnReqVO { + + @Schema(description = "明细编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "明细编号不能为空") + private Long id; + + @Schema(description = "本次归还数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "本次归还数量不能为空") + @Min(value = 1, message = "本次归还数量不能小于 {value}") + private Integer quantity; + + @Schema(description = "归还备注", example = "物品完好,已归还入库") + @Size(max = 500, message = "归还备注长度不能超过 {max} 个字符") + private String returnRemark; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/item/OaSupplyItemPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/item/OaSupplyItemPageReqVO.java new file mode 100644 index 0000000000..9968e9bf7d --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/item/OaSupplyItemPageReqVO.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.oa.controller.admin.supply.vo.item; + +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 OaSupplyItemPageReqVO extends PageParam { + + @Schema(description = "物品名称", example = "A4 打印纸") + private String name; + + @Schema(description = "物品编码", example = "BGYP0001") + private String no; + + @Schema(description = "类别", example = "1") + private Integer category; + + @Schema(description = "管理类型", example = "1") + private Integer manageType; + + @Schema(description = "状态", example = "0") + private Integer status; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/item/OaSupplyItemRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/item/OaSupplyItemRespVO.java new file mode 100644 index 0000000000..f1d80ac544 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/item/OaSupplyItemRespVO.java @@ -0,0 +1,68 @@ +package cn.iocoder.yudao.module.oa.controller.admin.supply.vo.item; + +import cn.iocoder.yudao.module.oa.enums.supply.*; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 办公用品 Response VO") +@Data +public class OaSupplyItemRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "所属部门编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long deptId; + + @Schema(description = "物品名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "A4 打印纸") + private String name; + + @Schema(description = "物品编码", example = "BGYP0001") + private String no; + + @Schema(description = "类别", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer category; + + @Schema(description = "管理类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer manageType; + + @Schema(description = "规格型号", example = "A4 80g") + private String model; + + @Schema(description = "计量单位", example = "包") + private String unit; + + @Schema(description = "参考单价", example = "100.00") + private BigDecimal referencePrice; + + @Schema(description = "库存数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer stockQuantity; + + @Schema(description = "最低库存", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer minStockQuantity; + + @Schema(description = "物品图片", example = "https://example.com/image.png") + private String picUrl; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + + @Schema(description = "显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer sort; + + @Schema(description = "备注", example = "请提前联系行政部确认") + private String remark; + + @Schema(description = "所属部门", example = "行政部") + private String deptName; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/item/OaSupplyItemSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/item/OaSupplyItemSaveReqVO.java new file mode 100644 index 0000000000..2a662aed18 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/item/OaSupplyItemSaveReqVO.java @@ -0,0 +1,84 @@ +package cn.iocoder.yudao.module.oa.controller.admin.supply.vo.item; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.supply.*; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.math.BigDecimal; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 办公用品新增/修改 Request VO") +@Data +public class OaSupplyItemSaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "所属部门编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "所属部门不能为空") + private Long deptId; + + @Schema(description = "物品名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "A4 打印纸") + @NotBlank(message = "物品名称不能为空") + @Size(max = 128, message = "物品名称长度不能超过 {max} 个字符") + private String name; + + @Schema(description = "物品编码", example = "BGYP0001") + @Size(max = 64, message = "物品编码长度不能超过 {max} 个字符") + private String no; + + @Schema(description = "类别", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "类别不能为空") + @InEnum(value = OaSupplyCategoryEnum.class, message = "类别必须是 {value}") + private Integer category; + + @Schema(description = "管理类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "管理类型不能为空") + @InEnum(value = OaSupplyManageTypeEnum.class, message = "管理类型必须是 {value}") + private Integer manageType; + + @Schema(description = "规格型号", example = "A4 80g") + @Size(max = 128, message = "规格型号长度不能超过 {max} 个字符") + private String model; + + @Schema(description = "计量单位", example = "包") + @Size(max = 32, message = "计量单位长度不能超过 {max} 个字符") + private String unit; + + @Schema(description = "参考单价", example = "100.00") + @DecimalMin(value = "0", message = "参考单价不能小于 {value}") + @Digits(integer = 16, fraction = 2, message = "参考单价整数位不能超过 {integer} 位,小数位不能超过 {fraction} 位") + private BigDecimal referencePrice; + + @Schema(description = "库存数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "库存数量不能为空") + @Min(value = 0, message = "库存数量不能小于 {value}") + private Integer stockQuantity; + + @Schema(description = "最低库存", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "最低库存不能为空") + @Min(value = 0, message = "最低库存不能小于 {value}") + private Integer minStockQuantity; + + @Schema(description = "物品图片", example = "https://example.com/image.png") + @Size(max = 2048, message = "物品图片长度不能超过 {max} 个字符") + private String picUrl; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + @InEnum(value = CommonStatusEnum.class, message = "状态必须是 {value}") + private Integer status; + + @Schema(description = "显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "显示顺序不能为空") + private Integer sort; + + @Schema(description = "备注", example = "请提前联系行政部确认") + @Size(max = 500, message = "备注长度不能超过 {max} 个字符") + private String remark; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/item/OaSupplyItemStockInReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/item/OaSupplyItemStockInReqVO.java new file mode 100644 index 0000000000..19e37ae2f7 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/supply/vo/item/OaSupplyItemStockInReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.oa.controller.admin.supply.vo.item; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - 办公用品入库 Request VO") +@Data +public class OaSupplyItemStockInReqVO { + + @Schema(description = "办公用品编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "办公用品编号不能为空") + private Long id; + + @Schema(description = "入库数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "入库数量不能为空") + @Min(value = 1, message = "入库数量不能小于 {value}") + private Integer quantity; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/OaTaskController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/OaTaskController.java new file mode 100644 index 0000000000..ffe0c3bbcc --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/OaTaskController.java @@ -0,0 +1,240 @@ +package cn.iocoder.yudao.module.oa.controller.admin.task; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.task.vo.OaTaskFeedbackReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.task.vo.OaTaskLogRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.task.vo.OaTaskPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.task.vo.OaTaskReceiverRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.task.vo.OaTaskRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.task.vo.OaTaskSaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.task.vo.OaTaskRankingRespVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.task.OaTaskDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.task.OaTaskLogDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.task.OaTaskReceiverDO; +import cn.iocoder.yudao.module.oa.service.task.OaTaskService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - OA 任务") +@RestController +@RequestMapping("/oa/task") +@Validated +public class OaTaskController { + + @Resource + private OaTaskService taskService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + // ==================== 我发布的 ==================== + + @PostMapping("/create") + @Operation(summary = "创建任务") + @PreAuthorize("@ss.hasPermission('oa:task:create')") + public CommonResult createTask(@Valid @RequestBody OaTaskSaveReqVO createReqVO) { + return success(taskService.createTask(createReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新任务") + @PreAuthorize("@ss.hasPermission('oa:task:update')") + public CommonResult updateTask(@Valid @RequestBody OaTaskSaveReqVO updateReqVO) { + taskService.updateTask(updateReqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除发布的任务") + @Parameter(name = "id", description = "任务编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:task:delete')") + public CommonResult deleteTask(@RequestParam("id") Long id) { + taskService.deleteTask(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/published-page") + @Operation(summary = "获得我发布的任务分页") + @PreAuthorize("@ss.hasPermission('oa:task:query')") + public CommonResult> getPublishedTaskPage(@Valid OaTaskPageReqVO pageReqVO) { + Long userId = getLoginUserId(); + PageResult pageResult = taskService.getPublishedTaskPage(pageReqVO, userId); + return success(new PageResult<>(buildTaskRespVOList(pageResult.getList(), Collections.emptyList(), userId), + pageResult.getTotal())); + } + + // ==================== 我的任务 ==================== + + @DeleteMapping("/delete-received") + @Operation(summary = "删除接收的任务") + @Parameter(name = "id", description = "任务编号", required = true, example = "1024") + public CommonResult deleteReceivedTask(@RequestParam("id") Long id) { + taskService.deleteReceivedTask(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/received-page") + @Operation(summary = "获得我的任务分页") + @PreAuthorize("@ss.hasPermission('oa:task:query')") + public CommonResult> getReceivedTaskPage(@Valid OaTaskPageReqVO pageReqVO) { + Long userId = getLoginUserId(); + PageResult pageResult = taskService.getReceivedTaskPage(pageReqVO, userId); + return success(new PageResult<>(buildTaskRespVOList(pageResult.getList(), Collections.emptyList(), userId), + pageResult.getTotal())); + } + + // ==================== 公共操作 ==================== + + @PostMapping("/feedback") + @Operation(summary = "创建任务反馈") + public CommonResult feedbackTask( + @Valid @RequestBody OaTaskFeedbackReqVO createReqVO) { + taskService.feedbackTask(createReqVO, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得任务详情") + @Parameter(name = "id", description = "任务编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:task:query')") + public CommonResult getTask(@RequestParam("id") Long id) { + Long userId = getLoginUserId(); + OaTaskDO task = taskService.getTask(id, userId); + List logs = taskService.getTaskLogList(id); + return success(buildTaskRespVO(task, logs, userId)); + } + + // ==================== 任务统计 ==================== + + @GetMapping("/get-status-count") + @Operation(summary = "获得我的任务状态统计") + public CommonResult> getTaskStatusCount() { + return success(taskService.getTaskStatusCountMap(getLoginUserId())); + } + + @GetMapping("/get-completed-ranking") + @Operation(summary = "获得任务完成排行") + public CommonResult> getCompletedTaskRanking() { + // 1. 查询完成任务数量和用户信息 + Map countMap = taskService.getCompletedTaskCountMapByUserId(); + if (CollUtil.isEmpty(countMap)) { + return success(Collections.emptyList()); + } + Map userMap = adminUserApi.getUserMap(countMap.keySet()); + // 2. 拼接排行展示字段,保留 Service 返回的排行顺序 + return success(convertList(countMap.entrySet(), entry -> { + OaTaskRankingRespVO ranking = new OaTaskRankingRespVO() + .setUserId(entry.getKey()).setCompletedCount(entry.getValue()); + MapUtils.findAndThen(userMap, entry.getKey(), user -> ranking.setUserName(user.getNickname())); + return ranking; + })); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接任务详情 + * + * @param task 任务 + * @param logs 反馈日志 + * @param loginUserId 当前登录用户编号 + * @return 任务响应 + */ + private OaTaskRespVO buildTaskRespVO(OaTaskDO task, List logs, Long loginUserId) { + if (task == null) { + return null; + } + return CollUtil.getFirst(buildTaskRespVOList(Collections.singletonList(task), logs, loginUserId)); + } + + /** + * 拼接任务响应列表 + * + * @param tasks 任务列表 + * @param logs 任务反馈日志列表 + * @param loginUserId 当前登录用户编号 + * @return 任务响应列表 + */ + private List buildTaskRespVOList(List tasks, List logs, + Long loginUserId) { + if (CollUtil.isEmpty(tasks)) { + return Collections.emptyList(); + } + // 1. 查询接收人、发布人和反馈人信息 + Map> receiverListMap = + taskService.getTaskReceiverListMap(convertSet(tasks, OaTaskDO::getId)); + Set userIds = Stream.of( + tasks.stream().map(task -> NumberUtils.parseLong(task.getCreator())), + receiverListMap.values().stream().flatMap(Collection::stream).map(OaTaskReceiverDO::getUserId), + logs.stream().map(OaTaskLogDO::getUserId)) + .flatMap(stream -> stream).filter(Objects::nonNull).collect(Collectors.toSet()); + Map userMap = adminUserApi.getUserMap(userIds); + Map deptMap = deptApi.getDeptMap(convertSet(userMap.values(), AdminUserRespDTO::getDeptId)); + // 2. 拼接任务、接收人和反馈信息 + Map> logListMap = convertMultiMap(logs, OaTaskLogDO::getTaskId); + return convertList(tasks, taskDO -> { + OaTaskRespVO task = BeanUtils.toBean(taskDO, OaTaskRespVO.class) + .setPublisherUserId(NumberUtils.parseLong(taskDO.getCreator())) + .setReceivers(Collections.emptyList()).setLogs(Collections.emptyList()); + MapUtils.findAndThen(userMap, task.getPublisherUserId(), user -> { + task.setPublisherUserName(user.getNickname()); + MapUtils.findAndThen(deptMap, user.getDeptId(), dept -> task.setPublisherDeptName(dept.getName())); + }); + MapUtils.findAndThen(receiverListMap, task.getId(), receivers -> { + task.setReceivers(BeanUtils.toBean(receivers, OaTaskReceiverRespVO.class, receiver -> + MapUtils.findAndThen(userMap, receiver.getUserId(), user -> { + receiver.setUserName(user.getNickname()); + MapUtils.findAndThen(deptMap, user.getDeptId(), dept -> receiver.setDeptName(dept.getName())); + }))); + OaTaskReceiverDO loginUserReceiver = CollUtil.findOne(receivers, receiver -> receiver.getUserId().equals(loginUserId)); + if (loginUserReceiver != null) { + task.setReceiverStatus(loginUserReceiver.getStatus()); + } + }); + MapUtils.findAndThen(logListMap, task.getId(), taskLogs -> + task.setLogs(BeanUtils.toBean(taskLogs, OaTaskLogRespVO.class, log -> + MapUtils.findAndThen(userMap, log.getUserId(), user -> log.setUserName(user.getNickname()))))); + return task; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskFeedbackReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskFeedbackReqVO.java new file mode 100644 index 0000000000..07a5a4351d --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskFeedbackReqVO.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.oa.controller.admin.task.vo; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.task.OaTaskStatusEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - OA 任务反馈新增 Request VO") +@Data +public class OaTaskFeedbackReqVO { + + @Schema(description = "任务编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "任务编号不能为空") + private Long taskId; + + @Schema(description = "是否从发布管理反馈", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + @NotNull(message = "反馈场景不能为空") + private Boolean publisher; + + @Schema(description = "任务状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + @NotNull(message = "任务状态不能为空") + @InEnum(value = OaTaskStatusEnum.class, message = "任务状态必须是 {value}") + private Integer status; + + @Schema(description = "反馈内容", example = "已完成盘点,结果已提交") + @Size(max = 1000, message = "反馈内容长度不能超过 1000 个字符") + private String content; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskLogRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskLogRespVO.java new file mode 100644 index 0000000000..f151676b95 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskLogRespVO.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.oa.controller.admin.task.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - OA 任务反馈日志 Response VO") +@Data +public class OaTaskLogRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "反馈人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long userId; + + @Schema(description = "反馈人昵称", example = "芋道") + private String userName; + + @Schema(description = "变更后的状态", example = "3") + private Integer status; + + @Schema(description = "反馈内容", example = "已完成盘点,结果已提交") + private String content; + + @Schema(description = "反馈时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskPageReqVO.java new file mode 100644 index 0000000000..249cdc0e69 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskPageReqVO.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.oa.controller.admin.task.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.task.OaTaskStatusEnum; +import cn.iocoder.yudao.module.oa.enums.task.OaTaskTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - OA 任务分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaTaskPageReqVO extends PageParam { + + @Schema(description = "标题", example = "完成 OA 任务管理迁移") + private String title; + + @Schema(description = "任务类型", example = "1") + @InEnum(value = OaTaskTypeEnum.class, message = "任务类型必须是 {value}") + private Integer type; + + @Schema(description = "任务状态", example = "1") + @InEnum(value = OaTaskStatusEnum.class, message = "任务状态必须是 {value}") + private Integer status; + + @Schema(description = "是否取消", example = "false") + private Boolean canceled; + + @Schema(description = "发布人用户编号", example = "1") + private Long publisherUserId; + + @Schema(description = "发布时间范围", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] publishTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskRankingRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskRankingRespVO.java new file mode 100644 index 0000000000..1d2d13a9ea --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskRankingRespVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.oa.controller.admin.task.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - OA 任务完成排行 Response VO") +@Data +public class OaTaskRankingRespVO { + + @Schema(description = "用户编号", example = "1") + private Long userId; + + @Schema(description = "用户昵称", example = "芋道") + private String userName; + + @Schema(description = "完成任务数量", example = "10") + private Long completedCount; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskReceiverRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskReceiverRespVO.java new file mode 100644 index 0000000000..5cbdd994fd --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskReceiverRespVO.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.oa.controller.admin.task.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - OA 任务接收人 Response VO") +@Data +public class OaTaskReceiverRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "接收人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long userId; + + @Schema(description = "接收人昵称", example = "芋道") + private String userName; + + @Schema(description = "接收人部门名称", example = "研发部") + private String deptName; + + @Schema(description = "接收状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Integer status; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime updateTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskRespVO.java new file mode 100644 index 0000000000..c6c34540f5 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskRespVO.java @@ -0,0 +1,70 @@ +package cn.iocoder.yudao.module.oa.controller.admin.task.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - OA 任务 Response VO") +@Data +public class OaTaskRespVO { + + @Schema(description = "任务编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "发布人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long publisherUserId; + + @Schema(description = "发布人昵称", example = "芋道") + private String publisherUserName; + + @Schema(description = "发布人部门名称", example = "研发部") + private String publisherDeptName; + + @Schema(description = "任务类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer type; + + @Schema(description = "总体状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Integer status; + + @Schema(description = "当前接收人的状态", example = "3") + private Integer receiverStatus; + + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "本周办公用品盘点") + private String title; + + @Schema(description = "任务描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "完成本周办公用品盘点并提交结果") + private String description; + + @Schema(description = "任务评价", example = "已按期完成,盘点结果准确") + private String comment; + + @Schema(description = "开始时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime startTime; + + @Schema(description = "结束时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789380000000") + private LocalDateTime endTime; + + @Schema(description = "是否置顶", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean top; + + @Schema(description = "是否取消", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean canceled; + + @Schema(description = "接收人列表") + private List receivers; + + @Schema(description = "反馈日志列表") + private List logs; + + @Schema(description = "发布时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime publishTime; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + + @Schema(description = "修改时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime updateTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskSaveReqVO.java new file mode 100644 index 0000000000..f4e02a00d9 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/task/vo/OaTaskSaveReqVO.java @@ -0,0 +1,75 @@ +package cn.iocoder.yudao.module.oa.controller.admin.task.vo; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.task.OaTaskTypeEnum; +import cn.iocoder.yudao.module.oa.enums.task.OaTaskStatusEnum; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +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.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - OA 任务新增/修改 Request VO") +@Data +public class OaTaskSaveReqVO { + + @Schema(description = "任务编号", example = "1024") + private Long id; + + @Schema(description = "任务类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "任务类型不能为空") + @InEnum(value = OaTaskTypeEnum.class, message = "任务类型必须是 {value}") + private Integer type; + + @Schema(description = "任务状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "任务状态不能为空") + @InEnum(value = OaTaskStatusEnum.class, message = "任务状态必须是 {value}") + private Integer status; + + @Schema(description = "是否置顶", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + @NotNull(message = "是否置顶不能为空") + private Boolean top; + + @Schema(description = "是否取消", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + @NotNull(message = "是否取消不能为空") + private Boolean canceled; + + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "完成 OA 任务管理迁移") + @NotBlank(message = "任务标题不能为空") + @Size(max = 255, message = "任务标题长度不能超过 255 个字符") + private String title; + + @Schema(description = "任务描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "完成本周办公用品盘点并提交结果") + @NotBlank(message = "任务描述不能为空") + @Size(max = 2000, message = "任务描述长度不能超过 2000 个字符") + private String description; + + @Schema(description = "任务评价", example = "已按期完成,盘点结果准确") + @Size(max = 1000, message = "任务评价长度不能超过 1000 个字符") + private String comment; + + @Schema(description = "开始时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + @NotNull(message = "开始时间不能为空") + private LocalDateTime startTime; + + @Schema(description = "结束时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789380000000") + @NotNull(message = "结束时间不能为空") + private LocalDateTime endTime; + + @Schema(description = "接收人用户编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1, 2]") + @NotEmpty(message = "任务接收人不能为空") + private List<@NotNull(message = "接收人编号不能为空") Long> receiverUserIds; + + @JsonIgnore + @AssertTrue(message = "任务结束时间必须晚于开始时间") + public boolean isTimeRangeValid() { + return startTime == null || endTime == null || startTime.isBefore(endTime); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/OaTravelApplyController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/OaTravelApplyController.java new file mode 100644 index 0000000000..14da785738 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/OaTravelApplyController.java @@ -0,0 +1,146 @@ +package cn.iocoder.yudao.module.oa.controller.admin.travel; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.travel.vo.apply.*; +import cn.iocoder.yudao.module.oa.controller.admin.travel.vo.apply.OaTravelApplyRespVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.travel.*; +import cn.iocoder.yudao.module.oa.service.travel.*; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import java.util.*; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 出差申请") +@RestController +@RequestMapping("/oa/travel-apply") +@Validated +public class OaTravelApplyController { + + @Resource + private OaTravelApplyService travelApplyService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + @PostMapping("/create") + @Operation(summary = "创建出差申请草稿") + @PreAuthorize("@ss.hasPermission('oa:travel-apply:save')") + public CommonResult createTravelApply(@Valid @RequestBody OaTravelApplySaveReqVO reqVO) { + return success(travelApplyService.createTravelApply(reqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新出差申请草稿") + @PreAuthorize("@ss.hasPermission('oa:travel-apply:save')") + public CommonResult updateTravelApply(@Valid @RequestBody OaTravelApplySaveReqVO reqVO) { + travelApplyService.updateTravelApply(reqVO, getLoginUserId()); + return success(true); + } + + @PostMapping("/submit") + @Operation(summary = "提交出差申请") + @PreAuthorize("@ss.hasPermission('oa:travel-apply:save')") + public CommonResult submitTravelApply(@Valid @RequestBody OaTravelApplySubmitReqVO submitReqVO) { + travelApplyService.submitTravelApply(submitReqVO, getLoginUserId()); + return success(true); + } + + @PutMapping("/cancel") + @Operation(summary = "撤回出差申请") + @Parameter(name = "id", description = "申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:travel-apply:save')") + public CommonResult cancelTravelApply(@RequestParam("id") Long id) { + travelApplyService.cancelTravelApply(id, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除出差申请") + @Parameter(name = "id", description = "申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:travel-apply:delete')") + public CommonResult deleteTravelApply(@RequestParam("id") Long id) { + travelApplyService.deleteTravelApply(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得出差申请详情") + @Parameter(name = "id", description = "申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:travel-apply:query')") + public CommonResult getTravelApply(@RequestParam("id") Long id) { + OaTravelApplyDO record = travelApplyService.getTravelApply(id); + return success(buildTravelApplyRespVO(record)); + } + + @GetMapping("/page") + @Operation(summary = "获得本人出差申请分页") + @PreAuthorize("@ss.hasPermission('oa:travel-apply:query')") + public CommonResult> getTravelApplyPage(@Valid OaTravelApplyPageReqVO reqVO) { + PageResult page = travelApplyService.getTravelApplyPage(getLoginUserId(), reqVO); + return success(new PageResult<>(buildTravelApplyRespVOList(page.getList()), page.getTotal())); + } + + @GetMapping("/approved-list") + @Operation(summary = "获得本人可关联的已通过出差申请") + public CommonResult> getApprovedTravelApplyList() { + List applies = travelApplyService.getApprovedTravelApplyList(getLoginUserId()); + return success(BeanUtils.toBean(applies, OaTravelApplyRespVO.class)); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接出差申请详情 + * + * @param record 单据 + * @return 响应信息 + */ + private OaTravelApplyRespVO buildTravelApplyRespVO(OaTravelApplyDO record) { + if (record == null) { + return null; + } + return CollUtil.getFirst(buildTravelApplyRespVOList(Collections.singletonList(record))); + } + + /** + * 批量拼接申请人与部门展示信息 + * + * @param records 单据列表 + * @return 响应列表 + */ + private List buildTravelApplyRespVOList(List records) { + if (CollUtil.isEmpty(records)) { + return Collections.emptyList(); + } + // 1. 批量查询展示信息 + Map users = adminUserApi.getUserMap( + convertSet(records, record -> Long.valueOf(record.getCreator()))); + Map depts = deptApi.getDeptMap(convertSet(records, OaTravelApplyDO::getDeptId)); + // 2. 转换并拼接 VO + return convertList(records, record -> { + OaTravelApplyRespVO vo = BeanUtils.toBean(record, OaTravelApplyRespVO.class); + MapUtils.findAndThen(users, Long.valueOf(record.getCreator()), user -> vo.setCreatorName(user.getNickname())); + MapUtils.findAndThen(depts, record.getDeptId(), dept -> vo.setDeptName(dept.getName())); + return vo; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/OaTravelReimbursementController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/OaTravelReimbursementController.java new file mode 100644 index 0000000000..93434aa4cd --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/OaTravelReimbursementController.java @@ -0,0 +1,143 @@ +package cn.iocoder.yudao.module.oa.controller.admin.travel; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.travel.vo.reimbursement.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.travel.*; +import cn.iocoder.yudao.module.oa.service.travel.*; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import java.util.*; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 出差报销") +@RestController +@RequestMapping("/oa/travel-reimbursement") +@Validated +public class OaTravelReimbursementController { + + @Resource + private OaTravelReimbursementService travelReimbursementService; + @Resource + private OaTravelApplyService travelApplyService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + @PostMapping("/create") + @Operation(summary = "创建出差报销草稿") + @PreAuthorize("@ss.hasPermission('oa:travel-reimbursement:save')") + public CommonResult createTravelReimbursement(@Valid @RequestBody OaTravelReimbursementSaveReqVO reqVO) { + return success(travelReimbursementService.createTravelReimbursement(reqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新出差报销草稿") + @PreAuthorize("@ss.hasPermission('oa:travel-reimbursement:save')") + public CommonResult updateTravelReimbursement(@Valid @RequestBody OaTravelReimbursementSaveReqVO reqVO) { + travelReimbursementService.updateTravelReimbursement(reqVO, getLoginUserId()); + return success(true); + } + + @PostMapping("/submit") + @Operation(summary = "提交出差报销") + @PreAuthorize("@ss.hasPermission('oa:travel-reimbursement:save')") + public CommonResult submitTravelReimbursement(@Valid @RequestBody OaTravelReimbursementSubmitReqVO submitReqVO) { + travelReimbursementService.submitTravelReimbursement(submitReqVO, getLoginUserId()); + return success(true); + } + + @PutMapping("/cancel") + @Operation(summary = "撤回出差报销") + @Parameter(name = "id", description = "报销编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:travel-reimbursement:save')") + public CommonResult cancelTravelReimbursement(@RequestParam("id") Long id) { + travelReimbursementService.cancelTravelReimbursement(id, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除出差报销") + @Parameter(name = "id", description = "报销编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:travel-reimbursement:delete')") + public CommonResult deleteTravelReimbursement(@RequestParam("id") Long id) { + travelReimbursementService.deleteTravelReimbursement(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得出差报销详情") + @Parameter(name = "id", description = "报销编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:travel-reimbursement:query')") + public CommonResult getTravelReimbursement(@RequestParam("id") Long id) { + OaTravelReimbursementDO record = travelReimbursementService.getTravelReimbursement(id); + return success(buildTravelReimbursementRespVO(record)); + } + + @GetMapping("/page") + @Operation(summary = "获得本人出差报销分页") + @PreAuthorize("@ss.hasPermission('oa:travel-reimbursement:query')") + public CommonResult> getTravelReimbursementPage(@Valid OaTravelReimbursementPageReqVO reqVO) { + PageResult page = travelReimbursementService.getTravelReimbursementPage(getLoginUserId(), reqVO); + return success(new PageResult<>(buildTravelReimbursementRespVOList(page.getList()), page.getTotal())); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接出差报销详情 + * + * @param record 单据 + * @return 响应信息 + */ + private OaTravelReimbursementRespVO buildTravelReimbursementRespVO(OaTravelReimbursementDO record) { + if (record == null) { + return null; + } + return CollUtil.getFirst(buildTravelReimbursementRespVOList(Collections.singletonList(record))); + } + + /** + * 批量拼接申请人与部门展示信息 + * + * @param records 单据列表 + * @return 响应列表 + */ + private List buildTravelReimbursementRespVOList(List records) { + if (CollUtil.isEmpty(records)) { + return Collections.emptyList(); + } + // 1. 批量查询展示信息 + Map users = adminUserApi.getUserMap( + convertSet(records, record -> Long.valueOf(record.getCreator()))); + Map depts = deptApi.getDeptMap(convertSet(records, OaTravelReimbursementDO::getDeptId)); + Map applies = travelApplyService.getTravelApplyMap( + convertSet(records, OaTravelReimbursementDO::getTravelApplyId, record -> record.getTravelApplyId() != null)); + // 2. 转换并拼接 VO + return convertList(records, record -> { + OaTravelReimbursementRespVO vo = BeanUtils.toBean(record, OaTravelReimbursementRespVO.class); + MapUtils.findAndThen(users, Long.valueOf(record.getCreator()), user -> vo.setCreatorName(user.getNickname())); + MapUtils.findAndThen(depts, record.getDeptId(), dept -> vo.setDeptName(dept.getName())); + MapUtils.findAndThen(applies, record.getTravelApplyId(), apply -> vo.setTravelApplyNo(apply.getNo())); + return vo; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/apply/OaTravelApplyPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/apply/OaTravelApplyPageReqVO.java new file mode 100644 index 0000000000..394fad32f6 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/apply/OaTravelApplyPageReqVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.oa.controller.admin.travel.vo.apply; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; +import java.time.LocalDateTime; +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 出差申请分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaTravelApplyPageReqVO extends PageParam { + + @Schema(description = "单据编号", example = "CC202609140001") + private String no; + + @Schema(description = "审批状态", example = "-1") + private Integer status; + + @Schema(description = "申请部门编号", example = "1024") + private Long deptId; + + @Schema(description = "创建时间", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + + @Schema(description = "报销状态", example = "false") + private Boolean reimburseStatus; + + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/apply/OaTravelApplyRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/apply/OaTravelApplyRespVO.java new file mode 100644 index 0000000000..49583006af --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/apply/OaTravelApplyRespVO.java @@ -0,0 +1,72 @@ +package cn.iocoder.yudao.module.oa.controller.admin.travel.vo.apply; + +import cn.iocoder.yudao.module.oa.dal.dataobject.travel.OaTravelApplyDO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 出差申请 Response VO") +@Data +public class OaTravelApplyRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "单据编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "CC202609140001") + private String no; + + @Schema(description = "出差事由", example = "赴北京参加客户项目验收") + private String reason; + + @Schema(description = "开始日期", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime startTime; + + @Schema(description = "结束日期", type = "integer", format = "int64", example = "1789380000000") + private LocalDateTime endTime; + + @Schema(description = "同行人", example = "张三、李四") + private String companion; + + @Schema(description = "预计费用", example = "100.00") + private BigDecimal estimatedPrice; + + @Schema(description = "备注", example = "请提前联系行政部确认") + private String remark; + + @Schema(description = "出差天数", example = "1") + private Integer days; + + @Schema(description = "审批状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "-1") + private Integer status; + + @Schema(description = "报销状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean reimburseStatus; + + @Schema(description = "流程实例编号", example = "1024") + private String processInstanceId; + + @Schema(description = "申请人编号", example = "1024") + private String creator; + + @Schema(description = "申请人姓名", example = "张三") + private String creatorName; + + @Schema(description = "申请部门编号", example = "1024") + private Long deptId; + + @Schema(description = "申请部门", example = "行政部") + private String deptName; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + + @Schema(description = "明细") + private List items; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + private List fileUrls; + + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/apply/OaTravelApplySaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/apply/OaTravelApplySaveReqVO.java new file mode 100644 index 0000000000..c8fc155c93 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/apply/OaTravelApplySaveReqVO.java @@ -0,0 +1,93 @@ +package cn.iocoder.yudao.module.oa.controller.admin.travel.vo.apply; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import cn.iocoder.yudao.framework.dict.validation.InDict; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.Valid; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 出差申请新增/修改 Request VO") +@Data +public class OaTravelApplySaveReqVO { + + @Schema(description = "编号,新建时不传", example = "1024") + private Long id; + + @Schema(description = "出差事由", requiredMode = Schema.RequiredMode.REQUIRED, example = "赴北京参加客户项目验收") + @NotBlank(message = "出差事由不能为空") + private String reason; + + @Schema(description = "开始日期", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + @NotNull(message = "开始日期不能为空") + private LocalDateTime startTime; + + @Schema(description = "结束日期", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789380000000") + @NotNull(message = "结束日期不能为空") + private LocalDateTime endTime; + + @Schema(description = "同行人", example = "张三、李四") + private String companion; + + @Schema(description = "预计费用", example = "100.00") + @DecimalMin(value = "0", message = "金额不能小于零") + private BigDecimal estimatedPrice; + + @Schema(description = "备注", example = "请提前联系行政部确认") + private String remark; + + @Schema(description = "行程明细", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "请添加有效明细") + private List<@NotNull(message = "明细不能为空") @Valid Item> items; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + private List<@NotBlank(message = "附件地址不能为空") String> fileUrls; + + @AssertTrue(message = "结束日期不能早于开始日期") + @JsonIgnore + public boolean isTimeRangeValid() { + return startTime == null || endTime == null + || !endTime.isBefore(startTime); + } + + @Schema(description = "管理后台 - 出差申请明细 Request VO") + @Data + public static class Item { + + @Schema(description = "出发地区编号", example = "110101") + private Integer departureAreaId; + + @Schema(description = "到达地区编号", example = "110101") + private Integer arrivalAreaId; + + @Schema(description = "开始日期", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime startTime; + + @Schema(description = "结束日期", type = "integer", format = "int64", example = "1789380000000") + private LocalDateTime endTime; + + @Schema(description = "交通方式", example = "2") + @InDict(type = DictTypeConstants.TRANSPORT_TYPE, message = "交通方式必须是 {value}") + private Integer transportType; + + @Schema(description = "备注", example = "请提前联系行政部确认") + private String remark; + + @AssertTrue(message = "行程结束日期不能早于开始日期") + @JsonIgnore + public boolean isTimeRangeValid() { + return startTime == null || endTime == null + || !endTime.toLocalDate().isBefore(startTime.toLocalDate()); + } + + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/apply/OaTravelApplySubmitReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/apply/OaTravelApplySubmitReqVO.java new file mode 100644 index 0000000000..24a14b51a3 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/apply/OaTravelApplySubmitReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.oa.controller.admin.travel.vo.apply; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Schema(description = "管理后台 - 出差申请提交 Request VO") +@Data +public class OaTravelApplySubmitReqVO { + + @Schema(description = "单据编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "单据编号不能为空") + private Long id; + + @Schema(description = "发起人自选审批人") + private Map> startUserSelectAssignees; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/reimbursement/OaTravelReimbursementPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/reimbursement/OaTravelReimbursementPageReqVO.java new file mode 100644 index 0000000000..7cd87f418e --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/reimbursement/OaTravelReimbursementPageReqVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.oa.controller.admin.travel.vo.reimbursement; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; +import java.time.LocalDateTime; +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 出差报销分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaTravelReimbursementPageReqVO extends PageParam { + + @Schema(description = "单据编号", example = "CLBX202609140001") + private String no; + + @Schema(description = "审批状态", example = "-1") + private Integer status; + + @Schema(description = "申请部门编号", example = "1024") + private Long deptId; + + @Schema(description = "创建时间", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + + @Schema(description = "支付状态", example = "false") + private Boolean payStatus; + + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/reimbursement/OaTravelReimbursementRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/reimbursement/OaTravelReimbursementRespVO.java new file mode 100644 index 0000000000..e0aeb195d2 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/reimbursement/OaTravelReimbursementRespVO.java @@ -0,0 +1,75 @@ +package cn.iocoder.yudao.module.oa.controller.admin.travel.vo.reimbursement; + +import cn.iocoder.yudao.module.oa.dal.dataobject.travel.OaTravelReimbursementDO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 出差报销 Response VO") +@Data +public class OaTravelReimbursementRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "单据编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "CLBX202609140001") + private String no; + + @Schema(description = "出差事由", example = "赴北京参加客户项目验收") + private String reason; + + @Schema(description = "开始日期", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime startTime; + + @Schema(description = "结束日期", type = "integer", format = "int64", example = "1789380000000") + private LocalDateTime endTime; + + @Schema(description = "关联出差申请编号", example = "1024") + private Long travelApplyId; + + @Schema(description = "备注", example = "请提前联系行政部确认") + private String remark; + + @Schema(description = "出差天数", example = "1") + private Integer days; + + @Schema(description = "审批状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "-1") + private Integer status; + + @Schema(description = "支付状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean payStatus; + + @Schema(description = "报销总金额", example = "100.00") + private BigDecimal totalPrice; + + @Schema(description = "关联出差单号", example = "CC202609140001") + private String travelApplyNo; + + @Schema(description = "流程实例编号", example = "1024") + private String processInstanceId; + + @Schema(description = "申请人编号", example = "1024") + private String creator; + + @Schema(description = "申请人姓名", example = "张三") + private String creatorName; + + @Schema(description = "申请部门编号", example = "1024") + private Long deptId; + + @Schema(description = "申请部门", example = "行政部") + private String deptName; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + + @Schema(description = "明细") + private List items; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + private List fileUrls; + + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/reimbursement/OaTravelReimbursementSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/reimbursement/OaTravelReimbursementSaveReqVO.java new file mode 100644 index 0000000000..7ef711da6b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/reimbursement/OaTravelReimbursementSaveReqVO.java @@ -0,0 +1,85 @@ +package cn.iocoder.yudao.module.oa.controller.admin.travel.vo.reimbursement; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import cn.iocoder.yudao.framework.dict.validation.InDict; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.Valid; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 出差报销新增/修改 Request VO") +@Data +public class OaTravelReimbursementSaveReqVO { + + @Schema(description = "编号,新建时不传", example = "1024") + private Long id; + + @Schema(description = "出差事由", requiredMode = Schema.RequiredMode.REQUIRED, example = "赴北京参加客户项目验收") + @NotBlank(message = "出差事由不能为空") + private String reason; + + @Schema(description = "开始日期", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + @NotNull(message = "开始日期不能为空") + private LocalDateTime startTime; + + @Schema(description = "结束日期", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789380000000") + @NotNull(message = "结束日期不能为空") + private LocalDateTime endTime; + + @Schema(description = "关联出差申请编号", example = "1024") + private Long travelApplyId; + + @Schema(description = "备注", example = "请提前联系行政部确认") + private String remark; + + @Schema(description = "费用明细", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "请添加有效明细") + private List<@NotNull(message = "明细不能为空") @Valid Item> items; + + @Schema(description = "附件地址列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"https://example.com/file.pdf\"]") + @NotEmpty(message = "请上传报销附件") + private List<@NotBlank(message = "附件地址不能为空") String> fileUrls; + + @AssertTrue(message = "结束日期不能早于开始日期") + @JsonIgnore + public boolean isTimeRangeValid() { + return startTime == null || endTime == null + || !endTime.isBefore(startTime); + } + + @Schema(description = "管理后台 - 出差报销明细 Request VO") + @Data + public static class Item { + + @Schema(description = "费用类型", example = "1") + @InDict(type = DictTypeConstants.EXPENSE_TYPE, message = "费用类型必须是 {value}") + private Integer expenseType; + + @Schema(description = "发生日期", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime expenseTime; + + @Schema(description = "出发地", example = "上海") + private String departureCity; + + @Schema(description = "到达地", example = "北京") + private String arrivalCity; + + @Schema(description = "金额", requiredMode = Schema.RequiredMode.REQUIRED, example = "100.00") + @NotNull(message = "费用明细金额不能为空") + @DecimalMin(value = "0", message = "金额不能小于零") + private BigDecimal price; + + @Schema(description = "费用说明", example = "客户项目验收往返交通费") + private String description; + + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/reimbursement/OaTravelReimbursementSubmitReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/reimbursement/OaTravelReimbursementSubmitReqVO.java new file mode 100644 index 0000000000..da5d916495 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/travel/vo/reimbursement/OaTravelReimbursementSubmitReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.oa.controller.admin.travel.vo.reimbursement; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Schema(description = "管理后台 - 出差报销提交 Request VO") +@Data +public class OaTravelReimbursementSubmitReqVO { + + @Schema(description = "单据编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "单据编号不能为空") + private Long id; + + @Schema(description = "发起人自选审批人") + private Map> startUserSelectAssignees; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/OaVehicleApplyController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/OaVehicleApplyController.java new file mode 100644 index 0000000000..8d41393a0a --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/OaVehicleApplyController.java @@ -0,0 +1,155 @@ +package cn.iocoder.yudao.module.oa.controller.admin.vehicle; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.OaVehiclePageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.OaVehicleRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.apply.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.OaVehicleApplyDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.OaVehicleDO; +import cn.iocoder.yudao.module.oa.service.vehicle.OaVehicleApplyService; +import cn.iocoder.yudao.module.oa.service.vehicle.OaVehicleService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 用车申请") +@RestController +@RequestMapping("/oa/vehicle-apply") +@Validated +public class OaVehicleApplyController { + + @Resource + private OaVehicleApplyService vehicleApplyService; + @Resource + private OaVehicleService vehicleService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + @GetMapping("/vehicle-page") + @Operation(summary = "获得用车申请可选的车辆分页") + @PreAuthorize("@ss.hasPermission('oa:vehicle-apply:create')") + public CommonResult> getAvailableVehiclePage(@Valid OaVehiclePageReqVO pageReqVO) { + PageResult pageResult = vehicleService.getVehiclePage(pageReqVO); + return success(BeanUtils.toBean(pageResult, OaVehicleRespVO.class)); + } + + @PostMapping("/create") + @Operation(summary = "创建用车申请草稿") + @PreAuthorize("@ss.hasPermission('oa:vehicle-apply:create')") + public CommonResult createVehicleApply(@Valid @RequestBody OaVehicleApplySaveReqVO reqVO) { + return success(vehicleApplyService.createVehicleApply(reqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新用车申请草稿") + @PreAuthorize("@ss.hasPermission('oa:vehicle-apply:update')") + public CommonResult updateVehicleApply(@Valid @RequestBody OaVehicleApplySaveReqVO reqVO) { + vehicleApplyService.updateVehicleApply(reqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除用车申请草稿") + @Parameter(name = "id", description = "申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:vehicle-apply:delete')") + public CommonResult deleteVehicleApply(@RequestParam("id") Long id) { + vehicleApplyService.deleteVehicleApply(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/submit") + @Operation(summary = "提交用车申请") + @Parameter(name = "id", description = "申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:vehicle-apply:create')") + public CommonResult submitVehicleApply(@RequestParam("id") Long id) { + vehicleApplyService.submitVehicleApply(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/cancel") + @Operation(summary = "撤销用车申请") + @Parameter(name = "id", description = "申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:vehicle-apply:update')") + public CommonResult cancelVehicleApply(@RequestParam("id") Long id) { + vehicleApplyService.cancelVehicleApply(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得用车申请详情") + @Parameter(name = "id", description = "申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:vehicle-apply:query')") + public CommonResult getVehicleApply(@RequestParam("id") Long id) { + OaVehicleApplyDO apply = vehicleApplyService.getVehicleApply(id); + return success(buildVehicleApplyRespVO(apply)); + } + + @GetMapping("/page") + @Operation(summary = "获得本人的用车申请分页") + @PreAuthorize("@ss.hasPermission('oa:vehicle-apply:query')") + public CommonResult> getVehicleApplyPage(@Valid OaVehicleApplyPageReqVO pageReqVO) { + PageResult pageResult = vehicleApplyService.getVehicleApplyPage(getLoginUserId(), pageReqVO); + return success(new PageResult<>(buildVehicleApplyRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接用车申请详情 + * + * @param apply 用车申请 + * @return 用车申请响应 + */ + private OaVehicleApplyRespVO buildVehicleApplyRespVO(OaVehicleApplyDO apply) { + if (apply == null) { + return null; + } + return CollUtil.getFirst(buildVehicleApplyRespVOList(Collections.singletonList(apply))); + } + + /** + * 构建用车申请响应列表,批量拼接关联名称 + * + * @param applies 申请列表 + * @return 申请响应列表 + */ + private List buildVehicleApplyRespVOList(List applies) { + if (CollUtil.isEmpty(applies)) { + return Collections.emptyList(); + } + // 1. 批量查询申请人、申请部门和关联业务信息 + Set userIds = convertSet(applies, OaVehicleApplyDO::getUserId); + Map userMap = adminUserApi.getUserMap(userIds); + Map deptMap = deptApi.getDeptMap(convertSet(applies, OaVehicleApplyDO::getDeptId)); + // 2. 转换并拼接展示字段 + return convertList(applies, item -> { + OaVehicleApplyRespVO vo = BeanUtils.toBean(item, OaVehicleApplyRespVO.class); + MapUtils.findAndThen(userMap, item.getUserId(), user -> vo.setUserName(user.getNickname())); + MapUtils.findAndThen(deptMap, item.getDeptId(), dept -> vo.setDeptName(dept.getName())); + return vo; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/OaVehicleController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/OaVehicleController.java new file mode 100644 index 0000000000..5f56d85f06 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/OaVehicleController.java @@ -0,0 +1,114 @@ +package cn.iocoder.yudao.module.oa.controller.admin.vehicle; + +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.oa.controller.admin.vehicle.vo.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.OaVehicleDO; +import cn.iocoder.yudao.module.oa.service.vehicle.OaVehicleService; +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 cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import java.util.*; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +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("/oa/vehicle") +@Validated +public class OaVehicleController { + + @Resource + private DeptApi deptApi; + + @Resource + private OaVehicleService vehicleService; + + @PostMapping("/create") + @Operation(summary = "创建车辆") + @PreAuthorize("@ss.hasPermission('oa:vehicle:create')") + public CommonResult createVehicle(@Valid @RequestBody OaVehicleSaveReqVO createReqVO) { + return success(vehicleService.createVehicle(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新车辆") + @PreAuthorize("@ss.hasPermission('oa:vehicle:update')") + public CommonResult updateVehicle(@Valid @RequestBody OaVehicleSaveReqVO updateReqVO) { + vehicleService.updateVehicle(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除车辆") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:vehicle:delete')") + public CommonResult deleteVehicle(@RequestParam("id") Long id) { + vehicleService.deleteVehicle(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得车辆") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:vehicle:query')") + public CommonResult getVehicle(@RequestParam("id") Long id) { + OaVehicleDO vehicle = vehicleService.validateVehicleExists(id); + return success(buildVehicleRespVO(vehicle)); + } + + @GetMapping("/page") + @Operation(summary = "获得车辆分页") + @PreAuthorize("@ss.hasPermission('oa:vehicle:query')") + public CommonResult> getVehiclePage(@Valid OaVehiclePageReqVO pageReqVO) { + PageResult pageResult = vehicleService.getVehiclePage(pageReqVO); + return success(new PageResult<>(buildVehicleRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接车辆详情 + * + * @param vehicle 车辆 + * @return 车辆响应 + */ + private OaVehicleRespVO buildVehicleRespVO(OaVehicleDO vehicle) { + if (vehicle == null) { + return null; + } + return CollUtil.getFirst(buildVehicleRespVOList(Collections.singletonList(vehicle))); + } + + /** + * 构建车辆响应列表,批量拼接所属部门名称 + * + * @param vehicles 车辆列表 + * @return 车辆响应列表 + */ + private List buildVehicleRespVOList(List vehicles) { + if (CollUtil.isEmpty(vehicles)) { + return Collections.emptyList(); + } + // 1. 批量查询所属部门 + Map deptMap = deptApi.getDeptMap(convertSet(vehicles, OaVehicleDO::getDeptId)); + // 2. 转换并拼接部门名称 + return convertList(vehicles, vehicle -> { + OaVehicleRespVO respVO = BeanUtils.toBean(vehicle, OaVehicleRespVO.class); + MapUtils.findAndThen(deptMap, vehicle.getDeptId(), dept -> respVO.setDeptName(dept.getName())); + return respVO; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/OaVehicleReturnController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/OaVehicleReturnController.java new file mode 100644 index 0000000000..cd7bc58f47 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/OaVehicleReturnController.java @@ -0,0 +1,148 @@ +package cn.iocoder.yudao.module.oa.controller.admin.vehicle; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.returning.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.OaVehicleApplyDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.OaVehicleReturnDO; +import cn.iocoder.yudao.module.oa.service.vehicle.OaVehicleApplyService; +import cn.iocoder.yudao.module.oa.service.vehicle.OaVehicleReturnService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 还车申请") +@RestController +@RequestMapping("/oa/vehicle-return") +@Validated +public class OaVehicleReturnController { + + @Resource + private OaVehicleApplyService vehicleApplyService; + @Resource + private OaVehicleReturnService vehicleReturnService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + @PostMapping("/create") + @Operation(summary = "创建还车申请草稿") + @PreAuthorize("@ss.hasPermission('oa:vehicle-return:create')") + public CommonResult createVehicleReturn(@Valid @RequestBody OaVehicleReturnSaveReqVO reqVO) { + return success(vehicleReturnService.createVehicleReturn(reqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新还车申请草稿") + @PreAuthorize("@ss.hasPermission('oa:vehicle-return:update')") + public CommonResult updateVehicleReturn(@Valid @RequestBody OaVehicleReturnSaveReqVO reqVO) { + vehicleReturnService.updateVehicleReturn(reqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除还车申请草稿") + @Parameter(name = "id", description = "还车申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:vehicle-return:delete')") + public CommonResult deleteVehicleReturn(@RequestParam("id") Long id) { + vehicleReturnService.deleteVehicleReturn(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/submit") + @Operation(summary = "提交还车申请") + @Parameter(name = "id", description = "还车申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:vehicle-return:create')") + public CommonResult submitVehicleReturn(@RequestParam("id") Long id) { + vehicleReturnService.submitVehicleReturn(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/cancel") + @Operation(summary = "取消还车申请") + @Parameter(name = "id", description = "还车申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:vehicle-return:update')") + public CommonResult cancelVehicleReturn(@RequestParam("id") Long id) { + vehicleReturnService.cancelVehicleReturn(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得还车申请详情") + @Parameter(name = "id", description = "还车申请编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:vehicle-return:query')") + public CommonResult getVehicleReturn(@RequestParam("id") Long id) { + OaVehicleReturnDO vehicleReturn = vehicleReturnService.getVehicleReturn(id); + return success(buildVehicleReturnRespVO(vehicleReturn)); + } + + @GetMapping("/page") + @Operation(summary = "获得本人还车申请分页") + @PreAuthorize("@ss.hasPermission('oa:vehicle-return:query')") + public CommonResult> getVehicleReturnPage( + @Valid OaVehicleReturnPageReqVO pageReqVO) { + PageResult pageResult = vehicleReturnService.getVehicleReturnPage(getLoginUserId(), pageReqVO); + return success(new PageResult<>(buildVehicleReturnRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接还车申请详情 + * + * @param vehicleReturn 还车申请 + * @return 还车申请响应 + */ + private OaVehicleReturnRespVO buildVehicleReturnRespVO(OaVehicleReturnDO vehicleReturn) { + if (vehicleReturn == null) { + return null; + } + return CollUtil.getFirst(buildVehicleReturnRespVOList(Collections.singletonList(vehicleReturn))); + } + + /** + * 构建还车申请响应列表,批量拼接关联名称 + * + * @param returns 申请列表 + * @return 申请响应列表 + */ + private List buildVehicleReturnRespVOList(List returns) { + if (CollUtil.isEmpty(returns)) { + return Collections.emptyList(); + } + // 1. 批量查询申请人、申请部门和关联业务信息 + Map userMap = adminUserApi.getUserMap(convertSet(returns, OaVehicleReturnDO::getUserId)); + Map deptMap = deptApi.getDeptMap(convertSet(returns, OaVehicleReturnDO::getDeptId)); + Map applyMap = vehicleApplyService.getVehicleApplyMap(convertSet(returns, OaVehicleReturnDO::getApplyId)); + // 2. 转换并拼接展示字段 + return convertList(returns, item -> { + OaVehicleReturnRespVO vo = BeanUtils.toBean(item, OaVehicleReturnRespVO.class); + MapUtils.findAndThen(userMap, item.getUserId(), user -> vo.setUserName(user.getNickname())); + MapUtils.findAndThen(deptMap, item.getDeptId(), dept -> vo.setDeptName(dept.getName())); + MapUtils.findAndThen(applyMap, item.getApplyId(), apply -> + vo.setApplyNo(apply.getNo()).setVehicleNo(apply.getVehicleNo())); + return vo; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/OaVehiclePageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/OaVehiclePageReqVO.java new file mode 100644 index 0000000000..c1be47675a --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/OaVehiclePageReqVO.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.vehicle.OaVehicleStatusEnum; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 车辆分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaVehiclePageReqVO extends PageParam { + + @Schema(description = "车牌号", example = "沪A12345") + private String no; + + @Schema(description = "车辆名称", example = "商务接待车") + private String name; + + @Schema(description = "所属部门编号", example = "103") + private Long deptId; + + @Schema(description = "分类", example = "公务用车") + private String category; + + @Schema(description = "状态", example = "0") + @InEnum(value = OaVehicleStatusEnum.class, message = "状态必须是 {value}") + private Integer status; + + @Schema(description = "车型", example = "商务车") + private String type; + + @Schema(description = "品牌型号", example = "别克 GL8") + private String brandModel; + + @Schema(description = "交强险到期时间", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] compulsoryInsuranceExpireTime; + + @Schema(description = "商业险到期时间", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] commercialInsuranceExpireTime; + + @Schema(description = "年检到期时间", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] inspectionExpireTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/OaVehicleRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/OaVehicleRespVO.java new file mode 100644 index 0000000000..789f06a01c --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/OaVehicleRespVO.java @@ -0,0 +1,67 @@ +package cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 车辆 Response VO") +@Data +public class OaVehicleRespVO { + + @Schema(description = "编号", example = "1024", requiredMode = Schema.RequiredMode.REQUIRED) + private Long id; + + @Schema(description = "车牌号", example = "沪A12345", requiredMode = Schema.RequiredMode.REQUIRED) + private String no; + + @Schema(description = "车辆名称", example = "商务接待车", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @Schema(description = "所属部门编号", example = "103") + private Long deptId; + + @Schema(description = "所属部门名称", example = "研发部门") + private String deptName; + + @Schema(description = "车型", example = "商务车", requiredMode = Schema.RequiredMode.REQUIRED) + private String type; + + @Schema(description = "车辆分类", example = "公务用车") + private String category; + + @Schema(description = "品牌型号", example = "别克 GL8") + private String brandModel; + + @Schema(description = "座位数", example = "7", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer seatCount; + + @Schema(description = "裸车价格(元)", example = "250000.00", requiredMode = Schema.RequiredMode.REQUIRED) + private BigDecimal barePrice; + + @Schema(description = "交强险到期时间", example = "1789002000000", type = "integer", format = "int64") + private LocalDateTime compulsoryInsuranceExpireTime; + + @Schema(description = "商业险到期时间", example = "1789002000000", type = "integer", format = "int64") + private LocalDateTime commercialInsuranceExpireTime; + + @Schema(description = "年检到期时间", example = "1789002000000", type = "integer", format = "int64") + private LocalDateTime inspectionExpireTime; + + @Schema(description = "车辆照片 URL", example = "https://example.com/vehicle.png") + private String picUrl; + + @Schema(description = "车辆台账状态", example = "0", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer status; + + @Schema(description = "显示顺序", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer sort; + + @Schema(description = "备注", example = "部门公务用车") + private String remark; + + @Schema(description = "创建时间", example = "1789002000000", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64") + private LocalDateTime createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/OaVehicleSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/OaVehicleSaveReqVO.java new file mode 100644 index 0000000000..38ffd489f1 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/OaVehicleSaveReqVO.java @@ -0,0 +1,82 @@ +package cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.vehicle.OaVehicleStatusEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 车辆新增/修改 Request VO") +@Data +public class OaVehicleSaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "车牌号", requiredMode = Schema.RequiredMode.REQUIRED, example = "沪A12345") + @NotBlank(message = "车牌号不能为空") + @Size(max = 32, message = "车牌号长度不能超过 32 个字符") + private String no; + + @Schema(description = "车辆名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "商务接待车") + @NotBlank(message = "车辆名称不能为空") + @Size(max = 128, message = "车辆名称长度不能超过 128 个字符") + private String name; + + @Schema(description = "所属部门编号", example = "103") + private Long deptId; + + @Schema(description = "车型", requiredMode = Schema.RequiredMode.REQUIRED, example = "商务车") + @NotBlank(message = "车型不能为空") + @Size(max = 64, message = "车型长度不能超过 64 个字符") + private String type; + + @Schema(description = "车辆分类", example = "公务用车") + @Size(max = 64, message = "车辆分类长度不能超过 64 个字符") + private String category; + + @Schema(description = "品牌型号", example = "别克 GL8") + @Size(max = 128, message = "品牌型号长度不能超过 128 个字符") + private String brandModel; + + @Schema(description = "座位数", requiredMode = Schema.RequiredMode.REQUIRED, example = "7") + @NotNull(message = "座位数不能为空") + @DecimalMin(value = "1", message = "座位数不能小于 1") + private Integer seatCount; + + @Schema(description = "裸车价格(元)", requiredMode = Schema.RequiredMode.REQUIRED, example = "250000.00") + @NotNull(message = "裸车价格(元)不能为空") + @DecimalMin(value = "0", message = "裸车价格(元)不能小于 0") + @Digits(integer = 16, fraction = 2, message = "裸车价格整数位不能超过 {integer} 位,小数位不能超过 {fraction} 位") + private BigDecimal barePrice; + + @Schema(description = "交强险到期时间", example = "1789002000000", type = "integer", format = "int64") + private LocalDateTime compulsoryInsuranceExpireTime; + + @Schema(description = "商业险到期时间", example = "1789002000000", type = "integer", format = "int64") + private LocalDateTime commercialInsuranceExpireTime; + + @Schema(description = "年检到期时间", example = "1789002000000", type = "integer", format = "int64") + private LocalDateTime inspectionExpireTime; + + @Schema(description = "车辆照片 URL", example = "https://example.com/vehicle.png") + @Size(max = 2048, message = "照片地址长度不能超过 2048 个字符") + private String picUrl; + + @Schema(description = "显示顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "显示顺序不能为空") + private Integer sort; + + @Schema(description = "车辆状态", example = "0", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "车辆状态不能为空") + @InEnum(value = OaVehicleStatusEnum.class, message = "车辆状态必须是 {value}") + private Integer status; + + @Schema(description = "备注", example = "部门公务用车") + @Size(max = 500, message = "备注长度不能超过 500 个字符") + private String remark; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/apply/OaVehicleApplyPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/apply/OaVehicleApplyPageReqVO.java new file mode 100644 index 0000000000..ecc640d3b5 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/apply/OaVehicleApplyPageReqVO.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.apply; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.enums.vehicle.OaVehicleReturnStatusEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 用车申请分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaVehicleApplyPageReqVO extends PageParam { + + @Schema(description = "申请单号", example = "YC202609140001") + private String no; + + @Schema(description = "车牌号", example = "沪A12345") + private String vehicleNo; + + @Schema(description = "审批状态", example = "-1") + @InEnum(value = BpmProcessInstanceStatusEnum.class, message = "审批状态必须是 {value}") + private Integer status; + + @Schema(description = "还车状态", example = "0") + @InEnum(value = OaVehicleReturnStatusEnum.class, message = "还车状态必须是 {value}") + private Integer returnStatus; + + @Schema(description = "申请部门编号", example = "1024") + private Long deptId; + + @Schema(description = "创建时间", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/apply/OaVehicleApplyRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/apply/OaVehicleApplyRespVO.java new file mode 100644 index 0000000000..6e9f87c5d1 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/apply/OaVehicleApplyRespVO.java @@ -0,0 +1,74 @@ +package cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.apply; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 用车申请 Response VO") +@Data +public class OaVehicleApplyRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "申请单号", requiredMode = Schema.RequiredMode.REQUIRED, example = "YC202609140001") + private String no; + + @Schema(description = "车辆编号", example = "1024") + private Long vehicleId; + + @Schema(description = "车牌号", example = "沪A12345") + private String vehicleNo; + + @Schema(description = "申请人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long userId; + + @Schema(description = "申请部门编号", example = "1024") + private Long deptId; + + @Schema(description = "预计出车时间", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime startTime; + + @Schema(description = "预计回车时间", type = "integer", format = "int64", example = "1789380000000") + private LocalDateTime endTime; + + @Schema(description = "出车地点", example = "公司停车场") + private String startLocation; + + @Schema(description = "预计回车地点", example = "公司停车场") + private String endLocation; + + @Schema(description = "用车事由", example = "前往客户现场参加项目评审") + private String reason; + + @Schema(description = "随行人", example = "张三、李四") + private String passenger; + + + @Schema(description = "审批状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "-1") + private Integer status; + + @Schema(description = "还车状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer returnStatus; + + @Schema(description = "流程实例编号", example = "1024") + private String processInstanceId; + + @Schema(description = "备注", example = "请提前联系行政部确认") + private String remark; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + private List fileUrls; + + @Schema(description = "申请人姓名", example = "张三") + private String userName; + + @Schema(description = "申请部门", example = "行政部") + private String deptName; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/apply/OaVehicleApplySaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/apply/OaVehicleApplySaveReqVO.java new file mode 100644 index 0000000000..093f564c7c --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/apply/OaVehicleApplySaveReqVO.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.apply; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 用车申请新增/修改 Request VO") +@Data +public class OaVehicleApplySaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "车辆编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "车辆不能为空") + private Long vehicleId; + + @Schema(description = "预计出车时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + @NotNull(message = "预计出车时间不能为空") + private LocalDateTime startTime; + + @Schema(description = "预计回车时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789380000000") + @NotNull(message = "预计回车时间不能为空") + private LocalDateTime endTime; + + @Schema(description = "出车地点", requiredMode = Schema.RequiredMode.REQUIRED, example = "公司停车场") + @NotBlank(message = "出车地点不能为空") + @Size(max = 255, message = "出车地点长度不能超过 {max} 个字符") + private String startLocation; + + @Schema(description = "预计回车地点", requiredMode = Schema.RequiredMode.REQUIRED, example = "公司停车场") + @NotBlank(message = "预计回车地点不能为空") + @Size(max = 255, message = "预计回车地点长度不能超过 {max} 个字符") + private String endLocation; + + @Schema(description = "用车事由", requiredMode = Schema.RequiredMode.REQUIRED, example = "前往客户现场参加项目评审") + @NotBlank(message = "用车事由不能为空") + @Size(max = 500, message = "用车事由长度不能超过 {max} 个字符") + private String reason; + + @Schema(description = "随行人", example = "张三、李四") + @Size(max = 500, message = "随行人长度不能超过 500 个字符") + private String passenger; + + @Schema(description = "备注", example = "请提前联系行政部确认") + @Size(max = 500, message = "备注长度不能超过 {max} 个字符") + private String remark; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + @Size(max = 5, message = "附件最多上传 5 个") + private List<@NotBlank(message = "附件地址不能为空") @Size(max = 2048, message = "附件地址长度不能超过 {max} 个字符") String> fileUrls; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/returning/OaVehicleReturnPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/returning/OaVehicleReturnPageReqVO.java new file mode 100644 index 0000000000..88e31949b5 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/returning/OaVehicleReturnPageReqVO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.returning; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 还车申请分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaVehicleReturnPageReqVO extends PageParam { + + @Schema(description = "还车申请单号", example = "HC202609140001") + private String no; + + @Schema(description = "用车申请编号", example = "1024") + private Long applyId; + + @Schema(description = "审批状态", example = "-1") + @InEnum(value = BpmProcessInstanceStatusEnum.class, message = "审批状态必须是 {value}") + private Integer status; + + @Schema(description = "车牌号", example = "沪A12345") + private String vehicleNo; + + @Schema(description = "用车申请单号", example = "YC202609140001") + private String applyNo; + + @Schema(description = "申请部门编号", example = "1024") + private Long deptId; + + @Schema(description = "创建时间", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/returning/OaVehicleReturnRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/returning/OaVehicleReturnRespVO.java new file mode 100644 index 0000000000..46d1316ce4 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/returning/OaVehicleReturnRespVO.java @@ -0,0 +1,79 @@ +package cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.returning; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 还车申请 Response VO") +@Data +public class OaVehicleReturnRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "还车申请单号", requiredMode = Schema.RequiredMode.REQUIRED, example = "HC202609140001") + private String no; + + @Schema(description = "用车申请编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long applyId; + + @Schema(description = "车辆编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long vehicleId; + + @Schema(description = "申请人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long userId; + + @Schema(description = "申请部门编号", example = "1024") + private Long deptId; + + @Schema(description = "实际出车时间", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime actualStartTime; + + @Schema(description = "实际出车地点", example = "公司停车场") + private String startLocation; + + @Schema(description = "用车事由", example = "前往客户现场参加项目评审") + private String reason; + + @Schema(description = "随行人", example = "张三、李四") + private String passenger; + + @Schema(description = "实际回车时间", type = "integer", format = "int64", example = "1789380000000") + private LocalDateTime actualReturnTime; + + @Schema(description = "实际回车地点", example = "公司停车场") + private String returnLocation; + + + + + @Schema(description = "审批状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "-1") + private Integer status; + + @Schema(description = "流程实例编号", example = "1024") + private String processInstanceId; + + @Schema(description = "还车说明", example = "车辆已停放到公司停车场") + private String remark; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + private List fileUrls; + + @Schema(description = "申请人姓名", example = "张三") + private String userName; + + @Schema(description = "申请部门", example = "行政部") + private String deptName; + + @Schema(description = "用车申请单号", example = "YC202609140001") + private String applyNo; + + @Schema(description = "车牌号", example = "沪A12345") + private String vehicleNo; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/returning/OaVehicleReturnSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/returning/OaVehicleReturnSaveReqVO.java new file mode 100644 index 0000000000..a2d2430cab --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/vehicle/vo/returning/OaVehicleReturnSaveReqVO.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.returning; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 还车申请新增/修改 Request VO") +@Data +public class OaVehicleReturnSaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "用车申请编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "用车申请不能为空") + private Long applyId; + + @Schema(description = "实际出车时间", type = "integer", format = "int64", example = "1789347600000") + @NotNull(message = "实际出车时间不能为空") + private LocalDateTime actualStartTime; + + @Schema(description = "实际出车地点", example = "公司停车场") + @NotBlank(message = "实际出车地点不能为空") + @Size(max = 255, message = "实际出车地点长度不能超过 {max} 个字符") + private String startLocation; + + @Schema(description = "用车事由", example = "前往客户现场参加项目评审") + @NotBlank(message = "用车事由不能为空") + @Size(max = 500, message = "用车事由长度不能超过 {max} 个字符") + private String reason; + + @Schema(description = "随行人", example = "张三、李四") + @Size(max = 500, message = "随行人长度不能超过 {max} 个字符") + private String passenger; + + @Schema(description = "实际回车时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789380000000") + @NotNull(message = "实际回车时间不能为空") + @PastOrPresent(message = "实际回车时间不能晚于当前时间") + private LocalDateTime actualReturnTime; + + @Schema(description = "实际回车地点", requiredMode = Schema.RequiredMode.REQUIRED, example = "公司停车场") + @NotBlank(message = "实际回车地点不能为空") + @Size(max = 255, message = "实际回车地点长度不能超过 {max} 个字符") + private String returnLocation; + + @Schema(description = "还车说明", example = "车辆已停放到公司停车场") + @Size(max = 500, message = "还车说明长度不能超过 {max} 个字符") + private String remark; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + @Size(max = 5, message = "附件最多上传 5 个") + private List<@NotBlank(message = "附件地址不能为空") @Size(max = 2048, message = "附件地址长度不能超过 {max} 个字符") String> fileUrls; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/OaWorkReportController.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/OaWorkReportController.java new file mode 100644 index 0000000000..208a3093ee --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/OaWorkReportController.java @@ -0,0 +1,171 @@ +package cn.iocoder.yudao.module.oa.controller.admin.workreport; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.workreport.vo.OaWorkReportPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.workreport.vo.OaWorkReportRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.workreport.vo.OaWorkReportSaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.workreport.vo.OaWorkReportStatisticsReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.workreport.vo.OaWorkReportStatisticsRespVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.workreport.OaWorkReportDO; +import cn.iocoder.yudao.module.oa.service.workreport.OaWorkReportService; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import cn.iocoder.yudao.module.oa.enums.workreport.OaWorkReportTypeEnum; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - OA 工作汇报") +@RestController +@RequestMapping("/oa/work-report") +@Validated +public class OaWorkReportController { + + @Resource + private OaWorkReportService workReportService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + // ==================== 我的汇报 ==================== + + @PostMapping("/create") + @Operation(summary = "创建工作汇报草稿") + @PreAuthorize("@ss.hasPermission('oa:work-report:create')") + public CommonResult createWorkReport(@Valid @RequestBody OaWorkReportSaveReqVO createReqVO) { + return success(workReportService.createWorkReport(createReqVO, getLoginUserId())); + } + + @PutMapping("/update") + @Operation(summary = "更新工作汇报草稿") + @PreAuthorize("@ss.hasPermission('oa:work-report:update')") + public CommonResult updateWorkReport(@Valid @RequestBody OaWorkReportSaveReqVO updateReqVO) { + workReportService.updateWorkReport(updateReqVO, getLoginUserId()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除工作汇报草稿") + @Parameter(name = "id", description = "汇报编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:work-report:delete')") + public CommonResult deleteWorkReport(@RequestParam("id") Long id) { + workReportService.deleteWorkReport(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/submit") + @Operation(summary = "提交工作汇报") + @Parameter(name = "id", description = "汇报编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:work-report:update')") + public CommonResult submitWorkReport(@RequestParam("id") Long id) { + workReportService.submitWorkReport(id, getLoginUserId()); + return success(true); + } + + @PutMapping("/cancel") + @Operation(summary = "取消提交工作汇报") + @Parameter(name = "id", description = "汇报编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('oa:work-report:update')") + public CommonResult cancelWorkReport(@RequestParam("id") Long id) { + workReportService.cancelWorkReport(id, getLoginUserId()); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得工作汇报详情") + @Parameter(name = "id", description = "汇报编号", required = true, example = "1024") + @PreAuthorize("@ss.hasAnyPermissions('oa:work-report:query', 'oa:work-report:statistics')") + public CommonResult getWorkReport(@RequestParam("id") Long id) { + OaWorkReportDO workReport = workReportService.getWorkReport(id, getLoginUserId()); + return success(buildWorkReportRespVO(workReport)); + } + + @GetMapping("/page") + @Operation(summary = "获得我的工作汇报分页") + @PreAuthorize("@ss.hasPermission('oa:work-report:query')") + public CommonResult> getWorkReportPage( + @Valid OaWorkReportPageReqVO pageReqVO) { + PageResult pageResult = workReportService.getWorkReportPage(pageReqVO, getLoginUserId()); + return success(new PageResult<>(buildWorkReportRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + // ==================== 汇报统计 ==================== + + @GetMapping("/statistics") + @Operation(summary = "获得工作汇报统计") + @PreAuthorize("@ss.hasPermission('oa:work-report:statistics')") + public CommonResult getWorkReportStatistics( + @Valid OaWorkReportStatisticsReqVO reqVO) { + return success(workReportService.getWorkReportStatistics(reqVO, getLoginUserId())); + } + + // ==================== 拼接 VO ==================== + + /** + * 拼接工作汇报详情 + * + * @param workReport 工作汇报 + * @return 工作汇报响应 + */ + private OaWorkReportRespVO buildWorkReportRespVO(OaWorkReportDO workReport) { + if (workReport == null) { + return null; + } + return CollUtil.getFirst(buildWorkReportRespVOList(Collections.singletonList(workReport))); + } + + /** + * 拼接工作汇报响应列表 + * + * @param workReports 工作汇报列表 + * @return 工作汇报响应列表 + */ + private List buildWorkReportRespVOList(List workReports) { + if (CollUtil.isEmpty(workReports)) { + return Collections.emptyList(); + } + Map userMap = adminUserApi.getUserMap( + convertSet(workReports, report -> NumberUtils.parseLong(report.getCreator()))); + Map deptMap = deptApi.getDeptMap( + convertSet(workReports, OaWorkReportDO::getDeptId)); + return convertList(workReports, workReport -> { + Long userId = NumberUtils.parseLong(workReport.getCreator()); + OaWorkReportRespVO respVO = BeanUtils.toBean(workReport, OaWorkReportRespVO.class).setUserId(userId) + .setPeriodKey(OaWorkReportTypeEnum.valueOf(workReport.getType()).formatPeriod(workReport.getStartTime())); + MapUtils.findAndThen(userMap, userId, user -> respVO.setUserName(user.getNickname())); + MapUtils.findAndThen(deptMap, workReport.getDeptId(), dept -> respVO.setDeptName(dept.getName())); + return respVO; + }); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/vo/OaWorkReportPageReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/vo/OaWorkReportPageReqVO.java new file mode 100644 index 0000000000..977d3a10ef --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/vo/OaWorkReportPageReqVO.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.oa.controller.admin.workreport.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.workreport.OaWorkReportStatusEnum; +import cn.iocoder.yudao.module.oa.enums.workreport.OaWorkReportTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - OA 我的工作汇报分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaWorkReportPageReqVO extends PageParam { + + @Schema(description = "汇报类型", example = "1") + @InEnum(value = OaWorkReportTypeEnum.class, message = "汇报类型必须是 {value}") + private Integer type; + + @Schema(description = "汇报状态", example = "1") + @InEnum(value = OaWorkReportStatusEnum.class, message = "汇报状态必须是 {value}") + private Integer status; + + @Schema(description = "汇报单号", example = "HB202609140001") + private String no; + + @Schema(description = "部门编号", example = "1024") + private Long deptId; + + @Schema(description = "汇报开始时间范围", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] periodTime; + + @Schema(description = "开始日期", example = "2026-09-14 09:00:00") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime startTime; + + @Schema(description = "结束日期", example = "2026-09-14 18:00:00") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime endTime; + + @Schema(description = "创建时间", example = "[\"2026-09-14 00:00:00\", \"2026-09-14 23:59:59\"]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/vo/OaWorkReportRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/vo/OaWorkReportRespVO.java new file mode 100644 index 0000000000..bec75b4101 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/vo/OaWorkReportRespVO.java @@ -0,0 +1,77 @@ +package cn.iocoder.yudao.module.oa.controller.admin.workreport.vo; + +import cn.iocoder.yudao.module.oa.dal.dataobject.workreport.OaWorkReportDO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - OA 工作汇报 Response VO") +@Data +public class OaWorkReportRespVO { + + @Schema(description = "汇报编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "汇报单号", requiredMode = Schema.RequiredMode.REQUIRED, example = "HB202609140001") + private String no; + + @Schema(description = "汇报类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer type; + + @Schema(description = "汇报状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer status; + + @Schema(description = "汇报标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-09-14 工作日报") + private String title; + + @Schema(description = "汇报周期标识", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-09-14") + private String periodKey; + + @Schema(description = "周期开始时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime startTime; + + @Schema(description = "周期结束时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789380000000") + private LocalDateTime endTime; + + @Schema(description = "工作总结", example = "本周已完成库存盘点并整理差异清单") + private String summary; + + @Schema(description = "工作计划补充说明", example = "下周完成盘点差异核实") + private String plan; + + @Schema(description = "问题与协调事项", example = "需协调仓库复核盘点差异") + private String problem; + + @Schema(description = "已完成工作项") + private List workItems; + + @Schema(description = "工作计划项") + private List planItems; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + private List fileUrls; + + @Schema(description = "备注", example = "请提前联系行政部确认") + private String remark; + + @Schema(description = "汇报人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long userId; + + @Schema(description = "汇报人昵称", example = "张三") + private String userName; + + @Schema(description = "汇报人部门编号", example = "1024") + private Long deptId; + + @Schema(description = "汇报人部门名称", example = "研发部") + private String deptName; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime updateTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/vo/OaWorkReportSaveReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/vo/OaWorkReportSaveReqVO.java new file mode 100644 index 0000000000..2cfb21e8eb --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/vo/OaWorkReportSaveReqVO.java @@ -0,0 +1,126 @@ +package cn.iocoder.yudao.module.oa.controller.admin.workreport.vo; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.workreport.OaWorkReportTypeEnum; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Max; +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; +import java.util.List; + +@Schema(description = "管理后台 - OA 工作汇报新增/修改 Request VO") +@Data +public class OaWorkReportSaveReqVO { + + @Schema(description = "汇报编号", example = "1024") + private Long id; + + @Schema(description = "汇报类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "汇报类型不能为空") + @InEnum(value = OaWorkReportTypeEnum.class, message = "汇报类型必须是 {value}") + private Integer type; + + @Schema(description = "汇报标题", example = "2026-09-10 工作日报") + @Size(max = 255, message = "汇报标题不能超过 255 个字符") + private String title; + + @Schema(description = "周期开始时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789347600000") + @NotNull(message = "周期开始时间不能为空") + private LocalDateTime startTime; + + @Schema(description = "周期结束时间", requiredMode = Schema.RequiredMode.REQUIRED, type = "integer", format = "int64", example = "1789380000000") + @NotNull(message = "周期结束时间不能为空") + private LocalDateTime endTime; + + @Schema(description = "工作总结", example = "本周已完成库存盘点并整理差异清单") + @Size(max = 5000, message = "工作总结不能超过 5000 个字符") + private String summary; + + @Schema(description = "工作计划补充说明", example = "下周完成盘点差异核实") + @Size(max = 5000, message = "工作计划补充说明不能超过 5000 个字符") + private String plan; + + @Schema(description = "问题与协调事项", example = "需协调仓库复核盘点差异") + @Size(max = 5000, message = "问题与协调事项不能超过 5000 个字符") + private String problem; + + @Schema(description = "已完成工作项") + @Valid + @Size(max = 100, message = "已完成工作项不能超过 100 项") + private List<@NotNull(message = "已完成工作项不能为空") WorkItem> workItems; + + @Schema(description = "工作计划项") + @Valid + @Size(max = 100, message = "工作计划项不能超过 100 项") + private List<@NotNull(message = "工作计划项不能为空") PlanItem> planItems; + + @Schema(description = "附件地址列表", example = "[\"https://example.com/file.pdf\"]") + @Size(max = 10, message = "附件不能超过 10 个") + private List<@NotBlank(message = "附件地址不能为空") @Size(max = 1024, message = "附件地址不能超过 1024 个字符") String> fileUrls; + + @Schema(description = "备注", example = "请提前联系行政部确认") + @Size(max = 1000, message = "备注不能超过 1000 个字符") + private String remark; + + /** + * 校验汇报周期的起止时间顺序,空值由字段必填注解校验 + * + * @return 时间顺序是否有效 + */ + @JsonIgnore + @AssertTrue(message = "周期结束时间不能早于开始时间") + public boolean isTimeRangeValid() { + return startTime == null || endTime == null || !startTime.isAfter(endTime); + } + + /** + * 保存时可填写文字总结、计划说明或工作明细 + * + * @return 是否填写了汇报内容 + */ + @JsonIgnore + @AssertTrue(message = "请填写工作总结、计划说明或工作明细") + public boolean isContentValid() { + return StrUtil.isNotBlank(summary) || StrUtil.isNotBlank(plan) + || CollUtil.isNotEmpty(workItems) || CollUtil.isNotEmpty(planItems); + } + + @Schema(description = "管理后台 - 工作汇报已完成工作项 Request VO") + @Data + public static class WorkItem { + + @Schema(description = "工作内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "完成办公用品库存盘点") + @NotBlank(message = "工作内容不能为空") + @Size(max = 1000, message = "内容不能超过 1000 个字符") + private String content; + + @Schema(description = "完成进度", requiredMode = Schema.RequiredMode.REQUIRED, example = "100") + @NotNull(message = "完成进度不能为空") + @Min(value = 0, message = "完成进度不能小于 0") + @Max(value = 100, message = "完成进度不能大于 100") + private Integer progress; + + } + + @Schema(description = "管理后台 - 工作汇报工作计划项 Request VO") + @Data + public static class PlanItem { + + @Schema(description = "计划内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "下周完成各部门办公用品盘点差异核实,并按实际需求补充库存。") + @NotBlank(message = "计划内容不能为空") + @Size(max = 1000, message = "内容不能超过 1000 个字符") + private String content; + + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/vo/OaWorkReportStatisticsReqVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/vo/OaWorkReportStatisticsReqVO.java new file mode 100644 index 0000000000..110e6eaf27 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/vo/OaWorkReportStatisticsReqVO.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.oa.controller.admin.workreport.vo; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.workreport.OaWorkReportTypeEnum; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.AssertTrue; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - OA 工作汇报统计 Request VO") +@Data +public class OaWorkReportStatisticsReqVO { + + @Schema(description = "汇报类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "汇报类型不能为空") + @InEnum(value = OaWorkReportTypeEnum.class, message = "汇报类型必须是 {value}") + private Integer type; + + @Schema(description = "统计开始时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-09-01 00:00:00") + @NotNull(message = "统计开始时间不能为空") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime startTime; + + @Schema(description = "统计结束时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-09-30 23:59:59") + @NotNull(message = "统计结束时间不能为空") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime endTime; + + @Schema(description = "完整周期查询开始时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-09-01 00:00:00") + @NotNull(message = "查询开始时间不能为空") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime queryStartTime; + + @Schema(description = "完整周期查询结束时间", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-09-30 23:59:59") + @NotNull(message = "查询结束时间不能为空") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime queryEndTime; + + @Schema(description = "部门编号", example = "103") + private Long deptId; + + @JsonIgnore + @AssertTrue(message = "统计结束时间不能早于开始时间") + public boolean isTimeRangeValid() { + return startTime == null || endTime == null || !startTime.isAfter(endTime); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/vo/OaWorkReportStatisticsRespVO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/vo/OaWorkReportStatisticsRespVO.java new file mode 100644 index 0000000000..35ca343927 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/admin/workreport/vo/OaWorkReportStatisticsRespVO.java @@ -0,0 +1,88 @@ +package cn.iocoder.yudao.module.oa.controller.admin.workreport.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - OA 工作汇报统计 Response VO") +@Data +public class OaWorkReportStatisticsRespVO { + + @Schema(description = "统计人数", example = "1") + private Integer userCount; + + @Schema(description = "应填数量", example = "1") + private Integer expectedCount; + + @Schema(description = "已填数量", example = "1") + private Integer submittedCount; + + @Schema(description = "未填数量", example = "1") + private Integer missingCount; + + @Schema(description = "员工统计列表") + private List users; + + @Schema(description = "管理后台 - 员工工作汇报统计 Response VO") + @Data + public static class UserStatistics { + + @Schema(description = "员工用户编号", example = "1024") + private Long userId; + + @Schema(description = "员工昵称", example = "张三") + private String userName; + + @Schema(description = "部门编号", example = "1024") + private Long deptId; + + @Schema(description = "部门名称", example = "行政部") + private String deptName; + + @Schema(description = "应填数量", example = "1") + private Integer expectedCount; + + @Schema(description = "已填数量", example = "1") + private Integer submittedCount; + + @Schema(description = "未填数量", example = "1") + private Integer missingCount; + + @Schema(description = "已提交汇报") + private List submittedReports; + + @Schema(description = "未填周期", example = "[\"2026-09-14\"]") + private List missingPeriodKeys; + + } + + @Schema(description = "管理后台 - 已提交汇报摘要 Response VO") + @Data + public static class Report { + + @Schema(description = "汇报编号", example = "1024") + private Long id; + + @Schema(description = "汇报单号", example = "HB202609140001") + private String no; + + @Schema(description = "汇报标题", example = "2026-09-14 工作日报") + private String title; + + @Schema(description = "汇报状态", example = "2") + private Integer status; + + @Schema(description = "开始时间", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime startTime; + + @Schema(description = "创建时间", type = "integer", format = "int64", example = "1789347600000") + private LocalDateTime createTime; + + @Schema(description = "汇报周期", example = "2026-09-14") + private String periodKey; + + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/package-info.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/package-info.java new file mode 100644 index 0000000000..b3d9e0394c --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/controller/package-info.java @@ -0,0 +1,5 @@ +/** + * 提供 RESTful API 给前端: + * admin 包:提供给管理后台 yudao-ui-admin 前端项目 + */ +package cn.iocoder.yudao.module.oa.controller; diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/announcement/OaAnnouncementDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/announcement/OaAnnouncementDO.java new file mode 100644 index 0000000000..349175bfcf --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/announcement/OaAnnouncementDO.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.announcement; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.oa.enums.announcement.OaAnnouncementTypeEnum; +import cn.iocoder.yudao.module.oa.enums.schedule.OaPriorityEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * OA 公告 DO + * + * @author 芋道源码 + */ +@TableName("oa_announcement") +@KeySequence("oa_announcement_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaAnnouncementDO extends BaseDO { + + /** + * 公告编号 + */ + @TableId + private Long id; + /** + * 公告类型 + * + * 枚举 {@link OaAnnouncementTypeEnum} + */ + private Integer type; + /** + * 优先级 + * + * 枚举 {@link OaPriorityEnum} + */ + private Integer priority; + /** + * 标题 + */ + private String title; + /** + * 内容 + */ + private String content; + /** + * 相关链接 + */ + private String url; + /** + * 是否置顶 + */ + private Boolean top; + // 公告创建即发布,发布时间复用 BaseDO.createTime,编辑时保持不变。 + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/announcement/OaAnnouncementReceiverDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/announcement/OaAnnouncementReceiverDO.java new file mode 100644 index 0000000000..ee829d0b72 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/announcement/OaAnnouncementReceiverDO.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.announcement; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * OA 公告接收关系 DO + * + * @author 芋道源码 + */ +@TableName("oa_announcement_receiver") +@KeySequence("oa_announcement_receiver_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaAnnouncementReceiverDO extends BaseDO { + + /** + * 接收关系编号 + */ + @TableId + private Long id; + /** + * 公告编号 + * + * 关联 {@link OaAnnouncementDO#getId()} + */ + private Long announcementId; + /** + * 接收人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long receiverUserId; + /** + * 是否已读 + */ + private Boolean readStatus; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/attendance/OaAttendanceDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/attendance/OaAttendanceDO.java new file mode 100644 index 0000000000..6eebd8567d --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/attendance/OaAttendanceDO.java @@ -0,0 +1,64 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.attendance; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.oa.enums.attendance.OaAttendanceStatusEnum; +import cn.iocoder.yudao.module.oa.enums.attendance.OaAttendanceTypeEnum; +import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.time.LocalDateTime; + +/** + * OA 考勤 DO + * + * @author 芋道源码 + */ +@TableName("oa_attendance") +@KeySequence("oa_attendance_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaAttendanceDO extends BaseDO { + + /** + * 考勤编号 + */ + @TableId + private Long id; + /** + * 用户编号 + * + * 关联 {@link AdminUserDO#getId()} + */ + private Long userId; + /** + * 考勤类型 + * + * 枚举 {@link OaAttendanceTypeEnum} + */ + private Integer type; + /** + * 考勤状态 + * + * 枚举 {@link OaAttendanceStatusEnum} + */ + private Integer status; + /** + * 考勤时间 + */ + private LocalDateTime attendanceTime; + /** + * 考勤 IP + */ + private String attendanceIp; + /** + * 考勤备注 + */ + private String remark; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/contact/OaContactCategoryDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/contact/OaContactCategoryDO.java new file mode 100644 index 0000000000..93d0d22526 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/contact/OaContactCategoryDO.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.contact; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * OA 联系人分类 DO + * + * @author 芋道源码 + */ +@TableName("oa_contact_category") +@KeySequence("oa_contact_category_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaContactCategoryDO extends BaseDO { + + /** + * 分类编号 + */ + @TableId + private Long id; + /** + * 用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + /** + * 分类名称 + */ + private String name; + /** + * 显示顺序 + */ + private Integer sort; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/contact/OaContactDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/contact/OaContactDO.java new file mode 100644 index 0000000000..fe0e99dfe0 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/contact/OaContactDO.java @@ -0,0 +1,78 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.contact; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.system.enums.common.SexEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * OA 外部联系人 DO + * + * @author 芋道源码 + */ +@TableName("oa_contact") +@KeySequence("oa_contact_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaContactDO extends BaseDO { + + /** + * 联系人编号 + */ + @TableId + private Long id; + /** + * 分类编号 + * + * 关联 {@link OaContactCategoryDO#getId()} + */ + private Long categoryId; + /** + * 姓名 + */ + private String name; + /** + * 姓名拼音 + */ + private String pinyin; + /** + * 性别 + * + * 枚举 {@link SexEnum} + */ + private Integer sex; + /** + * 手机号码 + */ + private String mobile; + /** + * 邮箱 + */ + private String email; + /** + * 地址 + */ + private String address; + /** + * 公司名称 + */ + private String companyName; + /** + * 公司电话 + */ + private String companyPhone; + /** + * 头像地址 + */ + private String avatar; + /** + * 备注 + */ + private String remark; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/contact/OaContactShareDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/contact/OaContactShareDO.java new file mode 100644 index 0000000000..0705256676 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/contact/OaContactShareDO.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.contact; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * OA 联系人关联 DO,creator 为实际共享人,userId 为持有人 + * + * @author 芋道源码 + */ +@TableName("oa_contact_share") +@KeySequence("oa_contact_share_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaContactShareDO extends BaseDO { + + /** + * 共享记录编号 + */ + @TableId + private Long id; + /** + * 联系人编号 + * + * 关联 {@link OaContactDO#getId()} + */ + private Long contactId; + /** + * 共享接收人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + /** + * 接收人分类编号 + * + * 关联 {@link OaContactCategoryDO#getId()} + */ + private Long categoryId; + /** + * 是否已处理 + */ + private Boolean handleStatus; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/discussion/OaDiscussionDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/discussion/OaDiscussionDO.java new file mode 100644 index 0000000000..8bcb9b9c1c --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/discussion/OaDiscussionDO.java @@ -0,0 +1,78 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.discussion; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.oa.enums.discussion.OaDiscussionTypeEnum; +import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * OA 讨论 DO + * + * @author 芋道源码 + */ +@TableName(value = "oa_discussion", autoResultMap = true) +@KeySequence("oa_discussion_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaDiscussionDO extends BaseDO { + + /** + * 讨论编号 + */ + @TableId + private Long id; + /** + * 发布人用户编号 + * + * 关联 {@link AdminUserDO#getId()} + */ + private Long userId; + /** + * 类型 + * + * 枚举 {@link OaDiscussionTypeEnum} + */ + private Integer type; + /** + * 标题 + */ + private String title; + /** + * 内容 + */ + private String content; + /** + * 附件地址列表 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List fileUrls; + /** + * 访问次数 + */ + private Integer visitCount; + + /** + * 是否允许多选投票 + */ + private Boolean voteMultiple; + /** + * 投票开始时间 + */ + private LocalDateTime voteStartTime; + /** + * 投票结束时间 + */ + private LocalDateTime voteEndTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/discussion/OaDiscussionLikeDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/discussion/OaDiscussionLikeDO.java new file mode 100644 index 0000000000..020afac9b8 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/discussion/OaDiscussionLikeDO.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.discussion; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * OA 讨论点赞 DO + * + * @author 芋道源码 + */ +@TableName("oa_discussion_like") +@KeySequence("oa_discussion_like_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaDiscussionLikeDO extends BaseDO { + + /** + * 点赞记录编号 + */ + @TableId + private Long id; + /** + * 讨论编号 + * + * 关联 {@link OaDiscussionDO#getId()} + */ + private Long discussionId; + /** + * 回复编号 + * + * 关联 {@link OaDiscussionReplyDO#getId()} + */ + private Long replyId; + /** + * 点赞人用户编号 + * + * 关联 {@link AdminUserDO#getId()} + */ + private Long userId; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/discussion/OaDiscussionReplyDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/discussion/OaDiscussionReplyDO.java new file mode 100644 index 0000000000..a1b0544db9 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/discussion/OaDiscussionReplyDO.java @@ -0,0 +1,63 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.discussion; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * OA 讨论回复 DO + * + * @author 芋道源码 + */ +@TableName("oa_discussion_reply") +@KeySequence("oa_discussion_reply_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaDiscussionReplyDO extends BaseDO { + + /** + * 主回复的父回复编号 + */ + public static final Long PARENT_ID_ROOT = 0L; + + /** + * 回复编号 + */ + @TableId + private Long id; + /** + * 讨论编号 + * + * 关联 {@link OaDiscussionDO#getId()} + */ + private Long discussionId; + /** + * 回复人用户编号 + * + * 关联 {@link AdminUserDO#getId()} + */ + private Long userId; + /** + * 父回复编号 + * + * 关联 {@link #getId()} + */ + private Long parentId; + /** + * 被回复人用户编号 + * + * 关联 {@link AdminUserDO#getId()} + */ + private Long replyUserId; + /** + * 回复内容 + */ + private String content; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/discussion/OaVoteOptionDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/discussion/OaVoteOptionDO.java new file mode 100644 index 0000000000..7f55f2069d --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/discussion/OaVoteOptionDO.java @@ -0,0 +1,47 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.discussion; + +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.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * OA 投票选项 DO + * + * @author 芋道源码 + */ +@TableName("oa_vote_option") +@KeySequence("oa_vote_option_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaVoteOptionDO extends BaseDO { + + /** + * 投票选项编号 + */ + @TableId + private Long id; + /** + * 讨论编号 + * + * 关联 {@link OaDiscussionDO#getId()} + */ + private Long discussionId; + /** + * 选项标题 + */ + private String title; + /** + * 选项颜色 + */ + private String color; + /** + * 显示顺序 + */ + private Integer sort; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/discussion/OaVoteRecordDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/discussion/OaVoteRecordDO.java new file mode 100644 index 0000000000..e2b68d855b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/discussion/OaVoteRecordDO.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.discussion; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * OA 投票记录 DO + * + * @author 芋道源码 + */ +@TableName("oa_vote_record") +@KeySequence("oa_vote_record_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaVoteRecordDO extends BaseDO { + + /** + * 投票记录编号 + */ + @TableId + private Long id; + /** + * 讨论编号 + * + * 关联 {@link OaDiscussionDO#getId()} + */ + private Long discussionId; + /** + * 投票选项编号 + * + * 关联 {@link OaVoteOptionDO#getId()} + */ + private Long optionId; + /** + * 投票人用户编号 + * + * 关联 {@link AdminUserDO#getId()} + */ + private Long userId; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/file/OaFileFavoriteDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/file/OaFileFavoriteDO.java new file mode 100644 index 0000000000..881c5e65aa --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/file/OaFileFavoriteDO.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.file; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * OA 云盘收藏 DO + * + * @author 芋道源码 + */ +@TableName(value = "oa_file_favorite", autoResultMap = true) +@KeySequence("oa_file_favorite_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaFileFavoriteDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 收藏节点编号 + * + * 关联 {@link OaFileNodeDO#getId()} + */ + private Long nodeId; + /** + * 收藏用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/file/OaFileNodeDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/file/OaFileNodeDO.java new file mode 100644 index 0000000000..d7e965aeee --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/file/OaFileNodeDO.java @@ -0,0 +1,90 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.file; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.oa.enums.file.OaFileCategoryEnum; +import cn.iocoder.yudao.module.oa.enums.file.OaFileNodeStatusEnum; +import cn.iocoder.yudao.module.oa.enums.file.OaFileNodeTypeEnum; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * OA 云盘文件节点 DO + * + * @author 芋道源码 + */ +@TableName(value = "oa_file_node", autoResultMap = true) +@KeySequence("oa_file_node_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaFileNodeDO extends BaseDO { + + /** + * 根目录的父节点编号 + */ + public static final Long PARENT_ID_ROOT = 0L; + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 父目录编号,根目录为 {@link #PARENT_ID_ROOT} + * + * 关联 {@link OaFileNodeDO#getId()} + */ + private Long parentId; + /** + * 节点类型 + * + * 枚举 {@link OaFileNodeTypeEnum} + */ + private Integer type; + /** + * 节点名称 + */ + private String name; + /** + * 扩展名 + */ + private String extension; + /** + * 文件分类 + * + * 枚举 {@link OaFileCategoryEnum},文件夹为空 + * 文件上传时通过 {@link OaFileCategoryEnum#getByExtension(String)} 计算 + */ + private Integer category; + /** + * 文件大小,单位字节 + */ + private Long size; + /** + * 文件访问地址 + * + * 由服务端文件上传结果设置,文件夹为空 + */ + private String url; + /** + * 所属部门编号 + * + * 关联 {@link DeptRespDTO#getId()} + */ + private Long deptId; + /** + * 节点状态,区分正常节点与回收站节点 + * + * 枚举 {@link OaFileNodeStatusEnum} + * 1、移入回收站及恢复通过本字段表达,不使用 BaseDO.deleted + * 2、彻底删除使用 BaseDO.deleted,不物理删除数据库记录或底层文件 + * 3、后续访问节点需校验祖先状态,恢复目录不改变此前单独回收的子节点状态 + */ + private Integer status; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/file/OaFilePermissionDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/file/OaFilePermissionDO.java new file mode 100644 index 0000000000..76004eb67c --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/file/OaFilePermissionDO.java @@ -0,0 +1,65 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.file; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.oa.enums.file.OaFilePermissionLevelEnum; +import cn.iocoder.yudao.module.oa.enums.file.OaFileSubjectTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * OA 云盘共享权限 DO + * + * @author 芋道源码 + */ +@TableName(value = "oa_file_permission", autoResultMap = true) +@KeySequence("oa_file_permission_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaFilePermissionDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 共享节点编号 + * + * 关联 {@link OaFileNodeDO#getId()} + */ + private Long nodeId; + /** + * 共享主体类型 + * + * 枚举 {@link OaFileSubjectTypeEnum} + */ + private Integer subjectType; + /** + * 共享主体编号 + * + * 用户主体关联 {@link cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO#getId()} + * 部门主体关联 {@link cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO#getId()} + */ + private Long subjectId; + /** + * 共享权限级别 + * + * 枚举 {@link OaFilePermissionLevelEnum} + */ + private Integer level; + /** + * 是否继承到子目录 + */ + private Boolean inherit; + /** + * 权限到期时间 + */ + private LocalDateTime expireTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/leave/OaLeaveApplyDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/leave/OaLeaveApplyDO.java new file mode 100644 index 0000000000..7fd6cb0644 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/leave/OaLeaveApplyDO.java @@ -0,0 +1,88 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.leave; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.flowable.engine.history.HistoricProcessInstance; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * OA 请假申请 DO + * + * 申请人使用 {@link BaseDO#getCreator()},申请时间使用 {@link BaseDO#getCreateTime()} + * + * @author 芋道源码 + */ +@TableName(value = "oa_leave_apply", autoResultMap = true) +@KeySequence("oa_leave_apply_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaLeaveApplyDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 申请标题 + */ + private String title; + /** + * 紧急程度 + * + * 字典 {@link DictTypeConstants#APPLY_URGENCY} + */ + private Integer urgency; + /** + * 请假类型 + * + * 字典 {@link DictTypeConstants#LEAVE_TYPE} + */ + private Integer type; + /** + * 请假开始时间 + */ + private LocalDateTime startTime; + /** + * 请假结束时间 + */ + private LocalDateTime endTime; + /** + * 请假天数 + */ + private Integer days; + /** + * 申请原因 + */ + private String reason; + /** + * 审批状态,未开始表示尚未提交的草稿 + * + * 枚举 {@link BpmProcessInstanceStatusEnum} + */ + private Integer status; + /** + * BPM 流程实例编号 + * + * 关联 {@link HistoricProcessInstance#getId()} + */ + private String processInstanceId; + /** + * 附件地址列表 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List fileUrls; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/mail/OaMailAccountDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/mail/OaMailAccountDO.java new file mode 100644 index 0000000000..eee6edd5ee --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/mail/OaMailAccountDO.java @@ -0,0 +1,63 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.mail; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * OA 企业邮箱账号 DO + * + * 使用 {@link #getCreator()} 作为账号归属人,关联 {@link AdminUserRespDTO#getId()}。 + * 仅保存账号配置,不保存远端邮件、附件及同步状态。 + * + * @author 芋道源码 + */ +@TableName("oa_mail_account") +@KeySequence("oa_mail_account_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaMailAccountDO extends BaseDO { + + /** + * 账号编号 + */ + @TableId + private Long id; + /** + * 服务配置编号 + * + * 关联 {@link OaMailProviderDO#getId()} + */ + private Long providerId; + /** + * 邮箱地址 + */ + private String mail; + /** + * 登录用户名 + */ + private String username; + /** + * 密码或授权码 + */ + private String password; + /** + * 是否默认发件账号 + */ + private Boolean defaultStatus; + /** + * 状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/mail/OaMailFolderDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/mail/OaMailFolderDO.java new file mode 100644 index 0000000000..7fa7e30071 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/mail/OaMailFolderDO.java @@ -0,0 +1,55 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.mail; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.oa.enums.mail.OaMailFolderTypeEnum; +import com.baomidou.mybatisplus.annotation.*; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 企业邮箱文件夹 DO + * + * @author 芋道源码 + */ +@TableName("oa_mail_folder") +@KeySequence("oa_mail_folder_seq") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaMailFolderDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 邮箱账号编号 + * + * 关联 {@link OaMailAccountDO#getId()} + */ + private Long accountId; + /** + * 远端文件夹完整名称 + */ + private String name; + /** + * 文件夹类型 + * + * 枚举 {@link OaMailFolderTypeEnum} + */ + private String type; + /** + * 远端文件夹 UID 有效期 + */ + private Long uidValidity; + /** + * 最近完整同步时间 + */ + private LocalDateTime syncTime; + /** + * 远端文件夹是否可用 + */ + private Boolean available; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/mail/OaMailMessageDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/mail/OaMailMessageDO.java new file mode 100644 index 0000000000..ac15467503 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/mail/OaMailMessageDO.java @@ -0,0 +1,119 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.mail; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.*; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 企业邮箱邮件索引 DO + * + * @author 芋道源码 + */ +@TableName(value = "oa_mail_message", autoResultMap = true) +@KeySequence("oa_mail_message_seq") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaMailMessageDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 邮箱账号编号 + * + * 关联 {@link OaMailAccountDO#getId()} + */ + private Long accountId; + /** + * 文件夹编号 + * + * 关联 {@link OaMailFolderDO#getId()} + */ + private Long folderId; + /** + * 远端文件夹 UID 有效期 + */ + private Long uidValidity; + /** + * 远端邮件 UID + */ + private Long uid; + /** + * 邮件主题 + */ + private String subject; + /** + * 经安全过滤的正文缓存 + * + * null 表示未读取,空字符串表示正文为空 + */ + private String content; + /** + * 回复地址 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List replyTos; + /** + * 附件目录缓存,附件文件仍从远端读取 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List attachments; + /** + * 发件人 + */ + private String sender; + /** + * 收件人 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List recipients; + /** + * 抄送人 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List ccs; + /** + * 接收时间 + */ + private LocalDateTime receiveTime; + /** + * 是否已读 + */ + private Boolean readStatus; + /** + * 是否有附件 + */ + private Boolean hasAttach; + /** + * 邮件大小(字节) + */ + private Integer size; + + /** + * 远端附件目录信息 + */ + @Data + public static class Attachment { + + /** + * MIME 部件路径 + */ + private String part; + /** + * 附件名称 + */ + private String name; + /** + * 附件大小,单位字节 + */ + private Integer size; + + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/mail/OaMailProviderDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/mail/OaMailProviderDO.java new file mode 100644 index 0000000000..b866e74114 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/mail/OaMailProviderDO.java @@ -0,0 +1,77 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.mail; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * OA 企业邮箱服务配置 DO + * + * @author 芋道源码 + */ +@TableName(value = "oa_mail_provider", autoResultMap = true) +@KeySequence("oa_mail_provider_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaMailProviderDO extends BaseDO { + + /** + * 服务配置编号 + */ + @TableId + private Long id; + /** + * 名称 + */ + private String name; + /** + * IMAP 连接配置 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private ConnectionConfig imap; + /** + * SMTP 连接配置 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private ConnectionConfig smtp; + /** + * 状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + + /** + * 邮箱连接配置 + */ + @Data + public static class ConnectionConfig { + + /** + * 服务器域名 + */ + private String host; + /** + * 服务器端口 + */ + private Integer port; + /** + * 是否开启 SSL + */ + private Boolean sslEnable; + /** + * 是否开启 STARTTLS + */ + private Boolean starttlsEnable; + + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/meetingroom/OaMeetingRoomBookingDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/meetingroom/OaMeetingRoomBookingDO.java new file mode 100644 index 0000000000..fc65fe49e4 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/meetingroom/OaMeetingRoomBookingDO.java @@ -0,0 +1,130 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.meetingroom; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.enums.meetingroom.OaMeetingRoomReminderTypeEnum; +import cn.iocoder.yudao.module.oa.enums.meetingroom.OaMeetingRoomUseStatusEnum; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.flowable.engine.history.HistoricProcessInstance; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * OA 会议室预定 DO + * + * @author 芋道源码 + */ +@TableName(value = "oa_meeting_room_booking", autoResultMap = true) +@KeySequence("oa_meeting_room_booking_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaMeetingRoomBookingDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 预定申请单号 + */ + private String no; + /** + * 会议室编号 + * + * 关联 {@link OaMeetingRoomDO#getId()} + */ + private Long roomId; + /** + * 会议主题 + */ + private String title; + /** + * 会议开始时间 + */ + private LocalDateTime startTime; + /** + * 会议结束时间 + */ + private LocalDateTime endTime; + /** + * 主持人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long moderatorUserId; + /** + * 参会用户编号列表 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List attendeeUserIds; + /** + * 会议提醒类型 + * + * 枚举 {@link OaMeetingRoomReminderTypeEnum} + */ + private Integer reminderType; + /** + * 是否已发送会议开始提醒 + */ + private Boolean reminded; + /** + * 会议说明 + */ + private String description; + /** + * 申请部门编号 + * + * 关联 {@link DeptRespDTO#getId()} + */ + private Long deptId; + /** + * 审批状态 + * + * 枚举 {@link BpmProcessInstanceStatusEnum} + * 无需审批时提交后直接通过 + */ + private Integer status; + /** + * BPM 流程实例编号 + * + * 关联 {@link HistoricProcessInstance#getId()} + * 无需审批时为空 + */ + private String processInstanceId; + /** + * 提交时是否需要审批 + * + * 取自 {@link OaMeetingRoomDO#getNeedApproval()},草稿时可为空 + */ + private Boolean needApproval; + /** + * 使用状态 + * + * 枚举 {@link OaMeetingRoomUseStatusEnum} + */ + private Integer useStatus; + /** + * 申请备注 + */ + private String remark; + /** + * 附件地址列表 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List fileUrls; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/meetingroom/OaMeetingRoomDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/meetingroom/OaMeetingRoomDO.java new file mode 100644 index 0000000000..a2c4e04631 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/meetingroom/OaMeetingRoomDO.java @@ -0,0 +1,114 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.meetingroom; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import cn.iocoder.yudao.module.oa.enums.meetingroom.OaMeetingRoomBookingScopeEnum; +import cn.iocoder.yudao.module.oa.enums.meetingroom.OaMeetingRoomStatusEnum; +import cn.iocoder.yudao.module.oa.enums.meetingroom.OaMeetingRoomTypeEnum; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.util.List; + +/** + * OA 会议室 DO + * + * @author 芋道源码 + */ +@TableName(value = "oa_meeting_room", autoResultMap = true) +@KeySequence("oa_meeting_room_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaMeetingRoomDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 会议室名称 + */ + private String name; + /** + * 会议室位置 + */ + private String location; + /** + * 会议室类型 + * + * 枚举 {@link OaMeetingRoomTypeEnum} + */ + private Integer type; + /** + * 负责人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + * 联系方式使用 {@link AdminUserRespDTO#getMobile()} + */ + private Long managerUserId; + /** + * 可用状态 + * + * 枚举 {@link OaMeetingRoomStatusEnum} + */ + private Integer status; + /** + * 会议室图片 URL + */ + private String picUrl; + /** + * 坐席数 + */ + private Integer seatCount; + /** + * 设备列表 + * + * 字典 {@link DictTypeConstants#MEETING_ROOM_EQUIPMENT} + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List equipments; + /** + * 是否允许预定 + */ + private Boolean allowBooking; + /** + * 预定是否需要审批 + */ + private Boolean needApproval; + /** + * 可预定范围 + * + * 枚举 {@link OaMeetingRoomBookingScopeEnum} + */ + private Integer bookingScope; + /** + * 可预定用户编号列表 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List bookingUserIds; + /** + * 显示顺序 + */ + private Integer sort; + /** + * 备注 + */ + private String remark; + /** + * 附件地址列表 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List fileUrls; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/note/OaNoteCategoryDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/note/OaNoteCategoryDO.java new file mode 100644 index 0000000000..5aa75b3d9d --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/note/OaNoteCategoryDO.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.note; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * OA 笔记目录 DO + * + * @author 芋道源码 + */ +@TableName("oa_note_category") +@KeySequence("oa_note_category_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaNoteCategoryDO extends BaseDO { + + /** + * 目录编号 + */ + @TableId + private Long id; + /** + * 用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + /** + * 目录名称 + */ + private String name; + /** + * 显示顺序 + */ + private Integer sort; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/note/OaNoteDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/note/OaNoteDO.java new file mode 100644 index 0000000000..f09e29a2fb --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/note/OaNoteDO.java @@ -0,0 +1,70 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.note; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.oa.enums.note.OaNoteTypeEnum; +import cn.iocoder.yudao.module.oa.enums.schedule.OaPriorityEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.util.List; + +/** + * OA 笔记 DO + * + * @author 芋道源码 + */ +@TableName(value = "oa_note", autoResultMap = true) +@KeySequence("oa_note_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaNoteDO extends BaseDO { + + /** + * 笔记编号 + */ + @TableId + private Long id; + /** + * 目录编号 + * + * 关联 {@link OaNoteCategoryDO#getId()} + */ + private Long categoryId; + /** + * 笔记类型 + * + * 枚举 {@link OaNoteTypeEnum} + */ + private Integer type; + /** + * 优先级 + * + * 枚举 {@link OaPriorityEnum} + */ + private Integer priority; + /** + * 标题 + */ + private String title; + /** + * 内容 + */ + private String content; + /** + * 是否收藏 + */ + private Boolean favorite; + /** + * 附件地址列表 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List fileUrls; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/note/OaNoteReceiverDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/note/OaNoteReceiverDO.java new file mode 100644 index 0000000000..e9cf950eb1 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/note/OaNoteReceiverDO.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.note; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * OA 笔记持有关系 DO + * + * @author 芋道源码 + */ +@TableName("oa_note_receiver") +@KeySequence("oa_note_receiver_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaNoteReceiverDO extends BaseDO { + + /** + * 持有关系编号 + */ + @TableId + private Long id; + /** + * 笔记编号 + * + * 关联 {@link OaNoteDO#getId()} + */ + private Long noteId; + /** + * 持有人用户编号,包含创建人和共享接收人 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/officialdoc/OaOfficialDocReceiveDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/officialdoc/OaOfficialDocReceiveDO.java new file mode 100644 index 0000000000..39aea2b317 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/officialdoc/OaOfficialDocReceiveDO.java @@ -0,0 +1,154 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.officialdoc; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import cn.iocoder.yudao.module.oa.enums.officialdoc.OaOfficialDocHandleStatusEnum; +import cn.iocoder.yudao.module.oa.enums.officialdoc.OaOfficialDocReceiveTypeEnum; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.flowable.engine.history.HistoricProcessInstance; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * OA 公文收文 DO + * + * @author 芋道源码 + */ +@TableName(value = "oa_official_doc_receive", autoResultMap = true) +@KeySequence("oa_official_doc_receive_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaOfficialDocReceiveDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 单据编号 + */ + private String no; + /** + * 来源发文编号 + * + * 关联 {@link OaOfficialDocSendDO#getId()},手工新增收文时为空 + */ + private Long sendId; + /** + * 收文类型 + * + * 枚举 {@link OaOfficialDocReceiveTypeEnum} + * 字典 {@link DictTypeConstants#OFFICIAL_DOC_RECEIVE_TYPE} + */ + private Integer receiveType; + /** + * 标题 + * + * 自动生成时冗余 {@link OaOfficialDocSendDO#getTitle()},手工收文直接填写 + */ + private String title; + /** + * 来文字号 + * + * 自动生成时冗余 {@link OaOfficialDocSendDO#getDocumentNo()},手工收文直接填写 + */ + private String documentNo; + /** + * 密级 + * + * 字典 {@link DictTypeConstants#OFFICIAL_DOC_SECRET_LEVEL} + * 自动生成时冗余 {@link OaOfficialDocSendDO#getSecrecyLevel()},手工收文直接填写 + */ + private Integer secrecyLevel; + /** + * 紧急程度 + * + * 字典 {@link DictTypeConstants#OFFICIAL_DOC_URGENCY_LEVEL} + * 自动生成时冗余 {@link OaOfficialDocSendDO#getUrgencyLevel()},手工收文直接填写 + */ + private Integer urgencyLevel; + /** + * 收文时间 + */ + private LocalDateTime receiveTime; + /** + * 收文部门编号 + * + * 关联 {@link DeptRespDTO#getId()} + */ + private Long receiveDeptId; + /** + * 主办人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long handlerUserId; + /** + * 领导批示 + */ + private String instruction; + /** + * 办理结果 + * + * 收文办理的结果说明,独立于流程审批意见 + */ + private String result; + /** + * 办理期限 + */ + private LocalDateTime deadlineTime; + /** + * 内容摘要 + */ + private String summary; + /** + * 备注 + */ + private String remark; + /** + * 附件地址列表 + * + * 收文自身附件,不等同于来源发文的附件列表 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List fileUrls; + /** + * 正式公文地址 + * + * 冗余 {@link OaOfficialDocSendDO#getFormalFileUrl()},独立于收文附件 + */ + private String formalFileUrl; + /** + * 办理状态 + * + * 枚举 {@link OaOfficialDocHandleStatusEnum} + * 字典 {@link DictTypeConstants#OFFICIAL_DOC_HANDLE_STATUS} + */ + private Integer handleStatus; + /** + * 审批状态 + * + * 枚举 {@link BpmProcessInstanceStatusEnum} + */ + private Integer status; + /** + * BPM 流程实例编号 + * + * 关联 {@link HistoricProcessInstance#getId()} + */ + private String processInstanceId; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/officialdoc/OaOfficialDocSendDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/officialdoc/OaOfficialDocSendDO.java new file mode 100644 index 0000000000..a07ad7b55c --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/officialdoc/OaOfficialDocSendDO.java @@ -0,0 +1,152 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.officialdoc; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.flowable.engine.history.HistoricProcessInstance; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * OA 公文发文 DO + * + * @author 芋道源码 + */ +@TableName(value = "oa_official_doc_send", autoResultMap = true) +@KeySequence("oa_official_doc_send_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaOfficialDocSendDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 单据编号 + * + * 发文业务单号,与 {@link #documentNo} 的公文文号独立 + */ + private String no; + /** + * 套红模板编号 + * + * 关联 {@link OaOfficialDocTemplateDO#getId()} + */ + private Long templateId; + /** + * 标题 + */ + private String title; + /** + * 发文字号前缀 + * + * 初始取自 {@link OaOfficialDocTemplateDO#getNoPrefix()},保存发文自身取值,不随模板修改联动 + */ + private String noPrefix; + /** + * 发文年度 + */ + private Integer year; + /** + * 发文序号 + */ + private Integer sequence; + /** + * 公文文号 + * + * 冗余 {@link #noPrefix}、{@link #year}、{@link #sequence} 组成的完整文号,用于查询和展示 + */ + private String documentNo; + /** + * 密级 + * + * 字典 {@link DictTypeConstants#OFFICIAL_DOC_SECRET_LEVEL} + */ + private Integer secrecyLevel; + /** + * 紧急程度 + * + * 字典 {@link DictTypeConstants#OFFICIAL_DOC_URGENCY_LEVEL} + */ + private Integer urgencyLevel; + /** + * 公开类别 + * + * 字典 {@link DictTypeConstants#OFFICIAL_DOC_PUBLIC_CATEGORY} + */ + private Integer disclosureType; + /** + * 发文时间 + */ + private LocalDateTime issueTime; + /** + * 发文部门编号 + * + * 关联 {@link DeptRespDTO#getId()} + */ + private Long sendDeptId; + /** + * 主送部门编号列表 + * + * 关联 {@link DeptRespDTO#getId()} + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List mainDeptIds; + /** + * 抄送部门编号列表 + * + * 关联 {@link DeptRespDTO#getId()} + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List copyDeptIds; + /** + * 签发人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long signerUserId; + /** + * 正文 + */ + private String content; + /** + * 附件地址列表 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List fileUrls; + /** + * 正式公文地址 + */ + private String formalFileUrl; + /** + * 附注 + */ + private String remark; + /** + * 审批状态 + * + * 枚举 {@link BpmProcessInstanceStatusEnum} + */ + private Integer status; + /** + * BPM 流程实例编号 + * + * 关联 {@link HistoricProcessInstance#getId()} + */ + private String processInstanceId; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/officialdoc/OaOfficialDocTemplateDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/officialdoc/OaOfficialDocTemplateDO.java new file mode 100644 index 0000000000..f63a5b1214 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/officialdoc/OaOfficialDocTemplateDO.java @@ -0,0 +1,77 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.officialdoc; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * OA 套红模板 DO + * + * @author 芋道源码 + */ +@TableName("oa_official_doc_template") +@KeySequence("oa_official_doc_template_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaOfficialDocTemplateDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 模板名称 + */ + private String name; + /** + * 红头名称 + * + * 模板版式文本,不是 System 部门名称的冗余字段 + */ + private String authorityName; + /** + * 红头名称字号 + */ + private Integer fontSize; + /** + * 发文字号前缀 + * + * 模板配置,供发文初始化文号前缀使用 + */ + private String noPrefix; + /** + * 印章图片地址 + * + * 复用平台图片上传,不关联印章资产 + */ + private String sealPicUrl; + /** + * 分隔线类型 + * + * 字典 {@link DictTypeConstants#OFFICIAL_DOC_SEPARATOR_TYPE} + */ + private Integer separatorType; + /** + * 状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + /** + * 显示顺序 + */ + private Integer sort; + /** + * 备注 + */ + private String remark; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/overtime/OaOvertimeApplyDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/overtime/OaOvertimeApplyDO.java new file mode 100644 index 0000000000..acb447f7c2 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/overtime/OaOvertimeApplyDO.java @@ -0,0 +1,81 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.overtime; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.flowable.engine.history.HistoricProcessInstance; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * OA 加班申请 DO + * + * 申请人使用 {@link BaseDO#getCreator()},申请时间使用 {@link BaseDO#getCreateTime()} + * + * @author 芋道源码 + */ +@TableName("oa_overtime_apply") +@KeySequence("oa_overtime_apply_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaOvertimeApplyDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 申请标题 + */ + private String title; + /** + * 紧急程度 + * + * 字典 {@link DictTypeConstants#APPLY_URGENCY} + */ + private Integer urgency; + /** + * 加班类型 + * + * 字典 {@link DictTypeConstants#OVERTIME_TYPE} + */ + private Integer type; + /** + * 加班开始时间 + */ + private LocalDateTime startTime; + /** + * 加班结束时间 + */ + private LocalDateTime endTime; + /** + * 加班天数 + */ + private BigDecimal days; + /** + * 申请原因 + */ + private String reason; + /** + * 审批状态,未开始表示尚未提交的草稿 + * + * 枚举 {@link BpmProcessInstanceStatusEnum} + */ + private Integer status; + /** + * BPM 流程实例编号 + * + * 关联 {@link HistoricProcessInstance#getId()} + */ + private String processInstanceId; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/plan/OaPlanDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/plan/OaPlanDO.java new file mode 100644 index 0000000000..4d01e4617b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/plan/OaPlanDO.java @@ -0,0 +1,81 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.plan; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.oa.enums.plan.OaPlanStatusEnum; +import cn.iocoder.yudao.module.oa.enums.plan.OaPlanTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * OA 工作计划 DO + * + * @author 芋道源码 + */ +@TableName(value = "oa_plan", autoResultMap = true) +@KeySequence("oa_plan_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaPlanDO extends BaseDO { + + /** + * 计划编号 + */ + @TableId + private Long id; + /** + * 计划类型 + * + * 枚举 {@link OaPlanTypeEnum} + */ + private Integer type; + /** + * 计划状态 + * + * 枚举 {@link OaPlanStatusEnum} + */ + private Integer status; + /** + * 标题 + */ + private String title; + /** + * 标签 + */ + private String label; + /** + * 计划内容 + */ + private String content; + /** + * 计划总结 + */ + private String summary; + /** + * 计划点评 + */ + private String comment; + /** + * 开始时间 + */ + private LocalDateTime startTime; + /** + * 结束时间 + */ + private LocalDateTime endTime; + /** + * 附件地址列表 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List fileUrls; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/regular/OaRegularApplyDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/regular/OaRegularApplyDO.java new file mode 100644 index 0000000000..d39d43c009 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/regular/OaRegularApplyDO.java @@ -0,0 +1,96 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.regular; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.flowable.engine.history.HistoricProcessInstance; + +import java.time.LocalDateTime; + +/** + * OA 转正申请 DO + * + * 申请人使用 {@link BaseDO#getCreator()},申请时间使用 {@link BaseDO#getCreateTime()} + * + * @author 芋道源码 + */ +@TableName("oa_regular_apply") +@KeySequence("oa_regular_apply_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaRegularApplyDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 申请标题 + */ + private String title; + /** + * 紧急程度 + * + * 字典 {@link DictTypeConstants#APPLY_URGENCY} + */ + private Integer urgency; + /** + * 试用开始时间 + */ + private LocalDateTime startTime; + /** + * 试用结束时间 + */ + private LocalDateTime endTime; + /** + * 试用天数 + * + * 根据 {@link #startTime} 和 {@link #endTime} 计算,不重复保存流程公共天数 + */ + private Integer days; + /** + * 试用期或实习期心得体会 + */ + private String experience; + /** + * 对本岗位职责要求的理解 + */ + private String understanding; + /** + * 试用期或实习期的成长 + */ + private String growth; + /** + * 目前存在的不足 + */ + private String deficiency; + /** + * 本岗位工作改进计划 + */ + private String improvement; + /** + * 对公司产品的意见及建议 + */ + private String suggestion; + /** + * 审批状态,未开始表示尚未提交的草稿 + * + * 枚举 {@link BpmProcessInstanceStatusEnum} + */ + private Integer status; + /** + * BPM 流程实例编号 + * + * 关联 {@link HistoricProcessInstance#getId()} + */ + private String processInstanceId; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/reimbursement/OaReimbursementDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/reimbursement/OaReimbursementDO.java new file mode 100644 index 0000000000..ac89f03ac3 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/reimbursement/OaReimbursementDO.java @@ -0,0 +1,140 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.reimbursement; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.flowable.engine.history.HistoricProcessInstance; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** + * OA 费用报销 DO + * + * 申请人使用 {@link BaseDO#getCreator()},申请时间使用 {@link BaseDO#getCreateTime()} + * + * @author 芋道源码 + */ +@TableName(value = "oa_reimbursement", autoResultMap = true) +@KeySequence("oa_reimbursement_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaReimbursementDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 申请标题 + */ + private String title; + /** + * 紧急程度 + * + * 字典 {@link DictTypeConstants#APPLY_URGENCY} + */ + private Integer urgency; + /** + * 申请原因 + */ + private String reason; + /** + * 证明人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long witnessUserId; + /** + * 相关客户名称 + * + * 填写客户名称文本,不关联客户档案 + */ + private String customerName; + /** + * 报销方式 + * + * 字典 {@link DictTypeConstants#REIMBURSEMENT_PAYMENT_METHOD} + */ + private Integer paymentMethod; + /** + * 票据总数 + * + * 汇总 {@link Item#getInvoiceCount()},不由用户单独填写 + */ + private Integer invoiceCount; + /** + * 报销总金额,单位:元 + * + * 汇总 {@link Item#getPrice()},不由用户单独填写 + */ + private BigDecimal totalPrice; + /** + * 审批状态,未开始表示尚未提交的草稿 + * + * 枚举 {@link BpmProcessInstanceStatusEnum} + */ + private Integer status; + /** + * BPM 流程实例编号 + * + * 关联 {@link HistoricProcessInstance#getId()} + */ + private String processInstanceId; + /** + * 报销明细 + * + * 明细随报销单保存,不维护独立编号及生命周期 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List items; + /** + * 附件地址列表 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List fileUrls; + + /** + * 报销明细 + */ + @Data + public static class Item { + + /** + * 费用产生时间 + */ + private LocalDateTime expenseTime; + /** + * 费用类型 + * + * 字典 {@link DictTypeConstants#EXPENSE_TYPE} + */ + private Integer expenseType; + /** + * 费用说明 + */ + private String description; + /** + * 票据张数 + */ + private Integer invoiceCount; + /** + * 报销金额,单位:元 + */ + private BigDecimal price; + + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/resign/OaResignApplyDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/resign/OaResignApplyDO.java new file mode 100644 index 0000000000..a3459d9d36 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/resign/OaResignApplyDO.java @@ -0,0 +1,83 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.resign; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.flowable.engine.history.HistoricProcessInstance; + +/** + * OA 离职申请 DO + * + * 申请人使用 {@link BaseDO#getCreator()},申请时间使用 {@link BaseDO#getCreateTime()} + * + * @author 芋道源码 + */ +@TableName("oa_resign_apply") +@KeySequence("oa_resign_apply_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaResignApplyDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 申请标题 + */ + private String title; + /** + * 紧急程度 + * + * 字典 {@link DictTypeConstants#APPLY_URGENCY} + */ + private Integer urgency; + /** + * 申请原因 + */ + private String reason; + /** + * 工作交接人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long handoverUserId; + /** + * 未完成事宜 + */ + private String unfinishedWork; + /** + * 是否有未完成的费用报销 + * + * 勾选表示存在未完成报销,不表示报销已经完成 + */ + private Boolean hasPendingReimbursement; + /** + * 申请人的意见及建议 + * + * 保留申请数据,是否展示输入入口由表单评审确定 + */ + private String suggestion; + /** + * 审批状态,未开始表示尚未提交的草稿 + * + * 枚举 {@link BpmProcessInstanceStatusEnum} + */ + private Integer status; + /** + * BPM 流程实例编号 + * + * 关联 {@link HistoricProcessInstance#getId()} + */ + private String processInstanceId; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/schedule/OaScheduleDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/schedule/OaScheduleDO.java new file mode 100644 index 0000000000..f31728a69b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/schedule/OaScheduleDO.java @@ -0,0 +1,69 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.schedule; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.oa.enums.schedule.OaPriorityEnum; +import cn.iocoder.yudao.module.oa.enums.schedule.OaScheduleTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.time.LocalDateTime; + +/** + * OA 日程 DO + * + * @author 芋道源码 + */ +@TableName("oa_schedule") +@KeySequence("oa_schedule_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaScheduleDO extends BaseDO { + + /** + * 日程编号 + */ + @TableId + private Long id; + /** + * 日程类型 + * + * 枚举 {@link OaScheduleTypeEnum} + */ + private Integer type; + /** + * 优先级 + * + * 枚举 {@link OaPriorityEnum} + */ + private Integer priority; + /** + * 标题 + */ + private String title; + /** + * 描述 + */ + private String description; + /** + * 开始时间 + */ + private LocalDateTime startTime; + /** + * 结束时间 + */ + private LocalDateTime endTime; + /** + * 是否提醒 + */ + private Boolean remind; + /** + * 是否已提醒(创建人和参与人全部发送成功后标记) + */ + private Boolean reminded; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/schedule/OaScheduleParticipantDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/schedule/OaScheduleParticipantDO.java new file mode 100644 index 0000000000..154defd449 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/schedule/OaScheduleParticipantDO.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.schedule; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * OA 日程参与人 DO + * + * @author 芋道源码 + */ +@TableName("oa_schedule_participant") +@KeySequence("oa_schedule_participant_seq") +@Data +@EqualsAndHashCode(callSuper = true) +public class OaScheduleParticipantDO extends BaseDO { + + /** + * 参与记录编号 + */ + @TableId + private Long id; + /** + * 日程编号 + * + * 关联 {@link OaScheduleDO#getId()} + */ + private Long scheduleId; + /** + * 参与人用户编号 + * + * 关联 {@link AdminUserDO#getId()} + */ + private Long userId; + /** + * 是否已读 + */ + private Boolean readStatus; + /** + * 首次阅读时间 + */ + private LocalDateTime readTime; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/seal/OaSealApplyDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/seal/OaSealApplyDO.java new file mode 100644 index 0000000000..fa7ec7960d --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/seal/OaSealApplyDO.java @@ -0,0 +1,181 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.seal; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import org.flowable.engine.history.HistoricProcessInstance; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import cn.iocoder.yudao.module.oa.enums.seal.OaSealApplyTypeEnum; +import cn.iocoder.yudao.module.oa.enums.seal.OaSealUseModeEnum; +import cn.iocoder.yudao.module.oa.enums.seal.OaSealUseStatusEnum; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** + * OA 用印申请 DO + * + * @author 芋道源码 + */ +@TableName(value = "oa_seal_apply", autoResultMap = true) +@KeySequence("oa_seal_apply_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaSealApplyDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 申请单号 + */ + private String no; + /** + * 申请人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + /** + * 申请部门编号 + * + * 关联 {@link DeptRespDTO#getId()} + */ + private Long deptId; + /** + * 印章编号 + * + * 关联 {@link OaSealDO#getId()} + */ + private Long sealId; + /** + * 印章编号快照 + * + * 冗余 {@link OaSealDO#getNo()} + */ + private String sealNo; + /** + * 印章名称快照 + * + * 冗余 {@link OaSealDO#getName()} + */ + private String sealName; + /** + * 印章类型快照 + * + * 冗余 {@link OaSealDO#getType()} + */ + private Integer sealType; + /** + * 保管部门编号快照 + * + * 冗余 {@link OaSealDO#getKeeperDeptId()} + */ + private Long keeperDeptId; + /** + * 保管人用户编号快照 + * + * 冗余 {@link OaSealDO#getKeeperUserId()} + */ + private Long keeperUserId; + /** + * 用印事由 + */ + private String reason; + /** + * 用印类型 + * + * 枚举 {@link OaSealApplyTypeEnum} + * 字典 {@link DictTypeConstants#SEAL_APPLY_TYPE} + */ + private Integer type; + /** + * 用印方式 + * + * 枚举 {@link OaSealUseModeEnum} + * 字典 {@link DictTypeConstants#SEAL_USE_MODE} + */ + private Integer mode; + /** + * 文件标题 + */ + private String documentTitle; + /** + * 文件类型 + */ + private String documentType; + /** + * 文件份数 + */ + private Integer documentCount; + /** + * 合同金额 + */ + private BigDecimal contractPrice; + /** + * 合同对方 + */ + private String contractParty; + /** + * 预计用印时间 + */ + private LocalDateTime expectedUseTime; + /** + * 实际用印时间 + */ + private LocalDateTime actualUseTime; + /** + * 预计归还时间 + */ + private LocalDateTime expectedReturnTime; + /** + * 实际归还时间 + */ + private LocalDateTime actualReturnTime; + /** + * 用印业务状态 + * + * 枚举 {@link OaSealUseStatusEnum} + * 字典 {@link DictTypeConstants#SEAL_USE_STATUS} + */ + private Integer useStatus; + /** + * 审批状态,取 BPM 流程实例状态 + * + * 枚举 {@link BpmProcessInstanceStatusEnum} + */ + private Integer status; + /** + * BPM 流程实例编号 + * + * 关联 {@link HistoricProcessInstance#getId()} + */ + private String processInstanceId; + /** + * 是否紧急 + */ + private Boolean urgent; + /** + * 备注 + */ + private String remark; + /** + * 附件地址列表 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List fileUrls; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/seal/OaSealDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/seal/OaSealDO.java new file mode 100644 index 0000000000..c9a3e6627d --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/seal/OaSealDO.java @@ -0,0 +1,105 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.seal; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import cn.iocoder.yudao.module.oa.enums.seal.OaSealStatusEnum; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.time.LocalDateTime; + +/** + * OA 印章 DO + * + * @author 芋道源码 + */ +@TableName(value = "oa_seal", autoResultMap = true) +@KeySequence("oa_seal_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaSealDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 所属部门编号 + * + * 关联 {@link DeptRespDTO#getId()},与保管部门独立维护 + */ + private Long deptId; + /** + * 印章编号 + */ + private String no; + /** + * 印章名称 + */ + private String name; + /** + * 印章类型 + * + * 字典 {@link DictTypeConstants#SEAL_TYPE} + */ + private Integer type; + /** + * 印章分类 + * + * 字典 {@link DictTypeConstants#SEAL_CATEGORY} + */ + private Integer category; + /** + * 保管人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long keeperUserId; + /** + * 保管部门编号 + * + * 关联 {@link DeptRespDTO#getId()} + */ + private Long keeperDeptId; + /** + * 印章台账状态 + * + * 枚举 {@link OaSealStatusEnum} + * + * 字典 {@link DictTypeConstants#SEAL_STATUS} + */ + private Integer status; + /** + * 购买时间 + */ + private LocalDateTime purchaseTime; + /** + * 启用时间 + */ + private LocalDateTime enableTime; + /** + * 停用时间 + */ + private LocalDateTime disableTime; + /** + * 印章照片地址 + */ + private String picUrl; + /** + * 显示顺序 + */ + private Integer sort; + /** + * 备注 + */ + private String remark; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/supply/OaSupplyApplyDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/supply/OaSupplyApplyDO.java new file mode 100644 index 0000000000..1dbaaa1d7a --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/supply/OaSupplyApplyDO.java @@ -0,0 +1,87 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.supply; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.oa.enums.supply.*; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import org.flowable.engine.history.HistoricProcessInstance; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * OA 用品领用申请 DO + * + * @author 芋道源码 + */ +@TableName(value = "oa_supply_apply", autoResultMap = true) +@KeySequence("oa_supply_apply_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaSupplyApplyDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 申请单号 + */ + private String no; + /** + * 申请部门编号 + * + * 关联 {@link DeptRespDTO#getId()} + */ + private Long deptId; + /** + * 领用日期 + */ + private LocalDateTime applyTime; + /** + * 使用类型 + * + * 枚举 {@link OaSupplyUseTypeEnum} + */ + private Integer useType; + /** + * 领取方式 + * + * 字典 {@link DictTypeConstants#SUPPLY_PICKUP_METHOD} + */ + private Integer pickupMethod; + /** + * 申请事由 + */ + private String reason; + /** + * 审批状态 + * + * 枚举 {@link BpmProcessInstanceStatusEnum} + */ + private Integer status; + /** + * BPM 流程实例编号 + * + * 关联 {@link HistoricProcessInstance#getId()} + */ + private String processInstanceId; + /** + * 附件地址列表 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List fileUrls; + /** + * 备注 + */ + private String remark; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/supply/OaSupplyApplyItemDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/supply/OaSupplyApplyItemDO.java new file mode 100644 index 0000000000..f921cc2691 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/supply/OaSupplyApplyItemDO.java @@ -0,0 +1,104 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.supply; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.oa.enums.supply.*; +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; + +import java.time.LocalDateTime; + +/** + * OA 用品申请明细 DO + * + * @author 芋道源码 + */ +@TableName(value = "oa_supply_apply_item", autoResultMap = true) +@KeySequence("oa_supply_apply_item_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaSupplyApplyItemDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 领用申请编号 + * + * 关联 {@link OaSupplyApplyDO#getId()} + */ + private Long applyId; + /** + * 办公用品编号 + * + * 关联 {@link OaSupplyItemDO#getId()} + */ + private Long itemId; + /** + * 物品名称快照 + * + * 冗余 {@link OaSupplyItemDO#getName()} + */ + private String itemName; + /** + * 规格型号快照 + * + * 冗余 {@link OaSupplyItemDO#getModel()} + */ + private String model; + /** + * 计量单位快照 + * + * 冗余 {@link OaSupplyItemDO#getUnit()} + */ + private String unit; + /** + * 管理类型快照 + * + * 枚举 {@link OaSupplyManageTypeEnum} + * 冗余 {@link OaSupplyItemDO#getManageType()} + */ + private Integer manageType; + /** + * 申请数量 + */ + private Integer applyQuantity; + /** + * 实发数量 + */ + private Integer issuedQuantity; + /** + * 累计归还数量 + */ + private Integer returnedQuantity; + /** + * 明细状态 + * + * 枚举 {@link OaSupplyApplyItemStatusEnum} + */ + private Integer status; + /** + * 发放人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long issueUserId; + /** + * 发放时间 + */ + private LocalDateTime issueTime; + /** + * 发放备注 + */ + private String issueRemark; + /** + * 归还备注 + */ + private String returnRemark; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/supply/OaSupplyItemDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/supply/OaSupplyItemDO.java new file mode 100644 index 0000000000..9d3f063fee --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/supply/OaSupplyItemDO.java @@ -0,0 +1,98 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.supply; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.oa.enums.supply.*; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; + +import java.math.BigDecimal; + +/** + * OA 办公用品 DO + * + * @author 芋道源码 + */ +@TableName(value = "oa_supply_item", autoResultMap = true) +@KeySequence("oa_supply_item_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaSupplyItemDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 所属部门编号 + * + * 关联 {@link DeptRespDTO#getId()} + */ + private Long deptId; + /** + * 物品名称 + */ + private String name; + /** + * 物品编码 + */ + private String no; + /** + * 类别 + * + * 枚举 {@link OaSupplyCategoryEnum} + * 字典 {@link DictTypeConstants#SUPPLY_CATEGORY} + */ + private Integer category; + /** + * 管理类型 + * + * 枚举 {@link OaSupplyManageTypeEnum} + */ + private Integer manageType; + /** + * 规格型号 + */ + private String model; + /** + * 计量单位 + */ + private String unit; + /** + * 参考单价 + */ + private BigDecimal referencePrice; + /** + * 库存数量 + */ + private Integer stockQuantity; + /** + * 最低库存预警 + */ + private Integer minStockQuantity; + /** + * 物品图片 URL + */ + private String picUrl; + /** + * 状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + /** + * 显示顺序 + */ + private Integer sort; + /** + * 备注 + */ + private String remark; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/task/OaTaskDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/task/OaTaskDO.java new file mode 100644 index 0000000000..c7ccb54f91 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/task/OaTaskDO.java @@ -0,0 +1,79 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.task; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.oa.enums.task.OaTaskStatusEnum; +import cn.iocoder.yudao.module.oa.enums.task.OaTaskTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.time.LocalDateTime; + +/** + * OA 任务 DO + * + * @author 芋道源码 + */ +@TableName("oa_task") +@KeySequence("oa_task_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaTaskDO extends BaseDO { + + /** + * 任务编号 + */ + @TableId + private Long id; + /** + * 任务类型 + * + * 枚举 {@link OaTaskTypeEnum} + */ + private Integer type; + /** + * 总体状态 + * + * 枚举 {@link OaTaskStatusEnum} + */ + private Integer status; + /** + * 标题 + */ + private String title; + /** + * 任务描述 + */ + private String description; + /** + * 任务评价 + */ + private String comment; + /** + * 开始时间 + */ + private LocalDateTime startTime; + /** + * 结束时间 + */ + private LocalDateTime endTime; + /** + * 业务发布时间,新增及编辑任务时刷新 + */ + private LocalDateTime publishTime; + /** + * 是否置顶 + */ + private Boolean top; + /** + * 是否取消 + * + * 取消后禁止反馈;恢复时保留原任务进度,不修改 status 或接收人状态。 + */ + private Boolean canceled; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/task/OaTaskLogDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/task/OaTaskLogDO.java new file mode 100644 index 0000000000..994eea8d6b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/task/OaTaskLogDO.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.task; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.oa.enums.task.OaTaskStatusEnum; +import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * OA 任务反馈日志 DO + * + * @author 芋道源码 + */ +@TableName("oa_task_log") +@KeySequence("oa_task_log_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaTaskLogDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 任务编号 + * + * 关联 {@link OaTaskDO#getId()} + */ + private Long taskId; + /** + * 反馈人用户编号 + * + * 关联 {@link AdminUserDO#getId()} + */ + private Long userId; + /** + * 变更后的状态 + * + * 枚举 {@link OaTaskStatusEnum} + */ + private Integer status; + /** + * 反馈内容 + */ + private String content; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/task/OaTaskReceiverDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/task/OaTaskReceiverDO.java new file mode 100644 index 0000000000..1487b41aed --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/task/OaTaskReceiverDO.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.task; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.oa.enums.task.OaTaskStatusEnum; +import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * OA 任务接收人 DO + * + * @author 芋道源码 + */ +@TableName("oa_task_receiver") +@KeySequence("oa_task_receiver_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaTaskReceiverDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 任务编号 + * + * 关联 {@link OaTaskDO#getId()} + */ + private Long taskId; + /** + * 接收人用户编号 + * + * 关联 {@link AdminUserDO#getId()} + */ + private Long userId; + /** + * 任务状态 + * + * 枚举 {@link OaTaskStatusEnum} + */ + private Integer status; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/travel/OaTravelApplyDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/travel/OaTravelApplyDO.java new file mode 100644 index 0000000000..9bc214cb1d --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/travel/OaTravelApplyDO.java @@ -0,0 +1,144 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.travel; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.flowable.engine.history.HistoricProcessInstance; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** + * OA 出差申请 DO + * + * @author 芋道源码 + */ +@TableName(value = "oa_travel_apply", autoResultMap = true) +@KeySequence("oa_travel_apply_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaTravelApplyDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 单据编号 + */ + private String no; + /** + * 申请部门编号 + * + * 关联 {@link DeptRespDTO#getId()} + */ + private Long deptId; + /** + * 出差事由 + */ + private String reason; + /** + * 出差开始时间 + */ + private LocalDateTime startTime; + /** + * 出差结束时间 + */ + private LocalDateTime endTime; + /** + * 出差天数 + */ + private Integer days; + /** + * 同行人 + * + * 填写姓名文本,不关联用户编号 + */ + private String companion; + /** + * 预计费用,单位:元 + */ + private BigDecimal estimatedPrice; + /** + * 是否已报销 + */ + private Boolean reimburseStatus; + /** + * 审批状态 + * + * 枚举 {@link BpmProcessInstanceStatusEnum} + */ + private Integer status; + /** + * BPM 流程实例编号 + * + * 关联 {@link HistoricProcessInstance#getId()} + */ + private String processInstanceId; + /** + * 备注 + */ + private String remark; + /** + * 行程明细 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List items; + /** + * 附件地址列表 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List fileUrls; + + /** + * 行程明细 + */ + @Data + public static class Item { + + /** + * 出发地区编号 + * + * 关联 {@link cn.iocoder.yudao.framework.ip.core.Area#getId()} + */ + private Integer departureAreaId; + /** + * 到达地区编号 + * + * 关联 {@link cn.iocoder.yudao.framework.ip.core.Area#getId()} + */ + private Integer arrivalAreaId; + /** + * 行程开始时间 + */ + private LocalDateTime startTime; + /** + * 行程结束时间 + */ + private LocalDateTime endTime; + /** + * 交通方式 + * + * 字典 {@link DictTypeConstants#TRANSPORT_TYPE} + */ + private Integer transportType; + /** + * 备注 + */ + private String remark; + + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/travel/OaTravelReimbursementDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/travel/OaTravelReimbursementDO.java new file mode 100644 index 0000000000..e6c945ecc1 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/travel/OaTravelReimbursementDO.java @@ -0,0 +1,146 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.travel; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.flowable.engine.history.HistoricProcessInstance; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** + * OA 出差报销 DO + * + * @author 芋道源码 + */ +@TableName(value = "oa_travel_reimbursement", autoResultMap = true) +@KeySequence("oa_travel_reimbursement_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaTravelReimbursementDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 单据编号 + */ + private String no; + /** + * 申请部门编号 + * + * 关联 {@link DeptRespDTO#getId()} + * 保存申请时的部门,不随申请人后续调岗变化 + */ + private Long deptId; + /** + * 关联出差申请编号 + * + * 关联 {@link OaTravelApplyDO#getId()},未关联申请时为空 + */ + private Long travelApplyId; + /** + * 出差事由 + */ + private String reason; + /** + * 出差开始时间 + */ + private LocalDateTime startTime; + /** + * 出差结束时间 + */ + private LocalDateTime endTime; + /** + * 出差天数 + * + * 冗余字段,由开始和结束时间的时长除以 24 小时计算,向上取整 + */ + private Integer days; + /** + * 报销总金额,单位:元 + * + * 冗余 {@link #getItems()} 中 {@link Item#getPrice()} 的合计 + */ + private BigDecimal totalPrice; + /** + * 是否已支付 + */ + private Boolean payStatus; + /** + * 审批状态 + * + * 枚举 {@link BpmProcessInstanceStatusEnum} + * 冗余 {@link #getProcessInstanceId()} 对应的流程实例状态,与出差申请的审批状态独立 + */ + private Integer status; + /** + * BPM 流程实例编号 + * + * 关联 {@link HistoricProcessInstance#getId()} + */ + private String processInstanceId; + /** + * 备注 + */ + private String remark; + /** + * 费用明细 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List items; + /** + * 附件地址列表 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List fileUrls; + + /** + * 费用明细 + */ + @Data + public static class Item { + + /** + * 费用类型 + * + * 字典 {@link DictTypeConstants#EXPENSE_TYPE} + */ + private Integer expenseType; + /** + * 费用发生时间 + */ + private LocalDateTime expenseTime; + /** + * 出发地 + */ + private String departureCity; + /** + * 到达地 + */ + private String arrivalCity; + /** + * 金额,单位:元 + */ + private BigDecimal price; + /** + * 费用说明 + */ + private String description; + + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/vehicle/OaVehicleApplyDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/vehicle/OaVehicleApplyDO.java new file mode 100644 index 0000000000..1f4295d7ee --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/vehicle/OaVehicleApplyDO.java @@ -0,0 +1,117 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.vehicle; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.enums.vehicle.OaVehicleReturnStatusEnum; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * OA 用车申请 DO + * + * @author 芋道源码 + */ +@TableName(value = "oa_vehicle_apply", autoResultMap = true) +@KeySequence("oa_vehicle_apply_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaVehicleApplyDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 用车申请单号 + */ + private String no; + /** + * 车辆编号 + * + * 关联 {@link OaVehicleDO#getId()} + */ + private Long vehicleId; + /** + * 车牌号快照 + * + * 冗余 {@link OaVehicleDO#getNo()} + */ + private String vehicleNo; + /** + * 申请人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + /** + * 申请部门编号 + * + * 关联 {@link DeptRespDTO#getId()} + */ + private Long deptId; + /** + * 预计出车时间 + */ + private LocalDateTime startTime; + /** + * 预计回车时间 + */ + private LocalDateTime endTime; + /** + * 出车地点 + */ + private String startLocation; + /** + * 预计回车地点 + */ + private String endLocation; + /** + * 用车事由 + */ + private String reason; + /** + * 随行人 + */ + private String passenger; + /** + * 审批状态 + * + * 枚举 {@link BpmProcessInstanceStatusEnum} + */ + private Integer status; + /** + * 还车业务状态 + * + * 枚举 {@link OaVehicleReturnStatusEnum} + */ + private Integer returnStatus; + /** + * BPM 流程实例编号 + * + * 关联 {@link org.flowable.engine.history.HistoricProcessInstance#getId()} + */ + private String processInstanceId; + /** + * 备注 + */ + private String remark; + /** + * 附件地址列表 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List fileUrls; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/vehicle/OaVehicleDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/vehicle/OaVehicleDO.java new file mode 100644 index 0000000000..d1e4f1cf04 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/vehicle/OaVehicleDO.java @@ -0,0 +1,103 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.vehicle; + +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.oa.enums.vehicle.OaVehicleStatusEnum; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * OA 车辆 DO + * + * @author 芋道源码 + */ +@TableName(value = "oa_vehicle", autoResultMap = true) +@KeySequence("oa_vehicle_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaVehicleDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 车牌号 + */ + private String no; + /** + * 车辆名称 + */ + private String name; + /** + * 所属部门编号 + * + * 关联 {@link DeptRespDTO#getId()} + */ + private Long deptId; + /** + * 车型 + */ + private String type; + /** + * 车辆分类 + * + * 字典 {@link DictTypeConstants#VEHICLE_CATEGORY} + */ + private String category; + /** + * 品牌型号 + */ + private String brandModel; + /** + * 座位数 + */ + private Integer seatCount; + /** + * 裸车价格(元) + */ + private BigDecimal barePrice; + /** + * 交强险到期时间 + */ + private LocalDateTime compulsoryInsuranceExpireTime; + /** + * 商业险到期时间 + */ + private LocalDateTime commercialInsuranceExpireTime; + /** + * 年检到期时间 + */ + private LocalDateTime inspectionExpireTime; + /** + * 车辆照片 URL + */ + private String picUrl; + /** + * 车辆台账状态 + * + * 枚举 {@link OaVehicleStatusEnum} + */ + private Integer status; + /** + * 显示顺序 + */ + private Integer sort; + /** + * 备注 + */ + private String remark; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/vehicle/OaVehicleReturnDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/vehicle/OaVehicleReturnDO.java new file mode 100644 index 0000000000..92469a1cbd --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/vehicle/OaVehicleReturnDO.java @@ -0,0 +1,111 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.vehicle; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import org.flowable.engine.history.HistoricProcessInstance; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * OA 还车申请 DO + * + * @author 芋道源码 + */ +@TableName(value = "oa_vehicle_return", autoResultMap = true) +@KeySequence("oa_vehicle_return_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaVehicleReturnDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 还车申请单号 + */ + private String no; + /** + * 用车申请编号 + * + * 关联 {@link OaVehicleApplyDO#getId()} + */ + private Long applyId; + /** + * 车辆编号,从用车申请生成 + * + * 关联 {@link OaVehicleDO#getId()} + */ + private Long vehicleId; + /** + * 申请人用户编号 + * + * 关联 {@link AdminUserRespDTO#getId()} + */ + private Long userId; + /** + * 申请部门编号 + * + * 关联 {@link DeptRespDTO#getId()} + */ + private Long deptId; + /** + * 实际出车时间 + */ + private LocalDateTime actualStartTime; + /** + * 实际出车地点 + */ + private String startLocation; + /** + * 用车事由 + */ + private String reason; + /** + * 随行人 + */ + private String passenger; + /** + * 实际回车时间 + */ + private LocalDateTime actualReturnTime; + /** + * 实际回车地点 + */ + private String returnLocation; + /** + * 审批状态 + * + * 枚举 {@link BpmProcessInstanceStatusEnum} + */ + private Integer status; + /** + * BPM 流程实例编号 + * + * 关联 {@link HistoricProcessInstance#getId()} + */ + private String processInstanceId; + /** + * 还车说明 + */ + private String remark; + /** + * 附件地址列表 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List fileUrls; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/workreport/OaWorkReportDO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/workreport/OaWorkReportDO.java new file mode 100644 index 0000000000..ef372692b3 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/dataobject/workreport/OaWorkReportDO.java @@ -0,0 +1,131 @@ +package cn.iocoder.yudao.module.oa.dal.dataobject.workreport; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.oa.enums.workreport.OaWorkReportStatusEnum; +import cn.iocoder.yudao.module.oa.enums.workreport.OaWorkReportTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * OA 工作汇报 DO + * + * @author 芋道源码 + */ +@TableName(value = "oa_work_report", autoResultMap = true) +@KeySequence("oa_work_report_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class OaWorkReportDO extends BaseDO { + + /** + * 汇报编号 + */ + @TableId + private Long id; + /** + * 汇报单号 + */ + private String no; + /** + * 汇报类型 + * + * 枚举 {@link OaWorkReportTypeEnum} + */ + private Integer type; + /** + * 汇报状态 + * + * 枚举 {@link OaWorkReportStatusEnum} + */ + private Integer status; + /** + * 汇报人所属部门编号 + * + * 关联 {@link cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO#getId()} + */ + private Long deptId; + /** + * 周期开始时间 + */ + private LocalDateTime startTime; + /** + * 周期结束时间 + */ + private LocalDateTime endTime; + /** + * 汇报标题 + */ + private String title; + /** + * 工作总结 + */ + private String summary; + /** + * 工作计划补充说明 + */ + private String plan; + /** + * 问题与协调事项 + */ + private String problem; + /** + * 已完成工作项 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List workItems; + /** + * 工作计划项 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List planItems; + /** + * 附件地址列表 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List fileUrls; + /** + * 备注 + */ + private String remark; + + /** + * 已完成工作项 + */ + @Data + public static class WorkItem { + + /** + * 工作内容 + */ + private String content; + /** + * 完成进度 + */ + private Integer progress; + + } + + /** + * 工作计划项 + */ + @Data + public static class PlanItem { + + /** + * 计划内容 + */ + private String content; + + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/announcement/OaAnnouncementMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/announcement/OaAnnouncementMapper.java new file mode 100644 index 0000000000..f7994a4a26 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/announcement/OaAnnouncementMapper.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.announcement; + +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.framework.mybatis.core.query.MPJLambdaWrapperX; +import cn.iocoder.yudao.module.oa.controller.admin.announcement.vo.OaAnnouncementPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.announcement.OaAnnouncementDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.announcement.OaAnnouncementReceiverDO; +import org.apache.ibatis.annotations.Mapper; + + +/** + * OA 公告 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaAnnouncementMapper extends BaseMapperX { + + default OaAnnouncementDO selectByIdForUpdate(Long id) { + return selectOneForUpdate(OaAnnouncementDO::getId, id); + } + + default PageResult selectPage(OaAnnouncementPageReqVO pageReqVO, Long publisherUserId) { + LambdaQueryWrapperX query = new LambdaQueryWrapperX() + .eq(OaAnnouncementDO::getCreator, publisherUserId.toString()) + .likeIfPresent(OaAnnouncementDO::getTitle, pageReqVO.getTitle()) + .eqIfPresent(OaAnnouncementDO::getType, pageReqVO.getType()) + .eqIfPresent(OaAnnouncementDO::getPriority, pageReqVO.getPriority()) + .betweenIfPresent(OaAnnouncementDO::getCreateTime, pageReqVO.getCreateTime()); + query.orderByDesc(OaAnnouncementDO::getTop) + .orderByDesc(OaAnnouncementDO::getUpdateTime) + .orderByDesc(OaAnnouncementDO::getId); + return selectPage(pageReqVO, query); + } + + default PageResult selectReceivedPage(OaAnnouncementPageReqVO pageReqVO, Long receiverUserId) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX() + .selectAll(OaAnnouncementDO.class) + .innerJoin(OaAnnouncementReceiverDO.class, OaAnnouncementReceiverDO::getAnnouncementId,OaAnnouncementDO::getId) + .eq(OaAnnouncementReceiverDO::getReceiverUserId, receiverUserId) + .eq(OaAnnouncementReceiverDO::getDeleted, false) + .eqIfPresent(OaAnnouncementReceiverDO::getReadStatus, pageReqVO.getReadStatus()) + .likeIfPresent(OaAnnouncementDO::getTitle, pageReqVO.getTitle()) + .eqIfPresent(OaAnnouncementDO::getType, pageReqVO.getType()) + .eqIfPresent(OaAnnouncementDO::getPriority, pageReqVO.getPriority()) + .betweenIfPresent(OaAnnouncementDO::getCreateTime, pageReqVO.getCreateTime()); + query.orderByDesc(OaAnnouncementDO::getTop) + .orderByAsc(OaAnnouncementReceiverDO::getReadStatus) + .orderByDesc(OaAnnouncementDO::getUpdateTime) + .orderByDesc(OaAnnouncementDO::getId); + return selectJoinPage(pageReqVO, OaAnnouncementDO.class, query); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/announcement/OaAnnouncementReceiverMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/announcement/OaAnnouncementReceiverMapper.java new file mode 100644 index 0000000000..f1a3e702a0 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/announcement/OaAnnouncementReceiverMapper.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.announcement; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.oa.dal.dataobject.announcement.OaAnnouncementReceiverDO; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.Collection; +import java.util.List; + +/** + * OA 公告接收关系 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaAnnouncementReceiverMapper extends BaseMapperX { + + default OaAnnouncementReceiverDO selectByAnnouncementIdAndReceiverUserId(Long announcementId, + Long receiverUserId) { + return selectOne(OaAnnouncementReceiverDO::getAnnouncementId, announcementId, + OaAnnouncementReceiverDO::getReceiverUserId, receiverUserId); + } + + default List selectListByAnnouncementIds(Collection announcementIds) { + return selectList(new LambdaQueryWrapperX() + .in(OaAnnouncementReceiverDO::getAnnouncementId, announcementIds)); + } + + default List selectListByAnnouncementId(Long announcementId) { + return selectList(OaAnnouncementReceiverDO::getAnnouncementId, announcementId); + } + + @Delete("DELETE FROM oa_announcement_receiver WHERE announcement_id = #{announcementId}") + void deleteByAnnouncementId(@Param("announcementId") Long announcementId); + + @Delete("DELETE FROM oa_announcement_receiver WHERE id = #{id}") + void deleteByIdPhysical(@Param("id") Long id); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/attendance/OaAttendanceMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/attendance/OaAttendanceMapper.java new file mode 100644 index 0000000000..aa2ec8188b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/attendance/OaAttendanceMapper.java @@ -0,0 +1,59 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.attendance; + +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.oa.controller.admin.attendance.vo.OaAttendancePageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.attendance.OaAttendanceDO; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * OA 考勤 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaAttendanceMapper extends BaseMapperX { + + default PageResult selectPageByUserIds(OaAttendancePageReqVO pageReqVO, List userIds) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .in(OaAttendanceDO::getUserId, userIds) + .eqIfPresent(OaAttendanceDO::getType, pageReqVO.getType()) + .eqIfPresent(OaAttendanceDO::getStatus, pageReqVO.getStatus()) + .betweenIfPresent(OaAttendanceDO::getAttendanceTime, pageReqVO.getAttendanceTime()) + .orderByDesc(OaAttendanceDO::getAttendanceTime) + .orderByDesc(OaAttendanceDO::getId)); + } + + default PageResult selectPageByUserId(OaAttendancePageReqVO pageReqVO, Long userId) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .eq(OaAttendanceDO::getUserId, userId) + .eqIfPresent(OaAttendanceDO::getType, pageReqVO.getType()) + .eqIfPresent(OaAttendanceDO::getStatus, pageReqVO.getStatus()) + .betweenIfPresent(OaAttendanceDO::getAttendanceTime, pageReqVO.getAttendanceTime()) + .orderByDesc(OaAttendanceDO::getAttendanceTime) + .orderByDesc(OaAttendanceDO::getId)); + } + + default List selectListByUserIdAndAttendanceTime( + Long userId, LocalDateTime[] attendanceTime) { + return selectList(new LambdaQueryWrapperX() + .eqIfPresent(OaAttendanceDO::getUserId, userId) + .betweenIfPresent(OaAttendanceDO::getAttendanceTime, attendanceTime) + .orderByAsc(OaAttendanceDO::getAttendanceTime) + .orderByAsc(OaAttendanceDO::getId)); + } + + default List selectListByUserIdsAndAttendanceTime( + List userIds, LocalDateTime[] attendanceTime) { + return selectList(new LambdaQueryWrapperX() + .in(OaAttendanceDO::getUserId, userIds) + .between(OaAttendanceDO::getAttendanceTime, attendanceTime[0], attendanceTime[1]) + .orderByAsc(OaAttendanceDO::getAttendanceTime) + .orderByAsc(OaAttendanceDO::getId)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/contact/OaContactCategoryMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/contact/OaContactCategoryMapper.java new file mode 100644 index 0000000000..66d05537cf --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/contact/OaContactCategoryMapper.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.contact; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.oa.dal.dataobject.contact.OaContactCategoryDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * OA 联系人分类 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaContactCategoryMapper extends BaseMapperX { + + default List selectListByUserId(Long userId) { + return selectList(new LambdaQueryWrapperX() + .eq(OaContactCategoryDO::getUserId, userId) + .orderByAsc(OaContactCategoryDO::getSort) + .orderByAsc(OaContactCategoryDO::getId)); + } + + default OaContactCategoryDO selectByUserIdAndName(Long userId, String name) { + return selectOne(OaContactCategoryDO::getUserId, userId, OaContactCategoryDO::getName, name); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/contact/OaContactMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/contact/OaContactMapper.java new file mode 100644 index 0000000000..05f95f2797 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/contact/OaContactMapper.java @@ -0,0 +1,66 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.contact; + +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.oa.controller.admin.contact.vo.OaContactPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.contact.OaContactDO; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; +import java.util.Locale; + +/** + * OA 外部联系人 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaContactMapper extends BaseMapperX { + + default PageResult selectReceivedPage(OaContactPageReqVO pageReqVO, Collection contactIds) { + return selectPage(pageReqVO, buildPageQuery(pageReqVO).in(OaContactDO::getId, contactIds)); + } + + default LambdaQueryWrapperX buildPageQuery(OaContactPageReqVO pageReqVO) { + LambdaQueryWrapperX query = new LambdaQueryWrapperX<>(); + query.likeRightIfPresent(OaContactDO::getPinyin, StrUtil.isBlank(pageReqVO.getAlphabet()) ? null + : pageReqVO.getAlphabet().toLowerCase(Locale.ROOT)) + .orderByAsc(OaContactDO::getPinyin).orderByAsc(OaContactDO::getId); + if (StrUtil.isNotBlank(pageReqVO.getKeyword())) { + query.and(wrapper -> wrapper.like(OaContactDO::getName, pageReqVO.getKeyword()) + .or().like(OaContactDO::getMobile, pageReqVO.getKeyword()) + .or().like(OaContactDO::getCompanyName, pageReqVO.getKeyword()) + .or().like(OaContactDO::getPinyin, pageReqVO.getKeyword())); + } + return query; + } + + default List selectListByCreator(String creator) { + return selectList(OaContactDO::getCreator, creator); + } + + default void updateForSave(OaContactDO contact) { + update(contact, new LambdaUpdateWrapper() + .eq(OaContactDO::getId, contact.getId()) + .set(contact.getCategoryId() == null, OaContactDO::getCategoryId, null) + .set(contact.getSex() == null, OaContactDO::getSex, null) + .set(contact.getMobile() == null, OaContactDO::getMobile, null) + .set(contact.getEmail() == null, OaContactDO::getEmail, null) + .set(contact.getAddress() == null, OaContactDO::getAddress, null) + .set(contact.getCompanyName() == null, OaContactDO::getCompanyName, null) + .set(contact.getCompanyPhone() == null, OaContactDO::getCompanyPhone, null) + .set(contact.getAvatar() == null, OaContactDO::getAvatar, null) + .set(contact.getRemark() == null, OaContactDO::getRemark, null)); + } + + default void updateCategoryIdToNullByCategoryId(Long categoryId) { + update(new LambdaUpdateWrapper() + .eq(OaContactDO::getCategoryId, categoryId) + .set(OaContactDO::getCategoryId, null)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/contact/OaContactShareMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/contact/OaContactShareMapper.java new file mode 100644 index 0000000000..67663137d1 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/contact/OaContactShareMapper.java @@ -0,0 +1,92 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.contact; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.query.MPJLambdaWrapperX; +import cn.iocoder.yudao.module.oa.controller.admin.contact.vo.OaContactPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.contact.OaContactDO; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.oa.dal.dataobject.contact.OaContactShareDO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; +import java.util.Locale; + +/** + * OA 联系人关联 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaContactShareMapper extends BaseMapperX { + + default PageResult selectSharedPage(OaContactPageReqVO reqVO, Long userId, + Collection contactIds) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX<>(); + query.selectAll(OaContactShareDO.class) + .innerJoin(OaContactDO.class, OaContactDO::getId, OaContactShareDO::getContactId) + .eq(OaContactDO::getDeleted, false) + .eq(OaContactShareDO::getCreator, userId.toString()) + .ne(OaContactShareDO::getUserId, userId) + .in(OaContactShareDO::getContactId, contactIds) + .eq(reqVO.getHandleStatus() != null, OaContactShareDO::getHandleStatus, reqVO.getHandleStatus()) + .orderByDesc(OaContactShareDO::getCreateTime).orderByDesc(OaContactShareDO::getId); + if (StrUtil.isNotBlank(reqVO.getAlphabet())) { + query.likeRight(OaContactDO::getPinyin, reqVO.getAlphabet().toLowerCase(Locale.ROOT)); + } + if (StrUtil.isNotBlank(reqVO.getKeyword())) { + query.and(w -> w.like(OaContactDO::getName, reqVO.getKeyword()) + .or().like(OaContactDO::getMobile, reqVO.getKeyword()) + .or().like(OaContactDO::getCompanyName, reqVO.getKeyword()) + .or().like(OaContactDO::getPinyin, reqVO.getKeyword())); + } + return selectJoinPage(reqVO, OaContactShareDO.class, query); + } + + default List selectListByUserId(Long userId, Boolean handleStatus, Long categoryId) { + return selectList(new LambdaQueryWrapperX() + .eq(OaContactShareDO::getUserId, userId) + .eqIfPresent(OaContactShareDO::getHandleStatus, handleStatus) + .eqIfPresent(OaContactShareDO::getCategoryId, categoryId) + .orderByAsc(OaContactShareDO::getHandleStatus) + .orderByDesc(OaContactShareDO::getCreateTime) + .orderByDesc(OaContactShareDO::getId)); + } + + default List selectListByContactId(Long contactId) { + return selectList(OaContactShareDO::getContactId, contactId); + } + + default List selectListByContactIds(Collection contactIds) { + return selectList(new LambdaQueryWrapperX() + .in(OaContactShareDO::getContactId, contactIds) + .orderByDesc(OaContactShareDO::getCreateTime) + .orderByDesc(OaContactShareDO::getId)); + } + + default OaContactShareDO selectByContactIdAndUserId(Long contactId, Long userId) { + return selectOne(OaContactShareDO::getContactId, contactId, OaContactShareDO::getUserId, userId); + } + + default void deleteByContactIdAndUserId(Long contactId, Long userId) { + delete(new LambdaQueryWrapperX() + .eq(OaContactShareDO::getContactId, contactId) + .eq(OaContactShareDO::getUserId, userId)); + } + + default void updateHandleStatusAndCategoryIdById(OaContactShareDO updateObj) { + update(updateObj, new LambdaUpdateWrapper() + .eq(OaContactShareDO::getId, updateObj.getId()) + .set(updateObj.getCategoryId() == null, OaContactShareDO::getCategoryId, null)); + } + + default void updateCategoryIdToNullByCategoryId(Long categoryId) { + update(new LambdaUpdateWrapper() + .eq(OaContactShareDO::getCategoryId, categoryId) + .set(OaContactShareDO::getCategoryId, null)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/discussion/OaDiscussionLikeMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/discussion/OaDiscussionLikeMapper.java new file mode 100644 index 0000000000..57e35c7be6 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/discussion/OaDiscussionLikeMapper.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.discussion; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionLikeDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * OA 讨论点赞 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaDiscussionLikeMapper extends BaseMapperX { + + default List selectListByDiscussionIds(Collection discussionIds) { + return selectList(new LambdaQueryWrapperX() + .in(OaDiscussionLikeDO::getDiscussionId, discussionIds)); + } + + default List selectListByReplyIds(Collection replyIds) { + return selectList(new LambdaQueryWrapperX().in(OaDiscussionLikeDO::getReplyId, replyIds)); + } + + default OaDiscussionLikeDO selectByTargetAndUserId(Long discussionId, Long replyId, Long userId) { + return selectOne(new LambdaQueryWrapperX() + .eqIfPresent(OaDiscussionLikeDO::getDiscussionId, discussionId) + .eqIfPresent(OaDiscussionLikeDO::getReplyId, replyId) + .eq(OaDiscussionLikeDO::getUserId, userId)); + } + + default void deleteByDiscussionId(Long discussionId) { + delete(OaDiscussionLikeDO::getDiscussionId, discussionId); + } + + default void deleteByReplyIds(Collection replyIds) { + delete(new LambdaQueryWrapperX().in(OaDiscussionLikeDO::getReplyId, replyIds)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/discussion/OaDiscussionMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/discussion/OaDiscussionMapper.java new file mode 100644 index 0000000000..dc63a63713 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/discussion/OaDiscussionMapper.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.discussion; + +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.oa.controller.admin.discussion.vo.discussion.OaDiscussionPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionDO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * OA 讨论 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaDiscussionMapper extends BaseMapperX { + + default PageResult selectPage(OaDiscussionPageReqVO pageReqVO) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .likeIfPresent(OaDiscussionDO::getTitle, pageReqVO.getTitle()) + .eqIfPresent(OaDiscussionDO::getType, pageReqVO.getType()) + .eqIfPresent(OaDiscussionDO::getUserId, pageReqVO.getUserId()) + .betweenIfPresent(OaDiscussionDO::getCreateTime, pageReqVO.getCreateTime()) + .orderByDesc(OaDiscussionDO::getId)); + } + + default void updateVisitCount(Long id) { + update(new LambdaUpdateWrapper().eq(OaDiscussionDO::getId, id) + .setIncrBy(OaDiscussionDO::getVisitCount, 1)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/discussion/OaDiscussionReplyMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/discussion/OaDiscussionReplyMapper.java new file mode 100644 index 0000000000..4e5a57852a --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/discussion/OaDiscussionReplyMapper.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.discussion; + +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.oa.controller.admin.discussion.vo.reply.OaDiscussionReplyPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionReplyDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * OA 讨论回复 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaDiscussionReplyMapper extends BaseMapperX { + + default PageResult selectPage(OaDiscussionReplyPageReqVO pageReqVO) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .eq(OaDiscussionReplyDO::getDiscussionId, pageReqVO.getDiscussionId()) + .eq(OaDiscussionReplyDO::getParentId, OaDiscussionReplyDO.PARENT_ID_ROOT) + .eqIfPresent(OaDiscussionReplyDO::getUserId, pageReqVO.getUserId()) + .orderByAsc(OaDiscussionReplyDO::getId)); + } + + default List selectListByDiscussionIds(Collection discussionIds) { + return selectList(new LambdaQueryWrapperX() + .in(OaDiscussionReplyDO::getDiscussionId, discussionIds) + .orderByAsc(OaDiscussionReplyDO::getCreateTime) + .orderByAsc(OaDiscussionReplyDO::getId)); + } + + default void deleteByDiscussionId(Long discussionId) { + delete(OaDiscussionReplyDO::getDiscussionId, discussionId); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/discussion/OaVoteOptionMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/discussion/OaVoteOptionMapper.java new file mode 100644 index 0000000000..ae62bf68e7 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/discussion/OaVoteOptionMapper.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.discussion; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaVoteOptionDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * OA 投票选项 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaVoteOptionMapper extends BaseMapperX { + + default List selectListByDiscussionIds(Collection discussionIds) { + return selectList(new LambdaQueryWrapperX() + .in(OaVoteOptionDO::getDiscussionId, discussionIds) + .orderByAsc(OaVoteOptionDO::getSort) + .orderByAsc(OaVoteOptionDO::getId)); + } + + default void deleteByDiscussionId(Long discussionId) { + delete(OaVoteOptionDO::getDiscussionId, discussionId); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/discussion/OaVoteRecordMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/discussion/OaVoteRecordMapper.java new file mode 100644 index 0000000000..89342de9b4 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/discussion/OaVoteRecordMapper.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.discussion; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaVoteRecordDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * OA 投票记录 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaVoteRecordMapper extends BaseMapperX { + + default List selectListByDiscussionIds(Collection discussionIds) { + return selectList(new LambdaQueryWrapperX() + .in(OaVoteRecordDO::getDiscussionId, discussionIds)); + } + + default List selectListByDiscussionIdAndUserId(Long discussionId, Long userId) { + return selectList(OaVoteRecordDO::getDiscussionId, discussionId, OaVoteRecordDO::getUserId, userId); + } + + default void deleteByDiscussionId(Long discussionId) { + delete(OaVoteRecordDO::getDiscussionId, discussionId); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/file/OaFileFavoriteMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/file/OaFileFavoriteMapper.java new file mode 100644 index 0000000000..a11a5c5dde --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/file/OaFileFavoriteMapper.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.file; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.oa.dal.dataobject.file.OaFileFavoriteDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * OA 云盘收藏 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaFileFavoriteMapper extends BaseMapperX { + + default List selectListByUserId(Long userId) { + return selectList(OaFileFavoriteDO::getUserId, userId); + } + + default List selectListByUserIdAndNodeIds(Long userId, Collection nodeIds) { + return selectList(new LambdaQueryWrapperX() + .eq(OaFileFavoriteDO::getUserId, userId).in(OaFileFavoriteDO::getNodeId, nodeIds)); + } + + default OaFileFavoriteDO selectByUserIdAndNodeId(Long userId, Long nodeId) { + return selectOne(OaFileFavoriteDO::getUserId, userId, OaFileFavoriteDO::getNodeId, nodeId); + } + + default void deleteByNodeIds(Collection nodeIds) { + delete(new LambdaQueryWrapperX().in(OaFileFavoriteDO::getNodeId, nodeIds)); + } + + default void deleteByNodeIdAndUserId(Long nodeId, Long userId) { + delete(new LambdaQueryWrapperX() + .eq(OaFileFavoriteDO::getNodeId, nodeId).eq(OaFileFavoriteDO::getUserId, userId)); + } +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/file/OaFileNodeMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/file/OaFileNodeMapper.java new file mode 100644 index 0000000000..d8feb2e33a --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/file/OaFileNodeMapper.java @@ -0,0 +1,114 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.file; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; +import cn.iocoder.yudao.module.oa.controller.admin.file.vo.node.OaFileNodePageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.file.OaFileNodeDO; +import cn.iocoder.yudao.module.oa.enums.file.OaFileNodeStatusEnum; +import cn.iocoder.yudao.module.oa.enums.file.OaFileScopeEnum; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * OA 云盘文件节点 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaFileNodeMapper extends BaseMapperX { + + default List selectListByParentId(Long parentId) { + return selectList(OaFileNodeDO::getParentId, parentId); + } + + default List selectListByCreatorAndTypeForUpdate(String creator, Integer type) { + return selectList(new LambdaQueryWrapperX().eq(OaFileNodeDO::getCreator, creator) + .eq(OaFileNodeDO::getType, type).orderByAsc(OaFileNodeDO::getId).last("FOR UPDATE")); + } + + default List selectListByCreatorAndStatus(String creator, Integer status) { + return selectList(OaFileNodeDO::getCreator, creator, OaFileNodeDO::getStatus, status); + } + + default Long selectSumSizeByCreatorAndType(String creator, Integer type) { + // TODO @AI:不要写在一条里;分成多个;看看能不能去掉 COALESCE + return selectOne(new QueryWrapperX().select("COALESCE(SUM(size), 0) AS size") + .eq("creator", creator).eq("type", type)).getSize(); + } + + default PageResult selectPageByParentIdAndCreatorAndStatus( + OaFileNodePageReqVO reqVO, String creator, Integer status) { + return selectPage(reqVO, buildCandidateQuery(reqVO) + .eq(OaFileNodeDO::getParentId, reqVO.getParentId()) + .eqIfPresent(OaFileNodeDO::getCreator, creator).eq(OaFileNodeDO::getStatus, status) + .orderByAsc(OaFileNodeDO::getType) + .orderByDesc(OaFileNodeDO::getCreateTime, OaFileNodeDO::getId)); + } + + default List selectListByParentIds(Collection parentIds) { + return selectList(OaFileNodeDO::getParentId, parentIds); + } + + default List selectListByCreatorAndTypeAndStatus(String creator, Integer type, Integer status) { + return selectList(new LambdaQueryWrapperX().eq(OaFileNodeDO::getCreator, creator) + .eq(OaFileNodeDO::getType, type).eq(OaFileNodeDO::getStatus, status)); + } + + default List selectListByParentIdsAndTypeAndStatus(Collection parentIds, Integer type, Integer status) { + return selectList(new LambdaQueryWrapperX().in(OaFileNodeDO::getParentId, parentIds) + .eq(OaFileNodeDO::getType, type).eq(OaFileNodeDO::getStatus, status)); + } + + default List selectCandidateList(OaFileNodePageReqVO reqVO, Long userId, + Collection favoriteIds) { + LambdaQueryWrapperX query = buildCandidateQuery(reqVO); + boolean rootQuery = reqVO.getParentId() == null || OaFileNodeDO.PARENT_ID_ROOT.equals(reqVO.getParentId()); + if (OaFileScopeEnum.RECYCLE.getScope().equals(reqVO.getScope())) { + query.eq(OaFileNodeDO::getCreator, userId.toString()) + .eq(OaFileNodeDO::getStatus, OaFileNodeStatusEnum.RECYCLED.getStatus()); + } else { + query.eq(OaFileNodeDO::getStatus, OaFileNodeStatusEnum.NORMAL.getStatus()); + if (rootQuery && OaFileScopeEnum.MY.getScope().equals(reqVO.getScope())) { + query.eq(OaFileNodeDO::getCreator, userId.toString()); + } else if (rootQuery && OaFileScopeEnum.SHARED.getScope().equals(reqVO.getScope())) { + query.ne(OaFileNodeDO::getCreator, userId.toString()); + } else if (rootQuery && OaFileScopeEnum.FAVORITE.getScope().equals(reqVO.getScope())) { + query.in(OaFileNodeDO::getId, favoriteIds); + } + // 共享入口可能处于任意层级,不能用 parentId = 0 提前过滤 + if (!(rootQuery && OaFileScopeEnum.SHARED.getScope().equals(reqVO.getScope()))) { + query.eqIfPresent(OaFileNodeDO::getParentId, reqVO.getParentId()); + } + } + return selectList(query); + } + + default List selectListBySharedNodeIdsOrParentIdsAndCreatorNot(OaFileNodePageReqVO reqVO, Long userId, + Collection sharedNodeIds, Collection parentIds) { + return selectList(buildCandidateQuery(reqVO) + .eq(OaFileNodeDO::getStatus, OaFileNodeStatusEnum.NORMAL.getStatus()) + .ne(OaFileNodeDO::getCreator, userId.toString()) + .and(query -> query.in(CollUtil.isNotEmpty(sharedNodeIds), OaFileNodeDO::getId, sharedNodeIds) + .or(CollUtil.isNotEmpty(sharedNodeIds) && CollUtil.isNotEmpty(parentIds)) + .in(CollUtil.isNotEmpty(parentIds), OaFileNodeDO::getParentId, parentIds))); + } + + default LambdaQueryWrapperX buildCandidateQuery(OaFileNodePageReqVO reqVO) { + return new LambdaQueryWrapperX().likeIfPresent(OaFileNodeDO::getName, reqVO.getName()) + .eqIfPresent(OaFileNodeDO::getCategory, reqVO.getCategory()) + .betweenIfPresent(OaFileNodeDO::getCreateTime, reqVO.getCreateTime()); + } + + default OaFileNodeDO selectByIdForUpdate(Long id) { + return selectOneForUpdate(OaFileNodeDO::getId, id); + } + + + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/file/OaFilePermissionMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/file/OaFilePermissionMapper.java new file mode 100644 index 0000000000..b6e21437d0 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/file/OaFilePermissionMapper.java @@ -0,0 +1,64 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.file; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.oa.dal.dataobject.file.OaFilePermissionDO; +import cn.iocoder.yudao.module.oa.enums.file.OaFileSubjectTypeEnum; + +import java.time.LocalDateTime; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * OA 云盘共享权限 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaFilePermissionMapper extends BaseMapperX { + + default List selectListByUserIdAndDeptIdAndExpireTimeAfter(Long userId, Long deptId, LocalDateTime now) { + return selectList(new LambdaQueryWrapperX() + // 筛选主体 + .and(query -> { + query.eq(OaFilePermissionDO::getSubjectType, OaFileSubjectTypeEnum.USER.getType()).eq(OaFilePermissionDO::getSubjectId, userId); + if (deptId != null) { + query.or().eq(OaFilePermissionDO::getSubjectType, OaFileSubjectTypeEnum.DEPT.getType()).eq(OaFilePermissionDO::getSubjectId, deptId); + } + }) + // 筛选权限未过期 + .and(query -> query.isNull(OaFilePermissionDO::getExpireTime) + .or().gt(OaFilePermissionDO::getExpireTime, now))); + } + + default void updatePermission(OaFilePermissionDO permission) { + // 到期时间允许清空,明确更新 null,避免旧到期时间残留 + update(null, new LambdaUpdateWrapper() + .eq(OaFilePermissionDO::getId, permission.getId()) + .set(OaFilePermissionDO::getLevel, permission.getLevel()) + .set(OaFilePermissionDO::getInherit, permission.getInherit()) + .set(OaFilePermissionDO::getExpireTime, permission.getExpireTime())); + } + + default List selectListByNodeId(Long nodeId) { + return selectList(OaFilePermissionDO::getNodeId, nodeId); + } + + default List selectListByNodeIds(Collection nodeIds) { + return selectList(OaFilePermissionDO::getNodeId, nodeIds); + } + + default OaFilePermissionDO selectByNodeIdAndSubjectTypeAndSubjectId(Long nodeId, Integer subjectType, Long subjectId) { + return selectOne(new LambdaQueryWrapperX() + .eq(OaFilePermissionDO::getNodeId, nodeId) + .eq(OaFilePermissionDO::getSubjectType, subjectType) + .eq(OaFilePermissionDO::getSubjectId, subjectId)); + } + + default void deleteByNodeIds(Collection nodeIds) { + delete(new LambdaQueryWrapperX().in(OaFilePermissionDO::getNodeId, nodeIds)); + } +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/leave/OaLeaveApplyMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/leave/OaLeaveApplyMapper.java new file mode 100644 index 0000000000..324d6dd986 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/leave/OaLeaveApplyMapper.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.leave; + +import cn.hutool.core.map.MapUtil; +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.framework.mybatis.core.query.MPJLambdaWrapperX; +import cn.iocoder.yudao.module.oa.controller.admin.leave.vo.OaLeaveApplyPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.leave.OaLeaveApplyDO; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * OA 请假申请 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaLeaveApplyMapper extends BaseMapperX { + + default Map selectDaysMapByCreatorsAndStatusAndStartTime( + Collection userIds, Integer status, LocalDateTime[] startTime) { + List> days = selectMaps(new MPJLambdaWrapperX() + .selectAs(OaLeaveApplyDO::getCreator, "userId") + .selectSum(OaLeaveApplyDO::getDays, "days") + .in(OaLeaveApplyDO::getCreator, convertList(userIds, String::valueOf)) + .eq(OaLeaveApplyDO::getStatus, status) + .between(OaLeaveApplyDO::getStartTime, startTime[0], startTime[1]) + .groupBy(OaLeaveApplyDO::getCreator)); + return convertMap(days, row -> MapUtil.getLong(row, "userId"), row -> MapUtil.getInt(row, "days")); + } + + default PageResult selectPage(Long userId, OaLeaveApplyPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eq(OaLeaveApplyDO::getCreator, userId.toString()) + .likeIfPresent(OaLeaveApplyDO::getTitle, reqVO.getTitle()) + .eqIfPresent(OaLeaveApplyDO::getStatus, reqVO.getStatus()) + .orderByDesc(OaLeaveApplyDO::getCreateTime, OaLeaveApplyDO::getId)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/mail/OaMailAccountMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/mail/OaMailAccountMapper.java new file mode 100644 index 0000000000..8900d5eade --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/mail/OaMailAccountMapper.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.mail; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.oa.dal.dataobject.mail.OaMailAccountDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * OA 企业邮箱账号 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaMailAccountMapper extends BaseMapperX { + + default List selectListByStatus(Integer status) { + return selectList(new LambdaQueryWrapperX() + .select(OaMailAccountDO::getMail, OaMailAccountDO::getCreator).eq(OaMailAccountDO::getStatus, status) + .orderByAsc(OaMailAccountDO::getMail).orderByAsc(OaMailAccountDO::getId)); + } + + default OaMailAccountDO selectByIdForUpdate(Long id) { + return selectOneForUpdate(OaMailAccountDO::getId, id); + } + + default List selectListByCreatorAndStatus(String creator, Integer status) { + return selectList(new LambdaQueryWrapperX().eq(OaMailAccountDO::getCreator, creator) + .eqIfPresent(OaMailAccountDO::getStatus, status) + .orderByDesc(OaMailAccountDO::getDefaultStatus).orderByAsc(OaMailAccountDO::getId)); + } + + default List selectListByCreatorForUpdate(String creator) { + return selectList(new LambdaQueryWrapperX().eq(OaMailAccountDO::getCreator, creator) + .orderByAsc(OaMailAccountDO::getId).last("FOR UPDATE")); + } + + default Long selectCountByProviderId(Long providerId) { + return selectCount(OaMailAccountDO::getProviderId, providerId); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/mail/OaMailFolderMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/mail/OaMailFolderMapper.java new file mode 100644 index 0000000000..320e1aed81 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/mail/OaMailFolderMapper.java @@ -0,0 +1,23 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.mail; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.oa.dal.dataobject.mail.OaMailFolderDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * 企业邮箱文件夹 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaMailFolderMapper extends BaseMapperX { + + default List selectListByAccountId(Long accountId) { + return selectList(new LambdaQueryWrapperX() + .eq(OaMailFolderDO::getAccountId, accountId).orderByAsc(OaMailFolderDO::getId)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/mail/OaMailMessageMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/mail/OaMailMessageMapper.java new file mode 100644 index 0000000000..8bb7201cb9 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/mail/OaMailMessageMapper.java @@ -0,0 +1,58 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.mail; + +import cn.hutool.core.util.StrUtil; +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.oa.controller.admin.mail.vo.message.OaMailMessagePageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.mail.OaMailMessageDO; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 企业邮箱邮件索引 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaMailMessageMapper extends BaseMapperX { + + default OaMailMessageDO selectByIdForUpdate(Long id) { + return selectOneForUpdate(OaMailMessageDO::getId, id); + } + + default Map selectCountMapByAccountIdAndReadStatus(Long accountId, Boolean readStatus) { + List> rows = selectMaps(new QueryWrapper() + .select("folder_id AS FOLDER_ID", "COUNT(*) AS UNREAD_COUNT") + .eq("account_id", accountId) + .eq("read_status", readStatus) + .groupBy("folder_id")); + Map result = new HashMap<>(); + rows.forEach(row -> result.put(((Number) row.get("FOLDER_ID")).longValue(), + ((Number) row.get("UNREAD_COUNT")).longValue())); + return result; + } + + default List selectListByFolderId(Long folderId) { + return selectList(OaMailMessageDO::getFolderId, folderId); + } + + default PageResult selectPage(OaMailMessagePageReqVO reqVO, Long folderId) { + LambdaQueryWrapperX query = new LambdaQueryWrapperX() + .eq(OaMailMessageDO::getAccountId, reqVO.getAccountId()) + .eq(OaMailMessageDO::getFolderId, folderId) + .eqIfPresent(OaMailMessageDO::getReadStatus, reqVO.getReadStatus()) + .eqIfPresent(OaMailMessageDO::getHasAttach, reqVO.getHasAttach()); + if (StrUtil.isNotBlank(reqVO.getKeyword())) { + query.and(q -> q.like(OaMailMessageDO::getSubject, reqVO.getKeyword()) + .or().like(OaMailMessageDO::getSender, reqVO.getKeyword())); + } + return selectPage(reqVO, query.orderByDesc(OaMailMessageDO::getReceiveTime) + .orderByDesc(OaMailMessageDO::getId)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/mail/OaMailProviderMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/mail/OaMailProviderMapper.java new file mode 100644 index 0000000000..56164022a8 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/mail/OaMailProviderMapper.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.mail; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.oa.dal.dataobject.mail.OaMailProviderDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * OA 企业邮箱服务配置 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaMailProviderMapper extends BaseMapperX { + + default List selectListByStatus(Integer status) { + return selectList(new LambdaQueryWrapperX() + .eqIfPresent(OaMailProviderDO::getStatus, status) + .orderByAsc(OaMailProviderDO::getId)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/meetingroom/OaMeetingRoomBookingMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/meetingroom/OaMeetingRoomBookingMapper.java new file mode 100644 index 0000000000..02d5734ecb --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/meetingroom/OaMeetingRoomBookingMapper.java @@ -0,0 +1,83 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.meetingroom; + +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.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.controller.admin.meetingroom.vo.booking.OaMeetingRoomBookingPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.meetingroom.OaMeetingRoomBookingDO; +import cn.iocoder.yudao.module.oa.enums.meetingroom.OaMeetingRoomUseStatusEnum; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +/** + * OA 会议室预定 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaMeetingRoomBookingMapper extends BaseMapperX { + + default OaMeetingRoomBookingDO selectByNo(String no) { + return selectOne(OaMeetingRoomBookingDO::getNo, no); + } + + default PageResult selectPage(Long userId, OaMeetingRoomBookingPageReqVO reqVO, + Collection roomIds, Collection moderatorIds) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eq(OaMeetingRoomBookingDO::getCreator, userId.toString()) + .likeIfPresent(OaMeetingRoomBookingDO::getNo, reqVO.getNo()) + .likeIfPresent(OaMeetingRoomBookingDO::getTitle, reqVO.getTitle()) + .eqIfPresent(OaMeetingRoomBookingDO::getDeptId, reqVO.getDeptId()) + .inIfPresent(OaMeetingRoomBookingDO::getRoomId, roomIds) + .inIfPresent(OaMeetingRoomBookingDO::getModeratorUserId, moderatorIds) + .eqIfPresent(OaMeetingRoomBookingDO::getStatus, reqVO.getStatus()) + .eqIfPresent(OaMeetingRoomBookingDO::getUseStatus, reqVO.getUseStatus()) + .betweenIfPresent(OaMeetingRoomBookingDO::getStartTime, reqVO.getStartTime()) + .betweenIfPresent(OaMeetingRoomBookingDO::getEndTime, reqVO.getEndTime()) + .eqIfPresent(OaMeetingRoomBookingDO::getCreator, reqVO.getCreator()) + .betweenIfPresent(OaMeetingRoomBookingDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(OaMeetingRoomBookingDO::getId)); + } + + default List selectListByRoomIdAndTimeOverlap(Long roomId, LocalDateTime startTime, LocalDateTime endTime) { + return selectList(new LambdaQueryWrapperX() + .eq(OaMeetingRoomBookingDO::getRoomId, roomId) + .in(OaMeetingRoomBookingDO::getStatus, BpmProcessInstanceStatusEnum.RUNNING.getStatus(), BpmProcessInstanceStatusEnum.APPROVE.getStatus()) + .in(OaMeetingRoomBookingDO::getUseStatus, OaMeetingRoomUseStatusEnum.PENDING.getStatus(), OaMeetingRoomUseStatusEnum.IN_USE.getStatus()) + .lt(OaMeetingRoomBookingDO::getStartTime, endTime) + .gt(OaMeetingRoomBookingDO::getEndTime, startTime) + .orderByAsc(OaMeetingRoomBookingDO::getStartTime).orderByAsc(OaMeetingRoomBookingDO::getId)); + } + + default List selectListByReminderTypesAndStartTime(Collection reminderTypes, + LocalDateTime beginTime, LocalDateTime endTime) { + return selectList(new LambdaQueryWrapperX() + .eq(OaMeetingRoomBookingDO::getStatus, BpmProcessInstanceStatusEnum.APPROVE.getStatus()) + .eq(OaMeetingRoomBookingDO::getUseStatus, OaMeetingRoomUseStatusEnum.PENDING.getStatus()) + .eq(OaMeetingRoomBookingDO::getReminded, false) + .in(OaMeetingRoomBookingDO::getReminderType, reminderTypes) + .gt(OaMeetingRoomBookingDO::getStartTime, beginTime) + .le(OaMeetingRoomBookingDO::getStartTime, endTime) + .orderByAsc(OaMeetingRoomBookingDO::getStartTime).orderByAsc(OaMeetingRoomBookingDO::getId)); + } + + default int updateUseStatusByExpired(LocalDateTime endTime) { + return update(new OaMeetingRoomBookingDO().setUseStatus(OaMeetingRoomUseStatusEnum.CANCELLED.getStatus()), + new LambdaQueryWrapperX() + .eq(OaMeetingRoomBookingDO::getStatus, BpmProcessInstanceStatusEnum.APPROVE.getStatus()) + .eq(OaMeetingRoomBookingDO::getUseStatus, OaMeetingRoomUseStatusEnum.PENDING.getStatus()) + .le(OaMeetingRoomBookingDO::getEndTime, endTime)); + } + + default Long selectCountByRoomIdAndEndTimeAfter(Long roomId, LocalDateTime time) { + return selectCount(new LambdaQueryWrapperX() + .eq(OaMeetingRoomBookingDO::getRoomId, roomId) + .in(OaMeetingRoomBookingDO::getStatus, BpmProcessInstanceStatusEnum.RUNNING.getStatus(), BpmProcessInstanceStatusEnum.APPROVE.getStatus()) + .in(OaMeetingRoomBookingDO::getUseStatus, OaMeetingRoomUseStatusEnum.PENDING.getStatus(), OaMeetingRoomUseStatusEnum.IN_USE.getStatus()) + .gt(OaMeetingRoomBookingDO::getEndTime, time)); + } +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/meetingroom/OaMeetingRoomMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/meetingroom/OaMeetingRoomMapper.java new file mode 100644 index 0000000000..1c9b50faf2 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/meetingroom/OaMeetingRoomMapper.java @@ -0,0 +1,47 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.meetingroom; + +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.oa.controller.admin.meetingroom.vo.room.OaMeetingRoomPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.meetingroom.OaMeetingRoomDO; +import cn.iocoder.yudao.module.oa.enums.meetingroom.OaMeetingRoomBookingScopeEnum; +import cn.iocoder.yudao.module.oa.enums.meetingroom.OaMeetingRoomStatusEnum; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * OA 会议室 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaMeetingRoomMapper extends BaseMapperX { + + default PageResult selectPage(OaMeetingRoomPageReqVO reqVO, Collection managerIds, Long bookingUserId) { + LambdaQueryWrapperX query = new LambdaQueryWrapperX() + .likeIfPresent(OaMeetingRoomDO::getName, reqVO.getName()) + .likeIfPresent(OaMeetingRoomDO::getLocation, reqVO.getLocation()) + .eqIfPresent(OaMeetingRoomDO::getType, reqVO.getType()) + .eqIfPresent(OaMeetingRoomDO::getStatus, reqVO.getStatus()) + .inIfPresent(OaMeetingRoomDO::getManagerUserId, managerIds); + query.orderByAsc(OaMeetingRoomDO::getSort).orderByDesc(OaMeetingRoomDO::getId); + if (bookingUserId != null) { + // 可预定范围在分页前过滤,保证总数和每页记录一致 + query.eq(OaMeetingRoomDO::getAllowBooking, true) + .eq(OaMeetingRoomDO::getStatus, OaMeetingRoomStatusEnum.NORMAL.getStatus()) + .and(scope -> scope.eq(OaMeetingRoomDO::getBookingScope, OaMeetingRoomBookingScopeEnum.ALL.getScope()) + .or(specified -> specified.eq(OaMeetingRoomDO::getBookingScope, OaMeetingRoomBookingScopeEnum.SPECIFIED.getScope()) + .apply("JSON_CONTAINS(booking_user_ids, {0})", bookingUserId.toString()))); + } + return selectPage(reqVO, query); + } + + default List selectListByName(String name) { + return selectList(new LambdaQueryWrapperX().likeIfPresent(OaMeetingRoomDO::getName, name) + .orderByAsc(OaMeetingRoomDO::getSort).orderByDesc(OaMeetingRoomDO::getId)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/note/OaNoteCategoryMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/note/OaNoteCategoryMapper.java new file mode 100644 index 0000000000..f0c3310ed5 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/note/OaNoteCategoryMapper.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.note; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.oa.dal.dataobject.note.OaNoteCategoryDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * OA 笔记目录 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaNoteCategoryMapper extends BaseMapperX { + + default OaNoteCategoryDO selectByUserIdAndName(Long userId, String name) { + return selectOne(OaNoteCategoryDO::getUserId, userId, OaNoteCategoryDO::getName, name); + } + + default List selectListByUserId(Long userId) { + return selectList(new LambdaQueryWrapperX() + .eq(OaNoteCategoryDO::getUserId, userId) + .orderByAsc(OaNoteCategoryDO::getSort) + .orderByAsc(OaNoteCategoryDO::getId)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/note/OaNoteMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/note/OaNoteMapper.java new file mode 100644 index 0000000000..3fd7f273b3 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/note/OaNoteMapper.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.note; + +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.oa.controller.admin.note.vo.OaNotePageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.note.OaNoteDO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * OA 笔记 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaNoteMapper extends BaseMapperX { + + default PageResult selectMyPage(OaNotePageReqVO pageReqVO, Long userId, Collection ids) { + return selectPage(pageReqVO, buildPageQuery(pageReqVO) + .eq(OaNoteDO::getCreator, userId.toString()) + .in(OaNoteDO::getId, ids)); + } + + default PageResult selectReceivedPage(OaNotePageReqVO pageReqVO, Collection ids, Long userId) { + return selectPage(pageReqVO, buildPageQuery(pageReqVO).in(OaNoteDO::getId, ids) + .ne(OaNoteDO::getCreator, userId.toString())); + } + + default LambdaQueryWrapperX buildPageQuery(OaNotePageReqVO pageReqVO) { + return new LambdaQueryWrapperX() + .likeIfPresent(OaNoteDO::getTitle, pageReqVO.getTitle()) + .eqIfPresent(OaNoteDO::getCategoryId, pageReqVO.getCategoryId()) + .eqIfPresent(OaNoteDO::getType, pageReqVO.getType()) + .eqIfPresent(OaNoteDO::getPriority, pageReqVO.getPriority()) + .eqIfPresent(OaNoteDO::getFavorite, pageReqVO.getFavorite()) + .betweenIfPresent(OaNoteDO::getCreateTime, pageReqVO.getCreateTime()) + .orderByDesc(OaNoteDO::getPriority) + .orderByDesc(OaNoteDO::getCreateTime) + .orderByDesc(OaNoteDO::getId); + } + + default List selectListByCategoryIdAndIds(Long categoryId, Collection ids) { + return selectList(new LambdaQueryWrapperX() + .eq(OaNoteDO::getCategoryId, categoryId).in(OaNoteDO::getId, ids)); + } + + default void updateForSave(OaNoteDO note) { + update(note, new LambdaUpdateWrapper().eq(OaNoteDO::getId, note.getId()) + .set(note.getFileUrls() == null, OaNoteDO::getFileUrls, null)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/note/OaNoteReceiverMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/note/OaNoteReceiverMapper.java new file mode 100644 index 0000000000..31b139d3b0 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/note/OaNoteReceiverMapper.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.note; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.oa.dal.dataobject.note.OaNoteReceiverDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * OA 笔记接收人 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaNoteReceiverMapper extends BaseMapperX { + + default void deleteByNoteIdAndUserIds(Long noteId, Collection userIds) { + delete(new LambdaQueryWrapperX() + .eq(OaNoteReceiverDO::getNoteId, noteId) + .in(OaNoteReceiverDO::getUserId, userIds)); + } + + default List selectListByNoteId(Long noteId) { + return selectList(OaNoteReceiverDO::getNoteId, noteId); + } + + default OaNoteReceiverDO selectByNoteIdAndUserId(Long noteId, Long userId) { + return selectOne(OaNoteReceiverDO::getNoteId, noteId, OaNoteReceiverDO::getUserId, userId); + } + + default List selectListByUserId(Long userId) { + return selectList(OaNoteReceiverDO::getUserId, userId); + } + + default List selectListByNoteIds(Collection noteIds) { + return selectList(new LambdaQueryWrapperX().in(OaNoteReceiverDO::getNoteId, noteIds)); + } + + default void deleteByNoteId(Long noteId) { + delete(OaNoteReceiverDO::getNoteId, noteId); + } + + default void deleteByNoteIds(Collection noteIds) { + delete(new LambdaQueryWrapperX().in(OaNoteReceiverDO::getNoteId, noteIds)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/officialdoc/OaOfficialDocReceiveMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/officialdoc/OaOfficialDocReceiveMapper.java new file mode 100644 index 0000000000..b0fa0fc0ce --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/officialdoc/OaOfficialDocReceiveMapper.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.officialdoc; + +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.oa.controller.admin.officialdoc.vo.receive.OaOfficialDocReceivePageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.officialdoc.OaOfficialDocReceiveDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * OA 公文收文 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaOfficialDocReceiveMapper extends BaseMapperX { + + default OaOfficialDocReceiveDO selectByNo(String no) { + return selectOne(OaOfficialDocReceiveDO::getNo, no); + } + + default PageResult selectPage(Long deptId, OaOfficialDocReceivePageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eq(OaOfficialDocReceiveDO::getReceiveDeptId, deptId) + .likeIfPresent(OaOfficialDocReceiveDO::getTitle, reqVO.getTitle()) + .likeIfPresent(OaOfficialDocReceiveDO::getDocumentNo, reqVO.getDocumentNo()) + .eqIfPresent(OaOfficialDocReceiveDO::getStatus, reqVO.getStatus()).orderByDesc(OaOfficialDocReceiveDO::getId)); + } + + default List selectListBySendId(Long sendId) { + return selectList(OaOfficialDocReceiveDO::getSendId, sendId); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/officialdoc/OaOfficialDocSendMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/officialdoc/OaOfficialDocSendMapper.java new file mode 100644 index 0000000000..2902de8112 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/officialdoc/OaOfficialDocSendMapper.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.officialdoc; + +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.oa.controller.admin.officialdoc.vo.send.OaOfficialDocSendPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.officialdoc.OaOfficialDocSendDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * OA 公文发文 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaOfficialDocSendMapper extends BaseMapperX { + + default Long selectCountByTemplateId(Long id) { + return selectCount(OaOfficialDocSendDO::getTemplateId, id); + } + + default OaOfficialDocSendDO selectByNo(String no) { + return selectOne(OaOfficialDocSendDO::getNo, no); + } + + default Long selectCountByDocumentNoAndIdNot(String documentNo, Long id) { + return selectCount(new LambdaQueryWrapperX() + .eq(OaOfficialDocSendDO::getDocumentNo, documentNo) + .neIfPresent(OaOfficialDocSendDO::getId, id)); + } + + default PageResult selectPage(Long userId, OaOfficialDocSendPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eq(OaOfficialDocSendDO::getCreator, userId.toString()) + .likeIfPresent(OaOfficialDocSendDO::getTitle, reqVO.getTitle()) + .likeIfPresent(OaOfficialDocSendDO::getDocumentNo, reqVO.getDocumentNo()) + .eqIfPresent(OaOfficialDocSendDO::getStatus, reqVO.getStatus()).orderByDesc(OaOfficialDocSendDO::getId)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/officialdoc/OaOfficialDocTemplateMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/officialdoc/OaOfficialDocTemplateMapper.java new file mode 100644 index 0000000000..d6b1229085 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/officialdoc/OaOfficialDocTemplateMapper.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.officialdoc; + +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.oa.controller.admin.officialdoc.vo.template.OaOfficialDocTemplatePageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.officialdoc.OaOfficialDocTemplateDO; +import org.apache.ibatis.annotations.Mapper; +import java.util.List; + +/** + * OA 套红模板 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaOfficialDocTemplateMapper extends BaseMapperX { + + default PageResult selectPage(OaOfficialDocTemplatePageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(OaOfficialDocTemplateDO::getName, reqVO.getName()) + .eqIfPresent(OaOfficialDocTemplateDO::getStatus, reqVO.getStatus()).orderByAsc(OaOfficialDocTemplateDO::getSort).orderByDesc(OaOfficialDocTemplateDO::getId)); + } + + default List selectListByStatus(Integer status) { + return selectList(new LambdaQueryWrapperX() + .eqIfPresent(OaOfficialDocTemplateDO::getStatus, status).orderByAsc(OaOfficialDocTemplateDO::getSort).orderByDesc(OaOfficialDocTemplateDO::getId)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/overtime/OaOvertimeApplyMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/overtime/OaOvertimeApplyMapper.java new file mode 100644 index 0000000000..1601828b13 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/overtime/OaOvertimeApplyMapper.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.overtime; + +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.oa.controller.admin.overtime.vo.OaOvertimeApplyPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.overtime.OaOvertimeApplyDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * OA 加班申请 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaOvertimeApplyMapper extends BaseMapperX { + + default PageResult selectPage(Long userId, OaOvertimeApplyPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eq(OaOvertimeApplyDO::getCreator, userId.toString()) + .likeIfPresent(OaOvertimeApplyDO::getTitle, reqVO.getTitle()) + .eqIfPresent(OaOvertimeApplyDO::getStatus, reqVO.getStatus()) + .orderByDesc(OaOvertimeApplyDO::getCreateTime, OaOvertimeApplyDO::getId)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/package-info.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/package-info.java new file mode 100644 index 0000000000..fe8f2b3b4c --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/package-info.java @@ -0,0 +1,9 @@ +/** + * DAL = Data Access Layer 数据访问层 + * 1. data object:数据对象 + * 2. redis:Redis 的 CRUD 操作 + * 3. mysql:MySQL 的 CRUD 操作 + * + * 其中,MySQL 的表以 oa_ 作为前缀 + */ +package cn.iocoder.yudao.module.oa.dal.mysql; diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/plan/OaPlanMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/plan/OaPlanMapper.java new file mode 100644 index 0000000000..5db2a5f66d --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/plan/OaPlanMapper.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.plan; + +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.oa.controller.admin.plan.vo.OaPlanPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.plan.OaPlanDO; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +/** + * OA 工作计划 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaPlanMapper extends BaseMapperX { + + default PageResult selectPage(OaPlanPageReqVO pageReqVO, Long userId) { + LambdaQueryWrapperX query = new LambdaQueryWrapperX() + .eq(OaPlanDO::getCreator, userId.toString()) + .likeIfPresent(OaPlanDO::getTitle, pageReqVO.getTitle()) + .likeIfPresent(OaPlanDO::getLabel, pageReqVO.getLabel()) + .eqIfPresent(OaPlanDO::getType, pageReqVO.getType()) + .eqIfPresent(OaPlanDO::getStatus, pageReqVO.getStatus()) + .betweenIfPresent(OaPlanDO::getCreateTime, pageReqVO.getCreateTime()); + query.orderByDesc(OaPlanDO::getCreateTime).orderByDesc(OaPlanDO::getId); + return selectPage(pageReqVO, query); + } + + default List selectListByCreatorsAndTypeAndCreateTime(Collection creators, + Integer type, + LocalDateTime[] createTime) { + return selectList(new LambdaQueryWrapperX() + .in(OaPlanDO::getCreator, creators) + .eq(OaPlanDO::getType, type) + .betweenIfPresent(OaPlanDO::getCreateTime, createTime) + .orderByAsc(OaPlanDO::getCreator) + .orderByDesc(OaPlanDO::getCreateTime) + .orderByDesc(OaPlanDO::getId)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/regular/OaRegularApplyMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/regular/OaRegularApplyMapper.java new file mode 100644 index 0000000000..b8e298d6ae --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/regular/OaRegularApplyMapper.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.regular; + +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.oa.controller.admin.regular.vo.OaRegularApplyPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.regular.OaRegularApplyDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * OA 转正申请 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaRegularApplyMapper extends BaseMapperX { + + default PageResult selectPage(Long userId, OaRegularApplyPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eq(OaRegularApplyDO::getCreator, userId.toString()) + .likeIfPresent(OaRegularApplyDO::getTitle, reqVO.getTitle()) + .eqIfPresent(OaRegularApplyDO::getStatus, reqVO.getStatus()) + .orderByDesc(OaRegularApplyDO::getCreateTime, OaRegularApplyDO::getId)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/reimbursement/OaReimbursementMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/reimbursement/OaReimbursementMapper.java new file mode 100644 index 0000000000..3017c2f668 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/reimbursement/OaReimbursementMapper.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.reimbursement; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.oa.controller.admin.reimbursement.vo.OaReimbursementPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.reimbursement.OaReimbursementDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * OA 费用报销 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaReimbursementMapper extends BaseMapperX { + + default PageResult selectPage(Long userId, OaReimbursementPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eq(OaReimbursementDO::getCreator, userId.toString()) + .likeIfPresent(OaReimbursementDO::getTitle, reqVO.getTitle()) + .eqIfPresent(OaReimbursementDO::getStatus, reqVO.getStatus()) + .orderByDesc(OaReimbursementDO::getCreateTime, OaReimbursementDO::getId)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/resign/OaResignApplyMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/resign/OaResignApplyMapper.java new file mode 100644 index 0000000000..c991a3939e --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/resign/OaResignApplyMapper.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.resign; + +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.oa.controller.admin.resign.vo.OaResignApplyPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.resign.OaResignApplyDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * OA 离职申请 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaResignApplyMapper extends BaseMapperX { + + default PageResult selectPage(Long userId, OaResignApplyPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eq(OaResignApplyDO::getCreator, userId.toString()) + .likeIfPresent(OaResignApplyDO::getTitle, reqVO.getTitle()) + .eqIfPresent(OaResignApplyDO::getStatus, reqVO.getStatus()) + .orderByDesc(OaResignApplyDO::getCreateTime, OaResignApplyDO::getId)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/schedule/OaScheduleMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/schedule/OaScheduleMapper.java new file mode 100644 index 0000000000..0ef984a97e --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/schedule/OaScheduleMapper.java @@ -0,0 +1,61 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.schedule; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.oa.controller.admin.schedule.vo.OaSchedulePageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.schedule.OaScheduleDO; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +/** + * OA 日程 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaScheduleMapper extends BaseMapperX { + + default PageResult selectMyPage(OaSchedulePageReqVO pageReqVO, Long userId) { + return selectPage(pageReqVO, buildPageQuery(pageReqVO) + .eq(OaScheduleDO::getCreator, userId.toString())); + } + + default PageResult selectReceivedPage(OaSchedulePageReqVO pageReqVO, Collection ids) { + return selectPage(pageReqVO, buildPageQuery(pageReqVO).in(OaScheduleDO::getId, ids)); + } + + default PageResult selectPageByCreatorOrIds(OaSchedulePageReqVO pageReqVO, Long userId, + Collection ids) { + return selectPage(pageReqVO, buildPageQuery(pageReqVO) + .and(query -> query.eq(OaScheduleDO::getCreator, userId.toString()) + .or().in(OaScheduleDO::getId, ids))); + } + + default LambdaQueryWrapperX buildPageQuery(OaSchedulePageReqVO pageReqVO) { + return new LambdaQueryWrapperX() + .likeIfPresent(OaScheduleDO::getTitle, pageReqVO.getTitle()) + .eqIfPresent(OaScheduleDO::getType, pageReqVO.getType()) + .eqIfPresent(OaScheduleDO::getPriority, pageReqVO.getPriority()) + .betweenIfPresent(OaScheduleDO::getStartTime, pageReqVO.getStartTime()) + .leIfPresent(OaScheduleDO::getStartTime, ArrayUtil.get(pageReqVO.getOverlapTime(), 1)) + .geIfPresent(OaScheduleDO::getEndTime, ArrayUtil.get(pageReqVO.getOverlapTime(), 0)) + .orderByDesc(OaScheduleDO::getStartTime) + .orderByDesc(OaScheduleDO::getId); + } + + default List selectListByRemindAndRemindedAndStartTime(Boolean remind, Boolean reminded, LocalDateTime[] startTime) { + return selectList(new LambdaQueryWrapperX() + .eq(OaScheduleDO::getRemind, remind) + .eq(OaScheduleDO::getReminded, reminded) + .gt(OaScheduleDO::getStartTime, startTime[0]) + .lt(OaScheduleDO::getStartTime, startTime[1]) + .orderByAsc(OaScheduleDO::getStartTime) + .orderByAsc(OaScheduleDO::getId)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/schedule/OaScheduleParticipantMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/schedule/OaScheduleParticipantMapper.java new file mode 100644 index 0000000000..450ba5965b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/schedule/OaScheduleParticipantMapper.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.schedule; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.oa.dal.dataobject.schedule.OaScheduleParticipantDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * OA 日程参与人 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaScheduleParticipantMapper extends BaseMapperX { + + default OaScheduleParticipantDO selectByScheduleIdAndUserId(Long scheduleId, Long userId) { + return selectOne(new LambdaQueryWrapperX() + .eq(OaScheduleParticipantDO::getScheduleId, scheduleId) + .eq(OaScheduleParticipantDO::getUserId, userId)); + } + + default List selectListByScheduleId(Long scheduleId) { + return selectList(OaScheduleParticipantDO::getScheduleId, scheduleId); + } + + default void deleteByScheduleIdAndUserIds(Long scheduleId, Collection userIds) { + delete(new LambdaQueryWrapperX() + .eq(OaScheduleParticipantDO::getScheduleId, scheduleId) + .in(OaScheduleParticipantDO::getUserId, userIds)); + } + + default List selectListByScheduleIds(Collection scheduleIds) { + return selectList(new LambdaQueryWrapperX() + .in(OaScheduleParticipantDO::getScheduleId, scheduleIds)); + } + + default List selectListByUserId(Long userId) { + return selectList(OaScheduleParticipantDO::getUserId, userId); + } + + default void deleteByScheduleId(Long scheduleId) { + delete(OaScheduleParticipantDO::getScheduleId, scheduleId); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/seal/OaSealApplyMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/seal/OaSealApplyMapper.java new file mode 100644 index 0000000000..f9ed945698 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/seal/OaSealApplyMapper.java @@ -0,0 +1,61 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.seal; + +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.oa.controller.admin.seal.vo.apply.OaSealApplyPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.seal.OaSealApplyDO; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.List; + +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.enums.seal.OaSealUseModeEnum; + +/** + * OA 用印申请 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaSealApplyMapper extends BaseMapperX { + + default Long selectCountBySealId(Long id) { + return selectCount(OaSealApplyDO::getSealId, id); + } + + default OaSealApplyDO selectByNo(String no) { + return selectOne(OaSealApplyDO::getNo, no); + } + + default PageResult selectPage(Long userId, OaSealApplyPageReqVO reqVO) { + LambdaQueryWrapperX query = new LambdaQueryWrapperX() + .eq(OaSealApplyDO::getUserId, userId) + .likeIfPresent(OaSealApplyDO::getNo, reqVO.getNo()) + .eqIfPresent(OaSealApplyDO::getSealId, reqVO.getSealId()) + .likeIfPresent(OaSealApplyDO::getSealName, reqVO.getSealName()) + .eqIfPresent(OaSealApplyDO::getDeptId, reqVO.getDeptId()) + .betweenIfPresent(OaSealApplyDO::getCreateTime, reqVO.getCreateTime()) + .betweenIfPresent(OaSealApplyDO::getExpectedUseTime, reqVO.getExpectedUseTime()) + .eqIfPresent(OaSealApplyDO::getStatus, reqVO.getStatus()) + .eqIfPresent(OaSealApplyDO::getType, reqVO.getType()) + .eqIfPresent(OaSealApplyDO::getMode, reqVO.getMode()) + .eqIfPresent(OaSealApplyDO::getUrgent, reqVO.getUrgent()) + .orderByDesc(OaSealApplyDO::getId); + query.eqIfPresent(OaSealApplyDO::getUseStatus, reqVO.getUseStatus()); + return selectPage(reqVO, query); + } + + default List selectListBySealIdAndBorrowTimeConflict( + Long sealId, Long excludeId, LocalDateTime beginTime, LocalDateTime endTime) { + return selectList(new LambdaQueryWrapperX().eq(OaSealApplyDO::getSealId, sealId) + .ne(OaSealApplyDO::getId, excludeId) + .eq(OaSealApplyDO::getMode, OaSealUseModeEnum.BORROW.getMode()) + .le(OaSealApplyDO::getExpectedUseTime, endTime) + .ge(OaSealApplyDO::getExpectedReturnTime, beginTime) + .in(OaSealApplyDO::getStatus, BpmProcessInstanceStatusEnum.RUNNING.getStatus(), + BpmProcessInstanceStatusEnum.APPROVE.getStatus()) + .orderByAsc(OaSealApplyDO::getId)); + } +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/seal/OaSealMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/seal/OaSealMapper.java new file mode 100644 index 0000000000..33e2ef5b6c --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/seal/OaSealMapper.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.seal; + +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.oa.controller.admin.seal.vo.OaSealPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.seal.OaSealDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * OA 印章 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaSealMapper extends BaseMapperX { + + default PageResult selectPage(OaSealPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(OaSealDO::getNo, reqVO.getNo()) + .likeIfPresent(OaSealDO::getName, reqVO.getName()) + .eqIfPresent(OaSealDO::getCategory, reqVO.getCategory()) + .eqIfPresent(OaSealDO::getType, reqVO.getType()) + .eqIfPresent(OaSealDO::getKeeperUserId, reqVO.getKeeperUserId()) + .eqIfPresent(OaSealDO::getDeptId, reqVO.getDeptId()) + .eqIfPresent(OaSealDO::getStatus, reqVO.getStatus()) + .betweenIfPresent(OaSealDO::getPurchaseTime, reqVO.getPurchaseTime()) + .betweenIfPresent(OaSealDO::getEnableTime, reqVO.getEnableTime()) + .betweenIfPresent(OaSealDO::getDisableTime, reqVO.getDisableTime()) + .orderByAsc(OaSealDO::getSort) + .orderByDesc(OaSealDO::getId)); + } + + default OaSealDO selectByNo(String no) { + return selectOne(OaSealDO::getNo, no); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/supply/OaSupplyApplyItemMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/supply/OaSupplyApplyItemMapper.java new file mode 100644 index 0000000000..88d056b286 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/supply/OaSupplyApplyItemMapper.java @@ -0,0 +1,80 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.supply; + +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.framework.mybatis.core.query.MPJLambdaWrapperX; +import cn.iocoder.yudao.module.oa.dal.dataobject.supply.*; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.apply.*; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.issue.*; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * OA 用品申请明细 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaSupplyApplyItemMapper extends BaseMapperX { + + default Long selectCountByItemIdAndUnreturned(Long itemId, Integer manageType) { + return selectCount(new LambdaQueryWrapperX() + .eq(OaSupplyApplyItemDO::getItemId, itemId) + .eq(OaSupplyApplyItemDO::getManageType, manageType) + .apply("issued_quantity > returned_quantity")); + } + + default Long selectCountByItemIdAndStatus(Long itemId, Integer status) { + return selectCount(new LambdaQueryWrapperX() + .eq(OaSupplyApplyItemDO::getItemId, itemId) + .eq(OaSupplyApplyItemDO::getStatus, status)); + } + + default List selectListByApplyId(Long applyId) { + return selectList(new LambdaQueryWrapperX() + .eq(OaSupplyApplyItemDO::getApplyId, applyId).orderByAsc(OaSupplyApplyItemDO::getId)); + } + + default PageResult selectPage(OaSupplyIssuePageReqVO reqVO, + Collection creators) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX() + .selectAll(OaSupplyApplyItemDO.class) + .innerJoin(OaSupplyApplyDO.class, OaSupplyApplyDO::getId, OaSupplyApplyItemDO::getApplyId) + .likeIfPresent(OaSupplyApplyItemDO::getItemName, reqVO.getItemName()) + .eqIfPresent(OaSupplyApplyItemDO::getManageType, reqVO.getManageType()) + .eqIfPresent(OaSupplyApplyItemDO::getStatus, reqVO.getStatus()) + .eqIfPresent(OaSupplyApplyDO::getUseType, reqVO.getUseType()) + .betweenIfPresent(OaSupplyApplyDO::getCreateTime, reqVO.getCreateTime()); + if (creators != null) { + query.in(OaSupplyApplyDO::getCreator, creators); + } + query.orderByDesc(OaSupplyApplyItemDO::getId); + return selectJoinPage(reqVO, OaSupplyApplyItemDO.class, query); + } + + default void deleteByApplyId(Long applyId) { + delete(OaSupplyApplyItemDO::getApplyId, applyId); + } + + default void updateStatusByApplyId(Long applyId, Integer status) { + update(new OaSupplyApplyItemDO().setStatus(status), + new LambdaUpdateWrapper().eq(OaSupplyApplyItemDO::getApplyId, applyId)); + } + + default int updateByIdAndStatus(OaSupplyApplyItemDO item, Integer expectedStatus) { + return update(item, new LambdaUpdateWrapper() + .eq(OaSupplyApplyItemDO::getId, item.getId()) + .eq(OaSupplyApplyItemDO::getStatus, expectedStatus)); + } + + default int updateByIdAndReturnedQuantity(OaSupplyApplyItemDO item, Integer expectedReturnedQuantity) { + return update(item, new LambdaUpdateWrapper() + .eq(OaSupplyApplyItemDO::getId, item.getId()) + .eq(OaSupplyApplyItemDO::getReturnedQuantity, expectedReturnedQuantity)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/supply/OaSupplyApplyMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/supply/OaSupplyApplyMapper.java new file mode 100644 index 0000000000..fba80083cb --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/supply/OaSupplyApplyMapper.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.supply; + +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.oa.dal.dataobject.supply.*; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.apply.*; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.issue.*; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.item.*; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + + +/** + * OA 用品领用申请 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaSupplyApplyMapper extends BaseMapperX { + + default PageResult selectPage(OaSupplyApplyPageReqVO reqVO, Long userId) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eq(OaSupplyApplyDO::getCreator, userId.toString()) + .likeIfPresent(OaSupplyApplyDO::getNo, reqVO.getNo()) + .eqIfPresent(OaSupplyApplyDO::getStatus, reqVO.getStatus()) + .eqIfPresent(OaSupplyApplyDO::getDeptId, reqVO.getDeptId()) + .betweenIfPresent(OaSupplyApplyDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(OaSupplyApplyDO::getId)); + } + + default OaSupplyApplyDO selectByNo(String no) { + return selectOne(OaSupplyApplyDO::getNo, no); + } + + default int updateStatusAndClearProcessInstanceId(Long id, Integer oldStatus, Integer status) { + return update(new LambdaUpdateWrapper().eq(OaSupplyApplyDO::getId, id) + .eq(OaSupplyApplyDO::getStatus, oldStatus) + .set(OaSupplyApplyDO::getStatus, status).set(OaSupplyApplyDO::getProcessInstanceId, null)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/supply/OaSupplyItemMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/supply/OaSupplyItemMapper.java new file mode 100644 index 0000000000..777bf71858 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/supply/OaSupplyItemMapper.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.supply; + +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.oa.dal.dataobject.supply.*; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.item.*; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * OA 办公用品 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaSupplyItemMapper extends BaseMapperX { + + default PageResult selectPage(OaSupplyItemPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(OaSupplyItemDO::getName, reqVO.getName()) + .likeIfPresent(OaSupplyItemDO::getNo, reqVO.getNo()) + .eqIfPresent(OaSupplyItemDO::getCategory, reqVO.getCategory()) + .eqIfPresent(OaSupplyItemDO::getManageType, reqVO.getManageType()) + .eqIfPresent(OaSupplyItemDO::getStatus, reqVO.getStatus()) + .orderByAsc(OaSupplyItemDO::getSort).orderByDesc(OaSupplyItemDO::getId)); + } + + default OaSupplyItemDO selectByNo(String no) { + return selectOne(OaSupplyItemDO::getNo, no); + } + + default int updateStockQuantity(Long id, Integer quantity) { + LambdaUpdateWrapper update = new LambdaUpdateWrapper() + .eq(OaSupplyItemDO::getId, id) + .setIncrBy(OaSupplyItemDO::getStockQuantity, quantity); + if (quantity < 0) { + update.ge(OaSupplyItemDO::getStockQuantity, -quantity); + } + return update(update); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/task/OaTaskLogMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/task/OaTaskLogMapper.java new file mode 100644 index 0000000000..d788f4baf2 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/task/OaTaskLogMapper.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.task; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.oa.dal.dataobject.task.OaTaskLogDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * OA 任务反馈日志 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaTaskLogMapper extends BaseMapperX { + + default List selectListByTaskId(Long taskId) { + return selectList(new LambdaQueryWrapperX() + .eq(OaTaskLogDO::getTaskId, taskId) + .orderByAsc(OaTaskLogDO::getCreateTime) + .orderByAsc(OaTaskLogDO::getId)); + } + + default void deleteByTaskId(Long taskId) { + delete(OaTaskLogDO::getTaskId, taskId); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/task/OaTaskMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/task/OaTaskMapper.java new file mode 100644 index 0000000000..700dd83a3e --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/task/OaTaskMapper.java @@ -0,0 +1,63 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.task; + +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +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.framework.mybatis.core.query.MPJLambdaWrapperX; +import cn.iocoder.yudao.module.oa.controller.admin.task.vo.OaTaskPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.task.OaTaskDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * OA 任务 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaTaskMapper extends BaseMapperX { + + default Map selectCountMapByStatusGroupByCreator(Integer status) { + List> counts = selectMaps(new MPJLambdaWrapperX() + .selectAs(OaTaskDO::getCreator, "userId") + .selectCount(OaTaskDO::getId, "count") + .eq(OaTaskDO::getStatus, status) + .groupBy(OaTaskDO::getCreator)); + return convertMap(counts, row -> MapUtil.getLong(row, "userId"), row -> MapUtil.getLong(row, "count")); + } + + default PageResult selectPublishedPage(OaTaskPageReqVO pageReqVO, Long publisherUserId) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .eq(OaTaskDO::getCreator, publisherUserId.toString()) + .likeIfPresent(OaTaskDO::getTitle, pageReqVO.getTitle()) + .eqIfPresent(OaTaskDO::getType, pageReqVO.getType()) + .eqIfPresent(OaTaskDO::getStatus, pageReqVO.getStatus()) + .eqIfPresent(OaTaskDO::getCanceled, pageReqVO.getCanceled()) + .betweenIfPresent(OaTaskDO::getPublishTime, pageReqVO.getPublishTime()) + .orderByDesc(OaTaskDO::getTop) + .orderByDesc(OaTaskDO::getUpdateTime) + .orderByDesc(OaTaskDO::getId)); + } + + default PageResult selectReceivedPage(OaTaskPageReqVO pageReqVO, Collection taskIds) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .in(OaTaskDO::getId, taskIds) + .likeIfPresent(OaTaskDO::getTitle, pageReqVO.getTitle()) + .eqIfPresent(OaTaskDO::getType, pageReqVO.getType()) + .eqIfPresent(OaTaskDO::getCreator, StrUtil.toStringOrNull(pageReqVO.getPublisherUserId())) + .eqIfPresent(OaTaskDO::getCanceled, pageReqVO.getCanceled()) + .betweenIfPresent(OaTaskDO::getPublishTime, pageReqVO.getPublishTime()) + .orderByAsc(OaTaskDO::getCanceled) + .orderByAsc(OaTaskDO::getStatus) + .orderByDesc(OaTaskDO::getUpdateTime) + .orderByDesc(OaTaskDO::getId)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/task/OaTaskReceiverMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/task/OaTaskReceiverMapper.java new file mode 100644 index 0000000000..3a3a077e2e --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/task/OaTaskReceiverMapper.java @@ -0,0 +1,80 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.task; + +import cn.hutool.core.map.MapUtil; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.MPJLambdaWrapperX; +import cn.iocoder.yudao.module.oa.dal.dataobject.task.OaTaskReceiverDO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * OA 任务接收人 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaTaskReceiverMapper extends BaseMapperX { + + default OaTaskReceiverDO selectByTaskIdAndUserId(Long taskId, Long userId) { + return selectOne(OaTaskReceiverDO::getTaskId, taskId, OaTaskReceiverDO::getUserId, userId); + } + + default List selectListByTaskId(Long taskId) { + return selectList(new LambdaQueryWrapperX() + .eq(OaTaskReceiverDO::getTaskId, taskId) + .orderByAsc(OaTaskReceiverDO::getId)); + } + + default List selectListByTaskIds(Collection taskIds) { + return selectList(new LambdaQueryWrapperX() + .in(OaTaskReceiverDO::getTaskId, taskIds) + .orderByAsc(OaTaskReceiverDO::getId)); + } + + default List selectListByUserIdAndStatus(Long userId, Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(OaTaskReceiverDO::getUserId, userId) + .eqIfPresent(OaTaskReceiverDO::getStatus, status) + .orderByDesc(OaTaskReceiverDO::getUpdateTime) + .orderByDesc(OaTaskReceiverDO::getId)); + } + + default Map selectCountMapByUserIdGroupByStatus(Long userId) { + List> counts = selectMaps(new MPJLambdaWrapperX() + .selectAs(OaTaskReceiverDO::getStatus, "status") + .selectCount(OaTaskReceiverDO::getId, "count") + .eq(OaTaskReceiverDO::getUserId, userId) + .groupBy(OaTaskReceiverDO::getStatus)); + return convertMap(counts, row -> MapUtil.getInt(row, "status"), row -> MapUtil.getLong(row, "count")); + } + + default void updateStatusByTaskId(Long taskId, Integer status) { + update(new LambdaUpdateWrapper() + .eq(OaTaskReceiverDO::getTaskId, taskId) + .set(OaTaskReceiverDO::getStatus, status)); + } + + default void deleteByTaskIdAndUserId(Long taskId, Long userId) { + delete(new LambdaQueryWrapperX() + .eq(OaTaskReceiverDO::getTaskId, taskId) + .eq(OaTaskReceiverDO::getUserId, userId)); + } + + default void deleteByTaskIdAndUserIds(Long taskId, Collection userIds) { + delete(new LambdaQueryWrapperX() + .eq(OaTaskReceiverDO::getTaskId, taskId) + .in(OaTaskReceiverDO::getUserId, userIds)); + } + + default void deleteByTaskId(Long taskId) { + delete(OaTaskReceiverDO::getTaskId, taskId); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/travel/OaTravelApplyMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/travel/OaTravelApplyMapper.java new file mode 100644 index 0000000000..849caea12d --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/travel/OaTravelApplyMapper.java @@ -0,0 +1,61 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.travel; + +import cn.hutool.core.map.MapUtil; +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.framework.mybatis.core.query.MPJLambdaWrapperX; +import cn.iocoder.yudao.module.oa.controller.admin.travel.vo.apply.OaTravelApplyPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.travel.OaTravelApplyDO; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * OA 出差申请 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaTravelApplyMapper extends BaseMapperX { + + default Map selectDaysMapByCreatorsAndStatusAndStartTime( + Collection userIds, Integer status, LocalDateTime[] startTime) { + List> days = selectMaps(new MPJLambdaWrapperX() + .selectAs(OaTravelApplyDO::getCreator, "userId") + .selectSum(OaTravelApplyDO::getDays, "days") + .in(OaTravelApplyDO::getCreator, convertList(userIds, String::valueOf)) + .eq(OaTravelApplyDO::getStatus, status) + .between(OaTravelApplyDO::getStartTime, startTime[0], startTime[1]) + .groupBy(OaTravelApplyDO::getCreator)); + return convertMap(days, row -> MapUtil.getLong(row, "userId"), row -> MapUtil.getInt(row, "days")); + } + + default PageResult selectPage(Long userId, OaTravelApplyPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eq(OaTravelApplyDO::getCreator, userId.toString()) + .likeIfPresent(OaTravelApplyDO::getNo, reqVO.getNo()) + .eqIfPresent(OaTravelApplyDO::getStatus, reqVO.getStatus()) + .eqIfPresent(OaTravelApplyDO::getDeptId, reqVO.getDeptId()) + .eqIfPresent(OaTravelApplyDO::getReimburseStatus, reqVO.getReimburseStatus()) + .betweenIfPresent(OaTravelApplyDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(OaTravelApplyDO::getId)); + } + + default OaTravelApplyDO selectByNo(String no) { + return selectOne(OaTravelApplyDO::getNo, no); + } + + default List selectListByCreatorAndStatus(Long userId, Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(OaTravelApplyDO::getCreator, userId.toString()).eq(OaTravelApplyDO::getStatus, status) + .orderByDesc(OaTravelApplyDO::getId)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/travel/OaTravelReimbursementMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/travel/OaTravelReimbursementMapper.java new file mode 100644 index 0000000000..1c4a4751ce --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/travel/OaTravelReimbursementMapper.java @@ -0,0 +1,34 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.travel; + +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.oa.controller.admin.travel.vo.reimbursement.OaTravelReimbursementPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.travel.OaTravelReimbursementDO; +import org.apache.ibatis.annotations.Mapper; + + +/** + * OA 出差报销 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaTravelReimbursementMapper extends BaseMapperX { + + default PageResult selectPage(Long userId, OaTravelReimbursementPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eq(OaTravelReimbursementDO::getCreator, userId.toString()) + .likeIfPresent(OaTravelReimbursementDO::getNo, reqVO.getNo()) + .eqIfPresent(OaTravelReimbursementDO::getStatus, reqVO.getStatus()) + .eqIfPresent(OaTravelReimbursementDO::getDeptId, reqVO.getDeptId()) + .eqIfPresent(OaTravelReimbursementDO::getPayStatus, reqVO.getPayStatus()) + .betweenIfPresent(OaTravelReimbursementDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(OaTravelReimbursementDO::getId)); + } + + default OaTravelReimbursementDO selectByNo(String no) { + return selectOne(OaTravelReimbursementDO::getNo, no); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/vehicle/OaVehicleApplyMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/vehicle/OaVehicleApplyMapper.java new file mode 100644 index 0000000000..a6672debb5 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/vehicle/OaVehicleApplyMapper.java @@ -0,0 +1,66 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.vehicle; + +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.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.apply.OaVehicleApplyPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.OaVehicleApplyDO; +import cn.iocoder.yudao.module.oa.enums.vehicle.OaVehicleReturnStatusEnum; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; + +/** + * OA 用车申请 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaVehicleApplyMapper extends BaseMapperX { + + default Long selectCountByVehicleId(Long id) { + return selectCount(OaVehicleApplyDO::getVehicleId, id); + } + + default OaVehicleApplyDO selectByNo(String no) { + return selectOne(OaVehicleApplyDO::getNo, no); + } + + default int updateReturnStatusByIdAndReturnStatus(Long id, Integer expectedReturnStatus, Integer returnStatus) { + return update(new OaVehicleApplyDO().setReturnStatus(returnStatus), new LambdaQueryWrapperX() + .eq(OaVehicleApplyDO::getId, id) + .eq(OaVehicleApplyDO::getStatus, BpmProcessInstanceStatusEnum.APPROVE.getStatus()) + .eq(OaVehicleApplyDO::getReturnStatus, expectedReturnStatus)); + } + + default PageResult selectPage(Long userId, OaVehicleApplyPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eq(OaVehicleApplyDO::getUserId, userId) + .likeIfPresent(OaVehicleApplyDO::getNo, reqVO.getNo()) + .likeIfPresent(OaVehicleApplyDO::getVehicleNo, reqVO.getVehicleNo()) + .eqIfPresent(OaVehicleApplyDO::getStatus, reqVO.getStatus()) + .eqIfPresent(OaVehicleApplyDO::getReturnStatus, reqVO.getReturnStatus()) + .eqIfPresent(OaVehicleApplyDO::getDeptId, reqVO.getDeptId()) + .betweenIfPresent(OaVehicleApplyDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(OaVehicleApplyDO::getId)); + } + + default Long selectCountByVehicleIdAndTimeOverlap(Long vehicleId, Long excludeId, + LocalDateTime startTime, LocalDateTime endTime) { + return selectCount(buildActiveQuery() + .eq(OaVehicleApplyDO::getVehicleId, vehicleId) + .ne(OaVehicleApplyDO::getId, excludeId) + .lt(OaVehicleApplyDO::getStartTime, endTime) + .gt(OaVehicleApplyDO::getEndTime, startTime)); + } + + default LambdaQueryWrapper buildActiveQuery() { + return new LambdaQueryWrapperX() + .and(query -> query.eq(OaVehicleApplyDO::getStatus, BpmProcessInstanceStatusEnum.RUNNING.getStatus()) + .or(approved -> approved.eq(OaVehicleApplyDO::getStatus, BpmProcessInstanceStatusEnum.APPROVE.getStatus()) + .in(OaVehicleApplyDO::getReturnStatus, OaVehicleReturnStatusEnum.PENDING_RETURN.getStatus(), OaVehicleReturnStatusEnum.RETURNING.getStatus()))); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/vehicle/OaVehicleMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/vehicle/OaVehicleMapper.java new file mode 100644 index 0000000000..37c50a64d3 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/vehicle/OaVehicleMapper.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.vehicle; + +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.oa.controller.admin.vehicle.vo.OaVehiclePageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.OaVehicleDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * OA 车辆 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaVehicleMapper extends BaseMapperX { + + default PageResult selectPage(OaVehiclePageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(OaVehicleDO::getNo, reqVO.getNo()) + .likeIfPresent(OaVehicleDO::getName, reqVO.getName()) + .eqIfPresent(OaVehicleDO::getDeptId, reqVO.getDeptId()) + .eqIfPresent(OaVehicleDO::getCategory, reqVO.getCategory()) + .eqIfPresent(OaVehicleDO::getStatus, reqVO.getStatus()) + .likeIfPresent(OaVehicleDO::getType, reqVO.getType()) + .likeIfPresent(OaVehicleDO::getBrandModel, reqVO.getBrandModel()) + .betweenIfPresent(OaVehicleDO::getCompulsoryInsuranceExpireTime, reqVO.getCompulsoryInsuranceExpireTime()) + .betweenIfPresent(OaVehicleDO::getCommercialInsuranceExpireTime, reqVO.getCommercialInsuranceExpireTime()) + .betweenIfPresent(OaVehicleDO::getInspectionExpireTime, reqVO.getInspectionExpireTime()) + .orderByDesc(OaVehicleDO::getId)); + } + + default OaVehicleDO selectByNo(String no) { + return selectOne(OaVehicleDO::getNo, no); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/vehicle/OaVehicleReturnMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/vehicle/OaVehicleReturnMapper.java new file mode 100644 index 0000000000..8653cf830a --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/vehicle/OaVehicleReturnMapper.java @@ -0,0 +1,41 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.vehicle; + +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.MPJLambdaWrapperX; +import cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.OaVehicleApplyDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.OaVehicleDO; +import cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.returning.OaVehicleReturnPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.OaVehicleReturnDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * OA 还车申请 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaVehicleReturnMapper extends BaseMapperX { + + default OaVehicleReturnDO selectByNo(String no) { + return selectOne(OaVehicleReturnDO::getNo, no); + } + + default PageResult selectPage(Long userId, OaVehicleReturnPageReqVO reqVO) { + MPJLambdaWrapperX query = new MPJLambdaWrapperX() + .selectAll(OaVehicleReturnDO.class) + .leftJoin(OaVehicleApplyDO.class, OaVehicleApplyDO::getId, OaVehicleReturnDO::getApplyId) + .leftJoin(OaVehicleDO.class, OaVehicleDO::getId, OaVehicleReturnDO::getVehicleId) + .eq(OaVehicleReturnDO::getUserId, userId) + .likeIfPresent(OaVehicleReturnDO::getNo, reqVO.getNo()) + .eqIfPresent(OaVehicleReturnDO::getApplyId, reqVO.getApplyId()) + .eqIfPresent(OaVehicleReturnDO::getStatus, reqVO.getStatus()) + .likeIfPresent(OaVehicleDO::getNo, reqVO.getVehicleNo()) + .likeIfPresent(OaVehicleApplyDO::getNo, reqVO.getApplyNo()) + .eqIfPresent(OaVehicleReturnDO::getDeptId, reqVO.getDeptId()) + .betweenIfPresent(OaVehicleReturnDO::getCreateTime, reqVO.getCreateTime()); + query.orderByDesc(OaVehicleReturnDO::getId); + return selectJoinPage(reqVO, OaVehicleReturnDO.class, query); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/workreport/OaWorkReportMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/workreport/OaWorkReportMapper.java new file mode 100644 index 0000000000..b570d85333 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/workreport/OaWorkReportMapper.java @@ -0,0 +1,59 @@ +package cn.iocoder.yudao.module.oa.dal.mysql.workreport; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.oa.controller.admin.workreport.vo.OaWorkReportPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.workreport.OaWorkReportDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; +import java.time.LocalDateTime; + +/** + * OA 工作汇报 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface OaWorkReportMapper extends BaseMapperX { + + default OaWorkReportDO selectByNo(String no) { + return selectOne(OaWorkReportDO::getNo, no); + } + + default PageResult selectPage(OaWorkReportPageReqVO pageReqVO, Long userId) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .eq(OaWorkReportDO::getCreator, userId.toString()) + .likeIfPresent(OaWorkReportDO::getNo, pageReqVO.getNo()) + .eqIfPresent(OaWorkReportDO::getDeptId, pageReqVO.getDeptId()) + .betweenIfPresent(OaWorkReportDO::getStartTime, pageReqVO.getPeriodTime()) + .betweenIfPresent(OaWorkReportDO::getStartTime, + LocalDateTimeUtils.getDayBeginTime(pageReqVO.getStartTime()), + LocalDateTimeUtils.getDayEndTime(pageReqVO.getStartTime())) + .betweenIfPresent(OaWorkReportDO::getEndTime, + LocalDateTimeUtils.getDayBeginTime(pageReqVO.getEndTime()), + LocalDateTimeUtils.getDayEndTime(pageReqVO.getEndTime())) + .eqIfPresent(OaWorkReportDO::getType, pageReqVO.getType()) + .eqIfPresent(OaWorkReportDO::getStatus, pageReqVO.getStatus()) + .betweenIfPresent(OaWorkReportDO::getCreateTime, pageReqVO.getCreateTime()) + .orderByDesc(OaWorkReportDO::getStartTime) + .orderByDesc(OaWorkReportDO::getId)); + } + + default List selectListByCreatorsAndTypeAndStatusAndStartTimeBetween( + Collection creators, Integer type, Integer status, + LocalDateTime startTime, LocalDateTime endTime) { + return selectList(new LambdaQueryWrapperX() + .in(OaWorkReportDO::getCreator, creators) + .eq(OaWorkReportDO::getType, type) + .eq(OaWorkReportDO::getStatus, status) + .between(OaWorkReportDO::getStartTime, startTime, endTime) + .orderByAsc(OaWorkReportDO::getCreator) + .orderByAsc(OaWorkReportDO::getStartTime) + .orderByDesc(OaWorkReportDO::getId)); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/redis/RedisKeyConstants.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/redis/RedisKeyConstants.java new file mode 100644 index 0000000000..58fe24435f --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/redis/RedisKeyConstants.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.oa.dal.redis; + +/** + * OA Redis Key 枚举类 + * + * @author 芋道源码 + */ +public interface RedisKeyConstants { + + /** + * 序号的缓存 + * + * KEY 格式:oa:seq_no:{prefix} + * VALUE 数据格式:编号自增 + */ + String NO = "oa:seq_no:"; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/redis/no/OaNoRedisDAO.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/redis/no/OaNoRedisDAO.java new file mode 100644 index 0000000000..1d9cb0e660 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/redis/no/OaNoRedisDAO.java @@ -0,0 +1,91 @@ +package cn.iocoder.yudao.module.oa.dal.redis.no; + +import cn.hutool.core.date.DatePattern; +import cn.hutool.core.date.DateUtil; +import cn.iocoder.yudao.module.oa.dal.redis.RedisKeyConstants; +import jakarta.annotation.Resource; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.time.Duration; +import java.time.LocalDateTime; + +/** + * OA 业务单号的 Redis DAO + * + * @author 芋道源码 + */ +@Repository +public class OaNoRedisDAO { + + /** + * 用品领用申请 {@link cn.iocoder.yudao.module.oa.dal.dataobject.supply.OaSupplyApplyDO} + */ + public static final String SUPPLY_APPLY_NO_PREFIX = "YP"; + + /** + * 出差申请 {@link cn.iocoder.yudao.module.oa.dal.dataobject.travel.OaTravelApplyDO} + */ + public static final String TRAVEL_APPLY_NO_PREFIX = "CC"; + /** + * 出差报销 {@link cn.iocoder.yudao.module.oa.dal.dataobject.travel.OaTravelReimbursementDO} + */ + public static final String TRAVEL_REIMBURSEMENT_NO_PREFIX = "CLBX"; + + /** + * 用车申请 {@link cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.OaVehicleApplyDO} + */ + public static final String VEHICLE_APPLY_NO_PREFIX = "YC"; + /** + * 还车申请 {@link cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.OaVehicleReturnDO} + */ + public static final String VEHICLE_RETURN_NO_PREFIX = "HC"; + /** + * 印章编号 {@link cn.iocoder.yudao.module.oa.dal.dataobject.seal.OaSealDO} + */ + public static final String SEAL_NO_PREFIX = "YZ"; + /** + * 用印申请 {@link cn.iocoder.yudao.module.oa.dal.dataobject.seal.OaSealApplyDO} + */ + public static final String SEAL_APPLY_NO_PREFIX = "YY"; + + /** + * 会议室预定 {@link cn.iocoder.yudao.module.oa.dal.dataobject.meetingroom.OaMeetingRoomBookingDO} + */ + public static final String MEETING_ROOM_BOOKING_NO_PREFIX = "HY"; + + /** + * 工作汇报 {@link cn.iocoder.yudao.module.oa.dal.dataobject.workreport.OaWorkReportDO} + */ + public static final String WORK_REPORT_NO_PREFIX = "WR"; + + /** + * 公文发文 {@link cn.iocoder.yudao.module.oa.dal.dataobject.officialdoc.OaOfficialDocSendDO} + */ + public static final String OFFICIAL_DOC_SEND_NO_PREFIX = "FW"; + /** + * 公文收文 {@link cn.iocoder.yudao.module.oa.dal.dataobject.officialdoc.OaOfficialDocReceiveDO} + */ + public static final String OFFICIAL_DOC_RECEIVE_NO_PREFIX = "SW"; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + /** + * 生成序号,使用当前日期,格式为 {PREFIX} + yyyyMMdd + 6 位自增 + * 例如说:YC20260912000001 (没有中间空格) + * + * @param prefix 前缀 + * @return 序号 + */ + public String generate(String prefix) { + // 递增序号 + String noPrefix = prefix + DateUtil.format(LocalDateTime.now(), DatePattern.PURE_DATE_PATTERN); + String key = RedisKeyConstants.NO + noPrefix; + Long no = stringRedisTemplate.opsForValue().increment(key); + // 设置过期时间 + stringRedisTemplate.expire(key, Duration.ofDays(1L)); + return noPrefix + String.format("%06d", no); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/BpmModelConstants.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/BpmModelConstants.java new file mode 100644 index 0000000000..7453488aa3 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/BpmModelConstants.java @@ -0,0 +1,77 @@ +package cn.iocoder.yudao.module.oa.enums; + +/** + * OA 流程模型标识 + * + * @author 芋道源码 + */ +public interface BpmModelConstants { + + /** + * 费用报销 + */ + String REIMBURSEMENT = "oa_reimbursement"; + + /** + * 加班申请 + */ + String OVERTIME_APPLY = "oa_overtime_apply"; + + /** + * 请假申请 + */ + String LEAVE_APPLY = "oa_leave_apply"; + + /** + * 转正申请 + */ + String REGULAR_APPLY = "oa_regular_apply"; + + /** + * 离职申请 + */ + String RESIGN_APPLY = "oa_resign_apply"; + + /** + * 用品领用申请 + */ + String SUPPLY_APPLY = "oa_supply_apply"; + + /** + * 出差申请 + */ + String TRAVEL_APPLY = "oa_travel_apply"; + /** + * 出差报销 + */ + String TRAVEL_REIMBURSEMENT = "oa_travel_reimbursement"; + + /** + * 用印申请 + */ + String SEAL_APPLY = "oa_seal_apply"; + + /** + * 用车申请 + */ + String VEHICLE_APPLY = "oa_vehicle_apply"; + /** + * 还车申请 + */ + String VEHICLE_RETURN = "oa_vehicle_return"; + + /** + * 会议室预定 + */ + String MEETING_ROOM_BOOKING = "oa_meeting_room_booking"; + + /** + * 公文发文 + */ + String OFFICIAL_DOC_SEND = "oa_official_doc_send"; + /** + * 公文收文 + */ + String OFFICIAL_DOC_RECEIVE = "oa_official_doc_receive"; + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/DictTypeConstants.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/DictTypeConstants.java new file mode 100644 index 0000000000..b079d32ab6 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/DictTypeConstants.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.oa.enums; + +/** + * OA 字典类型常量 + * + * @author 芋道源码 + */ +public interface DictTypeConstants { + + String APPLY_URGENCY = "oa_apply_urgency"; // 申请紧急程度 + String REIMBURSEMENT_PAYMENT_METHOD = "oa_reimbursement_payment_method"; // 费用报销方式 + String OVERTIME_TYPE = "oa_overtime_type"; // 加班类型 + String LEAVE_TYPE = "oa_leave_type"; // 请假类型 + + String SEAL_TYPE = "oa_seal_type"; // 印章类型 + String SEAL_CATEGORY = "oa_seal_category"; // 印章分类 + String SEAL_STATUS = "oa_seal_status"; // 印章状态 + String SEAL_APPLY_TYPE = "oa_seal_apply_type"; // 用印类型 + String SEAL_USE_MODE = "oa_seal_use_mode"; // 用印方式 + String SEAL_USE_STATUS = "oa_seal_use_status"; // 用印状态 + + String VEHICLE_CATEGORY = "oa_vehicle_category"; // 车辆分类 + String VEHICLE_STATUS = "oa_vehicle_status"; // 车辆状态 + + String MEETING_ROOM_EQUIPMENT = "oa_meeting_room_equipment"; // 会议室设备 + + String SUPPLY_CATEGORY = "oa_supply_category"; // 办公用品分类 + String SUPPLY_PICKUP_METHOD = "oa_supply_pickup_method"; // 办公用品领取方式 + + String REIMBURSE_STATUS = "oa_reimburse_status"; // 报销状态 + String PAY_STATUS = "oa_pay_status"; // 支付状态 + String TRANSPORT_TYPE = "oa_transport_type"; // 交通方式 + String EXPENSE_TYPE = "oa_expense_type"; // 费用类型 + + String OFFICIAL_DOC_SECRET_LEVEL = "oa_official_doc_secret_level"; // 公文密级 + String OFFICIAL_DOC_URGENCY_LEVEL = "oa_official_doc_urgency_level"; // 公文紧急程度 + String OFFICIAL_DOC_PUBLIC_CATEGORY = "oa_official_doc_public_category"; // 公文公开类别 + String OFFICIAL_DOC_SEPARATOR_TYPE = "oa_official_doc_separator_type"; // 套红模板分隔线类型 + String OFFICIAL_DOC_HANDLE_STATUS = "oa_official_doc_handle_status"; // 公文收文办理状态 + String OFFICIAL_DOC_RECEIVE_TYPE = "oa_official_doc_receive_type"; // 公文收文类型 + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/ErrorCodeConstants.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/ErrorCodeConstants.java new file mode 100644 index 0000000000..41430206e5 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/ErrorCodeConstants.java @@ -0,0 +1,201 @@ +package cn.iocoder.yudao.module.oa.enums; + +import cn.iocoder.yudao.framework.common.exception.ErrorCode; + +/** + * OA 错误码常量 + * + * OA 系统,使用 1-054-000-000 段 + * + * @author 芋道源码 + */ +public interface ErrorCodeConstants { + + // ========== 流程申请 1-054-280-000 ========== + ErrorCode APPLY_NOT_EXISTS = new ErrorCode(1_054_280_000, "申请不存在"); + ErrorCode APPLY_ACCESS_DENIED = new ErrorCode(1_054_280_001, "无权查看该申请"); + ErrorCode APPLY_STATUS_INVALID = new ErrorCode(1_054_280_004, "申请状态不允许该操作"); + ErrorCode LEAVE_APPLY_DAYS_EXCEEDED = new ErrorCode(1_054_280_005, "单次{}不能超过 {} 天"); + + // ========== 出差管理 1-054-270-000 ========== + ErrorCode TRAVEL_NOT_EXISTS = new ErrorCode(1_054_270_000, "出差单据不存在"); + ErrorCode TRAVEL_ACCESS_DENIED = new ErrorCode(1_054_270_001, "无权操作该出差单据"); + ErrorCode TRAVEL_STATUS_INVALID = new ErrorCode(1_054_270_002, "当前单据状态不允许该操作"); + ErrorCode TRAVEL_APPLY_ALREADY_REIMBURSED = new ErrorCode(1_054_270_010, "所选出差申请已报销,不能重复报销"); + ErrorCode TRAVEL_APPLY_NOT_APPROVED = new ErrorCode(1_054_270_009, "所选出差申请尚未审批通过"); + ErrorCode TRAVEL_NO_DUPLICATE = new ErrorCode(1_054_270_011, "出差单据编号已存在"); + + // ========== 日程管理 1-054-100-000 ========== + ErrorCode SCHEDULE_NOT_EXISTS = new ErrorCode(1_054_100_000, "日程不存在"); + ErrorCode SCHEDULE_ACCESS_DENIED = new ErrorCode(1_054_100_001, "无权访问该日程"); + + // ========== 工作计划 1-054-110-000 ========== + ErrorCode PLAN_NOT_EXISTS = new ErrorCode(1_054_110_000, "工作计划不存在"); + ErrorCode PLAN_ACCESS_DENIED = new ErrorCode(1_054_110_001, "无权操作该工作计划"); + + // ========== 笔记管理 1-054-120-000 ========== + ErrorCode NOTE_NOT_EXISTS = new ErrorCode(1_054_120_000, "笔记不存在"); + ErrorCode NOTE_ACCESS_DENIED = new ErrorCode(1_054_120_001, "无权访问该笔记"); + ErrorCode NOTE_CATEGORY_NOT_EXISTS = new ErrorCode(1_054_120_002, "笔记目录不存在"); + ErrorCode NOTE_CATEGORY_NAME_DUPLICATE = new ErrorCode(1_054_120_003, "笔记目录名称已存在"); + + // ========== 通讯录 1-054-130-000 ========== + ErrorCode CONTACT_NOT_EXISTS = new ErrorCode(1_054_130_000, "联系人不存在"); + ErrorCode CONTACT_ACCESS_DENIED = new ErrorCode(1_054_130_001, "无权访问该联系人"); + ErrorCode CONTACT_CATEGORY_NOT_EXISTS = new ErrorCode(1_054_130_002, "联系人分类不存在"); + ErrorCode CONTACT_CATEGORY_NAME_DUPLICATE = new ErrorCode(1_054_130_003, "联系人分类名称已存在"); + + // ========== 讨论区 1-054-150-000 ========== + ErrorCode DISCUSSION_NOT_EXISTS = new ErrorCode(1_054_150_000, "讨论不存在"); + ErrorCode DISCUSSION_ACCESS_DENIED = new ErrorCode(1_054_150_001, "只能修改或删除自己发布的讨论"); + ErrorCode DISCUSSION_REPLY_NOT_EXISTS = new ErrorCode(1_054_150_002, "讨论回复不存在"); + ErrorCode DISCUSSION_REPLY_ACCESS_DENIED = new ErrorCode(1_054_150_003, "只有讨论发布人或超级管理员可以删除回复"); + ErrorCode DISCUSSION_VOTE_INVALID = new ErrorCode(1_054_150_004, "投票配置无效"); + ErrorCode DISCUSSION_VOTE_EXPIRED = new ErrorCode(1_054_150_005, "投票未开始或已结束"); + ErrorCode DISCUSSION_VOTE_DUPLICATE = new ErrorCode(1_054_150_006, "已经参与过该投票"); + ErrorCode DISCUSSION_VOTE_OPTION_NOT_EXISTS = new ErrorCode(1_054_150_007, "投票选项不存在"); + ErrorCode DISCUSSION_LIKE_TARGET_INVALID = new ErrorCode(1_054_150_009, "只能点赞讨论或主回复"); + + // ========== 考勤管理 1-054-160-000 ========== + ErrorCode ATTENDANCE_NOT_EXISTS = new ErrorCode(1_054_160_000, "考勤记录不存在"); + ErrorCode ATTENDANCE_CLOCK_TIME_INVALID = new ErrorCode(1_054_160_001, "仅允许在每天 {} 之后、{} 之前打卡"); + ErrorCode ATTENDANCE_STATUS_INVALID = new ErrorCode(1_054_160_002, "考勤类型与状态不匹配"); + + // ========== 任务管理 1-054-170-000 ========== + ErrorCode TASK_NOT_EXISTS = new ErrorCode(1_054_170_000, "任务不存在"); + ErrorCode TASK_ACCESS_DENIED = new ErrorCode(1_054_170_001, "无权访问该任务"); + ErrorCode TASK_RECEIVER_NOT_EXISTS = new ErrorCode(1_054_170_003, "当前用户不是该任务的接收人"); + ErrorCode TASK_STATUS_TRANSITION_INVALID = new ErrorCode(1_054_170_004, "任务状态流转不合法"); + ErrorCode TASK_RECEIVED_DELETE_DENIED = new ErrorCode(1_054_170_006, "只能删除已取消的接收任务"); + + // ========== 公告管理 1-054-180-000 ========== + ErrorCode ANNOUNCEMENT_NOT_EXISTS = new ErrorCode(1_054_180_000, "公告不存在"); + ErrorCode ANNOUNCEMENT_ACCESS_DENIED = new ErrorCode(1_054_180_001, "无权操作该公告"); + ErrorCode ANNOUNCEMENT_RECEIVER_NOT_EXISTS = new ErrorCode(1_054_180_002, "当前用户不是该公告的接收人"); + ErrorCode ANNOUNCEMENT_ALREADY_FORWARDED = new ErrorCode(1_054_180_003, "公告已转发,请勿重复转发"); + ErrorCode ANNOUNCEMENT_UNREAD_DELETE_DENIED = new ErrorCode(1_054_180_004, "请先阅读公告,再移除收到的公告"); + + // ========== 企业邮箱 1-054-190-000 ========== + ErrorCode MAIL_PROVIDER_NOT_EXISTS = new ErrorCode(1_054_190_000, "邮箱服务配置不存在"); + ErrorCode MAIL_PROVIDER_DISABLED = new ErrorCode(1_054_190_001, "邮箱服务配置未启用"); + ErrorCode MAIL_PROVIDER_IN_USE = new ErrorCode(1_054_190_002, "邮箱服务配置已被账号引用"); + ErrorCode MAIL_ACCOUNT_NOT_EXISTS = new ErrorCode(1_054_190_003, "邮箱账号不存在或无权访问"); + ErrorCode MAIL_ACCOUNT_DISABLED = new ErrorCode(1_054_190_004, "邮箱账号未启用"); + ErrorCode MAIL_ACCOUNT_IDENTITY_IMMUTABLE = new ErrorCode(1_054_190_033, "邮箱绑定后不能修改地址、登录名或服务,请新增绑定"); + ErrorCode MAIL_ACCOUNT_PASSWORD_REQUIRED = new ErrorCode(1_054_190_005, "请输入邮箱密码或授权码"); + ErrorCode MAIL_MESSAGE_NOT_EXISTS = new ErrorCode(1_054_190_006, "邮件不存在或已移动,请先同步"); + ErrorCode MAIL_FOLDER_NOT_AVAILABLE = new ErrorCode(1_054_190_007, "文件夹不可用,请先同步"); + ErrorCode MAIL_SYNC_COUNT_EXCEEDED = new ErrorCode(1_054_190_008, "单文件夹超过 {} 封,请先缩小邮箱范围后同步"); + ErrorCode MAIL_SYNC_CHANGED = new ErrorCode(1_054_190_009, "同步期间邮箱发生变化,请重新同步"); + ErrorCode MAIL_UID_EXPIRED = new ErrorCode(1_054_190_010, "邮件标识已变化,请先同步"); + ErrorCode MAIL_PERMANENT_DELETE_UNSUPPORTED = new ErrorCode(1_054_190_011, "服务商不支持安全的单封彻底删除,请使用原邮箱客户端"); + ErrorCode MAIL_TRASH_NOT_EXISTS = new ErrorCode(1_054_190_012, "未识别到已删除文件夹,请使用原邮箱客户端"); + ErrorCode MAIL_MOVE_UNSUPPORTED = new ErrorCode(1_054_190_013, "服务商不支持安全移动,请使用原邮箱客户端操作"); + ErrorCode MAIL_RECIPIENT_REQUIRED = new ErrorCode(1_054_190_014, "请填写收件人"); + ErrorCode MAIL_SUBJECT_INVALID = new ErrorCode(1_054_190_015, "主题不能包含换行"); + ErrorCode MAIL_ADDRESS_INVALID = new ErrorCode(1_054_190_016, "邮件地址格式不正确,请检查收件人和抄送人"); + ErrorCode MAIL_NOT_DRAFT = new ErrorCode(1_054_190_017, "邮件不是草稿"); + ErrorCode MAIL_SYNC_FAILED = new ErrorCode(1_054_190_018, "邮箱同步失败,本地快照未更新,请检查连接后重试"); + ErrorCode MAIL_DETAIL_READ_FAILED = new ErrorCode(1_054_190_019, "邮件详情读取失败,请先同步或使用原邮箱客户端"); + ErrorCode MAIL_READ_STATUS_FAILED = new ErrorCode(1_054_190_020, "已读状态更新失败,请同步确认远端状态"); + ErrorCode MAIL_DELETE_UNCONFIRMED = new ErrorCode(1_054_190_021, "删除结果未确认,请先同步核实,勿重复操作"); + ErrorCode MAIL_DRAFT_FOLDER_NOT_EXISTS = new ErrorCode(1_054_190_022, "未识别到草稿箱,请先在原邮箱配置草稿文件夹"); + ErrorCode MAIL_DRAFT_REPLACE_UNSUPPORTED = new ErrorCode(1_054_190_023, "服务商不支持草稿安全替换,请使用原邮箱客户端"); + ErrorCode MAIL_DRAFT_SAVE_UNCONFIRMED = new ErrorCode(1_054_190_024, "草稿保存结果未确认,请同步草稿箱后核实,勿重复保存"); + ErrorCode MAIL_DRAFT_INDEX_NOT_FOUND = new ErrorCode(1_054_190_025, "草稿已保存,请同步后查看"); + ErrorCode MAIL_SOURCE_NOT_OWNED = new ErrorCode(1_054_190_026, "原邮件不属于当前邮箱"); + ErrorCode MAIL_SEND_UNCONFIRMED = new ErrorCode(1_054_190_027, "发送失败或结果未知,请先在原邮箱确认,勿直接重发"); + ErrorCode MAIL_DRAFT_NOT_OWNED = new ErrorCode(1_054_190_028, "只能修改当前账号的草稿"); + ErrorCode MAIL_SOURCE_READ_FAILED = new ErrorCode(1_054_190_029, "原邮件或附件读取失败,未执行发送"); + ErrorCode MAIL_ATTACHMENT_NOT_EXISTS = new ErrorCode(1_054_190_031, "邮件附件不存在"); + ErrorCode MAIL_ATTACHMENT_READ_FAILED = new ErrorCode(1_054_190_032, "邮件附件读取失败,请同步邮件后重试"); + ErrorCode MAIL_RESTORE_UNCONFIRMED = new ErrorCode(1_054_190_034, "恢复结果未确认,请先同步核实"); + ErrorCode MAIL_NOT_TRASH = new ErrorCode(1_054_190_035, "只能恢复已删除文件夹中的邮件"); + ErrorCode MAIL_INBOX_READ_FAILED = new ErrorCode(1_054_190_030, "收件箱读取失败,请检查邮箱连接配置或稍后重试"); + + // ========== 办公用品 1-054-200-000 ========== + ErrorCode SUPPLY_ITEM_PENDING_ISSUE = new ErrorCode(1_054_200_004, "物品尚有待发放明细,不能删除"); + ErrorCode SUPPLY_ITEM_NOT_RETURNED = new ErrorCode(1_054_200_002, "物品尚有未归还数量,不能删除"); + ErrorCode SUPPLY_ITEM_NOT_EXISTS = new ErrorCode(1_054_200_000, "办公用品不存在"); + ErrorCode SUPPLY_ITEM_NO_DUPLICATE = new ErrorCode(1_054_200_001, "用品编号已存在"); + ErrorCode SUPPLY_STOCK_INSUFFICIENT = new ErrorCode(1_054_200_003, "用品库存不足"); + ErrorCode SUPPLY_APPLY_NOT_EXISTS = new ErrorCode(1_054_200_005, "用品申请不存在"); + ErrorCode SUPPLY_APPLY_ACCESS_DENIED = new ErrorCode(1_054_200_006, "无权操作该用品申请"); + ErrorCode SUPPLY_APPLY_STATUS_INVALID = new ErrorCode(1_054_200_007, "当前申请状态不允许该操作"); + ErrorCode SUPPLY_APPLY_ITEM_INVALID = new ErrorCode(1_054_200_008, "用品已停用或重复"); + ErrorCode SUPPLY_APPLY_NO_DUPLICATE = new ErrorCode(1_054_200_009, "用品申请单号已存在"); + ErrorCode SUPPLY_APPLY_QTY_INVALID = new ErrorCode(1_054_200_010, "操作数量超过申请或待归还数量"); + ErrorCode SUPPLY_ISSUE_NOT_EXISTS = new ErrorCode(1_054_200_011, "用品发放或归还记录不存在"); + + // ========== 印章管理 1-054-210-000 ========== + ErrorCode SEAL_IN_USE = new ErrorCode(1_054_210_002, "印章已有业务单据引用,不能删除"); + ErrorCode SEAL_NOT_EXISTS = new ErrorCode(1_054_210_000, "印章不存在"); + ErrorCode SEAL_APPLY_NOT_EXISTS = new ErrorCode(1_054_210_013, "用印申请不存在"); + ErrorCode SEAL_APPLY_NOT_OWNER = new ErrorCode(1_054_210_014, "只能操作本人的用印申请"); + ErrorCode SEAL_APPLY_STATUS_INVALID = new ErrorCode(1_054_210_015, "用印申请状态不允许该操作"); + ErrorCode SEAL_APPLY_TIME_INVALID = new ErrorCode(1_054_210_016, "用印或归还时间不正确"); + ErrorCode SEAL_APPLY_TIME_CONFLICT = new ErrorCode(1_054_210_017, "该印章存在冲突预约或尚未归还"); + ErrorCode SEAL_APPLY_NO_DUPLICATE = new ErrorCode(1_054_210_021, "用印申请单号已存在"); + ErrorCode SEAL_NO_DUPLICATE = new ErrorCode(1_054_210_001, "印章编号已存在"); + + // ========== 车辆管理 1-054-220-000 ========== + ErrorCode VEHICLE_IN_USE = new ErrorCode(1_054_220_019, "车辆已有业务单据引用,不能删除"); + ErrorCode VEHICLE_NOT_EXISTS = new ErrorCode(1_054_220_000, "车辆不存在"); + ErrorCode VEHICLE_NO_DUPLICATE = new ErrorCode(1_054_220_001, "车牌号已存在"); + ErrorCode VEHICLE_NOT_ENABLED = new ErrorCode(1_054_220_002, "车辆非空闲状态,不能申请用车"); + ErrorCode VEHICLE_APPLY_NOT_EXISTS = new ErrorCode(1_054_220_003, "用车申请不存在"); + ErrorCode VEHICLE_APPLY_NOT_OWNER = new ErrorCode(1_054_220_004, "只能操作本人的用车申请"); + ErrorCode VEHICLE_APPLY_STATUS_INVALID = new ErrorCode(1_054_220_005, "用车申请状态不允许此操作"); + ErrorCode VEHICLE_APPLY_TIME_INVALID = new ErrorCode(1_054_220_006, "出车时间必须早于回车时间"); + ErrorCode VEHICLE_APPLY_TIME_CONFLICT = new ErrorCode(1_054_220_007, "该时段车辆已被预约,请调整用车时间"); + ErrorCode VEHICLE_RETURN_NOT_EXISTS = new ErrorCode(1_054_220_009, "还车申请不存在"); + ErrorCode VEHICLE_RETURN_NOT_OWNER = new ErrorCode(1_054_220_010, "只能操作本人的还车申请"); + ErrorCode VEHICLE_RETURN_STATUS_INVALID = new ErrorCode(1_054_220_011, "当前申请状态不允许还车操作"); + ErrorCode VEHICLE_RETURN_TIME_INVALID = new ErrorCode(1_054_220_014, "请填写实际出车时间,回车时间不能早于实际出车时间或晚于当前时间"); + + ErrorCode VEHICLE_APPLY_NO_DUPLICATE = new ErrorCode(1_054_220_017, "用车申请单号已存在"); + + ErrorCode VEHICLE_RETURN_NO_DUPLICATE = new ErrorCode(1_054_220_018, "还车申请单号已存在"); + + // ========== 云盘文件 1-054-230-000 ========== + ErrorCode FILE_NODE_NOT_EXISTS = new ErrorCode(1_054_230_000, "文件或目录不存在"); + ErrorCode FILE_NODE_NOT_AVAILABLE = new ErrorCode(1_054_230_001, "文件或所属目录已在回收站"); + ErrorCode FILE_NODE_PATH_INVALID = new ErrorCode(1_054_230_002, "文件目录结构异常或所属目录已删除"); + ErrorCode FILE_NODE_ACCESS_DENIED = new ErrorCode(1_054_230_003, "无权操作该文件或目录"); + ErrorCode FILE_NODE_NAME_DUPLICATE = new ErrorCode(1_054_230_004, "当前目录已存在同名文件或目录"); + ErrorCode FILE_NODE_MOVE_INVALID = new ErrorCode(1_054_230_005, "不能移动到自身或子目录"); + ErrorCode FILE_NODE_NOT_RECYCLED = new ErrorCode(1_054_230_007, "仅回收站内的文件可以恢复或彻底删除"); + ErrorCode FILE_STORAGE_NOT_ENOUGH = new ErrorCode(1_054_230_009, "云盘剩余容量不足,请清理文件后重试"); + ErrorCode FILE_PERMISSION_NOT_EXISTS = new ErrorCode(1_054_230_008, "共享权限不存在"); + + // ========== 工作汇报 1-054-240-000 ========== + ErrorCode WORK_REPORT_NOT_EXISTS = new ErrorCode(1_054_240_000, "工作汇报不存在"); + ErrorCode WORK_REPORT_ACCESS_DENIED = new ErrorCode(1_054_240_001, "无权访问该工作汇报"); + ErrorCode WORK_REPORT_STATUS_INVALID = new ErrorCode(1_054_240_002, "当前工作汇报状态不允许该操作"); + ErrorCode WORK_REPORT_NO_DUPLICATE = new ErrorCode(1_054_240_005, "工作汇报单号已存在"); + + // ========== 会议室 1-054-250-000 ========== + ErrorCode MEETING_ROOM_NOT_EXISTS = new ErrorCode(1_054_250_000, "会议室不存在"); + ErrorCode MEETING_ROOM_NOT_AVAILABLE = new ErrorCode(1_054_250_001, "会议室当前不可预定"); + ErrorCode MEETING_ROOM_SCOPE_INVALID = new ErrorCode(1_054_250_002, "指定成员不能为空"); + ErrorCode MEETING_ROOM_BOOKING_NOT_EXISTS = new ErrorCode(1_054_250_003, "会议室预定不存在"); + ErrorCode MEETING_ROOM_BOOKING_NOT_OWNER = new ErrorCode(1_054_250_004, "无权操作该会议室预定"); + ErrorCode MEETING_ROOM_BOOKING_STATUS_INVALID = new ErrorCode(1_054_250_005, "当前预定状态不允许该操作"); + ErrorCode MEETING_ROOM_BOOKING_TIME_INVALID = new ErrorCode(1_054_250_006, "会议时间不正确"); + ErrorCode MEETING_ROOM_BOOKING_TIME_CONFLICT = new ErrorCode(1_054_250_007, "会议室在该时段已被预定"); + ErrorCode MEETING_ROOM_HAS_BOOKING = new ErrorCode(1_054_250_009, "会议室存在有效预约,不能删除"); + ErrorCode MEETING_ROOM_BOOKING_NO_DUPLICATE = new ErrorCode(1_054_250_010, "会议室预定单号已存在"); + + // ========== 公文管理 1-054-260-000 ========== + ErrorCode OFFICIAL_DOC_TEMPLATE_NOT_EXISTS = new ErrorCode(1_054_260_000, "套红模板不存在"); + ErrorCode OFFICIAL_DOC_NOT_EXISTS = new ErrorCode(1_054_260_001, "公文不存在"); + ErrorCode OFFICIAL_DOC_ACCESS_DENIED = new ErrorCode(1_054_260_002, "无权操作该公文"); + ErrorCode OFFICIAL_DOC_STATUS_INVALID = new ErrorCode(1_054_260_003, "当前公文状态不允许该操作"); + ErrorCode OFFICIAL_DOC_PROCESS_MISMATCH = new ErrorCode(1_054_260_004, "公文与流程实例不匹配"); + ErrorCode OFFICIAL_DOC_TEMPLATE_DISABLED = new ErrorCode(1_054_260_005, "套红模板已停用"); + ErrorCode OFFICIAL_DOC_SEND_NO_DUPLICATE = new ErrorCode(1_054_260_007, "发文单据编号已存在"); + ErrorCode OFFICIAL_DOC_RECEIVE_NO_DUPLICATE = new ErrorCode(1_054_260_006, "收文单据编号已存在"); + ErrorCode OFFICIAL_DOC_TEMPLATE_IN_USE = new ErrorCode(1_054_260_008, "套红模板已有业务单据引用,不能删除"); + ErrorCode OFFICIAL_DOC_SEND_DOCUMENT_NO_DUPLICATE = new ErrorCode(1_054_260_009, "发文字号已存在"); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/MessageTemplateConstants.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/MessageTemplateConstants.java new file mode 100644 index 0000000000..7f01ebf8c0 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/MessageTemplateConstants.java @@ -0,0 +1,15 @@ +package cn.iocoder.yudao.module.oa.enums; + +/** + * OA 消息模板常量 + * + * @author 芋道源码 + */ +public interface MessageTemplateConstants { + + // ======================= 站内信模板 ======================= + + String MEETING_ROOM_BOOKING_REMINDER = "oa_meeting_room_booking_reminder"; // 会议开始提醒 + String SCHEDULE_REMINDER = "oa_schedule_reminder"; // 日程开始提醒 + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/announcement/OaAnnouncementTypeEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/announcement/OaAnnouncementTypeEnum.java new file mode 100644 index 0000000000..2febd6a340 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/announcement/OaAnnouncementTypeEnum.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.oa.enums.announcement; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 公告类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaAnnouncementTypeEnum implements ArrayValuable { + + ANNOUNCEMENT(1, "公告"), + NOTICE(2, "通知"), + VOTE(3, "投票"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaAnnouncementTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/attendance/OaAttendanceStatusEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/attendance/OaAttendanceStatusEnum.java new file mode 100644 index 0000000000..8b2042ad24 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/attendance/OaAttendanceStatusEnum.java @@ -0,0 +1,41 @@ +package cn.iocoder.yudao.module.oa.enums.attendance; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 考勤状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaAttendanceStatusEnum implements ArrayValuable { + + NORMAL(1, "正常"), + LATE(2, "迟到"), + EARLY(3, "早退"), + LEAVE(4, "请假"), + TRAVEL(5, "出差"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaAttendanceStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 状态 + */ + private final Integer status; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/attendance/OaAttendanceTypeEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/attendance/OaAttendanceTypeEnum.java new file mode 100644 index 0000000000..02cbd6cd1e --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/attendance/OaAttendanceTypeEnum.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.oa.enums.attendance; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 考勤类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaAttendanceTypeEnum implements ArrayValuable { + + CLOCK_IN(1, "上班打卡"), + CLOCK_OUT(2, "下班打卡"), + LEAVE(3, "请假"), + TRAVEL(4, "出差"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaAttendanceTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/discussion/OaDiscussionTypeEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/discussion/OaDiscussionTypeEnum.java new file mode 100644 index 0000000000..ef50046352 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/discussion/OaDiscussionTypeEnum.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.oa.enums.discussion; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 讨论类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaDiscussionTypeEnum implements ArrayValuable { + + ANNOUNCEMENT(1, "公告"), + DISCUSSION(2, "讨论"), + VOTE(3, "投票"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaDiscussionTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFileCategoryEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFileCategoryEnum.java new file mode 100644 index 0000000000..d011e2d7fb --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFileCategoryEnum.java @@ -0,0 +1,84 @@ +package cn.iocoder.yudao.module.oa.enums.file; + +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; +import java.util.Locale; + +/** + * 云盘文件分类枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaFileCategoryEnum implements ArrayValuable { + + IMAGE(1, "图片", new String[]{"bmp", "gif", "jpeg", "jpg", "png", "svg", "webp", "ico", + "heic", "heif", "raw", "psd", "ai", "eps", "tif", "tiff", "avif"}), + DOCUMENT(2, "文档", new String[]{"doc", "docx", "pdf", "txt", "rtf", "odt", "ppt", "pptx", + "xls", "xlsx", "csv", "md", "html", "htm", "xml", "json", "yaml", "yml", "log", + "ods", "odp", "epub"}), + VIDEO(3, "视频", new String[]{"mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", "m4v", + "3gp", "rm", "rmvb", "mpg", "mpeg", "ts", "m2ts"}), + AUDIO(4, "音频", new String[]{"mp3", "wav", "flac", "aac", "ogg", "wma", "m4a", "ape", + "amr", "mid", "midi", "opus", "aif", "aiff"}), + ARCHIVE(5, "压缩包", new String[]{"zip", "rar", "7z", "tar", "gz", "bz2", "xz", "iso", + "dmg", "cab", "arj", "lzh", "tgz", "tbz2", "txz", "zst"}), + OTHER(6, "其他", new String[]{}); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaFileCategoryEnum::getCategory).toArray(Integer[]::new); + + /** + * 分类值 + */ + private final Integer category; + /** + * 分类名称 + */ + private final String name; + /** + * 文件扩展名列表,不含点号 + */ + private final String[] extensions; + + /** + * 根据分类值获得枚举 + * + * @param category 分类值 + * @return 分类枚举,不存在时返回 null + */ + public static OaFileCategoryEnum valueOf(Integer category) { + return ArrayUtil.firstMatch(item -> item.getCategory().equals(category), values()); + } + + /** + * 根据扩展名获得分类 + * + * @param extension 文件扩展名,兼容前导点号和大小写 + * @return 分类枚举,无扩展名或未匹配时返回其他 + */ + public static OaFileCategoryEnum getByExtension(String extension) { + if (StrUtil.isBlank(extension)) { + return OTHER; + } + String normalizedExtension = StrUtil.removePrefix(extension.trim(), ".").toLowerCase(Locale.ROOT); + for (OaFileCategoryEnum category : values()) { + if (Arrays.asList(category.getExtensions()).contains(normalizedExtension)) { + return category; + } + } + return OTHER; + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFileNodeStatusEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFileNodeStatusEnum.java new file mode 100644 index 0000000000..0fa07510eb --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFileNodeStatusEnum.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.oa.enums.file; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 云盘节点状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaFileNodeStatusEnum implements ArrayValuable { + + NORMAL(0, "正常"), + RECYCLED(1, "回收站"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaFileNodeStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 云盘节点状态 + */ + private final Integer status; + /** + * 名称 + */ + private final String name; + + /** + * 获得节点状态枚举 + * + * @param status 节点状态 + * @return 节点状态枚举,不存在时返回 null + */ + public static OaFileNodeStatusEnum valueOf(Integer status) { + return ArrayUtil.firstMatch(item -> item.getStatus().equals(status), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFileNodeTypeEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFileNodeTypeEnum.java new file mode 100644 index 0000000000..1f6c7a0475 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFileNodeTypeEnum.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.oa.enums.file; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 云盘节点类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaFileNodeTypeEnum implements ArrayValuable { + + FOLDER(0, "文件夹"), + FILE(1, "文件"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaFileNodeTypeEnum::getType).toArray(Integer[]::new); + + /** + * 云盘节点类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + /** + * 获得节点类型枚举 + * + * @param type 节点类型 + * @return 节点类型枚举,不存在时返回 null + */ + public static OaFileNodeTypeEnum valueOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFilePermissionLevelEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFilePermissionLevelEnum.java new file mode 100644 index 0000000000..aeb74e37d9 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFilePermissionLevelEnum.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.oa.enums.file; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 云盘权限级别枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaFilePermissionLevelEnum implements ArrayValuable { + + READ(1, "只读"), + DOWNLOAD(2, "下载"), + EDIT(3, "编辑"), + MANAGE(4, "管理"); + + /** + * 无可访问权限,仅用于权限计算,不作为可授予的权限级别 + */ + public static final int LEVEL_NONE = 0; + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaFilePermissionLevelEnum::getLevel).toArray(Integer[]::new); + + /** + * 云盘权限级别 + */ + private final Integer level; + /** + * 名称 + */ + private final String name; + + /** + * 获得权限级别枚举 + * + * @param level 权限级别 + * @return 权限级别枚举,不存在时返回 null + */ + public static OaFilePermissionLevelEnum valueOf(Integer level) { + return ArrayUtil.firstMatch(item -> item.getLevel().equals(level), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFileScopeEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFileScopeEnum.java new file mode 100644 index 0000000000..2c870187e3 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFileScopeEnum.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.oa.enums.file; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 云盘文件列表范围枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaFileScopeEnum implements ArrayValuable { + + MY("my", "我的文件"), + SHARED("shared", "共享文件"), + FAVORITE("favorite", "我的收藏"), + RECYCLE("recycle", "回收站"); + + public static final String[] ARRAYS = Arrays.stream(values()) + .map(OaFileScopeEnum::getScope).toArray(String[]::new); + + /** + * 列表范围 + */ + private final String scope; + /** + * 名称 + */ + private final String name; + + @Override + public String[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFileSubjectTypeEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFileSubjectTypeEnum.java new file mode 100644 index 0000000000..98d34697f8 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/file/OaFileSubjectTypeEnum.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.oa.enums.file; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 云盘共享主体类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaFileSubjectTypeEnum implements ArrayValuable { + + USER(1, "用户"), + DEPT(2, "部门"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaFileSubjectTypeEnum::getType).toArray(Integer[]::new); + + /** + * 云盘共享主体类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + /** + * 获得共享主体类型枚举 + * + * @param type 共享主体类型 + * @return 共享主体类型枚举,不存在时返回 null + */ + public static OaFileSubjectTypeEnum valueOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/leave/OaLeaveTypeEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/leave/OaLeaveTypeEnum.java new file mode 100644 index 0000000000..1977e9fe12 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/leave/OaLeaveTypeEnum.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.oa.enums.leave; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 请假类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaLeaveTypeEnum implements ArrayValuable { + + ANNUAL(1, "年假", null), + PERSONAL(2, "事假", 4), + SICK(3, "病假", null), + MARRIAGE(4, "婚假", 10), + MATERNITY(5, "产假及哺乳假", null), + PATERNITY(6, "陪产假", 10), + BEREAVEMENT(7, "丧假", null); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaLeaveTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + /** + * 单次申请天数上限,为空时不限制;不包含年度累计额度 + */ + private final Integer maxDays; + + public static OaLeaveTypeEnum valueOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/mail/OaMailCapabilityEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/mail/OaMailCapabilityEnum.java new file mode 100644 index 0000000000..f14f4bbf65 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/mail/OaMailCapabilityEnum.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.oa.enums.mail; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * IMAP 协议能力枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaMailCapabilityEnum implements ArrayValuable { + + UID_PLUS("UIDPLUS", "按 UID 操作"), + MOVE("MOVE", "移动邮件"), + ID("ID", "客户端身份"); + + public static final String[] ARRAYS = Arrays.stream(values()) + .map(OaMailCapabilityEnum::getValue).toArray(String[]::new); + + /** + * 值 + */ + private final String value; + /** + * 名称 + */ + private final String name; + + @Override + public String[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/mail/OaMailComposeModeEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/mail/OaMailComposeModeEnum.java new file mode 100644 index 0000000000..9eb6443300 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/mail/OaMailComposeModeEnum.java @@ -0,0 +1,41 @@ +package cn.iocoder.yudao.module.oa.enums.mail; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 邮件写信方式枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaMailComposeModeEnum implements ArrayValuable { + + NEW("new", "新邮件"), + DRAFT("draft", "编辑草稿"), + REPLY("reply", "回复"), + REPLY_ALL("replyAll", "回复全部"), + FORWARD("forward", "转发"); + + public static final String[] ARRAYS = Arrays.stream(values()) + .map(OaMailComposeModeEnum::getValue).toArray(String[]::new); + + /** + * 值 + */ + private final String value; + /** + * 名称 + */ + private final String name; + + @Override + public String[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/mail/OaMailFolderKeyEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/mail/OaMailFolderKeyEnum.java new file mode 100644 index 0000000000..8f646d9dd5 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/mail/OaMailFolderKeyEnum.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.oa.enums.mail; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 企业邮箱标准目录入口枚举 + * + * 未读邮件为虚拟入口,自定义目录使用数据库编号。 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaMailFolderKeyEnum implements ArrayValuable { + + INBOX("INBOX"), + UNREAD("UNREAD"), + SENT("SENT"), + DRAFTS("DRAFTS"), + TRASH("TRASH"); + + public static final String[] ARRAYS = Arrays.stream(values()) + .map(OaMailFolderKeyEnum::getKey).toArray(String[]::new); + + /** + * 入口标识 + */ + private final String key; + + @Override + public String[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/mail/OaMailFolderTypeEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/mail/OaMailFolderTypeEnum.java new file mode 100644 index 0000000000..ecae738905 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/mail/OaMailFolderTypeEnum.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.oa.enums.mail; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 企业邮箱文件夹类型枚举 + * + * 将服务商的远端目录映射为本地业务类型,未读邮件不属于实际文件夹。 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaMailFolderTypeEnum implements ArrayValuable { + + INBOX("INBOX", "收件箱"), + SENT("SENT", "已发送"), + DRAFTS("DRAFTS", "草稿箱"), + TRASH("TRASH", "已删除"), + CUSTOM("CUSTOM", "自定义文件夹"); + + public static final String[] ARRAYS = Arrays.stream(values()) + .map(OaMailFolderTypeEnum::getType).toArray(String[]::new); + + /** + * 类型 + */ + private final String type; + /** + * 名称 + */ + private final String name; + + /** + * 获得自定义目录的同步标识 + * + * @param fullName 远端完整路径 + * @return 同步标识 + */ + public static String getCustomKey(String fullName) { + return CUSTOM.getType() + ":" + fullName; + } + + @Override + public String[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/meetingroom/OaMeetingRoomBookingScopeEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/meetingroom/OaMeetingRoomBookingScopeEnum.java new file mode 100644 index 0000000000..f2fbda2dde --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/meetingroom/OaMeetingRoomBookingScopeEnum.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.oa.enums.meetingroom; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 会议室可预定范围枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaMeetingRoomBookingScopeEnum implements ArrayValuable { + + ALL(0, "全部成员"), + SPECIFIED(1, "指定成员"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaMeetingRoomBookingScopeEnum::getScope).toArray(Integer[]::new); + + /** + * 会议室可预定范围 + */ + private final Integer scope; + /** + * 名称 + */ + private final String name; + + public static OaMeetingRoomBookingScopeEnum valueOf(Integer scope) { + return ArrayUtil.firstMatch(item -> item.getScope().equals(scope), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/meetingroom/OaMeetingRoomReminderTypeEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/meetingroom/OaMeetingRoomReminderTypeEnum.java new file mode 100644 index 0000000000..9b7ec0660c --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/meetingroom/OaMeetingRoomReminderTypeEnum.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.oa.enums.meetingroom; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 会议提醒类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaMeetingRoomReminderTypeEnum implements ArrayValuable { + + NONE(1, "不提醒", null), + BEFORE_FIVE_MINUTES(2, "提前 5 分钟", 5), + BEFORE_TEN_MINUTES(3, "提前 10 分钟", 10), + BEFORE_FIFTEEN_MINUTES(4, "提前 15 分钟", 15), + BEFORE_THIRTY_MINUTES(5, "提前 30 分钟", 30); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaMeetingRoomReminderTypeEnum::getType).toArray(Integer[]::new); + + /** + * 会议提醒类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + /** + * 提前提醒分钟数,为 null 时不提醒 + */ + private final Integer minutes; + + /** + * 根据类型获得枚举 + * + * @param type 类型 + * @return 对应枚举,不存在时返回 null + */ + public static OaMeetingRoomReminderTypeEnum valueOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/meetingroom/OaMeetingRoomStatusEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/meetingroom/OaMeetingRoomStatusEnum.java new file mode 100644 index 0000000000..f4f66d628b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/meetingroom/OaMeetingRoomStatusEnum.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.oa.enums.meetingroom; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 会议室可用状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaMeetingRoomStatusEnum implements ArrayValuable { + + NORMAL(0, "正常"), + MAINTENANCE(1, "维修中"), + DISABLED(2, "不可用"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaMeetingRoomStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 会议室可用状态 + */ + private final Integer status; + /** + * 名称 + */ + private final String name; + + /** + * 根据状态获得枚举 + * + * @param status 状态 + * @return 对应枚举,不存在时返回 null + */ + public static OaMeetingRoomStatusEnum valueOf(Integer status) { + return ArrayUtil.firstMatch(item -> item.getStatus().equals(status), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/meetingroom/OaMeetingRoomTypeEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/meetingroom/OaMeetingRoomTypeEnum.java new file mode 100644 index 0000000000..7974150d6a --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/meetingroom/OaMeetingRoomTypeEnum.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.oa.enums.meetingroom; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 会议室类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaMeetingRoomTypeEnum implements ArrayValuable { + + INTERNAL(1, "内部会议室"), + EXTERNAL(2, "外部会议室"), + TRAINING(3, "培训室"), + MULTIFUNCTION(4, "多功能厅"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaMeetingRoomTypeEnum::getType).toArray(Integer[]::new); + + /** + * 会议室类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + /** + * 根据类型获得枚举 + * + * @param type 类型 + * @return 对应枚举,不存在时返回 null + */ + public static OaMeetingRoomTypeEnum valueOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/meetingroom/OaMeetingRoomUseStatusEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/meetingroom/OaMeetingRoomUseStatusEnum.java new file mode 100644 index 0000000000..aa6c6b9b95 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/meetingroom/OaMeetingRoomUseStatusEnum.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.oa.enums.meetingroom; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 会议室预定使用状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaMeetingRoomUseStatusEnum implements ArrayValuable { + + PENDING(0, "待使用"), + IN_USE(1, "使用中"), + COMPLETED(2, "已完成"), + CANCELLED(3, "已取消"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaMeetingRoomUseStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 会议室预定使用状态 + */ + private final Integer status; + /** + * 名称 + */ + private final String name; + + /** + * 根据状态获得枚举 + * + * @param status 状态 + * @return 对应枚举,不存在时返回 null + */ + public static OaMeetingRoomUseStatusEnum valueOf(Integer status) { + return ArrayUtil.firstMatch(item -> item.getStatus().equals(status), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/note/OaNoteTypeEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/note/OaNoteTypeEnum.java new file mode 100644 index 0000000000..f1009c6daa --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/note/OaNoteTypeEnum.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.oa.enums.note; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 笔记类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaNoteTypeEnum implements ArrayValuable { + + PRIVATE(1, "我的笔记"), + COMPANY(2, "公司笔记"), + SHARED(3, "共享笔记"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaNoteTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/officialdoc/OaOfficialDocHandleStatusEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/officialdoc/OaOfficialDocHandleStatusEnum.java new file mode 100644 index 0000000000..4eef0688bc --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/officialdoc/OaOfficialDocHandleStatusEnum.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.oa.enums.officialdoc; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 公文收文办理状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaOfficialDocHandleStatusEnum implements ArrayValuable { + + WAIT_CLAIM(0, "待签收"), + CLAIMED(1, "已签收"), + PROCESSING(2, "办理中"), + COMPLETED(3, "已办结"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaOfficialDocHandleStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 办理状态 + */ + private final Integer status; + /** + * 名称 + */ + private final String name; + + public static OaOfficialDocHandleStatusEnum valueOf(Integer status) { + return ArrayUtil.firstMatch(item -> item.getStatus().equals(status), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/officialdoc/OaOfficialDocReceiveTypeEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/officialdoc/OaOfficialDocReceiveTypeEnum.java new file mode 100644 index 0000000000..dcbfbb7ed6 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/officialdoc/OaOfficialDocReceiveTypeEnum.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.oa.enums.officialdoc; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 公文收文类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaOfficialDocReceiveTypeEnum implements ArrayValuable { + + /** + * 主送,需办理 + */ + MAIN(0, "主送"), + /** + * 抄送,仅知会 + */ + COPY(1, "抄送"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaOfficialDocReceiveTypeEnum::getType).toArray(Integer[]::new); + + /** + * 收文类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + public static OaOfficialDocReceiveTypeEnum valueOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/plan/OaPlanStatusEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/plan/OaPlanStatusEnum.java new file mode 100644 index 0000000000..933a071e35 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/plan/OaPlanStatusEnum.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.oa.enums.plan; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 工作计划状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaPlanStatusEnum implements ArrayValuable { + + UNFINISHED(1, "未完成"), + FINISHED(2, "已完成"), + CANCELED(3, "已取消"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaPlanStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 状态 + */ + private final Integer status; + + /** + * 名称 + */ + private final String name; + + public static OaPlanStatusEnum valueOf(Integer status) { + return ArrayUtil.firstMatch(item -> item.getStatus().equals(status), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/plan/OaPlanTypeEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/plan/OaPlanTypeEnum.java new file mode 100644 index 0000000000..7962864d25 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/plan/OaPlanTypeEnum.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.oa.enums.plan; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 工作计划类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaPlanTypeEnum implements ArrayValuable { + + DAY(1, "日计划"), + WEEK(2, "周计划"), + MONTH(3, "月计划"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaPlanTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + + /** + * 名称 + */ + private final String name; + + public static OaPlanTypeEnum valueOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/schedule/OaPriorityEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/schedule/OaPriorityEnum.java new file mode 100644 index 0000000000..a5857ca4ea --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/schedule/OaPriorityEnum.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.oa.enums.schedule; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * OA 优先级枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaPriorityEnum implements ArrayValuable { + + NORMAL(1, "一般"), + IMPORTANT(2, "重要"), + URGENT(3, "紧急"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaPriorityEnum::getPriority).toArray(Integer[]::new); + + /** + * 优先级 + */ + private final Integer priority; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/schedule/OaScheduleTypeEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/schedule/OaScheduleTypeEnum.java new file mode 100644 index 0000000000..5b6f4c5bf0 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/schedule/OaScheduleTypeEnum.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.module.oa.enums.schedule; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 日程类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaScheduleTypeEnum implements ArrayValuable { + + REMINDER(1, "日程提醒"), + HOLIDAY(2, "假日安排"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaScheduleTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/seal/OaSealApplyTypeEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/seal/OaSealApplyTypeEnum.java new file mode 100644 index 0000000000..82dd6e438a --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/seal/OaSealApplyTypeEnum.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.oa.enums.seal; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 用印类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaSealApplyTypeEnum implements ArrayValuable { + + CONTRACT(1, "合同用印"), + AGREEMENT(2, "协议用印"), + CERTIFICATE(3, "证明用印"), + AUTHORIZATION(4, "授权用印"), + OTHER(5, "其他用印"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaSealApplyTypeEnum::getType).toArray(Integer[]::new); + + /** + * 用印类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + public static OaSealApplyTypeEnum valueOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/seal/OaSealStatusEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/seal/OaSealStatusEnum.java new file mode 100644 index 0000000000..492725ca6b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/seal/OaSealStatusEnum.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.oa.enums.seal; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 印章台账状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaSealStatusEnum implements ArrayValuable { + + AVAILABLE(0, "在库"), + DISABLED(1, "停用"), + IN_USE(2, "使用中"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaSealStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 印章台账状态 + */ + private final Integer status; + /** + * 名称 + */ + private final String name; + + public static OaSealStatusEnum valueOf(Integer status) { + return ArrayUtil.firstMatch(item -> item.getStatus().equals(status), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/seal/OaSealUseModeEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/seal/OaSealUseModeEnum.java new file mode 100644 index 0000000000..a3d2deb525 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/seal/OaSealUseModeEnum.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.oa.enums.seal; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 用印方式枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaSealUseModeEnum implements ArrayValuable { + + ONSITE(1, "现场用印"), + BORROW(2, "外借"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaSealUseModeEnum::getMode).toArray(Integer[]::new); + + /** + * 用印方式 + */ + private final Integer mode; + /** + * 名称 + */ + private final String name; + + public static OaSealUseModeEnum valueOf(Integer mode) { + return ArrayUtil.firstMatch(item -> item.getMode().equals(mode), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/seal/OaSealUseStatusEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/seal/OaSealUseStatusEnum.java new file mode 100644 index 0000000000..42ceb0cc16 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/seal/OaSealUseStatusEnum.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.oa.enums.seal; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 用印业务状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaSealUseStatusEnum implements ArrayValuable { + + PENDING(0, "待处理"), + COMPLETED(1, "已完成"), + BORROWED(2, "外借中"), + RETURNED(3, "已归还"), + OVERDUE(4, "已逾期"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaSealUseStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 用印业务状态 + */ + private final Integer status; + /** + * 名称 + */ + private final String name; + + public static OaSealUseStatusEnum valueOf(Integer status) { + return ArrayUtil.firstMatch(item -> item.getStatus().equals(status), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/supply/OaSupplyApplyItemStatusEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/supply/OaSupplyApplyItemStatusEnum.java new file mode 100644 index 0000000000..ccba4d6877 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/supply/OaSupplyApplyItemStatusEnum.java @@ -0,0 +1,41 @@ +package cn.iocoder.yudao.module.oa.enums.supply; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 办公用品申请明细状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaSupplyApplyItemStatusEnum implements ArrayValuable { + + APPLYING(-1, "申请中"), + PENDING(0, "待发放"), + RECEIVED(1, "已领用"), + RETURN_PENDING(2, "待归还"), + RETURNED(3, "已归还"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaSupplyApplyItemStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 状态 + */ + private final Integer status; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/supply/OaSupplyCategoryEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/supply/OaSupplyCategoryEnum.java new file mode 100644 index 0000000000..04d2825118 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/supply/OaSupplyCategoryEnum.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.oa.enums.supply; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 办公用品分类枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaSupplyCategoryEnum implements ArrayValuable { + + STATIONERY(1, "文具类"), + PRINTING_CONSUMABLE(2, "打印耗材"), + DAILY_NECESSITY(3, "生活用品"), + COMPUTER_ACCESSORY(4, "电脑办公"), + OFFICE_EQUIPMENT(5, "办公设备"), + ELECTRICAL_APPLIANCE(7, "电器"), + FINANCIAL_SUPPLY(6, "财务用品"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaSupplyCategoryEnum::getCategory).toArray(Integer[]::new); + + /** + * 分类 + */ + private final Integer category; + /** + * 名称 + */ + private final String name; + + /** + * 根据分类获得枚举 + * + * @param category 分类 + * @return 对应枚举,不存在时返回 null + */ + public static OaSupplyCategoryEnum valueOf(Integer category) { + return ArrayUtil.firstMatch(item -> item.getCategory().equals(category), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/supply/OaSupplyManageTypeEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/supply/OaSupplyManageTypeEnum.java new file mode 100644 index 0000000000..9957253ae3 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/supply/OaSupplyManageTypeEnum.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.oa.enums.supply; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 用品管理类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaSupplyManageTypeEnum implements ArrayValuable { + + CONSUMABLE(1, "消耗品"), + BORROWABLE(2, "借用品"), + ASSET_CANDIDATE(3, "资产品"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaSupplyManageTypeEnum::getType).toArray(Integer[]::new); + + /** + * 用品管理类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + public static OaSupplyManageTypeEnum valueOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/supply/OaSupplyUseTypeEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/supply/OaSupplyUseTypeEnum.java new file mode 100644 index 0000000000..17d304ae01 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/supply/OaSupplyUseTypeEnum.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.oa.enums.supply; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 用品使用类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaSupplyUseTypeEnum implements ArrayValuable { + + RECEIVE(1, "领用"), + BORROW(2, "借用"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaSupplyUseTypeEnum::getType).toArray(Integer[]::new); + + /** + * 用品使用类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + public static OaSupplyUseTypeEnum valueOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/task/OaTaskStatusEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/task/OaTaskStatusEnum.java new file mode 100644 index 0000000000..c10f9cbc40 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/task/OaTaskStatusEnum.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.oa.enums.task; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 任务状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaTaskStatusEnum implements ArrayValuable { + + NEW(1, "新任务", 20), + RECEIVED(2, "已接收", 40), + IN_PROGRESS(3, "进行中", 60), + SUBMITTED(4, "已提交", 80), + COMPLETED(5, "已完成", 100); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaTaskStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 状态 + */ + private final Integer status; + /** + * 名称 + */ + private final String name; + /** + * 完成进度 + */ + private final Integer progress; + + public static OaTaskStatusEnum valueOf(Integer status) { + return ArrayUtil.firstMatch(item -> item.getStatus().equals(status), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/task/OaTaskTypeEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/task/OaTaskTypeEnum.java new file mode 100644 index 0000000000..9cf21e3efe --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/task/OaTaskTypeEnum.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.module.oa.enums.task; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 任务类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaTaskTypeEnum implements ArrayValuable { + + WORK(1, "公事"), + PERSONAL(2, "私事"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaTaskTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/vehicle/OaVehicleReturnStatusEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/vehicle/OaVehicleReturnStatusEnum.java new file mode 100644 index 0000000000..a70fb9dded --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/vehicle/OaVehicleReturnStatusEnum.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.oa.enums.vehicle; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 车辆归还状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaVehicleReturnStatusEnum implements ArrayValuable { + + NOT_EFFECTIVE(0, "未生效"), + PENDING_RETURN(1, "待还车"), + RETURNING(2, "还车中"), + RETURNED(3, "已还车"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaVehicleReturnStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 车辆归还状态 + */ + private final Integer status; + /** + * 名称 + */ + private final String name; + + public static OaVehicleReturnStatusEnum valueOf(Integer status) { + return ArrayUtil.firstMatch(item -> item.getStatus().equals(status), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/vehicle/OaVehicleStatusEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/vehicle/OaVehicleStatusEnum.java new file mode 100644 index 0000000000..b11a4eae89 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/vehicle/OaVehicleStatusEnum.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.oa.enums.vehicle; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 车辆台账状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaVehicleStatusEnum implements ArrayValuable { + + IDLE(0, "空闲"), + DISABLED(1, "停用"), + IN_USE(2, "使用中"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaVehicleStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 车辆台账状态 + */ + private final Integer status; + /** + * 名称 + */ + private final String name; + + public static OaVehicleStatusEnum valueOf(Integer status) { + return ArrayUtil.firstMatch(item -> item.getStatus().equals(status), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/workreport/OaWorkReportStatusEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/workreport/OaWorkReportStatusEnum.java new file mode 100644 index 0000000000..6e7e183c52 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/workreport/OaWorkReportStatusEnum.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.oa.enums.workreport; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 工作汇报状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaWorkReportStatusEnum implements ArrayValuable { + + DRAFT(1, "草稿"), + SUBMITTED(2, "已提交"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaWorkReportStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 状态 + */ + private final Integer status; + /** + * 名称 + */ + private final String name; + + /** + * 获得指定状态对应的枚举 + * + * @param status 状态 + * @return 对应枚举,未匹配时返回 null + */ + public static OaWorkReportStatusEnum valueOf(Integer status) { + return ArrayUtil.firstMatch(item -> item.getStatus().equals(status), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/workreport/OaWorkReportTypeEnum.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/workreport/OaWorkReportTypeEnum.java new file mode 100644 index 0000000000..860e96cd25 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/enums/workreport/OaWorkReportTypeEnum.java @@ -0,0 +1,78 @@ +package cn.iocoder.yudao.module.oa.enums.workreport; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.YearMonth; +import java.time.DayOfWeek; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAdjusters; + +/** + * 工作汇报类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum OaWorkReportTypeEnum implements ArrayValuable { + + DAILY(1, "日报"), + WEEKLY(2, "周报"), + MONTHLY(3, "月报"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(OaWorkReportTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + /** + * 获得指定类型对应的枚举 + * + * @param type 类型 + * @return 对应枚举,未匹配时返回 null + */ + public static OaWorkReportTypeEnum valueOf(Integer type) { + return ArrayUtil.firstMatch(item -> item.getType().equals(type), values()); + } + + @Override + public Integer[] array() { + return ARRAYS; + } + + /** + * 按汇报类型格式化所属周期,仅用于展示与统计,不单独存储。 + * + * @param startTime 汇报开始时间 + * @return 日期、自然年周次或月份 + */ + public String formatPeriod(LocalDateTime startTime) { + if (this == DAILY) { + return startTime.toLocalDate().toString(); + } + if (this == WEEKLY) { + LocalDateTime mondayTime = startTime.with(LocalTime.MIN) + .with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + // 跨年同一周统一归到包含 1 月 1 日的年份,避免一周被统计两次 + int year = mondayTime.plusDays(6).getYear(); + LocalDateTime firstMondayTime = LocalDateTime.of(year, 1, 1, 0, 0) + .with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + return String.format("%d-%02d", year, ChronoUnit.WEEKS.between(firstMondayTime, mondayTime) + 1); + } + return YearMonth.from(startTime).toString(); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/framework/config/OaProperties.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/framework/config/OaProperties.java new file mode 100644 index 0000000000..1e4b9c12f3 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/framework/config/OaProperties.java @@ -0,0 +1,110 @@ +package cn.iocoder.yudao.module.oa.framework.config; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalTime; + +/** + * OA 模块配置 + * + * @author 芋道源码 + */ +@Component +@ConfigurationProperties(prefix = "yudao.oa") +@Validated +@Data +public class OaProperties { + + /** + * 考勤配置 + */ + @Valid + private Attendance attendance = new Attendance(); + + /** + * 日程配置 + */ + @Valid + private Schedule schedule = new Schedule(); + + /** + * 云盘配置 + */ + @Valid + @NotNull(message = "云盘配置不能为空") + private File file = new File(); + + /** + * 云盘配置 + */ + @Data + public static class File { + + /** + * 默认个人云盘容量,单位字节(5 GB) + */ + @NotNull(message = "云盘容量不能为空") + @Min(value = 1, message = "云盘容量必须大于 0") + private Long storageSize = 5L * 1024 * 1024 * 1024; + + /** + * 下载地址有效期,单位秒 + */ + @NotNull(message = "下载地址有效期不能为空") + @Min(value = 1, message = "下载地址有效期必须大于 0") + private Integer downloadUrlExpireSeconds = 300; + + } + + /** + * 日程配置 + */ + @Data + public static class Schedule { + + /** + * 提前提醒小时数 + */ + @Min(value = 1, message = "日程提前提醒小时数必须大于 0") + private Integer remindBeforeHours = 24; + + } + + /** + * 考勤配置 + */ + @Data + public static class Attendance { + + /** + * 月报应出勤天数 + */ + @NotNull(message = "月报应出勤天数不能为空") + @Min(value = 1, message = "月报应出勤天数必须大于 0") + private Integer monthlyWorkDays = 22; + + /** + * 每日最早打卡时间(不包含) + */ + @NotNull(message = "每日最早打卡时间不能为空") + private LocalTime clockBeginTime = LocalTime.of(5, 0); + /** + * 标准上班时间 + */ + @NotNull(message = "标准上班时间不能为空") + private LocalTime workBeginTime = LocalTime.of(8, 0); + /** + * 标准下班时间,同时作为每日打卡截止时间(不包含) + */ + @NotNull(message = "标准下班时间不能为空") + private LocalTime workEndTime = LocalTime.of(17, 0); + + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/framework/package-info.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/framework/package-info.java new file mode 100644 index 0000000000..18eaf3dfb4 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/framework/package-info.java @@ -0,0 +1,6 @@ +/** + * 属于 oa 模块的 framework 封装 + * + * @author 芋道源码 + */ +package cn.iocoder.yudao.module.oa.framework; diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingReminderJob.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingReminderJob.java new file mode 100644 index 0000000000..842650c8ec --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingReminderJob.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.oa.job.meetingroom; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler; +import cn.iocoder.yudao.framework.tenant.core.job.TenantJob; +import cn.iocoder.yudao.module.oa.service.meetingroom.OaMeetingRoomBookingService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +// TODO @AI:是不是分开 job 噢?因为毕竟是 remind; +/** + * OA 会议开始提醒与过期预定处理 Job + * + * @author 芋道源码 + */ +@Component +public class OaMeetingRoomBookingReminderJob implements JobHandler { + + @Resource + private OaMeetingRoomBookingService meetingRoomBookingService; + + @Override + @TenantJob + public String execute(String param) { + int expiredCount = meetingRoomBookingService.cancelExpiredMeetingRoomBookings(); + int count = meetingRoomBookingService.sendMeetingRoomBookingReminders(); + return StrUtil.format("取消过期未开始会议 {} 条,发送会议提醒 {} 条", expiredCount, count); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/package-info.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/package-info.java new file mode 100644 index 0000000000..78a0a9d548 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/package-info.java @@ -0,0 +1,4 @@ +/** + * 定时任务 + */ +package cn.iocoder.yudao.module.oa.job; diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/schedule/OaScheduleReminderJob.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/schedule/OaScheduleReminderJob.java new file mode 100644 index 0000000000..8085e1d23e --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/schedule/OaScheduleReminderJob.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.oa.job.schedule; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler; +import cn.iocoder.yudao.framework.tenant.core.job.TenantJob; +import cn.iocoder.yudao.module.oa.service.schedule.OaScheduleService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +/** + * OA 日程提醒 Job + * + * @author 芋道源码 + */ +@Component +public class OaScheduleReminderJob implements JobHandler { + + @Resource + private OaScheduleService scheduleService; + + @Override + @TenantJob + public String execute(String param) { + int count = scheduleService.sendScheduleReminders(); + return StrUtil.format("发送日程提醒 {} 条", count); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/package-info.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/package-info.java new file mode 100644 index 0000000000..6ebbd62c72 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/package-info.java @@ -0,0 +1,10 @@ +/** + * oa 包下,办公自动化(Office Automation),提供协同办公、行政管理和业务审批功能。 + * 例如说:日程计划、企业云盘、请假报销、会议室、车辆、印章、公文等等 + * + * 1. Controller URL:以 /oa/ 开头,避免和其它 Module 冲突 + * 2. DataObject 表名:以 oa_ 开头,方便在数据库中区分 + * + * 注意,由于 Oa 模块下,容易和其它模块重名,所以类名都加上 Oa 的前缀 + */ +package cn.iocoder.yudao.module.oa; diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/announcement/OaAnnouncementService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/announcement/OaAnnouncementService.java new file mode 100644 index 0000000000..12ec846a0e --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/announcement/OaAnnouncementService.java @@ -0,0 +1,117 @@ +package cn.iocoder.yudao.module.oa.service.announcement; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.announcement.vo.OaAnnouncementPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.announcement.vo.OaAnnouncementSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.announcement.OaAnnouncementDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.announcement.OaAnnouncementReceiverDO; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap; + +/** + * OA 公告 Service 接口 + * + * @author 芋道源码 + */ +public interface OaAnnouncementService { + + /** + * 创建公告 + * + * @param createReqVO 创建信息 + * @param userId 用户编号 + * @return 公告编号 + */ + Long createAnnouncement(OaAnnouncementSaveReqVO createReqVO, Long userId); + + /** + * 更新公告 + * + * @param updateReqVO 更新信息 + * @param userId 用户编号 + */ + void updateAnnouncement(OaAnnouncementSaveReqVO updateReqVO, Long userId); + + /** + * 删除发布的公告 + * + * @param id 公告编号 + * @param userId 用户编号 + */ + void deleteAnnouncement(Long id, Long userId); + + /** + * 删除接收的公告 + * + * @param id 公告编号 + * @param userId 用户编号 + */ + void deleteReceivedAnnouncement(Long id, Long userId); + + /** + * 获得公告 + * + * @param id 公告编号 + * @param userId 用户编号 + * @return 公告 + */ + OaAnnouncementDO getAnnouncement(Long id, Long userId); + + /** + * 获得发布的公告分页 + * + * @param pageReqVO 分页查询 + * @param userId 用户编号 + * @return 公告分页 + */ + PageResult getPublishedAnnouncementPage(OaAnnouncementPageReqVO pageReqVO, Long userId); + + /** + * 获得接收的公告分页 + * + * @param pageReqVO 分页查询 + * @param userId 用户编号 + * @return 公告分页 + */ + PageResult getReceivedAnnouncementPage(OaAnnouncementPageReqVO pageReqVO, Long userId); + + /** + * 标记公告为已读 + * + * @param id 公告编号 + * @param userId 用户编号 + */ + void updateAnnouncementReadStatus(Long id, Long userId); + + /** + * 转发公告给当前用户的直属下属 + * + * @param id 公告编号 + * @param userId 用户编号 + * @return 新增接收人数 + */ + int forwardAnnouncement(Long id, Long userId); + + /** + * 获得公告接收关系 Map + * + * @param announcementIds 公告编号集合 + * @return 公告编号与接收关系列表的 Map + */ + default Map> getAnnouncementReceiverListMap(Collection announcementIds) { + return convertMultiMap(getAnnouncementReceiverList(announcementIds), OaAnnouncementReceiverDO::getAnnouncementId); + } + + /** + * 获得公告接收关系列表 + * + * @param announcementIds 公告编号集合 + * @return 公告接收关系列表 + */ + List getAnnouncementReceiverList(Collection announcementIds); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/announcement/OaAnnouncementServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/announcement/OaAnnouncementServiceImpl.java new file mode 100644 index 0000000000..fcd20adc3d --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/announcement/OaAnnouncementServiceImpl.java @@ -0,0 +1,235 @@ +package cn.iocoder.yudao.module.oa.service.announcement; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.announcement.vo.OaAnnouncementPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.announcement.vo.OaAnnouncementSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.announcement.OaAnnouncementDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.announcement.OaAnnouncementReceiverDO; +import cn.iocoder.yudao.module.oa.dal.mysql.announcement.OaAnnouncementMapper; +import cn.iocoder.yudao.module.oa.dal.mysql.announcement.OaAnnouncementReceiverMapper; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.ANNOUNCEMENT_ACCESS_DENIED; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.ANNOUNCEMENT_ALREADY_FORWARDED; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.ANNOUNCEMENT_NOT_EXISTS; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.ANNOUNCEMENT_RECEIVER_NOT_EXISTS; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.ANNOUNCEMENT_UNREAD_DELETE_DENIED; + +/** + * OA 公告 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaAnnouncementServiceImpl implements OaAnnouncementService { + + @Resource + private OaAnnouncementMapper announcementMapper; + @Resource + private OaAnnouncementReceiverMapper announcementReceiverMapper; + + @Resource + private AdminUserApi adminUserApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createAnnouncement(OaAnnouncementSaveReqVO createReqVO, Long userId) { + // 1. 查询发布人负责部门及其子部门下的用户,作为公告接收人 + List receiverUserIds = convertList(adminUserApi.getUserListBySubordinate(userId), AdminUserRespDTO::getId); + + // 2. 新增公告 + OaAnnouncementDO announcement = BeanUtils.toBean(createReqVO, OaAnnouncementDO.class); + announcementMapper.insert(announcement); + + // 3. 新增公告接收关系,接收人在公告列表查看,不额外发送站内信 + createAnnouncementReceiverList(announcement.getId(), receiverUserIds); + return announcement.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateAnnouncement(OaAnnouncementSaveReqVO updateReqVO, Long userId) { + // 1. 校验公告属于当前发布人 + validateAnnouncementPublisher(updateReqVO.getId(), userId); + + // 2. 更新公告 + announcementMapper.updateById(BeanUtils.toBean(updateReqVO, OaAnnouncementDO.class)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteAnnouncement(Long id, Long userId) { + // 1. 校验公告属于当前发布人 + validateAnnouncementPublisher(id, userId); + + // 2. 删除公告接收关系和公告 + announcementReceiverMapper.deleteByAnnouncementId(id); + announcementMapper.deleteById(id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteReceivedAnnouncement(Long id, Long userId) { + // 1.1 校验公告存在 + validateAnnouncementExists(id); + // 1.2 校验当前用户是公告接收人 + OaAnnouncementReceiverDO receiver = validateAnnouncementReceiver(id, userId); + // 1.3 阅读后才允许移除 + if (Boolean.FALSE.equals(receiver.getReadStatus())) { + throw exception(ANNOUNCEMENT_UNREAD_DELETE_DENIED); + } + + // 2. 仅删除当前用户的接收关系,不影响公告及其他接收人 + announcementReceiverMapper.deleteByIdPhysical(receiver.getId()); + } + + @Override + public OaAnnouncementDO getAnnouncement(Long id, Long userId) { + OaAnnouncementDO announcement = validateAnnouncementExists(id); + if (ObjUtil.notEqual(announcement.getCreator(), userId.toString()) + && announcementReceiverMapper.selectByAnnouncementIdAndReceiverUserId(id, userId) == null) { + throw exception(ANNOUNCEMENT_ACCESS_DENIED); + } + return announcement; + } + + @Override + public PageResult getPublishedAnnouncementPage(OaAnnouncementPageReqVO pageReqVO, Long userId) { + return announcementMapper.selectPage(pageReqVO, userId); + } + + @Override + public PageResult getReceivedAnnouncementPage(OaAnnouncementPageReqVO pageReqVO, Long userId) { + return announcementMapper.selectReceivedPage(pageReqVO, userId); + } + + @Override + public void updateAnnouncementReadStatus(Long id, Long userId) { + // 1.1 校验公告存在 + validateAnnouncementExists(id); + // 1.2 校验当前用户是公告接收人 + OaAnnouncementReceiverDO receiver = validateAnnouncementReceiver(id, userId); + // 1.3 已读公告无需重复更新 + if (Boolean.TRUE.equals(receiver.getReadStatus())) { + return; + } + + // 2. 标记公告为已读 + announcementReceiverMapper.updateById(new OaAnnouncementReceiverDO() + .setId(receiver.getId()).setReadStatus(true)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int forwardAnnouncement(Long id, Long userId) { + // 1.1 锁定公告,串行处理同一公告的转发,避免并发请求同时通过未转发校验 + if (announcementMapper.selectByIdForUpdate(id) == null) { + throw exception(ANNOUNCEMENT_NOT_EXISTS); + } + // 1.2 校验当前用户的接收关系 + validateAnnouncementReceiver(id, userId); + // 1.3 查询当前用户的下属 + List subordinateUserIds = convertList(adminUserApi.getUserListBySubordinate(userId), AdminUserRespDTO::getId); + if (CollUtil.isEmpty(subordinateUserIds)) { + return 0; + } + // 1.4 与列表已转发状态一致:任一下属已经接收,就不允许再次转发 + List receivers = announcementReceiverMapper.selectListByAnnouncementId(id); + Set existingReceiverUserIds = convertSet(receivers, + OaAnnouncementReceiverDO::getReceiverUserId); + if (CollUtil.containsAny(subordinateUserIds, existingReceiverUserIds)) { + throw exception(ANNOUNCEMENT_ALREADY_FORWARDED); + } + + // 2. 新增下属的公告接收关系 + createAnnouncementReceiverList(id, subordinateUserIds); + return subordinateUserIds.size(); + } + + @Override + public List getAnnouncementReceiverList(Collection announcementIds) { + if (CollUtil.isEmpty(announcementIds)) { + return Collections.emptyList(); + } + return announcementReceiverMapper.selectListByAnnouncementIds(announcementIds); + } + + /** + * 新增公告接收关系 + * + * @param announcementId 公告编号 + * @param receiverUserIds 接收人用户编号列表 + */ + private void createAnnouncementReceiverList(Long announcementId, Collection receiverUserIds) { + if (CollUtil.isEmpty(receiverUserIds)) { + return; + } + List receivers = convertList(receiverUserIds, + receiverUserId -> new OaAnnouncementReceiverDO().setAnnouncementId(announcementId) + .setReceiverUserId(receiverUserId).setReadStatus(false)); + announcementReceiverMapper.insertBatch(receivers); + } + + /** + * 校验公告属于指定发布人 + * + * @param id 公告编号 + * @param userId 发布人用户编号 + * @return 公告 + */ + @SuppressWarnings("UnusedReturnValue") + private OaAnnouncementDO validateAnnouncementPublisher(Long id, Long userId) { + OaAnnouncementDO announcement = validateAnnouncementExists(id); + if (ObjUtil.notEqual(announcement.getCreator(), userId.toString())) { + throw exception(ANNOUNCEMENT_ACCESS_DENIED); + } + return announcement; + } + + /** + * 校验当前用户是公告接收人 + * + * @param announcementId 公告编号 + * @param userId 用户编号 + * @return 公告接收关系 + */ + private OaAnnouncementReceiverDO validateAnnouncementReceiver(Long announcementId, Long userId) { + OaAnnouncementReceiverDO receiver = announcementReceiverMapper.selectByAnnouncementIdAndReceiverUserId(announcementId, userId); + if (receiver == null) { + throw exception(ANNOUNCEMENT_RECEIVER_NOT_EXISTS); + } + return receiver; + } + + /** + * 校验公告是否存在 + * + * @param id 公告编号 + * @return 公告 + */ + private OaAnnouncementDO validateAnnouncementExists(Long id) { + OaAnnouncementDO announcement = announcementMapper.selectById(id); + if (announcement == null) { + throw exception(ANNOUNCEMENT_NOT_EXISTS); + } + return announcement; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/attendance/OaAttendanceService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/attendance/OaAttendanceService.java new file mode 100644 index 0000000000..66d2480c8e --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/attendance/OaAttendanceService.java @@ -0,0 +1,110 @@ +package cn.iocoder.yudao.module.oa.service.attendance; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.enums.attendance.OaAttendanceTypeEnum; +import java.time.LocalDateTime; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceMonthReportReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceMonthReportRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendancePageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceUpdateReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceWeekReportReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceWeekReportRespVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.attendance.OaAttendanceDO; + +import java.util.List; + +/** + * OA 考勤 Service 接口 + * + * @author 芋道源码 + */ +public interface OaAttendanceService { + + /** + * 审批通过后新增请假或出差考勤明细 + * + * @param userId 申请人编号 + * @param type 请假或出差类型 + * @param startTime 申请开始时间 + */ + void createApplyAttendance(Long userId, OaAttendanceTypeEnum type, LocalDateTime startTime); + + /** + * 执行当前用户打卡 + * + * @param userId 用户编号 + * @param attendanceIp 考勤 IP + * @return 考勤记录编号 + */ + Long clockAttendance(Long userId, String attendanceIp); + + /** + * 更新考勤记录 + * + * @param updateReqVO 修改信息 + * @param operatorId 当前操作用户编号 + */ + void updateAttendance(OaAttendanceUpdateReqVO updateReqVO, Long operatorId); + + /** + * 删除考勤记录 + * + * @param id 考勤记录编号 + * @param operatorId 当前操作用户编号 + */ + void deleteAttendance(Long id, Long operatorId); + + /** + * 获得考勤记录 + * + * @param id 考勤记录编号 + * @param operatorId 当前操作用户编号 + * @return 考勤记录 + */ + OaAttendanceDO getAttendance(Long id, Long operatorId); + + /** + * 获得考勤分页列表 + * + * @param pageReqVO 分页条件 + * @param operatorId 当前操作用户编号 + * @return 考勤分页 + */ + PageResult getAttendancePage(OaAttendancePageReqVO pageReqVO, Long operatorId); + + /** + * 获得指定用户的考勤分页列表 + * + * @param pageReqVO 分页条件 + * @param userId 用户编号 + * @return 考勤分页 + */ + PageResult getMyAttendancePage(OaAttendancePageReqVO pageReqVO, Long userId); + + /** + * 获得用户当天考勤列表 + * + * @param userId 用户编号 + * @return 当天考勤列表 + */ + List getTodayAttendanceList(Long userId); + + /** + * 获得考勤周报 + * + * @param reqVO 查询条件 + * @param operatorId 当前操作用户编号 + * @return 考勤周报 + */ + List getAttendanceWeekReport(OaAttendanceWeekReportReqVO reqVO, Long operatorId); + + /** + * 获得考勤月报 + * + * @param reqVO 查询条件 + * @param operatorId 当前操作用户编号 + * @return 考勤月报 + */ + List getAttendanceMonthReport(OaAttendanceMonthReportReqVO reqVO, Long operatorId); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/attendance/OaAttendanceServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/attendance/OaAttendanceServiceImpl.java new file mode 100644 index 0000000000..4ff8ed0e9b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/attendance/OaAttendanceServiceImpl.java @@ -0,0 +1,373 @@ +package cn.iocoder.yudao.module.oa.service.attendance; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceMonthReportReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceMonthReportRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendancePageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceUpdateReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceWeekReportReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceWeekReportRespVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.attendance.OaAttendanceDO; +import cn.iocoder.yudao.module.oa.dal.mysql.attendance.OaAttendanceMapper; +import cn.iocoder.yudao.module.oa.enums.attendance.OaAttendanceStatusEnum; +import cn.iocoder.yudao.module.oa.enums.attendance.OaAttendanceTypeEnum; +import cn.iocoder.yudao.module.oa.framework.config.OaProperties; +import cn.iocoder.yudao.module.oa.service.leave.OaLeaveApplyService; +import cn.iocoder.yudao.module.oa.service.travel.OaTravelApplyService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.count; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.ATTENDANCE_CLOCK_TIME_INVALID; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.ATTENDANCE_NOT_EXISTS; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.ATTENDANCE_STATUS_INVALID; + +/** + * OA 考勤 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaAttendanceServiceImpl implements OaAttendanceService { + + @Resource + private OaAttendanceMapper attendanceMapper; + + @Resource + private OaProperties properties; + + @Resource + private OaLeaveApplyService leaveApplyService; + @Resource + private OaTravelApplyService travelApplyService; + + @Resource + private AdminUserApi adminUserApi; + + @Override + public void createApplyAttendance(Long userId, OaAttendanceTypeEnum type, LocalDateTime startTime) { + // 1. 请假、出差记录沿用申请开始时间 + Integer status = type == OaAttendanceTypeEnum.LEAVE + ? OaAttendanceStatusEnum.LEAVE.getStatus() : OaAttendanceStatusEnum.TRAVEL.getStatus(); + + // 2. 保存考勤明细,不增加上班或下班打卡次数 + attendanceMapper.insert(new OaAttendanceDO().setUserId(userId).setType(type.getType()) + .setStatus(status).setAttendanceTime(startTime)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long clockAttendance(Long userId, String attendanceIp) { + // 1. 仅允许最早打卡时间之后、标准下班时间之前打卡,不包含边界 + LocalDateTime clockTime = LocalDateTime.now().withNano(0); + if (!clockTime.toLocalTime().isAfter(properties.getAttendance().getClockBeginTime()) + || !clockTime.toLocalTime().isBefore(properties.getAttendance().getWorkEndTime())) { + throw exception(ATTENDANCE_CLOCK_TIME_INVALID, + properties.getAttendance().getClockBeginTime(), properties.getAttendance().getWorkEndTime()); + } + + // 2. 查询用户当天打卡记录,判断当前操作是上班打卡还是下班打卡 + List attendances = getAttendanceListByUserIdAndDate(userId, clockTime.toLocalDate()); + OaAttendanceDO clockIn = CollUtil.findOne(attendances, + attendance -> ObjUtil.equals(attendance.getType(), OaAttendanceTypeEnum.CLOCK_IN.getType())); + + // 3.1 情况一:首次打卡创建上班记录 + if (clockIn == null) { + OaAttendanceDO attendance = new OaAttendanceDO().setUserId(userId) + .setType(OaAttendanceTypeEnum.CLOCK_IN.getType()) + .setStatus(calculateAttendanceStatus(OaAttendanceTypeEnum.CLOCK_IN, clockTime)) + .setAttendanceTime(clockTime).setAttendanceIp(attendanceIp); + attendanceMapper.insert(attendance); + return attendance.getId(); + } + + // 3.2 情况二:首次下班打卡创建下班记录 + OaAttendanceDO clockOut = CollUtil.findOne(attendances, + attendance -> ObjUtil.equals(attendance.getType(), OaAttendanceTypeEnum.CLOCK_OUT.getType())); + if (clockOut == null) { + OaAttendanceDO attendance = new OaAttendanceDO().setUserId(userId) + .setType(OaAttendanceTypeEnum.CLOCK_OUT.getType()) + .setStatus(calculateAttendanceStatus(OaAttendanceTypeEnum.CLOCK_OUT, clockTime)) + .setAttendanceTime(clockTime).setAttendanceIp(attendanceIp); + attendanceMapper.insert(attendance); + return attendance.getId(); + } + + // 3.3 情况三:重复下班打卡刷新下班时间和状态 + OaAttendanceDO updateObj = new OaAttendanceDO().setId(clockOut.getId()) + .setStatus(calculateAttendanceStatus(OaAttendanceTypeEnum.CLOCK_OUT, clockTime)) + .setAttendanceTime(clockTime).setAttendanceIp(attendanceIp); + attendanceMapper.updateById(updateObj); + return clockOut.getId(); + } + + @Override + public void updateAttendance(OaAttendanceUpdateReqVO updateReqVO, Long operatorId) { + // 1.1 校验考勤记录存在,且属于当前用户管理范围 + OaAttendanceDO attendance = validateAttendanceExists(updateReqVO.getId(), operatorId); + // 1.2 校验考勤类型和状态组合 + validateAttendanceStatus(attendance.getType(), updateReqVO.getStatus()); + + // 2. 修改考勤状态和备注 + attendanceMapper.updateById(BeanUtils.toBean(updateReqVO, OaAttendanceDO.class)); + } + + @Override + public void deleteAttendance(Long id, Long operatorId) { + // 1. 校验考勤记录存在且属于当前用户管理范围 + validateAttendanceExists(id, operatorId); + + // 2. 删除考勤记录 + attendanceMapper.deleteById(id); + } + + @Override + public OaAttendanceDO getAttendance(Long id, Long operatorId) { + return validateAttendanceExists(id, operatorId); + } + + @Override + public PageResult getAttendancePage(OaAttendancePageReqVO pageReqVO, Long operatorId) { + // 1. 查询管理范围与员工筛选的交集 + List userIds = getManagedUserIds(operatorId, pageReqVO.getUserId()); + if (CollUtil.isEmpty(userIds)) { + return new PageResult<>(Collections.emptyList(), 0L); + } + + // 2. 查询范围内的考勤记录 + return attendanceMapper.selectPageByUserIds(pageReqVO, userIds); + } + + @Override + public PageResult getMyAttendancePage(OaAttendancePageReqVO pageReqVO, Long userId) { + return attendanceMapper.selectPageByUserId(pageReqVO, userId); + } + + @Override + public List getTodayAttendanceList(Long userId) { + return getAttendanceListByUserIdAndDate(userId, LocalDate.now()); + } + + @Override + public List getAttendanceWeekReport(OaAttendanceWeekReportReqVO reqVO, Long operatorId) { + // 1. 查询管理范围内的用户,保留没有打卡记录的用户 + List userIds = getManagedUserIds(operatorId, reqVO.getUserId()); + if (CollUtil.isEmpty(userIds)) { + return Collections.emptyList(); + } + + // 2. 查询指定周的考勤记录 + LocalDate startDate = reqVO.getStartDate().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + LocalDate endDate = startDate.plusDays(6); + List attendances = attendanceMapper.selectListByUserIdsAndAttendanceTime( + userIds, LocalDateTimeUtils.getDateTimeRange(startDate, endDate)); + + // 3. 按用户和日期组装周报 + Map> userAttendanceMap = convertMultiMap(attendances, OaAttendanceDO::getUserId); + List reports = new ArrayList<>(userIds.size()); + for (Long userId : userIds) { + Map> dailyAttendanceMap = convertMultiMap( + userAttendanceMap.getOrDefault(userId, Collections.emptyList()), + attendance -> attendance.getAttendanceTime().toLocalDate()); + List dailyAttendances = new ArrayList<>(7); + for (int i = 0; i < 7; i++) { + LocalDate date = startDate.plusDays(i); + dailyAttendances.add(buildDailyAttendance( + date, dailyAttendanceMap.getOrDefault(date, Collections.emptyList()))); + } + reports.add(new OaAttendanceWeekReportRespVO().setUserId(userId).setDailyAttendances(dailyAttendances)); + } + return reports; + } + + @Override + public List getAttendanceMonthReport(OaAttendanceMonthReportReqVO reqVO, Long operatorId) { + // 1. 查询管理范围内的用户,保留没有打卡记录的用户 + List userIds = getManagedUserIds(operatorId, reqVO.getUserId()); + if (CollUtil.isEmpty(userIds)) { + return Collections.emptyList(); + } + + // 2.1 查询指定月份的考勤记录 + LocalDateTime[] monthTime = LocalDateTimeUtils.getMonthDateTimeRange(reqVO.getYear(), reqVO.getMonth()); + List attendances = attendanceMapper.selectListByUserIdsAndAttendanceTime(userIds, monthTime); + // 2.2 批量汇总审批通过的请假和出差天数,整笔归入申请开始月份 + Map leaveDaysMap = leaveApplyService.getApprovedLeaveDaysMap(userIds, monthTime); + Map travelDaysMap = travelApplyService.getApprovedTravelDaysMap(userIds, monthTime); + + // 3. 按用户汇总 + boolean pastMonth = YearMonth.of(reqVO.getYear(), reqVO.getMonth()) + .isBefore(YearMonth.from(LocalDateTime.now())); + Map> userAttendanceMap = convertMultiMap(attendances, OaAttendanceDO::getUserId); + List reports = new ArrayList<>(userIds.size()); + for (Long userId : userIds) { + List userAttendances = userAttendanceMap.getOrDefault(userId, Collections.emptyList()); + reports.add(new OaAttendanceMonthReportRespVO().setUserId(userId) + .setClockInCount(countAttendanceByType(userAttendances, OaAttendanceTypeEnum.CLOCK_IN)) + .setClockOutCount(countAttendanceByType(userAttendances, OaAttendanceTypeEnum.CLOCK_OUT)) + .setNormalCount(countAttendanceByStatus(userAttendances, OaAttendanceStatusEnum.NORMAL)) + .setLateCount(countAttendanceByStatus(userAttendances, OaAttendanceStatusEnum.LATE)) + .setEarlyCount(countAttendanceByStatus(userAttendances, OaAttendanceStatusEnum.EARLY)) + .setLeaveDays(leaveDaysMap.getOrDefault(userId, 0)) + .setTravelDays(travelDaysMap.getOrDefault(userId, 0)) + .setAbsentDays(pastMonth ? properties.getAttendance().getMonthlyWorkDays() - countAttendanceByType(userAttendances, OaAttendanceTypeEnum.CLOCK_OUT) : 0)); + } + return reports; + } + + /** + * 校验考勤记录存在且属于当前用户管理范围 + * + * @param id 考勤记录编号 + * @param userId 当前操作用户编号 + * @return 考勤记录 + */ + private OaAttendanceDO validateAttendanceExists(Long id, Long userId) { + OaAttendanceDO attendance = attendanceMapper.selectById(id); + if (attendance == null) { + throw exception(ATTENDANCE_NOT_EXISTS); + } + // 重要:越界记录与不存在记录统一返回,避免泄露其他用户的考勤 + List managedUserIds = getManagedUserIds(userId, attendance.getUserId()); + if (CollUtil.isEmpty(managedUserIds)) { + throw exception(ATTENDANCE_NOT_EXISTS); + } + return attendance; + } + + /** + * 获得当前用户管理范围内的用户编号 + * + * @param operatorId 当前操作用户编号 + * @param filterUserId 员工筛选编号,未指定时返回全部下属用户 + * @return 管理范围与员工筛选的交集 + */ + private List getManagedUserIds(Long operatorId, Long filterUserId) { + List users = adminUserApi.getUserListBySubordinate(operatorId); + return convertList(users, AdminUserRespDTO::getId, + user -> filterUserId == null || ObjUtil.equal(user.getId(), filterUserId)); + } + + /** + * 校验考勤类型与状态组合是否合法 + * + * @param type 考勤类型 + * @param status 考勤状态 + */ + private void validateAttendanceStatus(Integer type, Integer status) { + boolean valid = ObjUtil.equals(type, OaAttendanceTypeEnum.CLOCK_IN.getType()) + && (ObjUtil.equals(status, OaAttendanceStatusEnum.NORMAL.getStatus()) + || ObjUtil.equals(status, OaAttendanceStatusEnum.LATE.getStatus())); + valid = valid || ObjUtil.equals(type, OaAttendanceTypeEnum.CLOCK_OUT.getType()) + && (ObjUtil.equals(status, OaAttendanceStatusEnum.NORMAL.getStatus()) + || ObjUtil.equals(status, OaAttendanceStatusEnum.EARLY.getStatus())); + valid = valid || ObjUtil.equals(type, OaAttendanceTypeEnum.LEAVE.getType()) + && ObjUtil.equals(status, OaAttendanceStatusEnum.LEAVE.getStatus()); + valid = valid || ObjUtil.equals(type, OaAttendanceTypeEnum.TRAVEL.getType()) + && ObjUtil.equals(status, OaAttendanceStatusEnum.TRAVEL.getStatus()); + if (!valid) { + throw exception(ATTENDANCE_STATUS_INVALID); + } + } + + /** + * 计算考勤状态 + * + * @param type 考勤类型 + * @param attendanceTime 考勤时间 + * @return 考勤状态 + */ + private Integer calculateAttendanceStatus(OaAttendanceTypeEnum type, LocalDateTime attendanceTime) { + // 上班打卡晚于标准上班时间时,状态为迟到 + if (type == OaAttendanceTypeEnum.CLOCK_IN + && attendanceTime.toLocalTime().isAfter(properties.getAttendance().getWorkBeginTime())) { + return OaAttendanceStatusEnum.LATE.getStatus(); + } + // 下班打卡早于标准下班时间时,状态为早退 + if (type == OaAttendanceTypeEnum.CLOCK_OUT + && attendanceTime.toLocalTime().isBefore(properties.getAttendance().getWorkEndTime())) { + return OaAttendanceStatusEnum.EARLY.getStatus(); + } + // 其余打卡均为正常状态 + return OaAttendanceStatusEnum.NORMAL.getStatus(); + } + + /** + * 获得指定用户和日期的考勤记录列表 + * + * @param userId 用户编号 + * @param date 日期 + * @return 考勤记录列表 + */ + private List getAttendanceListByUserIdAndDate(Long userId, LocalDate date) { + return attendanceMapper.selectListByUserIdAndAttendanceTime( + userId, LocalDateTimeUtils.getDateTimeRange(date, date)); + } + + /** + * 构建指定日期的考勤明细 + * + * @param date 日期 + * @param attendances 当日考勤记录列表 + * @return 每日考勤明细 + */ + private OaAttendanceWeekReportRespVO.DailyAttendance buildDailyAttendance( + LocalDate date, List attendances) { + OaAttendanceDO clockIn = CollUtil.findOne(attendances, + attendance -> ObjUtil.equals(attendance.getType(), OaAttendanceTypeEnum.CLOCK_IN.getType())); + OaAttendanceDO clockOut = CollUtil.findOne(attendances, + attendance -> ObjUtil.equals(attendance.getType(), OaAttendanceTypeEnum.CLOCK_OUT.getType())); + return new OaAttendanceWeekReportRespVO.DailyAttendance().setDate(date) + .setClockInId(clockIn != null ? clockIn.getId() : null) + .setClockInTime(clockIn != null ? clockIn.getAttendanceTime() : null) + .setClockInStatus(clockIn != null ? clockIn.getStatus() : null) + .setClockOutId(clockOut != null ? clockOut.getId() : null) + .setClockOutTime(clockOut != null ? clockOut.getAttendanceTime() : null) + .setClockOutStatus(clockOut != null ? clockOut.getStatus() : null); + } + + /** + * 统计指定考勤类型的记录数 + * + * @param attendances 考勤记录列表 + * @param type 考勤类型 + * @return 考勤记录数 + */ + private int countAttendanceByType(List attendances, OaAttendanceTypeEnum type) { + return (int) count(attendances, attendance -> ObjUtil.equals(attendance.getType(), type.getType())); + } + + /** + * 统计指定考勤状态的记录数 + * + * @param attendances 考勤记录列表 + * @param status 考勤状态 + * @return 考勤记录数 + */ + private int countAttendanceByStatus(List attendances, OaAttendanceStatusEnum status) { + return (int) count(attendances, attendance -> ObjUtil.equals(attendance.getStatus(), status.getStatus())); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/contact/OaContactCategoryService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/contact/OaContactCategoryService.java new file mode 100644 index 0000000000..8752df4901 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/contact/OaContactCategoryService.java @@ -0,0 +1,88 @@ +package cn.iocoder.yudao.module.oa.service.contact; + +import cn.iocoder.yudao.module.oa.controller.admin.contact.vo.category.OaContactCategorySaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.contact.OaContactCategoryDO; + +import java.util.List; +import java.util.Map; +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; +import java.util.Collection; + +/** + * OA 联系人分类 Service 接口 + * + * @author 芋道源码 + */ +public interface OaContactCategoryService { + + /** + * 创建联系人分类 + * + * @param createReqVO 创建信息 + * @param userId 用户编号 + * @return 分类编号 + */ + Long createContactCategory(OaContactCategorySaveReqVO createReqVO, Long userId); + + /** + * 更新联系人分类 + * + * @param updateReqVO 更新信息 + * @param userId 用户编号 + */ + void updateContactCategory(OaContactCategorySaveReqVO updateReqVO, Long userId); + + /** + * 删除联系人分类 + * + * @param id 分类编号 + * @param userId 用户编号 + */ + void deleteContactCategory(Long id, Long userId); + + /** + * 获得用户的联系人分类列表 + * + * @param userId 用户编号 + * @return 联系人分类列表 + */ + List getContactCategoryList(Long userId); + + /** + * 获得分类详情 + * + * @param id 分类编号 + * @param userId 用户编号 + * @return 分类 + */ + OaContactCategoryDO getContactCategory(Long id, Long userId); + + /** + * 校验分类存在且属于当前用户 + * + * @param id 分类编号 + * @param userId 用户编号 + * @return 分类 + */ + OaContactCategoryDO validateContactCategory(Long id, Long userId); + + /** + * 获得指定编号的分类列表 + * + * @param ids 分类编号集合 + * @return 分类列表 + */ + List getContactCategoryList(Collection ids); + + /** + * 获得分类编号与分类的映射 + * + * @param ids 分类编号集合 + * @return 分类 Map + */ + default Map getContactCategoryMap(Collection ids) { + List categories = getContactCategoryList(ids); + return CollectionUtils.convertMap(categories, OaContactCategoryDO::getId); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/contact/OaContactCategoryServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/contact/OaContactCategoryServiceImpl.java new file mode 100644 index 0000000000..0186a40ae6 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/contact/OaContactCategoryServiceImpl.java @@ -0,0 +1,117 @@ +package cn.iocoder.yudao.module.oa.service.contact; + +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.contact.vo.category.OaContactCategorySaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.contact.OaContactCategoryDO; +import cn.iocoder.yudao.module.oa.dal.mysql.contact.OaContactCategoryMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.context.annotation.Lazy; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.List; +import java.util.Collection; +import java.util.Collections; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.CONTACT_CATEGORY_NAME_DUPLICATE; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.CONTACT_CATEGORY_NOT_EXISTS; + +/** + * OA 联系人分类 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaContactCategoryServiceImpl implements OaContactCategoryService { + + @Resource + private OaContactCategoryMapper contactCategoryMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private OaContactService contactService; + + @Override + public Long createContactCategory(OaContactCategorySaveReqVO createReqVO, Long userId) { + // 1. 校验分类名称唯一 + validateContactCategoryNameUnique(null, userId, createReqVO.getName()); + + // 2. 新增联系人分类 + OaContactCategoryDO category = BeanUtils.toBean(createReqVO, OaContactCategoryDO.class) + .setUserId(userId); + contactCategoryMapper.insert(category); + return category.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateContactCategory(OaContactCategorySaveReqVO updateReqVO, Long userId) { + // 1.1 校验联系人分类属于当前用户 + validateContactCategory(updateReqVO.getId(), userId); + // 1.2 校验分类名称唯一 + validateContactCategoryNameUnique(updateReqVO.getId(), userId, updateReqVO.getName()); + + // 2. 更新联系人分类 + contactCategoryMapper.updateById(BeanUtils.toBean(updateReqVO, OaContactCategoryDO.class)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteContactCategory(Long id, Long userId) { + // 1. 校验联系人分类属于当前用户 + validateContactCategory(id, userId); + + // 2.1 清空本人联系人及接收关系中的分类 + contactService.clearContactCategoryId(id); + // 2.2 删除联系人分类 + contactCategoryMapper.deleteById(id); + } + + @Override + public OaContactCategoryDO getContactCategory(Long id, Long userId) { + return validateContactCategory(id, userId); + } + + @Override + public List getContactCategoryList(Long userId) { + return contactCategoryMapper.selectListByUserId(userId); + } + + + @Override + public List getContactCategoryList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return contactCategoryMapper.selectByIds(ids); + } + + @Override + public OaContactCategoryDO validateContactCategory(Long id, Long userId) { + OaContactCategoryDO category = contactCategoryMapper.selectById(id); + if (category == null || ObjectUtil.notEqual(category.getUserId(), userId)) { + throw exception(CONTACT_CATEGORY_NOT_EXISTS); + } + return category; + } + + /** + * 校验同一用户的分类名称唯一 + * + * @param id 当前分类编号,新增时为空 + * @param userId 用户编号 + * @param name 分类名称 + */ + private void validateContactCategoryNameUnique(Long id, Long userId, String name) { + OaContactCategoryDO category = contactCategoryMapper.selectByUserIdAndName(userId, name); + if (category != null && ObjectUtil.notEqual(category.getId(), id)) { + throw exception(CONTACT_CATEGORY_NAME_DUPLICATE); + } + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/contact/OaContactService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/contact/OaContactService.java new file mode 100644 index 0000000000..84af3efb55 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/contact/OaContactService.java @@ -0,0 +1,143 @@ +package cn.iocoder.yudao.module.oa.service.contact; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; +import cn.iocoder.yudao.module.oa.controller.admin.contact.vo.OaContactPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.contact.vo.OaContactSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.contact.OaContactDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.contact.OaContactShareDO; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * OA 外部联系人 Service 接口 + * + * @author 芋道源码 + */ +public interface OaContactService { + + /** + * 创建外部联系人 + * + * @param createReqVO 创建信息 + * @param userId 用户编号 + * @return 联系人编号 + */ + Long createContact(OaContactSaveReqVO createReqVO, Long userId); + + /** + * 更新外部联系人 + * + * @param updateReqVO 更新信息 + * @param userId 用户编号 + */ + void updateContact(OaContactSaveReqVO updateReqVO, Long userId); + + /** + * 删除外部联系人 + * + * @param id 联系人编号 + * @param userId 用户编号 + */ + void deleteContact(Long id, Long userId); + + /** + * 删除接收到的共享联系人 + * + * @param contactId 联系人编号 + * @param userId 用户编号 + */ + void deleteReceivedContact(Long contactId, Long userId); + + /** + * 获得外部联系人 + * + * @param id 联系人编号 + * @param userId 用户编号 + * @return 外部联系人 + */ + OaContactDO getContact(Long id, Long userId); + + /** + * 获得我的联系人分页 + * + * @param pageReqVO 分页查询 + * @param userId 当前用户编号 + * @return 联系人分页 + */ + PageResult getMyContactPage(OaContactPageReqVO pageReqVO, Long userId); + + /** + * 获得共享给我的联系人分页 + * + * @param pageReqVO 分页查询 + * @param userId 当前用户编号 + * @return 联系人分页 + */ + PageResult getReceivedContactPage(OaContactPageReqVO pageReqVO, Long userId); + + /** + * 获得我共享的联系人分页 + * + * @param pageReqVO 分页查询 + * @param userId 当前用户编号 + * @return 联系人分页 + */ + PageResult getSharedContactPage(OaContactPageReqVO pageReqVO, Long userId); + + // TODO DONE @AI:方法注释只描述查询职责,与同类 Service 保持一致。 + /** + * 获得联系人列表 + * + * @param ids 联系人编号集合 + * @return 联系人列表 + */ + List getContactList(Collection ids); + + /** + * 共享外部联系人 + * + * @param contactId 联系人编号 + * @param userIds 共享接收人用户编号集合 + * @param currentUserId 当前用户编号 + */ + void shareContact(Long contactId, Collection userIds, Long currentUserId); + + /** + * 处理外部联系人共享 + * + * @param contactId 联系人编号 + * @param categoryId 接收人分类编号 + * @param userId 用户编号 + */ + void handleContactShare(Long contactId, Long categoryId, Long userId); + + /** + * 获得联系人共享记录 Map + * + * @param contactIds 联系人编号集合 + * @return 联系人编号与共享记录列表的 Map + */ + default Map> getContactShareListMap(Collection contactIds) { + List list = getContactShareList(contactIds); + return CollectionUtils.convertMultiMap(list, OaContactShareDO::getContactId); + } + + /** + * 获得联系人共享记录列表 + * + * @param contactIds 联系人编号集合 + * @return 共享记录列表 + */ + List getContactShareList(Collection contactIds); + + /** + * 清空分类下的联系人及接收关系分类编号 + * + * @param categoryId 分类编号 + */ + void clearContactCategoryId(Long categoryId); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/contact/OaContactServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/contact/OaContactServiceImpl.java new file mode 100644 index 0000000000..e47e8d47b6 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/contact/OaContactServiceImpl.java @@ -0,0 +1,271 @@ +package cn.iocoder.yudao.module.oa.service.contact; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.common.util.string.StrUtils; +import cn.iocoder.yudao.module.oa.controller.admin.contact.vo.OaContactPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.contact.vo.OaContactSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.contact.OaContactDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.contact.OaContactShareDO; +import cn.iocoder.yudao.module.oa.dal.mysql.contact.OaContactMapper; +import cn.iocoder.yudao.module.oa.dal.mysql.contact.OaContactShareMapper; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.context.annotation.Lazy; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +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.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.filterList; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.CONTACT_ACCESS_DENIED; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.CONTACT_NOT_EXISTS; + +/** + * OA 外部联系人 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaContactServiceImpl implements OaContactService { + + @Resource + private OaContactMapper contactMapper; + @Resource + private OaContactShareMapper contactShareMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private OaContactCategoryService contactCategoryService; + + @Resource + private AdminUserApi adminUserApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createContact(OaContactSaveReqVO createReqVO, Long userId) { + // 1. 校验联系人分类归属 + if (createReqVO.getCategoryId() != null) { + contactCategoryService.validateContactCategory(createReqVO.getCategoryId(), userId); + } + + // 2. 新增联系人 + OaContactDO contact = BeanUtils.toBean(createReqVO, OaContactDO.class) + .setPinyin(StrUtils.toPinyin(createReqVO.getName())); + contactMapper.insert(contact); + + // 3. 创建人也保存持有关系 + // 特殊:创建人和接收人均通过关联持有联系人,删除时只移除本人关联,最后一人移除后才删除正文。 + // 因此创建时也需保存本人关联,并标记为已处理,使其直接进入“我的联系人”。 + contactShareMapper.insert(new OaContactShareDO().setContactId(contact.getId()).setUserId(userId) + .setCategoryId(createReqVO.getCategoryId()).setHandleStatus(true)); + return contact.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateContact(OaContactSaveReqVO updateReqVO, Long userId) { + // 1.1 校验联系人属于当前用户 + validateContactOwner(updateReqVO.getId(), userId); + // 1.2 校验联系人分类归属 + if (updateReqVO.getCategoryId() != null) { + contactCategoryService.validateContactCategory(updateReqVO.getCategoryId(), userId); + } + + // 2. 更新联系人 + contactMapper.updateForSave(BeanUtils.toBean(updateReqVO, OaContactDO.class) + .setPinyin(StrUtils.toPinyin(updateReqVO.getName()))); + + // 3. 同步本人关联的分类 + OaContactShareDO share = contactShareMapper.selectByContactIdAndUserId(updateReqVO.getId(), userId); + contactShareMapper.updateHandleStatusAndCategoryIdById(new OaContactShareDO().setId(share.getId()) + .setCategoryId(updateReqVO.getCategoryId())); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteContact(Long id, Long userId) { + // 1. 校验联系人属于当前用户 + validateContactOwner(id, userId); + + // 2. 移除本人关联,最后一个持有人离开时删除正文 + deleteReceivedContact(id, userId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteReceivedContact(Long contactId, Long userId) { + // 1.1 校验联系人存在 + validateContactExists(contactId); + // 1.2 校验当前用户接收了该联系人 + OaContactShareDO contactShare = contactShareMapper.selectByContactIdAndUserId(contactId, userId); + if (contactShare == null) { + throw exception(CONTACT_ACCESS_DENIED); + } + + // 2. 删除联系人共享关系 + contactShareMapper.deleteByContactIdAndUserId(contactId, userId); + + // 3. 最后一个持有人离开时才删除正文 + if (CollUtil.isEmpty(contactShareMapper.selectListByContactId(contactId))) { + contactMapper.deleteById(contactId); + } + } + + @Override + public OaContactDO getContact(Long id, Long userId) { + // 1. 校验联系人存在 + OaContactDO contact = validateContactExists(id); + + // 2. 所有用户均通过本人关联校验访问权限 + if (contactShareMapper.selectByContactIdAndUserId(id, userId) == null) { + throw exception(CONTACT_ACCESS_DENIED); + } + return contact; + } + + @Override + public PageResult getMyContactPage(OaContactPageReqVO pageReqVO, Long userId) { + // 1. 查询本人已处理且符合本人分类的共享关系 + List shares = contactShareMapper.selectListByUserId(userId, true, + pageReqVO.getCategoryId()); + List contactIds = convertList(shares, OaContactShareDO::getContactId); + // 2. 查询本人创建及已处理接收的联系人 + return CollUtil.isEmpty(contactIds) ? PageResult.empty() : contactMapper.selectReceivedPage(pageReqVO, contactIds); + } + + @Override + public PageResult getReceivedContactPage(OaContactPageReqVO pageReqVO, Long userId) { + // 1. 按接收人的分类和处理状态查询共享关系 + List shares = contactShareMapper.selectListByUserId(userId, + pageReqVO.getHandleStatus(), pageReqVO.getCategoryId()); + // 创建人初始化的本人关联不是收到的共享 + shares = filterList(shares, share -> ObjectUtil.notEqual(NumberUtils.parseLong(share.getCreator()), userId)); + if (CollUtil.isEmpty(shares)) { + return PageResult.empty(); + } + // 2. 查询共享给本人的联系人 + List contactIds = convertList(shares, OaContactShareDO::getContactId); + return contactMapper.selectReceivedPage(pageReqVO, contactIds); + } + + @Override + public PageResult getSharedContactPage(OaContactPageReqVO pageReqVO, Long userId) { + // 1. 查询本人仍持有且符合本人分类的联系人 + List contactIds = convertList(contactShareMapper.selectListByUserId(userId, null, + pageReqVO.getCategoryId()), OaContactShareDO::getContactId); + if (CollUtil.isEmpty(contactIds)) { + return PageResult.empty(); + } + + // 2. 按本人发出的共享关系分页,每个接收人一行 + return contactShareMapper.selectSharedPage(pageReqVO, userId, contactIds); + } + + @Override + public List getContactList(Collection ids) { + return CollUtil.isEmpty(ids) ? Collections.emptyList() : contactMapper.selectByIds(ids); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void shareContact(Long contactId, Collection userIds, Long currentUserId) { + // 1.1 校验当前用户持有联系人,接收人可以再共享 + getContact(contactId, currentUserId); + // 1.2 校验共享接收人存在 + adminUserApi.validateUserList(userIds); + // 1.3 查询已有共享接收人 + List shares = contactShareMapper.selectListByContactId(contactId); + List sharedUserIds = convertList(shares, OaContactShareDO::getUserId); + List createUserIds = filterList(convertSet(userIds, userId -> userId), + userId -> ObjectUtil.notEqual(userId, currentUserId) && !sharedUserIds.contains(userId)); + if (CollUtil.isEmpty(createUserIds)) { + return; + } + + // 2. 新增尚未共享的接收人 + contactShareMapper.insertBatch(convertList(createUserIds, userId -> new OaContactShareDO() + .setContactId(contactId).setUserId(userId).setHandleStatus(false))); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void handleContactShare(Long contactId, Long categoryId, Long userId) { + // 1.1 校验共享关系存在 + OaContactShareDO contactShare = contactShareMapper.selectByContactIdAndUserId(contactId, userId); + if (contactShare == null) { + throw exception(CONTACT_ACCESS_DENIED); + } + // 1.2 创建人的分类通过联系人编辑维护,不能仅修改初始化的持有关系 + OaContactDO contact = validateContactExists(contactId); + if (ObjectUtil.equal(contact.getCreator(), userId.toString())) { + throw exception(CONTACT_ACCESS_DENIED); + } + // 1.3 校验接收人选择的分类属于本人 + if (categoryId != null) { + contactCategoryService.validateContactCategory(categoryId, userId); + } + + // 2. 更新处理状态和接收人分类 + OaContactShareDO updateObj = new OaContactShareDO().setId(contactShare.getId()) + .setHandleStatus(true).setCategoryId(categoryId); + contactShareMapper.updateHandleStatusAndCategoryIdById(updateObj); + } + + @Override + public List getContactShareList(Collection contactIds) { + if (CollUtil.isEmpty(contactIds)) { + return Collections.emptyList(); + } + return contactShareMapper.selectListByContactIds(contactIds); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void clearContactCategoryId(Long categoryId) { + // 1. 清空创建人的联系人分类 + contactMapper.updateCategoryIdToNullByCategoryId(categoryId); + // 2. 清空接收关系分类,不删除联系人或共享关系 + contactShareMapper.updateCategoryIdToNullByCategoryId(categoryId); + } + + /** + * 校验联系人存在且属于当前用户 + * + * @param id 联系人编号 + * @param userId 用户编号 + * @return 联系人 + */ + private OaContactDO validateContactOwner(Long id, Long userId) { + OaContactDO contact = getContact(id, userId); + if (ObjectUtil.notEqual(NumberUtils.parseLong(contact.getCreator()), userId)) { + throw exception(CONTACT_ACCESS_DENIED); + } + return contact; + } + + /** + * 校验联系人存在 + * + * @param id 联系人编号 + * @return 联系人 + */ + private OaContactDO validateContactExists(Long id) { + OaContactDO contact = contactMapper.selectById(id); + if (contact == null) { + throw exception(CONTACT_NOT_EXISTS); + } + return contact; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionLikeService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionLikeService.java new file mode 100644 index 0000000000..bf37811ab0 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionLikeService.java @@ -0,0 +1,76 @@ +package cn.iocoder.yudao.module.oa.service.discussion; + +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionLikeDO; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap; + +/** + * OA 讨论点赞 Service 接口 + * + * @author 芋道源码 + */ +public interface OaDiscussionLikeService { + + /** + * 点赞讨论或主回复,已有点赞时不重复新增 + * + * @param discussionId 讨论编号 + * @param replyId 回复编号 + * @param userId 用户编号 + */ + void createDiscussionLike(Long discussionId, Long replyId, Long userId); + + /** + * 取消讨论或主回复点赞,没有点赞时无需处理 + * + * @param discussionId 讨论编号 + * @param replyId 回复编号 + * @param userId 用户编号 + */ + void deleteDiscussionLike(Long discussionId, Long replyId, Long userId); + + /** + * 获得讨论点赞列表 + * + * @param discussionIds 讨论编号集合 + * @return 讨论点赞列表 + */ + List getDiscussionLikeList(Collection discussionIds); + + /** + * 获得回复点赞列表 + * + * @param replyIds 回复编号集合 + * @return 回复点赞列表 + */ + List getReplyLikeList(Collection replyIds); + + /** + * 删除讨论点赞,供讨论删除时级联调用 + * + * @param discussionId 讨论编号 + */ + void deleteDiscussionLikesByDiscussionId(Long discussionId); + + /** + * 删除回复点赞,供回复删除时级联调用 + * + * @param replyIds 回复编号集合 + */ + void deleteDiscussionLikesByReplyIds(Collection replyIds); + + /** + * 获得回复点赞 Map + * + * @param replyIds 回复编号集合 + * @return 回复编号与点赞列表的 Map + */ + default Map> getReplyLikeListMap(Collection replyIds) { + return convertMultiMap(getReplyLikeList(replyIds), OaDiscussionLikeDO::getReplyId); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionLikeServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionLikeServiceImpl.java new file mode 100644 index 0000000000..9d70f43d44 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionLikeServiceImpl.java @@ -0,0 +1,110 @@ +package cn.iocoder.yudao.module.oa.service.discussion; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionLikeDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionReplyDO; +import cn.iocoder.yudao.module.oa.dal.mysql.discussion.OaDiscussionLikeMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.context.annotation.Lazy; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +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.oa.enums.ErrorCodeConstants.DISCUSSION_LIKE_TARGET_INVALID; + +/** + * OA 讨论点赞 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaDiscussionLikeServiceImpl implements OaDiscussionLikeService { + + @Resource + private OaDiscussionLikeMapper discussionLikeMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private OaDiscussionService discussionService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private OaDiscussionReplyService discussionReplyService; + + @Override + @Transactional(rollbackFor = Exception.class) + public void createDiscussionLike(Long discussionId, Long replyId, Long userId) { + // 1.1 校验点赞对象存在 + validateLikeTargetExists(discussionId, replyId); + // 1.2 已点赞时直接返回,避免重试重复新增 + if (discussionLikeMapper.selectByTargetAndUserId(discussionId, replyId, userId) != null) { + return; + } + + // 2. 新增点赞 + discussionLikeMapper.insert(new OaDiscussionLikeDO().setDiscussionId(discussionId) + .setReplyId(replyId).setUserId(userId)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteDiscussionLike(Long discussionId, Long replyId, Long userId) { + // 1. 查询当前用户的点赞,没有点赞时无需处理 + OaDiscussionLikeDO like = discussionLikeMapper.selectByTargetAndUserId(discussionId, replyId, userId); + if (like == null) { + return; + } + + // 2. 删除当前用户的点赞 + discussionLikeMapper.deleteById(like.getId()); + } + + @Override + public List getDiscussionLikeList(Collection discussionIds) { + return CollUtil.isEmpty(discussionIds) ? Collections.emptyList() + : discussionLikeMapper.selectListByDiscussionIds(discussionIds); + } + + @Override + public List getReplyLikeList(Collection replyIds) { + return CollUtil.isEmpty(replyIds) ? Collections.emptyList() + : discussionLikeMapper.selectListByReplyIds(replyIds); + } + + @Override + public void deleteDiscussionLikesByDiscussionId(Long discussionId) { + discussionLikeMapper.deleteByDiscussionId(discussionId); + } + + @Override + public void deleteDiscussionLikesByReplyIds(Collection replyIds) { + if (CollUtil.isEmpty(replyIds)) { + return; + } + discussionLikeMapper.deleteByReplyIds(replyIds); + } + + /** + * 校验讨论或主回复存在 + * + * @param discussionId 讨论编号 + * @param replyId 回复编号 + */ + private void validateLikeTargetExists(Long discussionId, Long replyId) { + if (discussionId != null) { + discussionService.validateDiscussionExists(discussionId); + } else { + OaDiscussionReplyDO reply = discussionReplyService.validateDiscussionReplyExists(replyId); + if (ObjectUtil.notEqual(reply.getParentId(), OaDiscussionReplyDO.PARENT_ID_ROOT)) { + throw exception(DISCUSSION_LIKE_TARGET_INVALID); + } + } + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionReplyService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionReplyService.java new file mode 100644 index 0000000000..7efd01e9fa --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionReplyService.java @@ -0,0 +1,74 @@ +package cn.iocoder.yudao.module.oa.service.discussion; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.reply.OaDiscussionReplyPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.reply.OaDiscussionReplyCreateReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionReplyDO; + +import java.util.Collection; +import java.util.List; + +/** + * OA 讨论回复 Service 接口 + * + * @author 芋道源码 + */ +public interface OaDiscussionReplyService { + + /** + * 创建讨论回复 + * + * @param createReqVO 创建信息 + * @param userId 用户编号 + * @return 回复编号 + */ + Long createDiscussionReply(OaDiscussionReplyCreateReqVO createReqVO, Long userId); + + /** + * 删除讨论回复 + * + * @param id 回复编号 + * @param userId 用户编号 + */ + void deleteDiscussionReply(Long id, Long userId); + + /** + * 获得讨论回复列表 + * + * @param discussionIds 讨论编号集合 + * @return 讨论回复列表 + */ + List getDiscussionReplyList(Collection discussionIds); + + /** + * 获得讨论回复分页 + * + * @param pageReqVO 分页查询 + * @return 讨论回复分页 + */ + PageResult getDiscussionReplyPage(OaDiscussionReplyPageReqVO pageReqVO); + + /** + * 获得主回复下的全部子回复 + * + * @param replies 当前页的主回复 + * @return 子回复列表,按创建时间排序 + */ + List getDiscussionReplyDescendantList(List replies); + + /** + * 校验讨论回复存在 + * + * @param id 回复编号 + * @return 讨论回复 + */ + OaDiscussionReplyDO validateDiscussionReplyExists(Long id); + + /** + * 删除讨论下的回复及回复点赞,供讨论删除时级联调用 + * + * @param discussionId 讨论编号 + */ + void deleteDiscussionRepliesByDiscussionId(Long discussionId); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionReplyServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionReplyServiceImpl.java new file mode 100644 index 0000000000..9ac0d3b1fd --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionReplyServiceImpl.java @@ -0,0 +1,197 @@ +package cn.iocoder.yudao.module.oa.service.discussion; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.pojo.SortingField; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.common.util.object.PageUtils; +import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.reply.OaDiscussionReplyPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.reply.OaDiscussionReplyCreateReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionReplyDO; +import cn.iocoder.yudao.module.oa.dal.mysql.discussion.OaDiscussionReplyMapper; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import cn.iocoder.yudao.module.system.enums.permission.RoleCodeEnum; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.context.annotation.Lazy; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.ArrayDeque; +import java.util.Collection; +import java.util.Collections; +import java.util.Deque; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.DISCUSSION_REPLY_ACCESS_DENIED; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.DISCUSSION_REPLY_NOT_EXISTS; + +/** + * OA 讨论回复 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaDiscussionReplyServiceImpl implements OaDiscussionReplyService { + + @Resource + private OaDiscussionReplyMapper discussionReplyMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private OaDiscussionService discussionService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private OaDiscussionLikeService discussionLikeService; + + @Resource + private PermissionApi permissionApi; + + @Override + public Long createDiscussionReply(OaDiscussionReplyCreateReqVO createReqVO, Long userId) { + // 1.1 校验讨论存在 + discussionService.validateDiscussionExists(createReqVO.getDiscussionId()); + // 1.2 校验父回复属于当前讨论,并获得被回复人 + Long parentId = ObjectUtil.defaultIfNull(createReqVO.getParentId(), OaDiscussionReplyDO.PARENT_ID_ROOT); + Long replyUserId = null; + if (ObjectUtil.notEqual(OaDiscussionReplyDO.PARENT_ID_ROOT, parentId)) { + OaDiscussionReplyDO parentReply = validateDiscussionReplyExists(parentId); + if (ObjectUtil.notEqual(parentReply.getDiscussionId(), createReqVO.getDiscussionId())) { + throw exception(DISCUSSION_REPLY_NOT_EXISTS); + } + replyUserId = parentReply.getUserId(); + } + + // 2. 新增回复 + OaDiscussionReplyDO reply = BeanUtils.toBean(createReqVO, OaDiscussionReplyDO.class) + .setUserId(userId).setParentId(parentId).setReplyUserId(replyUserId); + discussionReplyMapper.insert(reply); + return reply.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteDiscussionReply(Long id, Long userId) { + // 1.1 校验回复存在 + OaDiscussionReplyDO reply = validateDiscussionReplyExists(id); + // 1.2 校验所属讨论存在 + OaDiscussionDO discussion = discussionService.validateDiscussionExists(reply.getDiscussionId()); + // 1.3 校验当前用户是讨论发布人或超级管理员 + if (ObjectUtil.notEqual(discussion.getUserId(), userId) && !isSuperAdmin(userId)) { + throw exception(DISCUSSION_REPLY_ACCESS_DENIED); + } + + // 2. 查询当前回复及其全部子回复编号 + Set replyIds = getReplyDescendantIds(reply, + discussionReplyMapper.selectListByDiscussionIds(Collections.singleton(reply.getDiscussionId()))); + + // 3.1 删除回复点赞 + discussionLikeService.deleteDiscussionLikesByReplyIds(replyIds); + // 3.2 删除当前回复及其全部子回复 + discussionReplyMapper.deleteByIds(replyIds); + } + + @Override + public List getDiscussionReplyList(Collection discussionIds) { + return CollUtil.isEmpty(discussionIds) ? Collections.emptyList() + : discussionReplyMapper.selectListByDiscussionIds(discussionIds); + } + + @Override + public PageResult getDiscussionReplyPage(OaDiscussionReplyPageReqVO pageReqVO) { + // 1. 校验讨论存在 + discussionService.validateDiscussionExists(pageReqVO.getDiscussionId()); + + // 2. 仅对主回复分页,子回复随所属楼层展示 + if (CollUtil.isEmpty(pageReqVO.getSortingFields())) { + pageReqVO.setSortingFields(Collections.singletonList(PageUtils.buildSortingField( + OaDiscussionReplyDO::getCreateTime, SortingField.ORDER_ASC))); + } + return discussionReplyMapper.selectPage(pageReqVO); + } + + @Override + public List getDiscussionReplyDescendantList(List replies) { + if (CollUtil.isEmpty(replies)) { + return Collections.emptyList(); + } + // 1. 批量查询所属讨论的回复,不逐个楼层查询数据库 + List allReplies = getDiscussionReplyList( + convertSet(replies, OaDiscussionReplyDO::getDiscussionId)); + // 2. 筛选当前页主回复的后代,保留原始时间顺序 + Set descendantIds = new HashSet<>(); + for (OaDiscussionReplyDO reply : replies) { + descendantIds.addAll(getReplyDescendantIds(reply, allReplies)); + descendantIds.remove(reply.getId()); + } + return convertList(allReplies, reply -> reply, reply -> descendantIds.contains(reply.getId())); + } + + @Override + public OaDiscussionReplyDO validateDiscussionReplyExists(Long id) { + OaDiscussionReplyDO reply = discussionReplyMapper.selectById(id); + if (reply == null) { + throw exception(DISCUSSION_REPLY_NOT_EXISTS); + } + return reply; + } + + /** + * 获得当前回复及其全部子回复编号 + * + * @param rootReply 当前回复 + * @param replies 所属讨论的回复列表 + * @return 回复编号集合 + */ + private static Set getReplyDescendantIds(OaDiscussionReplyDO rootReply, + List replies) { + Map> childReplyListMap = convertMultiMap(replies, OaDiscussionReplyDO::getParentId); + Set replyIds = new HashSet<>(); + Deque pendingReplyIds = new ArrayDeque<>(); + pendingReplyIds.add(rootReply.getId()); + replyIds.add(rootReply.getId()); + for (int i = 0; i <= replies.size() && CollUtil.isNotEmpty(pendingReplyIds); i++) { + Long replyId = pendingReplyIds.removeFirst(); + // 每个回复最多入队一次,避免脏数据形成循环关联 + for (OaDiscussionReplyDO childReply : childReplyListMap.getOrDefault(replyId, Collections.emptyList())) { + if (replyIds.add(childReply.getId())) { + pendingReplyIds.add(childReply.getId()); + } + } + } + return replyIds; + } + + /** + * 判断用户是否具有超级管理员角色 + * + * @param userId 用户编号 + * @return 是否为超级管理员 + */ + private boolean isSuperAdmin(Long userId) { + return permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteDiscussionRepliesByDiscussionId(Long discussionId) { + // 1. 查询讨论下的回复 + List replies = getDiscussionReplyList(Collections.singleton(discussionId)); + + // 2.1 删除回复点赞 + discussionLikeService.deleteDiscussionLikesByReplyIds(convertSet(replies, OaDiscussionReplyDO::getId)); + // 2.2 删除讨论下的回复 + discussionReplyMapper.deleteByDiscussionId(discussionId); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionService.java new file mode 100644 index 0000000000..8b11b9cbe2 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionService.java @@ -0,0 +1,80 @@ +package cn.iocoder.yudao.module.oa.service.discussion; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.discussion.OaDiscussionPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.discussion.OaDiscussionSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionDO; + +/** + * OA 讨论 Service 接口 + * + * @author 芋道源码 + */ +public interface OaDiscussionService { + + /** + * 创建讨论 + * + * @param createReqVO 创建信息 + * @param userId 用户编号 + * @return 讨论编号 + */ + Long createDiscussion(OaDiscussionSaveReqVO createReqVO, Long userId); + + /** + * 更新讨论 + * + * @param updateReqVO 更新信息 + * @param userId 用户编号 + */ + void updateDiscussion(OaDiscussionSaveReqVO updateReqVO, Long userId); + + /** + * 删除讨论 + * + * @param id 讨论编号 + * @param userId 用户编号 + */ + void deleteDiscussion(Long id, Long userId); + + /** + * 获得讨论 + * + * @param id 讨论编号 + * @return 讨论 + */ + OaDiscussionDO getDiscussion(Long id); + + /** + * 获得讨论分页 + * + * @param pageReqVO 分页查询 + * @return 讨论分页 + */ + PageResult getDiscussionPage(OaDiscussionPageReqVO pageReqVO); + + /** + * 获得管理范围内的讨论分页 + * + * @param pageReqVO 分页查询 + * @param userId 当前用户编号 + * @return 管理员可查询全部讨论,普通用户仅查询本人讨论 + */ + PageResult getDiscussionManagePage(OaDiscussionPageReqVO pageReqVO, Long userId); + + /** + * 增加讨论访问次数 + * + * @param id 讨论编号 + */ + void increaseDiscussionVisitCount(Long id); + + /** + * 校验讨论存在 + * + * @param id 讨论编号 + * @return 讨论,不存在时抛出异常 + */ + OaDiscussionDO validateDiscussionExists(Long id); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionServiceImpl.java new file mode 100644 index 0000000000..db899f2605 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionServiceImpl.java @@ -0,0 +1,203 @@ +package cn.iocoder.yudao.module.oa.service.discussion; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.pojo.SortingField; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.common.util.object.PageUtils; +import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.discussion.OaDiscussionPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.discussion.OaDiscussionSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionDO; +import cn.iocoder.yudao.module.oa.dal.mysql.discussion.OaDiscussionMapper; +import cn.iocoder.yudao.module.oa.enums.discussion.OaDiscussionTypeEnum; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import cn.iocoder.yudao.module.system.enums.permission.RoleCodeEnum; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.context.annotation.Lazy; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.Arrays; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.DISCUSSION_ACCESS_DENIED; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.DISCUSSION_NOT_EXISTS; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.DISCUSSION_VOTE_INVALID; + +/** + * OA 讨论 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaDiscussionServiceImpl implements OaDiscussionService { + + @Resource + private OaDiscussionMapper discussionMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private OaDiscussionReplyService discussionReplyService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private OaDiscussionLikeService discussionLikeService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private OaDiscussionVoteService discussionVoteService; + + @Resource + private PermissionApi permissionApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createDiscussion(OaDiscussionSaveReqVO createReqVO, Long userId) { + // 1. 新增讨论 + OaDiscussionDO discussion = BeanUtils.toBean(createReqVO, OaDiscussionDO.class) + .setUserId(userId).setVisitCount(0); + clearVoteConfigIfNotVote(discussion); + discussionMapper.insert(discussion); + + // 2. 新增投票选项 + discussionVoteService.createVoteOptionList(discussion.getId(), createReqVO.getVoteOptions()); + return discussion.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateDiscussion(OaDiscussionSaveReqVO updateReqVO, Long userId) { + // 1.1 校验讨论属于当前用户 + OaDiscussionDO discussion = validateDiscussionOwner(updateReqVO.getId(), userId); + // 1.2 投票结束时间必须晚于原开始时间 + if (ObjectUtil.equal(OaDiscussionTypeEnum.VOTE.getType(), discussion.getType()) + && (updateReqVO.getVoteEndTime() == null + || !updateReqVO.getVoteEndTime().isAfter(discussion.getVoteStartTime()))) { + throw exception(DISCUSSION_VOTE_INVALID); + } + + // 2. 更新讨论;注意:发布后不允许修改类型以及已有投票的基础配置和选项 + OaDiscussionDO updateObj = BeanUtils.toBean(updateReqVO, OaDiscussionDO.class) + .setUserId(null).setType(null); + if (OaDiscussionTypeEnum.VOTE.getType().equals(discussion.getType())) { + updateObj.setVoteMultiple(null).setVoteStartTime(null); + } else { + clearVoteConfigIfNotVote(updateObj); + } + discussionMapper.updateById(updateObj); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteDiscussion(Long id, Long userId) { + // 1. 校验讨论属于当前用户,或当前用户是超级管理员 + validateDiscussionDeletable(id, userId); + + // 2.1 删除讨论回复及回复点赞 + discussionReplyService.deleteDiscussionRepliesByDiscussionId(id); + // 2.2 删除讨论点赞 + discussionLikeService.deleteDiscussionLikesByDiscussionId(id); + // 2.3 删除投票记录及选项 + discussionVoteService.deleteDiscussionVotesByDiscussionId(id); + // 2.4 删除讨论 + discussionMapper.deleteById(id); + } + + @Override + public OaDiscussionDO getDiscussion(Long id) { + return discussionMapper.selectById(id); + } + + @Override + public PageResult getDiscussionPage(OaDiscussionPageReqVO pageReqVO) { + if (CollUtil.isEmpty(pageReqVO.getSortingFields())) { + pageReqVO.setSortingFields(Arrays.asList( + PageUtils.buildSortingField(OaDiscussionDO::getType, SortingField.ORDER_ASC), + PageUtils.buildSortingField(OaDiscussionDO::getUpdateTime, SortingField.ORDER_DESC))); + } + return discussionMapper.selectPage(pageReqVO); + } + + @Override + public PageResult getDiscussionManagePage(OaDiscussionPageReqVO pageReqVO, Long userId) { + // 1. 普通用户只能管理本人讨论,不接受请求指定的其他发布人 + if (!isSuperAdmin(userId)) { + pageReqVO.setUserId(userId); + } + + // 2. 复用讨论分页查询,管理员保留发布人筛选 + return getDiscussionPage(pageReqVO); + } + + @Override + public void increaseDiscussionVisitCount(Long id) { + // 1. 校验讨论存在 + validateDiscussionExists(id); + + // 2. 增加访问次数 + discussionMapper.updateVisitCount(id); + } + + /** + * 校验讨论存在且由当前用户发布 + * + * @param id 讨论编号 + * @param userId 当前用户编号 + * @return 讨论 + */ + private OaDiscussionDO validateDiscussionOwner(Long id, Long userId) { + OaDiscussionDO discussion = validateDiscussionExists(id); + if (ObjectUtil.notEqual(discussion.getUserId(), userId)) { + throw exception(DISCUSSION_ACCESS_DENIED); + } + return discussion; + } + + /** + * 校验当前用户是讨论发布人或超级管理员 + * + * @param id 讨论编号 + * @param userId 当前用户编号 + * @return 讨论 + */ + private OaDiscussionDO validateDiscussionDeletable(Long id, Long userId) { + OaDiscussionDO discussion = validateDiscussionExists(id); + if (ObjectUtil.notEqual(discussion.getUserId(), userId) && !isSuperAdmin(userId)) { + throw exception(DISCUSSION_ACCESS_DENIED); + } + return discussion; + } + + /** + * 判断用户是否具有超级管理员角色 + * + * @param userId 用户编号 + * @return 是否为超级管理员 + */ + private boolean isSuperAdmin(Long userId) { + return permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode()); + } + + @Override + public OaDiscussionDO validateDiscussionExists(Long id) { + OaDiscussionDO discussion = getDiscussion(id); + if (discussion == null) { + throw exception(DISCUSSION_NOT_EXISTS); + } + return discussion; + } + + /** + * 清理非投票讨论的投票配置,新增时不保存,修改时不覆盖原字段 + * + * @param discussion 讨论数据 + */ + private void clearVoteConfigIfNotVote(OaDiscussionDO discussion) { + if (OaDiscussionTypeEnum.VOTE.getType().equals(discussion.getType())) { + return; + } + discussion.setVoteMultiple(null).setVoteStartTime(null).setVoteEndTime(null); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionVoteService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionVoteService.java new file mode 100644 index 0000000000..2d72b8c40a --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionVoteService.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.oa.service.discussion; + +import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.vote.OaVoteOptionSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaVoteOptionDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaVoteRecordDO; + +import java.util.Collection; +import java.util.List; + +/** + * OA 讨论投票 Service 接口 + * + * @author 芋道源码 + */ +public interface OaDiscussionVoteService { + + /** + * 参与讨论投票 + * + * @param discussionId 讨论编号 + * @param optionIds 投票选项编号集合 + * @param userId 用户编号 + */ + void voteDiscussion(Long discussionId, Collection optionIds, Long userId); + + /** + * 获得投票选项列表 + * + * @param discussionIds 讨论编号集合 + * @return 投票选项列表 + */ + List getVoteOptionList(Collection discussionIds); + + /** + * 获得投票记录列表 + * + * @param discussionIds 讨论编号集合 + * @return 投票记录列表 + */ + List getVoteRecordList(Collection discussionIds); + + /** + * 创建讨论投票选项 + * + * @param discussionId 讨论编号 + * @param voteOptions 投票选项列表 + */ + void createVoteOptionList(Long discussionId, List voteOptions); + + /** + * 删除讨论投票记录和选项,供讨论删除时级联调用 + * + * @param discussionId 讨论编号 + */ + void deleteDiscussionVotesByDiscussionId(Long discussionId); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionVoteServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionVoteServiceImpl.java new file mode 100644 index 0000000000..f92adaf115 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionVoteServiceImpl.java @@ -0,0 +1,123 @@ +package cn.iocoder.yudao.module.oa.service.discussion; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.vote.OaVoteOptionSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaVoteOptionDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaVoteRecordDO; +import cn.iocoder.yudao.module.oa.dal.mysql.discussion.OaVoteOptionMapper; +import cn.iocoder.yudao.module.oa.dal.mysql.discussion.OaVoteRecordMapper; +import cn.iocoder.yudao.module.oa.enums.discussion.OaDiscussionTypeEnum; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.context.annotation.Lazy; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.DISCUSSION_VOTE_DUPLICATE; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.DISCUSSION_VOTE_EXPIRED; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.DISCUSSION_VOTE_INVALID; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.DISCUSSION_VOTE_OPTION_NOT_EXISTS; + +/** + * OA 讨论投票 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaDiscussionVoteServiceImpl implements OaDiscussionVoteService { + + @Resource + private OaVoteOptionMapper voteOptionMapper; + @Resource + private OaVoteRecordMapper voteRecordMapper; + @Resource + @Lazy + private OaDiscussionService discussionService; + + @Override + @Transactional(rollbackFor = Exception.class) + public void voteDiscussion(Long discussionId, Collection optionIds, Long userId) { + // 1.1 校验讨论存在 + OaDiscussionDO discussion = discussionService.validateDiscussionExists(discussionId); + // 1.2 校验讨论类型为投票 + if (ObjectUtil.notEqual(OaDiscussionTypeEnum.VOTE.getType(), discussion.getType())) { + throw exception(DISCUSSION_VOTE_INVALID); + } + // 1.3 校验投票时间 + LocalDateTime now = LocalDateTime.now(); + if (LocalDateTimeUtils.isNotBetween(discussion.getVoteStartTime(), discussion.getVoteEndTime(), now)) { + throw exception(DISCUSSION_VOTE_EXPIRED); + } + // 1.4 校验单选投票只能选择一个选项 + Set distinctOptionIds = CollUtil.newHashSet(optionIds); + if (Boolean.FALSE.equals(discussion.getVoteMultiple()) && distinctOptionIds.size() != 1) { + throw exception(DISCUSSION_VOTE_INVALID); + } + // 1.5 校验投票选项属于当前讨论 + Set existingOptionIds = convertSet(voteOptionMapper.selectListByDiscussionIds( + Collections.singleton(discussionId)), OaVoteOptionDO::getId); + if (!existingOptionIds.containsAll(distinctOptionIds)) { + throw exception(DISCUSSION_VOTE_OPTION_NOT_EXISTS); + } + // 1.6 单选只能提交一次;多选可以分次追加,但同一选项不能重复提交 + List existingVoteRecords = + voteRecordMapper.selectListByDiscussionIdAndUserId(discussionId, userId); + if (Boolean.FALSE.equals(discussion.getVoteMultiple()) && CollUtil.isNotEmpty(existingVoteRecords)) { + throw exception(DISCUSSION_VOTE_DUPLICATE); + } + Set votedOptionIds = convertSet(existingVoteRecords, OaVoteRecordDO::getOptionId); + if (!Collections.disjoint(distinctOptionIds, votedOptionIds)) { + throw exception(DISCUSSION_VOTE_DUPLICATE); + } + + // 2. 新增投票记录 + voteRecordMapper.insertBatch(convertList(distinctOptionIds, optionId -> new OaVoteRecordDO() + .setDiscussionId(discussionId).setOptionId(optionId).setUserId(userId))); + } + + @Override + public List getVoteOptionList(Collection discussionIds) { + return CollUtil.isEmpty(discussionIds) ? Collections.emptyList() + : voteOptionMapper.selectListByDiscussionIds(discussionIds); + } + + @Override + public List getVoteRecordList(Collection discussionIds) { + return CollUtil.isEmpty(discussionIds) ? Collections.emptyList() + : voteRecordMapper.selectListByDiscussionIds(discussionIds); + } + + @Override + public void createVoteOptionList(Long discussionId, List voteOptions) { + if (CollUtil.isEmpty(voteOptions)) { + return; + } + voteOptionMapper.insertBatch(convertList(voteOptions, voteOption -> + BeanUtils.toBean(voteOption, OaVoteOptionDO.class).setDiscussionId(discussionId))); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteDiscussionVotesByDiscussionId(Long discussionId) { + // 1. 删除投票记录 + voteRecordMapper.deleteByDiscussionId(discussionId); + + // 2. 删除投票选项 + voteOptionMapper.deleteByDiscussionId(discussionId); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFileFavoriteService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFileFavoriteService.java new file mode 100644 index 0000000000..9ea414b07b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFileFavoriteService.java @@ -0,0 +1,55 @@ +package cn.iocoder.yudao.module.oa.service.file; + +import cn.iocoder.yudao.module.oa.dal.dataobject.file.OaFileFavoriteDO; + +import java.util.Collection; +import java.util.List; + +/** + * 云盘收藏 Service 接口 + * + * @author 芋道源码 + */ +public interface OaFileFavoriteService { + + /** + * 获得用户收藏列表 + * + * @param userId 用户编号 + * @return 收藏列表 + */ + List getFileFavoriteList(Long userId); + + /** + * 获得用户在指定节点中的收藏列表 + * + * @param userId 用户编号 + * @param nodeIds 节点编号集合 + * @return 收藏列表 + */ + List getFileFavoriteListByUserIdAndNodeIds(Long userId, Collection nodeIds); + + /** + * 收藏文件节点 + * + * @param nodeId 节点编号 + * @param userId 用户编号 + */ + void createFileFavorite(Long nodeId, Long userId); + + /** + * 取消本人收藏 + * + * @param nodeId 节点编号 + * @param userId 用户编号 + */ + void deleteFileFavorite(Long nodeId, Long userId); + + /** + * 清理节点收藏 + * + * @param nodeIds 节点编号集合 + */ + void deleteFileFavoritesByNodeIds(Collection nodeIds); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFileFavoriteServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFileFavoriteServiceImpl.java new file mode 100644 index 0000000000..be76b5bd4a --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFileFavoriteServiceImpl.java @@ -0,0 +1,72 @@ +package cn.iocoder.yudao.module.oa.service.file; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.module.oa.dal.dataobject.file.OaFileFavoriteDO; +import cn.iocoder.yudao.module.oa.dal.mysql.file.OaFileFavoriteMapper; +import cn.iocoder.yudao.module.oa.enums.file.OaFilePermissionLevelEnum; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * 云盘收藏 Service 实现类 + * + * @author 芋道源码 + */ +@Service +public class OaFileFavoriteServiceImpl implements OaFileFavoriteService { + + @Resource + private OaFileFavoriteMapper fileFavoriteMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private OaFileNodeService fileNodeService; + + @Override + public List getFileFavoriteList(Long userId) { + return fileFavoriteMapper.selectListByUserId(userId); + } + + @Override + public List getFileFavoriteListByUserIdAndNodeIds(Long userId, Collection nodeIds) { + if (CollUtil.isEmpty(nodeIds)) { + return Collections.emptyList(); + } + return fileFavoriteMapper.selectListByUserIdAndNodeIds(userId, nodeIds); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void createFileFavorite(Long nodeId, Long userId) { + // 1.1 锁定节点并校验可见权限 + fileNodeService.lockFileNode(nodeId); + fileNodeService.validateFileNodePermission(nodeId, userId, OaFilePermissionLevelEnum.READ.getLevel()); + // 1.2 已收藏时直接返回,保证重复请求不会新增多条记录 + if (fileFavoriteMapper.selectByUserIdAndNodeId(userId, nodeId) != null) { + return; + } + + // 2. 新增本人收藏 + fileFavoriteMapper.insert(new OaFileFavoriteDO().setNodeId(nodeId).setUserId(userId)); + } + + @Override + public void deleteFileFavorite(Long nodeId, Long userId) { + fileFavoriteMapper.deleteByNodeIdAndUserId(nodeId, userId); + } + + @Override + public void deleteFileFavoritesByNodeIds(Collection nodeIds) { + if (CollUtil.isEmpty(nodeIds)) { + return; + } + fileFavoriteMapper.deleteByNodeIds(nodeIds); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFileNodeService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFileNodeService.java new file mode 100644 index 0000000000..d85b005d76 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFileNodeService.java @@ -0,0 +1,137 @@ +package cn.iocoder.yudao.module.oa.service.file; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.file.vo.node.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.file.OaFileNodeDO; + +import jakarta.validation.Valid; + +import java.util.List; + +/** + * 云盘文件节点 Service 接口 + * + * @author 芋道源码 + */ +public interface OaFileNodeService { + + /** + * 获得本人云盘空间使用情况,包含回收站文件,不包含他人共享文件 + * + * @param userId 当前用户编号 + * @return 空间使用情况 + */ + OaFileStorageRespVO getFileStorage(Long userId); + + /** + * 锁定文件节点,用于串行化收藏与共享修改 + * + * @param id 节点编号 + */ + void lockFileNode(Long id); + + /** + * 校验节点可访问且具有指定权限 + * + * @param id 节点编号 + * @param userId 当前用户 + * @param level 所需权限级别 + * @return 节点 + */ + OaFileNodeDO validateFileNodePermission(Long id, Long userId, Integer level); + + /** + * 获得云盘分页 + * + * @param reqVO 查询条件 + * @param userId 当前用户 + * @return 文件分页 + */ + PageResult getFileNodePage(OaFileNodePageReqVO reqVO, Long userId); + + /** + * 获得本人可移动到的目录 + * + * @param userId 当前用户 + * @return 目录列表 + */ + List getFileDirectoryList(Long userId); + + /** + * 创建文件或目录 + * + * @param reqVO 节点信息 + * @param userId 当前用户 + * @return 节点编号 + */ + Long createFileNode(@Valid OaFileNodeSaveReqVO reqVO, Long userId); + + /** + * 重命名文件或目录 + * + * @param reqVO 新名称 + * @param userId 当前用户 + */ + void updateFileNodeName(OaFileNodeRenameReqVO reqVO, Long userId); + + /** + * 移动本人文件或目录 + * + * @param reqVO 目标位置 + * @param userId 当前用户 + */ + void updateFileNodeParent(OaFileNodeMoveReqVO reqVO, Long userId); + + /** + * 复制文件或整棵目录到本人目录,不复制共享和收藏关系 + * + * @param reqVO 源节点及目标目录 + * @param userId 当前用户 + * @return 副本根节点编号 + */ + Long copyFileNode(@Valid OaFileNodeCopyReqVO reqVO, Long userId); + + /** + * 将本人节点移入回收站 + * + * @param id 节点编号 + * @param userId 当前用户 + */ + void recycleFileNode(Long id, Long userId); + + /** + * 恢复本人回收站节点 + * + * @param id 节点编号 + * @param userId 当前用户 + */ + void restoreFileNode(Long id, Long userId); + + /** + * 逻辑删除本人回收站节点及其子树,不清理物理文件 + * + * @param id 节点编号 + * @param userId 当前用户 + */ + void deleteFileNode(Long id, Long userId); + + /** + * 获得文件详情,具有下载权限时附带临时文件地址 + * + * @param id 节点编号 + * @param userId 当前用户 + * @return 文件详情,目录或仅具有查看权限时不返回文件地址 + */ + OaFileNodeRespVO getFileNode(Long id, Long userId); + + /** + * 校验节点及全部祖先处于正常状态 + * + * 仅校验目录结构和回收状态,不代替归属及共享权限校验 + * + * @param id 节点编号 + * @return 文件节点 + */ + OaFileNodeDO validateFileNodeAvailable(Long id); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFileNodeServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFileNodeServiceImpl.java new file mode 100644 index 0000000000..67dcc17f1a --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFileNodeServiceImpl.java @@ -0,0 +1,709 @@ +package cn.iocoder.yudao.module.oa.service.file; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.ObjUtil; +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.framework.common.util.object.PageUtils; +import cn.iocoder.yudao.module.infra.api.file.FileApi; +import cn.iocoder.yudao.module.oa.controller.admin.file.vo.node.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.file.OaFileFavoriteDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.file.OaFileNodeDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.file.OaFilePermissionDO; +import cn.iocoder.yudao.module.oa.dal.mysql.file.OaFileNodeMapper; +import cn.iocoder.yudao.module.oa.enums.file.OaFileCategoryEnum; +import cn.iocoder.yudao.module.oa.enums.file.OaFileNodeStatusEnum; +import cn.iocoder.yudao.module.oa.enums.file.OaFileNodeTypeEnum; +import cn.iocoder.yudao.module.oa.enums.file.OaFilePermissionLevelEnum; +import cn.iocoder.yudao.module.oa.enums.file.OaFileScopeEnum; +import cn.iocoder.yudao.module.oa.enums.file.OaFileSubjectTypeEnum; +import cn.iocoder.yudao.module.oa.framework.config.OaProperties; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +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.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; + +/** + * 云盘文件节点 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaFileNodeServiceImpl implements OaFileNodeService { + + @Resource + private OaProperties properties; + + @Resource + private OaFileNodeMapper fileNodeMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private OaFilePermissionService filePermissionService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private OaFileFavoriteService fileFavoriteService; + + @Resource + private FileApi fileApi; + @Resource + private AdminUserApi adminUserApi; + + @Override + public OaFileStorageRespVO getFileStorage(Long userId) { + // 1.1 数据库汇总本人文件容量,回收站文件仍占用容量 + Long usedSize = fileNodeMapper.selectSumSizeByCreatorAndType(userId.toString(), OaFileNodeTypeEnum.FILE.getType()); + // 1.2 数量统计只查询本人正常节点及共享给本人的候选节点 + Long deptId = adminUserApi.getUser(userId).getDeptId(); + List candidates = fileNodeMapper.selectListByCreatorAndStatus(userId.toString(), OaFileNodeStatusEnum.NORMAL.getStatus()); + candidates.addAll(getSharedFileNodeList(new OaFileNodePageReqVO().setScope(OaFileScopeEnum.SHARED.getScope()), userId, deptId)); + // 1.3 按需补齐祖先,并查询这些节点的授权 + Map nodes = getFileNodeMap(candidates); + List permissions = filePermissionService.getFilePermissionListByNodeIds(nodes.keySet()); + Set sharedNodeIds = convertSet(permissions, OaFilePermissionDO::getNodeId, + permission -> permission.getExpireTime() == null || permission.getExpireTime().isAfter(LocalDateTime.now())); + + // 2. 文件数量和共享数量仅统计可用节点,保留祖先状态及授权有效期校验 + long fileCount = 0; + long sharedCount = 0; + long receivedCount = 0; + for (OaFileNodeDO node : candidates) { + boolean isOwner = userId.toString().equals(node.getCreator()); + boolean isFile = ObjUtil.equal(node.getType(), OaFileNodeTypeEnum.FILE.getType()); + if (!isAvailable(node, nodes)) { + continue; + } + if (isOwner) { + if (isFile) { + fileCount++; + } + if (sharedNodeIds.contains(node.getId())) { + sharedCount++; + } + } else if (getLevel(node, nodes, permissions, userId, deptId) > OaFilePermissionLevelEnum.LEVEL_NONE) { + // 共享目录及继承权限的子节点只统计一个入口,与共享列表保持一致 + OaFileNodeDO parent = nodes.get(node.getParentId()); + if (parent == null || getLevel(parent, nodes, permissions, userId, deptId) == OaFilePermissionLevelEnum.LEVEL_NONE) { + receivedCount++; + } + } + } + return new OaFileStorageRespVO().setUsedSize(usedSize).setTotalSize(properties.getFile().getStorageSize()) + .setFileCount(fileCount).setSharedCount(sharedCount).setReceivedCount(receivedCount); + } + + @Override + public void lockFileNode(Long id) { + if (fileNodeMapper.selectByIdForUpdate(id) == null) { + throw exception(FILE_NODE_NOT_EXISTS); + } + } + + @Override + public OaFileNodeDO validateFileNodePermission(Long id, Long userId, Integer level) { + // 1. 校验目标及祖先均处于正常状态 + OaFileNodeDO node = validateFileNodeAvailable(id); + // 2. 计算本人、用户共享和部门共享权限 + Map nodes = getFileNodeMap(Collections.singletonList(node)); + List permissions = filePermissionService.getFilePermissionListByNodeIds(nodes.keySet()); + int currentLevel = getLevel(node, nodes, permissions, userId, adminUserApi.getUser(userId).getDeptId()); + if (currentLevel < level) { + throw exception(FILE_NODE_ACCESS_DENIED); + } + return node; + } + + @Override + public PageResult getFileNodePage(OaFileNodePageReqVO reqVO, Long userId) { + // 1.1 校验指定目录及祖先可见,回收站仍按原有范围筛选 + OaFileNodeDO parent = null; + if (reqVO.getParentId() != null && ObjUtil.notEqual(reqVO.getParentId(), OaFileNodeDO.PARENT_ID_ROOT)) { + parent = validateFileNodePermission(reqVO.getParentId(), userId, OaFilePermissionLevelEnum.READ.getLevel()); + } + Long deptId = adminUserApi.getUser(userId).getDeptId(); + // 1.2 本人根目录和已校验的本人目录,正常子节点均可见,直接使用数据库分页 + boolean ownRootQuery = OaFileScopeEnum.MY.getScope().equals(reqVO.getScope()) + && ObjUtil.equal(reqVO.getParentId(), OaFileNodeDO.PARENT_ID_ROOT); + boolean ownDirectoryQuery = parent != null && userId.toString().equals(parent.getCreator()) + && ObjUtil.equal(parent.getType(), OaFileNodeTypeEnum.FOLDER.getType()) + && !OaFileScopeEnum.RECYCLE.getScope().equals(reqVO.getScope()); + if (ownRootQuery || ownDirectoryQuery) { + PageResult pageResult = fileNodeMapper.selectPageByParentIdAndCreatorAndStatus(reqVO, + ownRootQuery ? userId.toString() : null, OaFileNodeStatusEnum.NORMAL.getStatus()); + return buildFileNodePageRespVO(pageResult, parent == null ? Collections.emptyMap() + : Collections.singletonMap(parent.getId(), parent), Collections.emptyList(), userId, deptId); + } + // 1.3 仅收藏入口需要提前查询收藏编号,用于筛选;普通列表分页后再补收藏状态 + Set favorites = Collections.emptySet(); + if (OaFileScopeEnum.FAVORITE.getScope().equals(reqVO.getScope()) + && (reqVO.getParentId() == null || ObjUtil.equal(reqVO.getParentId(), OaFileNodeDO.PARENT_ID_ROOT))) { + favorites = convertSet(fileFavoriteService.getFileFavoriteList(userId), OaFileFavoriteDO::getNodeId); + if (CollUtil.isEmpty(favorites)) { + return PageResult.empty(); + } + } + // 1.4 数据库筛选候选节点,再批量补齐祖先;祖先仅用于判权,不加入分页结果 + // 共享跨层级入口仍需候选集判权后分页,不能先截取一页再过滤权限 + boolean sharedRootQuery = OaFileScopeEnum.SHARED.getScope().equals(reqVO.getScope()) + && (reqVO.getParentId() == null || ObjUtil.equal(reqVO.getParentId(), OaFileNodeDO.PARENT_ID_ROOT)); + List candidates = sharedRootQuery ? getSharedFileNodeList(reqVO, userId, deptId) + : fileNodeMapper.selectCandidateList(reqVO, userId, favorites); + Map nodes = getFileNodeMap(candidates); + List permissions = filePermissionService.getFilePermissionListByNodeIds(nodes.keySet()); + + // 2. 按可见范围筛选,回收站只展示回收根节点,不重复展示被其遮蔽的子节点 + List result = new ArrayList<>(); + for (OaFileNodeDO node : candidates) { + boolean isOwner = userId.toString().equals(node.getCreator()); + boolean isRecycledScope = OaFileScopeEnum.RECYCLE.getScope().equals(reqVO.getScope()); + if (isRecycledScope) { + if (!isOwner || ObjUtil.notEqual(node.getStatus(), OaFileNodeStatusEnum.RECYCLED.getStatus()) + || !isAvailableParent(node, nodes)) { + continue; + } + } else if (!isAvailable(node, nodes)) { + continue; + } + int level = getLevel(node, nodes, permissions, userId, deptId); + if (!isRecycledScope && level == OaFilePermissionLevelEnum.LEVEL_NONE) { + continue; + } + if (OaFileScopeEnum.MY.getScope().equals(reqVO.getScope()) && !isOwner + && (reqVO.getParentId() == null || ObjUtil.equal(reqVO.getParentId(), OaFileNodeDO.PARENT_ID_ROOT))) { + continue; + } + if (OaFileScopeEnum.SHARED.getScope().equals(reqVO.getScope()) && isOwner + && (reqVO.getParentId() == null || ObjUtil.equal(reqVO.getParentId(), OaFileNodeDO.PARENT_ID_ROOT))) { + continue; + } + if (OaFileScopeEnum.FAVORITE.getScope().equals(reqVO.getScope()) + && (reqVO.getParentId() == null || ObjUtil.equal(reqVO.getParentId(), OaFileNodeDO.PARENT_ID_ROOT)) + && !favorites.contains(node.getId())) { + continue; + } + if (reqVO.getParentId() != null && !isRecycledScope) { + if (OaFileScopeEnum.SHARED.getScope().equals(reqVO.getScope()) && ObjUtil.equal(reqVO.getParentId(), OaFileNodeDO.PARENT_ID_ROOT)) { + OaFileNodeDO parentNode = nodes.get(node.getParentId()); + if (parentNode != null && getLevel(parentNode, nodes, permissions, userId, deptId) > OaFilePermissionLevelEnum.LEVEL_NONE) { + continue; + } + } else if (ObjUtil.notEqual(node.getParentId(), reqVO.getParentId())) { + continue; + } + } + result.add(node); + } + + // 3. 文件夹优先,同类型按创建时间及编号倒序,分页结果保持稳定 + result.sort(Comparator.comparing(OaFileNodeDO::getType) + .thenComparing(OaFileNodeDO::getCreateTime, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(OaFileNodeDO::getId, Comparator.reverseOrder())); + return buildFileNodePageRespVO(PageUtils.buildPageResult(reqVO, result), + nodes, permissions, userId, deptId); + } + + /** + * 拼接文件分页响应,仅转换当前页并补充收藏状态和权限 + * + * @param pageResult 文件分页 + * @param nodes 当前节点及祖先 + * @param permissions 相关节点的共享权限 + * @param userId 当前用户编号 + * @param deptId 当前用户部门编号 + * @return 文件分页响应 + */ + private PageResult buildFileNodePageRespVO(PageResult pageResult, + Map nodes, List permissions, Long userId, Long deptId) { + if (CollUtil.isEmpty(pageResult.getList())) { + return PageResult.empty(pageResult.getTotal()); + } + // 1. 仅查询当前页节点的本人收藏 + List favorites = fileFavoriteService.getFileFavoriteListByUserIdAndNodeIds(userId, + convertSet(pageResult.getList(), OaFileNodeDO::getId)); + Set favoriteNodeIds = convertSet(favorites, OaFileFavoriteDO::getNodeId); + // 2. 转换当前页并补充权限与收藏状态,保留查询总数 + List list = convertList(pageResult.getList(), node -> { + OaFileNodeRespVO respVO = BeanUtils.toBean(node, OaFileNodeRespVO.class); + respVO.setUrl(null); // 列表不暴露原始地址,预览和下载统一通过详情接口授权 + respVO.setLevel(getLevel(node, nodes, permissions, userId, deptId)); + respVO.setFavorite(favoriteNodeIds.contains(node.getId())); + return respVO; + }); + return new PageResult<>(list, pageResult.getTotal()); + } + + @Override + public List getFileDirectoryList(Long userId) { + // 1. 只查询本人正常目录及其祖先,不加载文件 + List candidates = fileNodeMapper.selectListByCreatorAndTypeAndStatus(userId.toString(), + OaFileNodeTypeEnum.FOLDER.getType(), OaFileNodeStatusEnum.NORMAL.getStatus()); + Map nodes = getFileNodeMap(candidates); + // 2. 筛选可用目录并转换响应 + return convertList(candidates, + node -> BeanUtils.toBean(node, OaFileNodeRespVO.class).setUrl(null), + node -> ObjUtil.equal(node.getType(), OaFileNodeTypeEnum.FOLDER.getType()) && isAvailable(node, nodes)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createFileNode(OaFileNodeSaveReqVO reqVO, Long userId) { + // 1.1 校验目标目录编辑权限 + if (ObjUtil.notEqual(reqVO.getParentId(), OaFileNodeDO.PARENT_ID_ROOT)) { + lockFileNode(reqVO.getParentId()); + OaFileNodeDO parent = validateFileNodePermission(reqVO.getParentId(), userId, OaFilePermissionLevelEnum.EDIT.getLevel()); + if (ObjUtil.notEqual(parent.getType(), OaFileNodeTypeEnum.FOLDER.getType())) { + throw exception(FILE_NODE_PATH_INVALID); + } + } + // 1.2 文件登记前校验本人剩余容量,回收站文件仍计入占用 + if (ObjUtil.equal(reqVO.getType(), OaFileNodeTypeEnum.FILE.getType())) { + Long usedSize = fileNodeMapper.selectSumSizeByCreatorAndType(userId.toString(), OaFileNodeTypeEnum.FILE.getType()); + if (reqVO.getSize() > properties.getFile().getStorageSize() - usedSize) { + throw exception(FILE_STORAGE_NOT_ENOUGH); + } + } + + // 2. 保存前端上传后的文件信息,文件分类由扩展名计算 + OaFileNodeDO node = BeanUtils.toBean(reqVO, OaFileNodeDO.class) + .setStatus(OaFileNodeStatusEnum.NORMAL.getStatus()).setSize(0L).setUrl(null); + if (ObjUtil.equal(reqVO.getType(), OaFileNodeTypeEnum.FILE.getType())) { + String extension = FileUtil.extName(reqVO.getName()).toLowerCase(Locale.ROOT); + node.setUrl(reqVO.getUrl()).setSize(reqVO.getSize()).setExtension(extension) + .setCategory(OaFileCategoryEnum.getByExtension(extension).getCategory()); + } + node.setName(getAvailableFileNodeName(node, reqVO.getParentId(), userId, null)); + node.setDeptId(adminUserApi.getUser(userId).getDeptId()); + fileNodeMapper.insert(node); + return node.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateFileNodeName(OaFileNodeRenameReqVO reqVO, Long userId) { + // 1.1 锁定节点并校验编辑权限 + lockFileNode(reqVO.getId()); + OaFileNodeDO node = validateFileNodePermission(reqVO.getId(), userId, OaFilePermissionLevelEnum.EDIT.getLevel()); + // 1.2 排除自身,同名时自动追加序号 + node.setName(reqVO.getName()); + String name = getAvailableFileNodeName(node, node.getParentId(), Long.valueOf(node.getCreator()), node.getId()); + + // 2. 更新名称,文件分类随新扩展名更新 + OaFileNodeDO update = new OaFileNodeDO().setId(node.getId()).setName(name); + if (ObjUtil.equal(node.getType(), OaFileNodeTypeEnum.FILE.getType())) { + String extension = FileUtil.extName(reqVO.getName()).toLowerCase(Locale.ROOT); + update.setExtension(extension).setCategory(OaFileCategoryEnum.getByExtension(extension).getCategory()); + } + fileNodeMapper.updateById(update); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateFileNodeParent(OaFileNodeMoveReqVO reqVO, Long userId) { + // 1.1 按编号锁定本人目录,串行校验目录移动,防止两个目录并发互移形成环 + Map folders = convertMap(fileNodeMapper.selectListByCreatorAndTypeForUpdate( + userId.toString(), OaFileNodeTypeEnum.FOLDER.getType()), OaFileNodeDO::getId); + // 1.2 锁定节点并校验编辑权限 + lockFileNode(reqVO.getId()); + OaFileNodeDO node = validateFileNodePermission(reqVO.getId(), userId, OaFilePermissionLevelEnum.EDIT.getLevel()); + // 1.3 校验节点归属,只允许移动本人节点 + validateOwner(node, userId); + // 1.4 校验目标目录权限、归属及目录层级 + if (ObjUtil.notEqual(reqVO.getParentId(), OaFileNodeDO.PARENT_ID_ROOT)) { + OaFileNodeDO parent = validateFileNodePermission(reqVO.getParentId(), userId, OaFilePermissionLevelEnum.EDIT.getLevel()); + validateOwner(parent, userId); + if (ObjUtil.notEqual(parent.getType(), OaFileNodeTypeEnum.FOLDER.getType())) { + throw exception(FILE_NODE_PATH_INVALID); + } + parent = folders.get(parent.getId()); + if (parent == null) { + throw exception(FILE_NODE_PATH_INVALID); + } + for (int i = 0; i < Short.MAX_VALUE && parent != null; i++) { + if (ObjUtil.equal(parent.getId(), node.getId())) { + throw exception(FILE_NODE_MOVE_INVALID); + } + parent = folders.get(parent.getParentId()); + } + if (parent != null) { + throw exception(FILE_NODE_PATH_INVALID); + } + } + // 1.5 目标目录同名时自动追加序号,排除当前节点 + String name = getAvailableFileNodeName(node, reqVO.getParentId(), userId, node.getId()); + + // 2. 更新父目录和名称,子树保持原有结构 + fileNodeMapper.updateById(new OaFileNodeDO().setId(node.getId()).setParentId(reqVO.getParentId()).setName(name)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long copyFileNode(OaFileNodeCopyReqVO reqVO, Long userId) { + // 1.1 校验源节点下载权限,只复制正常节点 + OaFileNodeDO root = validateFileNodePermission(reqVO.getId(), userId, OaFilePermissionLevelEnum.DOWNLOAD.getLevel()); + // 1.2 目标必须是本人目录,不能复制到源目录自身或下级目录 + if (ObjUtil.notEqual(reqVO.getParentId(), OaFileNodeDO.PARENT_ID_ROOT)) { + lockFileNode(reqVO.getParentId()); + OaFileNodeDO parent = validateFileNodePermission(reqVO.getParentId(), userId, OaFilePermissionLevelEnum.EDIT.getLevel()); + validateOwner(parent, userId); + if (ObjUtil.notEqual(parent.getType(), OaFileNodeTypeEnum.FOLDER.getType()) + || getFileNodeMap(Collections.singletonList(parent)).containsKey(root.getId())) { + throw exception(FILE_NODE_PATH_INVALID); + } + } + + // 2. 按层读取源子树,跳过回收节点,复制前校验每个节点的下载权限 + List sources = new ArrayList<>(Collections.singletonList(root)); + Collection parentIds = ObjUtil.equal(root.getType(), OaFileNodeTypeEnum.FOLDER.getType()) + ? Collections.singleton(root.getId()) : Collections.emptyList(); + while (CollUtil.isNotEmpty(parentIds)) { + List children = filterList(fileNodeMapper.selectListByParentIds(parentIds), + node -> ObjUtil.equal(node.getStatus(), OaFileNodeStatusEnum.NORMAL.getStatus())); + for (OaFileNodeDO child : children) { + validateFileNodePermission(child.getId(), userId, OaFilePermissionLevelEnum.DOWNLOAD.getLevel()); + } + sources.addAll(children); + parentIds = convertList(children, OaFileNodeDO::getId, + node -> ObjUtil.equal(node.getType(), OaFileNodeTypeEnum.FOLDER.getType())); + } + + // 3. 为副本根节点生成不重名的名称,保留文件扩展名 + String name = getAvailableFileNodeName(root, reqVO.getParentId(), userId, null); + + // 4. 保存新的节点树,底层文件由 Infra 复用;不继承原创建人、共享或收藏 + Long deptId = adminUserApi.getUser(userId).getDeptId(); + Map copiedIds = new HashMap<>(); + Map> childrenMap = convertMultiMap(sources, OaFileNodeDO::getParentId); + List sourceNodes = Collections.singletonList(root); + while (CollUtil.isNotEmpty(sourceNodes)) { + List copies = convertList(sourceNodes, source -> new OaFileNodeDO() + .setParentId(source == root ? reqVO.getParentId() : copiedIds.get(source.getParentId())) + .setName(source == root ? name : source.getName()).setType(source.getType()) + .setExtension(source.getExtension()).setCategory(source.getCategory()) + .setSize(source.getSize()).setUrl(source.getUrl()).setDeptId(deptId) + .setStatus(OaFileNodeStatusEnum.NORMAL.getStatus())); + fileNodeMapper.insertBatch(copies); + List children = new ArrayList<>(); + for (int i = 0; i < sourceNodes.size(); i++) { + OaFileNodeDO source = sourceNodes.get(i); + copiedIds.put(source.getId(), copies.get(i).getId()); + children.addAll(childrenMap.getOrDefault(source.getId(), Collections.emptyList())); + } + sourceNodes = children; + } + return copiedIds.get(root.getId()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void recycleFileNode(Long id, Long userId) { + // 1.1 锁定节点并校验节点处于正常状态 + lockFileNode(id); + OaFileNodeDO node = validateFileNodeAvailable(id); + // 1.2 校验节点属于本人 + validateOwner(node, userId); + + // 2. 仅回收当前节点,保留子节点原有状态 + fileNodeMapper.updateById(new OaFileNodeDO().setId(id).setStatus(OaFileNodeStatusEnum.RECYCLED.getStatus())); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void restoreFileNode(Long id, Long userId) { + // 1.1 锁定节点并校验节点存在 + lockFileNode(id); + OaFileNodeDO node = validateFileNodeExists(id); + // 1.2 校验节点属于本人 + validateOwner(node, userId); + // 1.3 校验节点处于回收站 + if (ObjUtil.notEqual(node.getStatus(), OaFileNodeStatusEnum.RECYCLED.getStatus())) { + throw exception(FILE_NODE_NOT_RECYCLED); + } + + // 2. 恢复到个人根目录,同名时在扩展名前追加序号 + String name = getAvailableFileNodeName(node, OaFileNodeDO.PARENT_ID_ROOT, userId, node.getId()); + fileNodeMapper.updateById(new OaFileNodeDO().setId(id).setParentId(OaFileNodeDO.PARENT_ID_ROOT) + .setName(name).setStatus(OaFileNodeStatusEnum.NORMAL.getStatus())); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteFileNode(Long id, Long userId) { + // 1.1 锁定节点并校验节点存在 + lockFileNode(id); + OaFileNodeDO root = validateFileNodeExists(id); + // 1.2 校验节点属于本人 + validateOwner(root, userId); + // 1.3 校验节点处于回收站 + if (ObjUtil.notEqual(root.getStatus(), OaFileNodeStatusEnum.RECYCLED.getStatus())) { + throw exception(FILE_NODE_NOT_RECYCLED); + } + + // 2. 仅删除所选节点及其共享、收藏,不递归删除子节点 + filePermissionService.deleteFilePermissionsByNodeIds(Collections.singleton(id)); + fileFavoriteService.deleteFileFavoritesByNodeIds(Collections.singleton(id)); + fileNodeMapper.deleteById(id); + } + + @Override + public OaFileNodeRespVO getFileNode(Long id, Long userId) { + // 1.1 校验节点及祖先均处于正常状态 + OaFileNodeDO node = validateFileNodeAvailable(id); + // 1.2 校验当前用户具有查看权限 + Map nodes = getFileNodeMap(Collections.singletonList(node)); + List permissions = filePermissionService.getFilePermissionListByNodeIds(nodes.keySet()); + int level = getLevel(node, nodes, permissions, userId, adminUserApi.getUser(userId).getDeptId()); + if (level < OaFilePermissionLevelEnum.READ.getLevel()) { + throw exception(FILE_NODE_ACCESS_DENIED); + } + + // 2. 查询当前用户的收藏状态 + List favorites = fileFavoriteService.getFileFavoriteListByUserIdAndNodeIds(userId, + Collections.singleton(id)); + + // 3. 拼接详情,仅为具有下载权限的文件签发临时地址 + OaFileNodeRespVO respVO = BeanUtils.toBean(node, OaFileNodeRespVO.class) + .setLevel(level) + .setFavorite(CollUtil.isNotEmpty(favorites)) + .setUrl(null); + if (ObjUtil.equal(node.getType(), OaFileNodeTypeEnum.FILE.getType()) + && level >= OaFilePermissionLevelEnum.DOWNLOAD.getLevel()) { + respVO.setUrl(fileApi.presignGetUrl(node.getUrl(), properties.getFile().getDownloadUrlExpireSeconds())); + } + return respVO; + } + + // ==================== 校验与权限计算 ==================== + + /** + * 获得共享给当前用户的候选节点 + * + * @param reqVO 查询条件 + * @param userId 当前用户编号 + * @param deptId 当前用户部门编号 + * @return 直接共享及目录继承范围内的候选节点,祖先状态和最终权限由调用方校验 + */ + private List getSharedFileNodeList(OaFileNodePageReqVO reqVO, Long userId, Long deptId) { + // 1. 查询本人、部门授权,以及本人创建且可向子节点传递管理权限的目录 + List permissions = filePermissionService.getValidFilePermissionList(userId, deptId); + Set sharedNodeIds = convertSet(permissions, OaFilePermissionDO::getNodeId); + Set inheritedParentIds = convertSet(fileNodeMapper.selectListByCreatorAndTypeAndStatus(userId.toString(), + OaFileNodeTypeEnum.FOLDER.getType(), OaFileNodeStatusEnum.NORMAL.getStatus()), OaFileNodeDO::getId); + inheritedParentIds.addAll(convertSet(permissions, OaFilePermissionDO::getNodeId, + permission -> Boolean.TRUE.equals(permission.getInherit()))); + + // 2. 按层批量补齐可继承的子目录,只查询目录,不拉取整棵文件树 + Collection parentIds = inheritedParentIds; + for (int i = 0; i < Short.MAX_VALUE && CollUtil.isNotEmpty(parentIds); i++) { + List children = fileNodeMapper.selectListByParentIdsAndTypeAndStatus(parentIds, + OaFileNodeTypeEnum.FOLDER.getType(), OaFileNodeStatusEnum.NORMAL.getStatus()); + parentIds = convertSet(children, OaFileNodeDO::getId, node -> !inheritedParentIds.contains(node.getId())); + inheritedParentIds.addAll(parentIds); + } + if (CollUtil.isEmpty(sharedNodeIds) && CollUtil.isEmpty(inheritedParentIds)) { + return Collections.emptyList(); + } + + // 3. 将授权编号、可继承目录和搜索条件交给 Mapper,进一步缩小文件候选集 + return fileNodeMapper.selectListBySharedNodeIdsOrParentIdsAndCreatorNot(reqVO, userId, sharedNodeIds, inheritedParentIds); + } + + /** + * 获得候选节点及全部祖先 + * + * @param candidates 候选节点 + * @return 节点及祖先 Map + */ + private Map getFileNodeMap(Collection candidates) { + Map nodes = convertMap(candidates, OaFileNodeDO::getId); + Collection currentNodes = candidates; + for (int i = 0; i < Short.MAX_VALUE; i++) { + Set parentIds = convertSet(currentNodes, OaFileNodeDO::getParentId, + node -> node.getParentId() != null + && ObjUtil.notEqual(node.getParentId(), OaFileNodeDO.PARENT_ID_ROOT) + && !nodes.containsKey(node.getParentId())); + if (CollUtil.isEmpty(parentIds)) { + return nodes; + } + currentNodes = fileNodeMapper.selectByIds(parentIds); + nodes.putAll(convertMap(currentNodes, OaFileNodeDO::getId)); + } + throw exception(FILE_NODE_PATH_INVALID); + } + + /** + * 校验文件节点由当前用户创建 + * + * @param node 文件节点 + * @param userId 当前用户编号 + */ + private void validateOwner(OaFileNodeDO node, Long userId) { + if (ObjUtil.notEqual(userId.toString(), node.getCreator())) { + throw exception(FILE_NODE_ACCESS_DENIED); + } + } + + /** + * 获得目标目录中的可用名称,同名时在文件扩展名前追加序号 + * + * @param node 源节点 + * @param parentId 目标目录编号 + * @param userId 当前用户编号 + * @param excludeId 排除的节点编号,新增或复制时为空 + * @return 不与目标目录正常节点重名的名称 + */ + private String getAvailableFileNodeName(OaFileNodeDO node, Long parentId, Long userId, Long excludeId) { + List siblings = fileNodeMapper.selectListByParentId(parentId); + Set names = convertSet(siblings, item -> item.getName().toLowerCase(Locale.ROOT), + item -> ObjUtil.equal(item.getStatus(), OaFileNodeStatusEnum.NORMAL.getStatus()) + && ObjUtil.notEqual(item.getId(), excludeId) + && (ObjUtil.notEqual(parentId, OaFileNodeDO.PARENT_ID_ROOT) + || userId.toString().equals(item.getCreator()))); + String name = node.getName(); + String baseName = ObjUtil.equal(node.getType(), OaFileNodeTypeEnum.FILE.getType()) + ? StrUtil.blankToDefault(FileUtil.mainName(name), name) : name; + String suffix = StrUtil.removePrefix(name, baseName); + for (int index = 1; names.contains(name.toLowerCase(Locale.ROOT)); index++) { + String number = "(" + index + ")"; + name = StrUtil.subPre(baseName, Math.max(0, 255 - suffix.length() - number.length())) + number + suffix; + } + return name; + } + + /** + * 判断节点是否位于根目录或可用的父级路径下 + * + * @param node 文件节点 + * @param nodes 节点及祖先 Map + * @return 父级路径是否可用 + */ + private boolean isAvailableParent(OaFileNodeDO node, Map nodes) { + return ObjUtil.equal(node.getParentId(), OaFileNodeDO.PARENT_ID_ROOT) + || isAvailable(nodes.get(node.getParentId()), nodes); + } + + /** + * 判断节点及祖先是否正常,并排除断链和循环路径 + * + * @param node 文件节点 + * @param nodes 节点及祖先 Map + * @return 节点路径是否可用 + */ + private boolean isAvailable(OaFileNodeDO node, Map nodes) { + Set visited = new HashSet<>(); + for (int i = 0; i < Short.MAX_VALUE && node != null && visited.add(node.getId()); i++) { + if (ObjUtil.notEqual(node.getStatus(), OaFileNodeStatusEnum.NORMAL.getStatus())) { + return false; + } + if (ObjUtil.equal(node.getParentId(), OaFileNodeDO.PARENT_ID_ROOT)) { + return true; + } + node = nodes.get(node.getParentId()); + if (node != null && ObjUtil.notEqual(node.getType(), OaFileNodeTypeEnum.FOLDER.getType())) { + return false; + } + } + return false; + } + + /** + * 计算本人、用户共享及部门共享中的最高权限等级 + * + * @param node 当前节点 + * @param nodes 当前节点及祖先 + * @param permissions 相关节点的共享权限 + * @param userId 当前用户编号 + * @param deptId 当前用户部门编号 + * @return 有效权限等级,无授权时返回 LEVEL_NONE + */ + private int getLevel(OaFileNodeDO node, Map nodes, + List permissions, Long userId, Long deptId) { + // 本人创建的节点直接拥有管理权限 + if (userId.toString().equals(node.getCreator())) { + return OaFilePermissionLevelEnum.MANAGE.getLevel(); + } + int level = OaFilePermissionLevelEnum.LEVEL_NONE; + Long nodeId = node.getId(); + Set visited = new HashSet<>(); + for (int i = 0; i < Short.MAX_VALUE && node != null && visited.add(node.getId()); i++) { + // 仅计算当前节点授权,以及祖先允许继承且尚未过期的授权 + for (OaFilePermissionDO permission : permissions) { + if (ObjUtil.notEqual(permission.getNodeId(), node.getId()) + || (permission.getExpireTime() != null && !permission.getExpireTime().isAfter(LocalDateTime.now())) + || (ObjUtil.notEqual(nodeId, node.getId()) && !Boolean.TRUE.equals(permission.getInherit()))) { + continue; + } + if ((ObjUtil.equal(permission.getSubjectType(), OaFileSubjectTypeEnum.USER.getType()) + && ObjUtil.equal(permission.getSubjectId(), userId)) + || (ObjUtil.equal(permission.getSubjectType(), OaFileSubjectTypeEnum.DEPT.getType()) + && deptId != null && ObjUtil.equal(permission.getSubjectId(), deptId))) { + level = Math.max(level, permission.getLevel()); + } + } + // 目录创建人对该目录下新增的文件保留管理能力 + if (userId.toString().equals(node.getCreator())) { + level = OaFilePermissionLevelEnum.MANAGE.getLevel(); + } + node = nodes.get(node.getParentId()); + } + return level; + } + + /** + * 校验文件节点存在 + * + * @param id 节点编号 + * @return 文件节点 + */ + private OaFileNodeDO validateFileNodeExists(Long id) { + OaFileNodeDO node = fileNodeMapper.selectById(id); + if (node == null) { + throw exception(FILE_NODE_NOT_EXISTS); + } + return node; + } + + @Override + public OaFileNodeDO validateFileNodeAvailable(Long id) { + // 1. 校验目标节点存在,逻辑删除的节点由 Mapper 自动过滤 + OaFileNodeDO node = validateFileNodeExists(id); + + // 2. 校验节点及祖先状态,防止通过搜索、收藏或直接编号访问回收目录中的文件 + Set visitedIds = new HashSet<>(); + OaFileNodeDO current = node; + for (int i = 0; i < Short.MAX_VALUE; i++) { + // 2.1 拒绝循环目录,避免异常数据导致无限向上查询 + if (!visitedIds.add(current.getId())) { + throw exception(FILE_NODE_PATH_INVALID); + } + // 2.2 任意一层已回收时,该节点均不可访问 + if (ObjUtil.notEqual(current.getStatus(), OaFileNodeStatusEnum.NORMAL.getStatus())) { + throw exception(FILE_NODE_NOT_AVAILABLE); + } + if (ObjUtil.equal(current.getParentId(), OaFileNodeDO.PARENT_ID_ROOT)) { + return node; + } + // 2.3 父节点必须存在且为目录,不能将断开的目录链视为根目录 + if (current.getParentId() == null) { + throw exception(FILE_NODE_PATH_INVALID); + } + current = fileNodeMapper.selectById(current.getParentId()); + if (current == null || ObjUtil.notEqual(current.getType(), OaFileNodeTypeEnum.FOLDER.getType())) { + throw exception(FILE_NODE_PATH_INVALID); + } + } + throw exception(FILE_NODE_PATH_INVALID); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFilePermissionService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFilePermissionService.java new file mode 100644 index 0000000000..ba05232445 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFilePermissionService.java @@ -0,0 +1,66 @@ +package cn.iocoder.yudao.module.oa.service.file; + +import cn.iocoder.yudao.module.oa.controller.admin.file.vo.permission.OaFilePermissionSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.file.OaFilePermissionDO; + +import java.util.Collection; +import java.util.List; + +/** + * 云盘共享权限 Service 接口 + * + * @author 芋道源码 + */ +public interface OaFilePermissionService { + + /** + * 获得指定节点的共享权限,供节点列表批量计算权限 + * + * @param nodeIds 节点编号集合 + * @return 共享权限列表 + */ + List getFilePermissionListByNodeIds(Collection nodeIds); + + /** + * 获得当前用户及所属部门尚未到期的共享权限 + * + * @param userId 用户编号 + * @param deptId 部门编号,允许为空 + * @return 有效共享权限列表 + */ + List getValidFilePermissionList(Long userId, Long deptId); + + /** + * 获得节点共享权限 + * + * @param nodeId 节点编号 + * @param userId 当前用户 + * @return 共享权限列表 + */ + List getFilePermissionList(Long nodeId, Long userId); + + /** + * 保存共享权限,同一主体覆盖更新 + * + * @param reqVO 共享信息 + * @param userId 当前用户 + * @return 权限编号 + */ + Long saveFilePermission(OaFilePermissionSaveReqVO reqVO, Long userId); + + /** + * 取消共享 + * + * @param id 权限编号 + * @param userId 当前用户 + */ + void deleteFilePermission(Long id, Long userId); + + /** + * 清理节点的全部共享权限 + * + * @param nodeIds 节点编号集合 + */ + void deleteFilePermissionsByNodeIds(Collection nodeIds); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFilePermissionServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFilePermissionServiceImpl.java new file mode 100644 index 0000000000..021b509f4d --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/file/OaFilePermissionServiceImpl.java @@ -0,0 +1,121 @@ +package cn.iocoder.yudao.module.oa.service.file; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.file.vo.permission.OaFilePermissionSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.file.OaFilePermissionDO; +import cn.iocoder.yudao.module.oa.dal.mysql.file.OaFilePermissionMapper; +import cn.iocoder.yudao.module.oa.enums.file.OaFilePermissionLevelEnum; +import cn.iocoder.yudao.module.oa.enums.file.OaFileSubjectTypeEnum; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +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.oa.enums.ErrorCodeConstants.*; + +/** + * 云盘共享权限 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaFilePermissionServiceImpl implements OaFilePermissionService { + + @Resource + private OaFilePermissionMapper filePermissionMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private OaFileNodeService fileNodeService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + @Override + public List getFilePermissionListByNodeIds(Collection nodeIds) { + if (CollUtil.isEmpty(nodeIds)) { + return Collections.emptyList(); + } + return filePermissionMapper.selectListByNodeIds(nodeIds); + } + + @Override + public List getValidFilePermissionList(Long userId, Long deptId) { + return filePermissionMapper.selectListByUserIdAndDeptIdAndExpireTimeAfter(userId, deptId, LocalDateTime.now()); + } + + @Override + public List getFilePermissionList(Long nodeId, Long userId) { + // 1. 校验节点管理权限 + fileNodeService.validateFileNodePermission(nodeId, userId, OaFilePermissionLevelEnum.MANAGE.getLevel()); + // 2. 查询节点共享权限 + return filePermissionMapper.selectListByNodeId(nodeId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long saveFilePermission(OaFilePermissionSaveReqVO reqVO, Long userId) { + // 1.1 校验节点管理权限 + fileNodeService.lockFileNode(reqVO.getNodeId()); + fileNodeService.validateFileNodePermission(reqVO.getNodeId(), userId, OaFilePermissionLevelEnum.MANAGE.getLevel()); + // 1.2 校验共享主体确实存在 + if (ObjUtil.equal(reqVO.getSubjectType(), OaFileSubjectTypeEnum.USER.getType())) { + adminUserApi.validateUser(reqVO.getSubjectId()); + } else if (ObjUtil.equal(reqVO.getSubjectType(), OaFileSubjectTypeEnum.DEPT.getType())) { + deptApi.validateDeptList(Collections.singleton(reqVO.getSubjectId())); + } else { + throw new IllegalArgumentException("不支持的共享主体类型"); + } + OaFilePermissionDO existing = filePermissionMapper.selectByNodeIdAndSubjectTypeAndSubjectId( + reqVO.getNodeId(), reqVO.getSubjectType(), reqVO.getSubjectId()); + + // 2.1 情况一:已有主体更新共享权限 + if (existing != null) { + OaFilePermissionDO permission = BeanUtils.toBean(reqVO, OaFilePermissionDO.class).setId(existing.getId()); + filePermissionMapper.updatePermission(permission); + return existing.getId(); + } + + // 2.2 情况二:新增主体共享权限 + OaFilePermissionDO permission = BeanUtils.toBean(reqVO, OaFilePermissionDO.class); + filePermissionMapper.insert(permission); + return permission.getId(); + } + + @Override + public void deleteFilePermission(Long id, Long userId) { + // 1.1 校验共享记录存在 + OaFilePermissionDO permission = filePermissionMapper.selectById(id); + if (permission == null) { + throw exception(FILE_PERMISSION_NOT_EXISTS); + } + // 1.2 校验节点管理权限 + fileNodeService.validateFileNodePermission(permission.getNodeId(), userId, OaFilePermissionLevelEnum.MANAGE.getLevel()); + + // 2. 逻辑删除共享记录 + filePermissionMapper.deleteById(id); + } + + @Override + public void deleteFilePermissionsByNodeIds(Collection nodeIds) { + if (CollUtil.isEmpty(nodeIds)) { + return; + } + filePermissionMapper.deleteByNodeIds(nodeIds); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/leave/OaLeaveApplyService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/leave/OaLeaveApplyService.java new file mode 100644 index 0000000000..e5d49e8149 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/leave/OaLeaveApplyService.java @@ -0,0 +1,78 @@ +package cn.iocoder.yudao.module.oa.service.leave; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.leave.vo.OaLeaveApplySaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.leave.vo.OaLeaveApplySubmitReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.leave.vo.OaLeaveApplyPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.leave.OaLeaveApplyDO; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Map; + +/** + * 请假申请 Service 接口 + * + * @author 芋道源码 + */ +public interface OaLeaveApplyService { + + /** + * 创建请假申请草稿 + * + * @param saveReqVO 申请内容 + * @return 申请编号 + */ + Long createLeaveApply(OaLeaveApplySaveReqVO saveReqVO); + + /** + * 更新本人请假申请草稿 + * + * @param saveReqVO 申请内容 + * @param userId 当前用户编号 + */ + void updateLeaveApply(OaLeaveApplySaveReqVO saveReqVO, Long userId); + + /** + * 提交本人请假申请草稿并发起审批 + * + * @param submitReqVO 申请编号和自选审批人 + * @param userId 当前用户编号 + */ + void submitLeaveApply(OaLeaveApplySubmitReqVO submitReqVO, Long userId); + + /** + * 获得请假申请 + * + * @param id 申请编号 + * @return 请假申请 + */ + OaLeaveApplyDO getLeaveApply(Long id); + + /** + * 获得本人请假申请分页 + * + * @param userId 申请人编号 + * @param pageReqVO 分页条件 + * @return 申请分页 + */ + PageResult getLeaveApplyPage(Long userId, OaLeaveApplyPageReqVO pageReqVO); + + /** + * 获得用户审批通过的请假天数合计 + * + * @param userIds 用户编号集合 + * @param startTime 申请开始时间范围,包含边界;跨月申请整笔计入开始月份 + * @return 用户编号与请假天数的映射,无申请的用户不返回 + */ + Map getApprovedLeaveDaysMap(Collection userIds, LocalDateTime[] startTime); + + /** + * 更新请假申请审批状态 + * + * @param id 申请编号 + * @param status 审批状态 + */ + void updateLeaveApplyStatus(Long id, Integer status); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/leave/OaLeaveApplyServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/leave/OaLeaveApplyServiceImpl.java new file mode 100644 index 0000000000..e03a48acf7 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/leave/OaLeaveApplyServiceImpl.java @@ -0,0 +1,194 @@ +package cn.iocoder.yudao.module.oa.service.leave; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils; +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.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.controller.admin.leave.vo.OaLeaveApplyPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.leave.vo.OaLeaveApplySaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.leave.vo.OaLeaveApplySubmitReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.leave.OaLeaveApplyDO; +import cn.iocoder.yudao.module.oa.dal.mysql.leave.OaLeaveApplyMapper; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import cn.iocoder.yudao.module.oa.enums.leave.OaLeaveTypeEnum; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import cn.iocoder.yudao.module.oa.service.attendance.OaAttendanceService; +import cn.iocoder.yudao.module.oa.enums.attendance.OaAttendanceTypeEnum; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; + +/** + * 请假申请 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaLeaveApplyServiceImpl implements OaLeaveApplyService { + + @Resource + @Lazy // 延迟,避免循环依赖报错 + private OaAttendanceService attendanceService; + + @Resource + private OaLeaveApplyMapper leaveApplyMapper; + + @Resource + private BpmProcessInstanceApi processInstanceApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createLeaveApply(OaLeaveApplySaveReqVO saveReqVO) { + // 1. 计算并校验单次请假天数 + Integer days = LocalDateTimeUtils.getDaysBetweenCeiling(saveReqVO.getStartTime(), saveReqVO.getEndTime()); + validateLeaveApplyDays(saveReqVO.getType(), days); + + // 2. 转换请假内容,保存草稿 + OaLeaveApplyDO leaveApply = BeanUtils.toBean(saveReqVO, OaLeaveApplyDO.class) + .setId(null).setStatus(BpmProcessInstanceStatusEnum.NOT_START.getStatus()) + .setDays(days); + leaveApplyMapper.insert(leaveApply); + return leaveApply.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateLeaveApply(OaLeaveApplySaveReqVO saveReqVO, Long userId) { + // 1.1 校验申请可修改 + validateLeaveApplyEditable(saveReqVO.getId(), userId); + // 1.2 计算并校验单次请假天数 + Integer days = LocalDateTimeUtils.getDaysBetweenCeiling(saveReqVO.getStartTime(), saveReqVO.getEndTime()); + validateLeaveApplyDays(saveReqVO.getType(), days); + + // 2. 更新请假申请 + OaLeaveApplyDO leaveApply = BeanUtils.toBean(saveReqVO, OaLeaveApplyDO.class) + .setDays(days); + leaveApplyMapper.updateById(leaveApply); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void submitLeaveApply(OaLeaveApplySubmitReqVO submitReqVO, Long userId) { + // 1.1 校验申请可提交 + OaLeaveApplyDO leaveApply = validateLeaveApplyEditable(submitReqVO.getId(), userId); + // 1.2 校验历史草稿的单次请假天数 + validateLeaveApplyDays(leaveApply.getType(), leaveApply.getDays()); + + // 2. 更新为审批中 + leaveApplyMapper.updateById(new OaLeaveApplyDO().setId(leaveApply.getId()) + .setStatus(BpmProcessInstanceStatusEnum.RUNNING.getStatus())); + + // 3.1 发起审批 + Map variables = new HashMap<>(); + variables.put("days", leaveApply.getDays()); + String processInstanceId = processInstanceApi.createProcessInstance(userId, + new BpmProcessInstanceCreateReqDTO().setProcessDefinitionKey(BpmModelConstants.LEAVE_APPLY) + .setBusinessKey(leaveApply.getId().toString()).setVariables(variables) + .setStartUserSelectAssignees(submitReqVO.getStartUserSelectAssignees())); + // 3.2 绑定流程编号 + leaveApplyMapper.updateById(new OaLeaveApplyDO().setId(leaveApply.getId()).setProcessInstanceId(processInstanceId)); + } + + @Override + public OaLeaveApplyDO getLeaveApply(Long id) { + return leaveApplyMapper.selectById(id); + } + + @Override + public PageResult getLeaveApplyPage(Long userId, OaLeaveApplyPageReqVO pageReqVO) { + return leaveApplyMapper.selectPage(userId, pageReqVO); + } + + @Override + public Map getApprovedLeaveDaysMap(Collection userIds, LocalDateTime[] startTime) { + if (CollUtil.isEmpty(userIds)) { + return Collections.emptyMap(); + } + return leaveApplyMapper.selectDaysMapByCreatorsAndStatusAndStartTime( + userIds, BpmProcessInstanceStatusEnum.APPROVE.getStatus(), startTime); + } + + /** + * 校验单次请假天数,不校验年度累计额度 + * + * @param type 请假类型 + * @param days 请假天数 + */ + private void validateLeaveApplyDays(Integer type, Integer days) { + OaLeaveTypeEnum leaveType = OaLeaveTypeEnum.valueOf(type); + if (leaveType != null && leaveType.getMaxDays() != null && days > leaveType.getMaxDays()) { + throw exception(LEAVE_APPLY_DAYS_EXCEEDED, leaveType.getName(), leaveType.getMaxDays()); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateLeaveApplyStatus(Long id, Integer status) { + // 1. 校验申请存在 + OaLeaveApplyDO apply = validateLeaveApplyExists(id); + + // 2. 更新审批状态 + leaveApplyMapper.updateById(new OaLeaveApplyDO().setId(id).setStatus(status)); + + // 3. 审批通过后生成考勤明细 + if (ObjUtil.equal(status, BpmProcessInstanceStatusEnum.APPROVE.getStatus())) { + attendanceService.createApplyAttendance(Long.valueOf(apply.getCreator()), + OaAttendanceTypeEnum.LEAVE, apply.getStartTime()); + } + } + + /** + * 校验申请可修改或提交 + * + * @param id 申请编号 + * @param userId 当前用户编号 + * @return 本人的未提交申请 + */ + private OaLeaveApplyDO validateLeaveApplyEditable(Long id, Long userId) { + // 1. 查询申请 + OaLeaveApplyDO leaveApply = leaveApplyMapper.selectById(id); + if (leaveApply == null) { + throw exception(APPLY_NOT_EXISTS); + } + // 2. 校验本人归属 + if (ObjUtil.notEqual(leaveApply.getCreator(), userId.toString())) { + throw exception(APPLY_ACCESS_DENIED); + } + // 3. 只有未提交草稿允许修改或提交 + if (ObjUtil.notEqual(leaveApply.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus())) { + throw exception(APPLY_STATUS_INVALID); + } + return leaveApply; + } + + /** + * 校验请假申请存在 + * + * @param id 申请编号 + * @return 申请 + */ + @SuppressWarnings("UnusedReturnValue") + private OaLeaveApplyDO validateLeaveApplyExists(Long id) { + OaLeaveApplyDO leaveApply = leaveApplyMapper.selectById(id); + if (leaveApply == null) { + throw exception(APPLY_NOT_EXISTS); + } + return leaveApply; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/leave/listener/OaLeaveApplyStatusListener.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/leave/listener/OaLeaveApplyStatusListener.java new file mode 100644 index 0000000000..0039eac654 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/leave/listener/OaLeaveApplyStatusListener.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.oa.service.leave.listener; + +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import cn.iocoder.yudao.module.oa.service.leave.OaLeaveApplyService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +/** + * 请假申请审批结果监听器 + * + * @author 芋道源码 + */ +@Component +public class OaLeaveApplyStatusListener extends BpmProcessInstanceStatusEventListener { + + @Resource + private OaLeaveApplyService leaveApplyService; + + @Override + protected String getProcessDefinitionKey() { + return BpmModelConstants.LEAVE_APPLY; + } + + @Override + protected void onEvent(BpmProcessInstanceStatusEvent event) { + leaveApplyService.updateLeaveApplyStatus(Long.valueOf(event.getBusinessKey()), event.getStatus()); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailAccountService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailAccountService.java new file mode 100644 index 0000000000..f26b91541d --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailAccountService.java @@ -0,0 +1,102 @@ +package cn.iocoder.yudao.module.oa.service.mail; +import cn.iocoder.yudao.module.oa.controller.admin.mail.vo.account.OaMailAccountSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.mail.OaMailAccountDO; + +import jakarta.validation.Valid; + +import java.util.List; +import java.util.Map; + +/** + * 企业邮箱账号 Service 接口 + * + * @author 芋道源码 + */ +public interface OaMailAccountService { + + /** + * 锁定当前用户的邮箱账号 + * + * @param id 邮箱账号编号 + * @param userId 当前用户编号 + * @return 锁定的邮箱账号 + */ + OaMailAccountDO lockMailAccount(Long id, Long userId); + + /** + * 创建邮箱账号 + * + * @param reqVO 邮箱账号信息 + * @param userId 当前用户编号 + * @return 邮箱账号编号 + */ + Long createMailAccount(@Valid OaMailAccountSaveReqVO reqVO, Long userId); + + /** + * 更新邮箱账号 + * + * @param reqVO 邮箱账号信息,密码为空时保留原密码 + * @param userId 当前用户编号 + */ + void updateMailAccount(@Valid OaMailAccountSaveReqVO reqVO, Long userId); + + /** + * 删除邮箱账号 + * + * @param id 邮箱账号编号 + * @param userId 当前用户编号 + */ + void deleteMailAccount(Long id, Long userId); + + /** + * 设置默认邮箱账号 + * + * @param id 邮箱账号编号 + * @param userId 当前用户编号 + */ + void updateMailAccountDefault(Long id, Long userId); + + /** + * 获得当前用户的邮箱账号 + * + * @param id 邮箱账号编号 + * @param userId 当前用户编号 + * @return 邮箱账号 + */ + OaMailAccountDO validateMailAccount(Long id, Long userId); + + /** + * 获得当前用户的邮箱账号列表 + * + * @param userId 当前用户编号 + * @param status 账号状态,空时查询全部状态 + * @return 邮箱账号列表 + */ + List getMailAccountListByUserIdAndStatus(Long userId, Integer status); + + /** + * 获得当前租户指定状态的邮箱地址列表 + * + * @param status 账号状态 + * @return 邮箱账号列表,仅包含地址和归属人 + */ + List getMailAccountListByStatus(Integer status); + + /** + * 获得邮箱服务配置关联的账号数量 + * + * @param providerId 邮箱服务配置编号 + * @return 邮箱账号数量 + */ + Long getMailAccountCountByProviderId(Long providerId); + + /** + * 测试邮箱账号连接:分别测试 IMAP 和 SMTP 认证,不发送邮件 + * + * @param id 邮箱账号编号 + * @param userId 当前用户编号 + * @return IMAP、SMTP 协议与连接是否成功的映射 + */ + Map testMailAccountConnection(Long id, Long userId); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailAccountServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailAccountServiceImpl.java new file mode 100644 index 0000000000..0620c35767 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailAccountServiceImpl.java @@ -0,0 +1,200 @@ +package cn.iocoder.yudao.module.oa.service.mail; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.mail.vo.account.OaMailAccountSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.mail.OaMailAccountDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.mail.OaMailProviderDO; +import cn.iocoder.yudao.module.oa.dal.mysql.mail.OaMailAccountMapper; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; + +/** + * 企业邮箱账号 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaMailAccountServiceImpl implements OaMailAccountService { + + @Resource(name = "oaMailAccountMapper") // 区分 System 模块的同名 Mapper + private OaMailAccountMapper mailAccountMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private OaMailProviderService mailProviderService; + + @Resource + private OaMailMessageClient mailMessageClient; + + @Override + public OaMailAccountDO lockMailAccount(Long id, Long userId) { + // 1. 校验本人账号 + validateMailAccount(id, userId); + + // 2. 加锁后重新确认归属 + OaMailAccountDO account = mailAccountMapper.selectByIdForUpdate(id); + if (account == null || ObjUtil.notEqual(userId.toString(), account.getCreator())) { + throw exception(MAIL_ACCOUNT_NOT_EXISTS); + } + return account; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createMailAccount(OaMailAccountSaveReqVO reqVO, Long userId) { + // 1.1 校验服务配置启用 + validateMailProviderEnabled(reqVO.getProviderId()); + // 1.2 创建时必须提供凭据 + if (StrUtil.isBlank(reqVO.getPassword())) { + throw exception(MAIL_ACCOUNT_PASSWORD_REQUIRED); + } + + // 2. 锁定本人账号,按请求调整默认值 + List accounts = mailAccountMapper.selectListByCreatorForUpdate(userId.toString()); + if (Boolean.TRUE.equals(reqVO.getDefaultStatus())) { + clearDefaultAccounts(accounts); + } + + // 3. 新增账号 + OaMailAccountDO account = BeanUtils.toBean(reqVO, OaMailAccountDO.class).setId(null); + mailAccountMapper.insert(account); + return account.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateMailAccount(OaMailAccountSaveReqVO reqVO, Long userId) { + // 1.1 锁定本人账号并校验归属 + List accounts = mailAccountMapper.selectListByCreatorForUpdate(userId.toString()); + OaMailAccountDO oldAccount = validateMailAccount(reqVO.getId(), userId); + // 1.2 绑定身份固定,换邮箱需新增绑定,避免旧邮件归到新账号 + if (ObjUtil.notEqual(oldAccount.getProviderId(), reqVO.getProviderId()) + || ObjUtil.notEqual(oldAccount.getMail(), reqVO.getMail()) + || ObjUtil.notEqual(oldAccount.getUsername(), reqVO.getUsername())) { + throw exception(MAIL_ACCOUNT_IDENTITY_IMMUTABLE); + } + // 1.3 校验服务配置 + validateMailProviderEnabled(reqVO.getProviderId()); + + // 2. 更新默认账号及凭据 + if (Boolean.TRUE.equals(reqVO.getDefaultStatus())) { + clearDefaultAccounts(accounts); + } + OaMailAccountDO account = BeanUtils.toBean(reqVO, OaMailAccountDO.class) + .setPassword(StrUtil.blankToDefault(reqVO.getPassword(), null)); + mailAccountMapper.updateById(account); + } + + @Override + public void deleteMailAccount(Long id, Long userId) { + // 1. 校验本人账号 + validateMailAccount(id, userId); + + // 2. 仅删除本地绑定 + mailAccountMapper.deleteById(id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateMailAccountDefault(Long id, Long userId) { + // 1.1 锁定当前用户的邮箱账号列表 + List accounts = mailAccountMapper.selectListByCreatorForUpdate(userId.toString()); + // 1.2 校验目标账号归属 + OaMailAccountDO account = validateMailAccount(id, userId); + // 1.3 校验目标账号已启用 + if (CommonStatusEnum.isDisable(account.getStatus())) { + throw exception(MAIL_ACCOUNT_DISABLED); + } + // 1.4 校验邮箱服务配置存在且启用 + validateMailProviderEnabled(account.getProviderId()); + + // 2.1 清除原默认账号 + clearDefaultAccounts(accounts); + // 2.2 设置目标账号为默认账号 + mailAccountMapper.updateById(new OaMailAccountDO().setId(id).setDefaultStatus(true)); + } + + @Override + public OaMailAccountDO validateMailAccount(Long id, Long userId) { + OaMailAccountDO account = mailAccountMapper.selectById(id); + if (account == null || ObjUtil.notEqual(userId.toString(), account.getCreator())) { + throw exception(MAIL_ACCOUNT_NOT_EXISTS); + } + return account; + } + + @Override + public List getMailAccountListByUserIdAndStatus(Long userId, Integer status) { + return mailAccountMapper.selectListByCreatorAndStatus(userId.toString(), status); + } + + @Override + public List getMailAccountListByStatus(Integer status) { + return mailAccountMapper.selectListByStatus(status); + } + + @Override + public Long getMailAccountCountByProviderId(Long providerId) { + return mailAccountMapper.selectCountByProviderId(providerId); + } + + @Override + public Map testMailAccountConnection(Long id, Long userId) { + // 1.1 校验账号归属和启用状态 + OaMailAccountDO account = validateMailAccount(id, userId); + if (CommonStatusEnum.isDisable(account.getStatus())) { + throw exception(MAIL_ACCOUNT_DISABLED); + } + // 1.2 校验服务配置 + OaMailProviderDO provider = validateMailProviderEnabled(account.getProviderId()); + + // 2. 分别连接两个协议,不发送真实邮件 + String password = account.getPassword(); + Map result = new LinkedHashMap<>(); + result.put("imap", mailMessageClient.testConnection(provider.getImap(), account.getUsername(), password, true)); + result.put("smtp", mailMessageClient.testConnection(provider.getSmtp(), account.getUsername(), password, false)); + return result; + } + + /** + * 校验邮箱服务配置存在且启用 + * + * @param providerId 邮箱服务配置编号 + * @return 邮箱服务配置 + */ + private OaMailProviderDO validateMailProviderEnabled(Long providerId) { + OaMailProviderDO provider = mailProviderService.validateMailProviderExists(providerId); + if (CommonStatusEnum.isDisable(provider.getStatus())) { + throw exception(MAIL_PROVIDER_DISABLED); + } + return provider; + } + + /** + * 清除邮箱账号的默认标志 + * + * @param accounts 已加锁的邮箱账号列表 + */ + private void clearDefaultAccounts(List accounts) { + for (OaMailAccountDO account : accounts) { + if (Boolean.TRUE.equals(account.getDefaultStatus())) { + mailAccountMapper.updateById(new OaMailAccountDO().setId(account.getId()).setDefaultStatus(false)); + } + } + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailFolderService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailFolderService.java new file mode 100644 index 0000000000..d5c8a10500 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailFolderService.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.oa.service.mail; + +import cn.iocoder.yudao.module.oa.dal.dataobject.mail.OaMailFolderDO; +import java.util.List; + +/** + * 企业邮箱文件夹 Service 接口 + * + * @author 芋道源码 + */ +public interface OaMailFolderService { + + /** + * 获得邮箱文件夹列表 + * + * @param accountId 邮箱账号编号 + * @return 文件夹列表 + */ + List getMailFolderList(Long accountId); + + /** + * 获得文件夹 + * + * @param id 文件夹编号 + * @return 文件夹,不存在时返回 null + */ + OaMailFolderDO getMailFolder(Long id); + + /** + * 校验文件夹可用及账号归属 + * + * @param id 文件夹编号 + * @param accountId 邮箱账号编号 + * @return 文件夹 + */ + OaMailFolderDO validateMailFolder(Long id, Long accountId); + + /** + * 创建文件夹索引 + * + * @param folder 文件夹信息 + * @return 文件夹编号 + */ + Long createMailFolder(OaMailFolderDO folder); + + /** + * 更新文件夹同步状态 + * + * @param folder 文件夹更新信息 + */ + void updateMailFolder(OaMailFolderDO folder); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailFolderServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailFolderServiceImpl.java new file mode 100644 index 0000000000..7fe1f3d91b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailFolderServiceImpl.java @@ -0,0 +1,62 @@ +package cn.iocoder.yudao.module.oa.service.mail; + +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.module.oa.dal.dataobject.mail.OaMailFolderDO; +import cn.iocoder.yudao.module.oa.dal.mysql.mail.OaMailFolderMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.MAIL_FOLDER_NOT_AVAILABLE; + +/** + * 企业邮箱文件夹 Service 实现类 + * + * @author 芋道源码 + */ +@Service +public class OaMailFolderServiceImpl implements OaMailFolderService { + + @Resource + private OaMailFolderMapper mailFolderMapper; + + @Override + public List getMailFolderList(Long accountId) { + return mailFolderMapper.selectListByAccountId(accountId); + } + + @Override + public OaMailFolderDO getMailFolder(Long id) { + return mailFolderMapper.selectById(id); + } + + @Override + public OaMailFolderDO validateMailFolder(Long id, Long accountId) { + // 1. 校验文件夹存在且可用 + OaMailFolderDO folder = mailFolderMapper.selectById(id); + if (folder == null || Boolean.FALSE.equals(folder.getAvailable())) { + throw exception(MAIL_FOLDER_NOT_AVAILABLE); + } + // 2. 校验文件夹账号归属 + if (ObjUtil.notEqual(folder.getAccountId(), accountId)) { + throw exception(MAIL_FOLDER_NOT_AVAILABLE); + } + return folder; + } + + @Override + public Long createMailFolder(OaMailFolderDO folder) { + // 1. 保存文件夹索引 + mailFolderMapper.insert(folder); + return folder.getId(); + } + + @Override + public void updateMailFolder(OaMailFolderDO folder) { + // 1. 更新文件夹同步信息 + mailFolderMapper.updateById(folder); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailMessageClient.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailMessageClient.java new file mode 100644 index 0000000000..3c48c5ca48 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailMessageClient.java @@ -0,0 +1,216 @@ +package cn.iocoder.yudao.module.oa.service.mail; + +import cn.iocoder.yudao.module.oa.controller.admin.mail.vo.message.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.mail.*; +import jakarta.mail.*; +import jakarta.mail.internet.*; + +import java.io.IOException; +import cn.hutool.core.lang.Pair; +import java.util.List; +import java.util.Map; + +/** + * 企业邮箱协议 Client + * + * @author 芋道源码 + */ +public interface OaMailMessageClient { + + /** + * 建立邮箱连接 + * + * 连接前校验账号及邮箱服务配置的启用状态。 + * + * @param account 邮箱账号 + * @return 已建立的邮箱连接,由调用方负责关闭 + * @throws MessagingException 连接或认证失败 + */ + Store openStore(OaMailAccountDO account) throws MessagingException; + + /** + * 获得远端邮件文件夹 + * + * 优先按 SPECIAL-USE 属性识别标准目录,未声明属性时匹配常见名称,不创建远端文件夹。 + * + * @param store 已建立的邮箱连接 + * @return 文件夹类型与远端完整名称的映射,自建目录以 CUSTOM:完整名称作为键 + * @throws MessagingException 读取文件夹失败 + */ + Map getFolders(Store store) throws MessagingException; + + /** + * 根据文件夹名称获得标准类型 + * + * @param name 远端文件夹名称 + * @return 标准文件夹类型,无法识别时返回 null + */ + String getFolderType(String name); + + /** + * 获得文件夹的邮件索引列表 + * + * 分批抓取完整信封快照,超过数量上限时拒绝同步,避免误删未同步的旧索引。 + * + * @param folder 已打开的远端文件夹 + * @return 邮件索引列表,不包含正文缓存 + * @throws MessagingException 读取邮件索引失败 + */ + List getMessageList(Folder folder) throws MessagingException; + + /** + * 获得远端邮件 + * + * 校验文件夹 UID 有效期,避免使用过期索引读取或修改其他邮件。 + * + * @param folder 已打开的远端文件夹 + * @param index 本地邮件索引 + * @return 远端邮件 + * @throws MessagingException 读取远端邮件失败 + */ + Message getMessage(Folder folder, OaMailMessageDO index) throws MessagingException; + + /** + * 获得远端邮件详情 + * + * 不自动修改邮件已读状态。 + * + * @param folder 已打开的远端文件夹 + * @param index 本地邮件索引 + * @return 包含安全正文和附件元数据的邮件详情 + * @throws MessagingException 读取邮件信息失败 + * @throws IOException 读取邮件内容失败 + */ + OaMailMessageDO getDetail(Folder folder, OaMailMessageDO index) throws MessagingException, IOException; + + /** + * 过滤邮件 HTML 正文 + * + * 使用 HTML 白名单过滤,前端仍须通过 sandbox 和 CSP 限制脚本和外部资源。 + * + * @param html 原始 HTML 正文,允许为空 + * @return 过滤后的 HTML 正文 + */ + String sanitizeHtml(String html); + + /** + * 复制邮件 MIME 内容 + * + * @param message 原始邮件 + * @return 内存中的邮件副本 + * @throws MessagingException 读取邮件信息失败 + * @throws IOException 读取邮件内容失败 + */ + MimeMessage copyMessage(Message message) throws MessagingException, IOException; + + /** + * 删除远端邮件 + * + * 普通删除将邮件移入已删除文件夹;彻底删除只清除当前 UID,不执行全文件夹清除。 + * + * @param store 已建立的邮箱连接 + * @param folder 邮件所在的已打开文件夹 + * @param message 待删除的邮件 + * @param trash 已删除文件夹的完整名称,彻底删除时允许为空 + * @param permanent 是否彻底删除 + * @throws MessagingException 移动或删除邮件失败 + */ + void deleteMessage(Store store, Folder folder, Message message, String trash, boolean permanent) throws MessagingException; + + /** + * 将单封邮件移动到指定文件夹 + * + * @param store 邮箱连接 + * @param folder 已打开的源文件夹 + * @param message 邮件 + * @param target 目标文件夹完整名称 + * @throws MessagingException 移动失败 + */ + void moveMessage(Store store, Folder folder, Message message, String target) throws MessagingException; + + /** + * 构建待保存或发送的邮件 + * + * 草稿允许暂缺收件人,发送时校验收件人及邮件头。 + * + * @param account 发件邮箱账号 + * @param reqVO 邮件内容 + * @param send 是否用于发送,false 时用于保存草稿 + * @return MIME 邮件 + * @throws MessagingException 构建邮件失败 + */ + MimeMessage createMessage(OaMailAccountDO account, OaMailMessageSaveReqVO reqVO, boolean send) throws MessagingException; + + /** + * 解析邮箱地址 + * + * 校验地址格式,禁止邮件头注入。 + * + * @param values 邮箱地址列表,每项为一个完整地址 + * @return 邮箱地址数组,空列表返回空数组 + * @throws AddressException 邮箱地址格式不正确 + */ + InternetAddress[] parseAddresses(List values) throws AddressException; + + /** + * 复制原邮件附件 + * + * 用于转发邮件或编辑草稿,保留原邮件附件。 + * + * @param target 目标邮件 + * @param source 原邮件 + * @param attachmentParts 保留的附件路径,null 时保留全部 + * @throws MessagingException 处理邮件结构失败 + * @throws IOException 读取邮件内容失败 + */ + void copyAttachments(MimeMessage target, MimeMessage source, List attachmentParts) throws MessagingException, IOException; + + /** + * 读取邮件中的指定附件,不允许将正文作为附件下载 + * + * @param message 远端邮件 + * @param path 附件 MIME 路径 + * @return 附件名称和内容 + * @throws MessagingException 邮件结构读取失败 + * @throws IOException 附件内容读取失败 + */ + Pair getAttachment(Message message, String path) throws MessagingException, IOException; + + /** + * 通过 SMTP 发送邮件 + * + * 仅投递一次,不自动重试;服务器接收成功不代表最终送达。 + * + * @param account 发件邮箱账号 + * @param message 待发送的邮件 + * @throws MessagingException 连接失败或投递结果未确认 + */ + void sendMessage(OaMailAccountDO account, MimeMessage message) throws MessagingException; + + /** + * 建立 IMAP 连接 + * + * @param config IMAP 连接配置 + * @param username 登录用户名 + * @param password 密码或授权码 + * @return 已建立的邮箱连接,由调用方负责关闭 + * @throws MessagingException 连接或认证失败 + */ + Store openImapStore(OaMailProviderDO.ConnectionConfig config, String username, String password) + throws MessagingException; + + /** + * 测试邮箱连接 + * + * 仅测试协议认证,不发送邮件。 + * + * @param config 邮箱连接配置 + * @param username 登录用户名 + * @param password 密码或授权码 + * @param imap 是否测试 IMAP,false 时测试 SMTP + * @return 是否连接成功 + */ + boolean testConnection(OaMailProviderDO.ConnectionConfig config, String username, String password, + boolean imap); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailMessageClientImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailMessageClientImpl.java new file mode 100644 index 0000000000..c56033d489 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailMessageClientImpl.java @@ -0,0 +1,668 @@ +package cn.iocoder.yudao.module.oa.service.mail; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.core.lang.Pair; +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.io.FileUtil; +import jakarta.activation.DataHandler; +import jakarta.mail.util.ByteArrayDataSource; +import org.springframework.web.multipart.MultipartFile; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.mail.vo.message.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.mail.*; +import jakarta.annotation.Resource; +import jakarta.mail.*; +import jakarta.mail.internet.*; +import org.eclipse.angus.mail.imap.IMAPFolder; +import org.eclipse.angus.mail.imap.IMAPStore; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Entities; +import org.jsoup.nodes.Element; +import org.jsoup.safety.Safelist; +import org.springframework.stereotype.Component; +import org.springframework.context.annotation.Lazy; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.http.ContentType; +import cn.iocoder.yudao.framework.common.util.date.DateUtils; +import cn.iocoder.yudao.module.oa.enums.mail.OaMailFolderTypeEnum; +import cn.iocoder.yudao.module.oa.enums.mail.OaMailCapabilityEnum; +import lombok.extern.slf4j.Slf4j; +import java.util.*; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; + +/** + * 企业邮箱远端操作:保持账号隔离,禁止下载外链和隐式全文件夹清除。 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class OaMailMessageClientImpl implements OaMailMessageClient { + + /** + * 单文件夹一次完整同步的最大邮件数量 + */ + private static final int MAX_MESSAGES = 100000; + /** + * 每次向远端批量获取的邮件数量 + */ + private static final int MESSAGE_FETCH_BATCH_SIZE = 100; + + /** + * IMAP SPECIAL-USE 属性与标准目录类型的映射 + */ + private static final Map FOLDER_ATTRIBUTE_TYPES = new HashMap<>(); + /** + * 服务商常用目录名称与标准目录类型的映射,名称统一为小写 + */ + private static final Map FOLDER_NAME_TYPES = new HashMap<>(); + + /** + * MIME 多部件类型匹配符 + */ + private static final String MULTIPART_TYPE = "multipart/*"; + /** + * 同一正文的多种格式 + */ + private static final String MULTIPART_ALTERNATIVE_TYPE = "multipart/alternative"; + + static { + // 初始化 FOLDER_ATTRIBUTE_TYPES + FOLDER_ATTRIBUTE_TYPES.put("\\sent", OaMailFolderTypeEnum.SENT.getType()); + FOLDER_ATTRIBUTE_TYPES.put("\\drafts", OaMailFolderTypeEnum.DRAFTS.getType()); + FOLDER_ATTRIBUTE_TYPES.put("\\trash", OaMailFolderTypeEnum.TRASH.getType()); + // 初始化 FOLDER_NAME_TYPES + for (String name : Arrays.asList("sent", "sent messages", "sent items", "已发送", "已发送邮件")) { + FOLDER_NAME_TYPES.put(name, OaMailFolderTypeEnum.SENT.getType()); + } + for (String name : Arrays.asList("drafts", "draft", "草稿箱", "草稿")) { + FOLDER_NAME_TYPES.put(name, OaMailFolderTypeEnum.DRAFTS.getType()); + } + for (String name : Arrays.asList("trash", "deleted messages", "deleted items", "已删除", "废纸篓")) { + FOLDER_NAME_TYPES.put(name, OaMailFolderTypeEnum.TRASH.getType()); + } + } + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private OaMailProviderService mailProviderService; + + // ==================== 连接入口 ==================== + + @Override + public Store openStore(OaMailAccountDO account) throws MessagingException { + OaMailProviderDO provider = getProvider(account); + return openImapStore(provider.getImap(), account.getUsername(), account.getPassword()); + } + + // ==================== 文件夹识别 ==================== + + @Override + public Map getFolders(Store store) throws MessagingException { + // 1. 查询可访问的远端目录 + Folder[] folders = store.getDefaultFolder().list("*"); + + // 2. 优先按 IMAP SPECIAL-USE 属性识别标准目录 + Map result = new LinkedHashMap<>(); + result.put(OaMailFolderTypeEnum.INBOX.getType(), OaMailFolderTypeEnum.INBOX.getType()); + for (Folder folder : folders) { + if (!holdsMessages(folder)) { + continue; + } + if (!(folder instanceof IMAPFolder)) { + continue; + } + for (String attribute : ((IMAPFolder) folder).getAttributes()) { + String type = FOLDER_ATTRIBUTE_TYPES.get(attribute.toLowerCase(Locale.ROOT)); + if (type != null) { + result.put(type, folder.getFullName()); + } + } + } + for (Folder folder : folders) { + if (!holdsMessages(folder)) { + continue; + } + String type = getFolderType(folder.getName()); + if (type != null) { + result.putIfAbsent(type, folder.getFullName()); + } + } + // 保留可存邮件的自建目录;完整路径作为临时键,避免同名子目录覆盖。 + for (Folder folder : folders) { + if (!holdsMessages(folder) || result.containsValue(folder.getFullName())) { + continue; + } + result.put(OaMailFolderTypeEnum.getCustomKey(folder.getFullName()), folder.getFullName()); + } + return result; + } + + @Override + public String getFolderType(String name) { + return FOLDER_NAME_TYPES.get(name.toLowerCase(Locale.ROOT)); + } + + // ==================== 邮件索引与正文读取 ==================== + + @Override + public List getMessageList(Folder folder) throws MessagingException { + int count = folder.getMessageCount(); + if (count > MAX_MESSAGES) { + throw exception(MAIL_SYNC_COUNT_EXCEEDED, MAX_MESSAGES); + } + UIDFolder uidFolder = (UIDFolder) folder; + List messages = new ArrayList<>(); + for (int begin = 1; begin <= count; begin += MESSAGE_FETCH_BATCH_SIZE) { + Message[] batch = folder.getMessages(begin, Math.min(count, begin + MESSAGE_FETCH_BATCH_SIZE - 1)); + FetchProfile profile = new FetchProfile(); + profile.add(FetchProfile.Item.ENVELOPE); + profile.add(FetchProfile.Item.FLAGS); + profile.add(FetchProfile.Item.CONTENT_INFO); + profile.add(UIDFolder.FetchProfileItem.UID); + folder.fetch(batch, profile); + for (Message message : batch) { + if (message.isExpunged() || message.isSet(Flags.Flag.DELETED)) { + continue; + } + Date date; + try { + date = message.getReceivedDate() == null ? message.getSentDate() : message.getReceivedDate(); + } catch (MessagingException e) { + // 网易部分历史邮件仅返回 UID 和 FLAGS,邮件头及正文均为空;保留索引,不中断其他邮件同步 + // 连接断开、超时等异常仍向上抛出,避免将不完整同步当成完整快照 + if (!"Failed to load IMAP envelope".equals(e.getMessage())) { + throw e; + } + messages.add(new OaMailMessageDO().setUid(uidFolder.getUID(message)).setUidValidity(uidFolder.getUIDValidity()) + .setSubject("(服务端暂未提供邮件内容)").setReadStatus(message.isSet(Flags.Flag.SEEN)) + .setHasAttach(false)); + log.warn("[getMessageList][服务端未提供邮件内容,目录({}) UID({})]", folder.getFullName(), uidFolder.getUID(message)); + continue; + } + messages.add(new OaMailMessageDO().setUid(uidFolder.getUID(message)).setUidValidity(uidFolder.getUIDValidity()) + .setSubject(StrUtil.nullToEmpty(message.getSubject())) + .setSender(addresses(message.getFrom())) + .setRecipients(addressList(message.getRecipients(Message.RecipientType.TO))) + .setCcs(addressList(message.getRecipients(Message.RecipientType.CC))) + .setReceiveTime(date == null ? null : DateUtils.of(date)) + .setReadStatus(message.isSet(Flags.Flag.SEEN)).setSize(message.getSize()).setHasAttach(hasAttachment(message, 0))); + } + } + if (folder.getMessageCount() != count) { + throw exception(MAIL_SYNC_CHANGED); + } + return messages; + } + + @Override + public Message getMessage(Folder folder, OaMailMessageDO index) throws MessagingException { + UIDFolder uidFolder = (UIDFolder) folder; + if (uidFolder.getUIDValidity() != index.getUidValidity()) { + throw exception(MAIL_UID_EXPIRED); + } + Message message = uidFolder.getMessageByUID(index.getUid()); + if (message == null || message.isExpunged() || message.isSet(Flags.Flag.DELETED)) { + throw exception(MAIL_MESSAGE_NOT_EXISTS); + } + return message; + } + + @Override + public OaMailMessageDO getDetail(Folder folder, OaMailMessageDO index) throws MessagingException, IOException { + // 1. 校验远端 UID 并读取邮件信封 + Message message = getMessage(folder, index); + OaMailMessageDO result = BeanUtils.toBean(index, OaMailMessageDO.class) + .setReplyTos(addressList(message.getReplyTo())) + .setReadStatus(message.isSet(Flags.Flag.SEEN)); + // 2. 解析安全正文和附件信息 + MimeMessage copy = copyMessage(message); + List attachments = new ArrayList<>(); + Map inlineImages = new HashMap<>(); + Document document = Jsoup.parse(readContent(copy, "", 0, attachments, inlineImages)); + for (Element image : document.select("img[src]")) { + String src = image.attr("src"); + if (StrUtil.startWithIgnoreCase(src, "cid:")) { + String data = inlineImages.get(src.substring(4)); + if (data != null) { + image.attr("src", data); + } else { + image.removeAttr("src"); + } + } + } + result.setContent(sanitizeHtml(document.body().html())).setAttachments(attachments); + return result; + } + + @Override + public String sanitizeHtml(String html) { + Document document = Jsoup.parse(StrUtil.nullToEmpty(html)); + // 内嵌图片只允许常用位图,拒绝 data HTML、SVG 等可执行内容 + for (Element image : document.select("img[src]")) { + String src = image.attr("src"); + if (StrUtil.startWithIgnoreCase(src, "data:") + && !src.matches("(?i)data:image/(png|jpeg|gif|webp);base64,[a-z0-9+/=\\s]+")) { + image.removeAttr("src"); + } + } + Safelist safelist = Safelist.relaxed().addTags("style", "details", "summary").addAttributes(":all", "style") + .addProtocols("img", "src", "cid", "data") + .addEnforcedAttribute("a", "target", "_blank").addEnforcedAttribute("a", "rel", "noopener noreferrer"); + return Jsoup.clean(document.body().html(), safelist); + } + + /** + * 解析邮件正文和附件元数据 + * + * 处理全部 MIME 子项,附件与正文分开展示。 + * + * @param part MIME 部件 + * @param path 当前 MIME 部件路径 + * @param depth 当前递归层级 + * @param attachments 附件元数据列表,解析出的附件追加到此列表 + * @param inlineImages Content-ID 与内嵌位图的对应关系 + * @return 安全 HTML 正文,无可读正文时返回空字符串 + * @throws MessagingException 读取邮件结构失败 + * @throws IOException 读取邮件内容失败 + */ + private String readContent(Part part, String path, int depth, List attachments, + Map inlineImages) + throws MessagingException, IOException { + String[] contentIds = part.getHeader("Content-ID"); + if (ArrayUtil.isNotEmpty(contentIds) && (part.isMimeType("image/png") || part.isMimeType("image/jpeg") + || part.isMimeType("image/gif") || part.isMimeType("image/webp"))) { + String contentId = StrUtil.strip(contentIds[0].trim(), "<", ">"); + String mimeType = new jakarta.mail.internet.ContentType(part.getContentType()).getBaseType(); + try (InputStream input = part.getInputStream()) { + inlineImages.put(contentId, "data:" + mimeType + ";base64," + Base64.getEncoder().encodeToString(IoUtil.readBytes(input))); + } + } + if (Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition()) || part.getFileName() != null) { + attachments.add(new OaMailMessageDO.Attachment().setPart(path).setName(MimeUtility.decodeText( + StrUtil.blankToDefault(part.getFileName(), "附件"))).setSize(part.getSize())); + return ""; + } + if (part.isMimeType(ContentType.TEXT_HTML.getValue())) { + return sanitizeHtml((String) part.getContent()); + } + if (part.isMimeType(ContentType.TEXT_PLAIN.getValue())) { + return "
" + Entities.escape((String) part.getContent()) + "
"; + } + if (!part.isMimeType(MULTIPART_TYPE)) { + return ""; + } + Multipart multipart = (Multipart) part.getContent(); + StringBuilder content = new StringBuilder(); + boolean alternative = part.isMimeType(MULTIPART_ALTERNATIVE_TYPE); + for (int i = 0; i < multipart.getCount(); i++) { + String child = readContent(multipart.getBodyPart(i), path.isEmpty() ? "" + i : path + "." + i, depth + 1, attachments, inlineImages); + if (alternative && !child.isEmpty()) { + content.setLength(0); + } + content.append(child); + } + return content.toString(); + } + + /** + * 判断邮件是否包含附件 + * + * 读取邮件结构,不下载附件内容。 + * + * @param part MIME 部件 + * @param depth 当前递归层级 + * @return 是否包含附件 + * @throws MessagingException 读取邮件结构失败 + */ + private boolean hasAttachment(Part part, int depth) throws MessagingException { + if (Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition()) || part.getFileName() != null) { + return true; + } + if (!part.isMimeType(MULTIPART_TYPE)) { + return false; + } + try { + Multipart multipart = (Multipart) part.getContent(); + for (int i = 0; i < multipart.getCount(); i++) { + if (hasAttachment(multipart.getBodyPart(i), depth + 1)) { + return true; + } + } + return false; + } catch (IOException e) { + log.warn("[mail][远端操作失败,异常类型({})]", e.getClass().getSimpleName()); + throw new MessagingException("无法读取附件结构"); + } + } + + // ==================== 邮件复制与写入 ==================== + + @Override + public MimeMessage copyMessage(Message message) throws MessagingException, IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + message.writeTo(bytes); + return new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(bytes.toByteArray())); + } + + @Override + public void deleteMessage(Store store, Folder folder, Message message, String trash, boolean permanent) throws MessagingException { + IMAPStore imapStore = (IMAPStore) store; + IMAPFolder imapFolder = (IMAPFolder) folder; + if (permanent) { + if (!imapStore.hasCapability(OaMailCapabilityEnum.UID_PLUS.getValue())) { + throw exception(MAIL_PERMANENT_DELETE_UNSUPPORTED); + } + message.setFlag(Flags.Flag.DELETED, true); + imapFolder.expunge(new Message[]{message}); + } else { + if (trash == null) { + throw exception(MAIL_TRASH_NOT_EXISTS); + } + moveMessage(store, folder, message, trash); + } + } + + @Override + public void moveMessage(Store store, Folder folder, Message message, String target) throws MessagingException { + if (!((IMAPStore) store).hasCapability(OaMailCapabilityEnum.MOVE.getValue())) { + throw exception(MAIL_MOVE_UNSUPPORTED); + } + ((IMAPFolder) folder).moveMessages(new Message[]{message}, store.getFolder(target)); + } + + /** + * 组装草稿或待发送邮件,清洗正文并将上传文件作为独立 MIME 附件 + * + * @param account 发件邮箱账号 + * @param reqVO 正文、收件地址及附件 + * @param send 是否发送,发送时要求至少一个收件人 + * @return 已组装的 MIME 邮件 + * @throws MessagingException 邮件地址或附件读取失败 + */ + @Override + public MimeMessage createMessage(OaMailAccountDO account, OaMailMessageSaveReqVO reqVO, boolean send) throws MessagingException { + MimeMessage message = new MimeMessage(Session.getInstance(new Properties())); + message.setFrom(new InternetAddress(account.getMail(), true)); + message.setRecipients(Message.RecipientType.TO, parseAddresses(reqVO.getRecipients())); + message.setRecipients(Message.RecipientType.CC, parseAddresses(reqVO.getCcs())); + if (send && ArrayUtil.isEmpty(message.getAllRecipients())) { + throw exception(MAIL_RECIPIENT_REQUIRED); + } + if (reqVO.getSubject() != null && (reqVO.getSubject().contains("\r") || reqVO.getSubject().contains("\n"))) { + throw exception(MAIL_SUBJECT_INVALID); + } + message.setSubject(StrUtil.nullToEmpty(reqVO.getSubject()), StandardCharsets.UTF_8.name()); + message.setContent(sanitizeHtml(reqVO.getContent()), ContentType.TEXT_HTML.getValue() + "; charset=" + StandardCharsets.UTF_8.name()); + // 有上传附件时使用 multipart/mixed,第一个部件为正文,其余部件为附件。 + if (CollUtil.isNotEmpty(reqVO.getFiles())) { + MimeMultipart multipart = new MimeMultipart("mixed"); + MimeBodyPart body = new MimeBodyPart(); + body.setContent(sanitizeHtml(reqVO.getContent()), "text/html; charset=UTF-8"); + multipart.addBodyPart(body); + for (MultipartFile file : reqVO.getFiles()) { + MimeBodyPart attachment = new MimeBodyPart(); + try { + attachment.setDataHandler(new DataHandler(new ByteArrayDataSource(file.getBytes(), + StrUtil.blankToDefault(file.getContentType(), "application/octet-stream")))); + } catch (IOException e) { + throw new MessagingException("无法读取上传附件", e); + } + attachment.setFileName(FileUtil.getName(StrUtil.blankToDefault(file.getOriginalFilename(), "附件")) + .replace('\r', '_').replace('\n', '_')); + attachment.setDisposition(Part.ATTACHMENT); + multipart.addBodyPart(attachment); + } + message.setContent(multipart); + } + message.setSentDate(new Date()); + message.saveChanges(); + return message; + } + + @Override + public InternetAddress[] parseAddresses(List values) throws AddressException { + if (CollUtil.isEmpty(values)) { + return new InternetAddress[0]; + } + List addresses = new ArrayList<>(); + for (String value : values) { + if (StrUtil.isBlank(value) || StrUtil.containsAny(value, "\r", "\n")) { + throw exception(MAIL_ADDRESS_INVALID); + } + InternetAddress address = new InternetAddress(value, true); + address.validate(); + addresses.add(address); + } + return addresses.toArray(new InternetAddress[0]); + } + + @Override + public void copyAttachments(MimeMessage target, MimeMessage source, List attachmentParts) throws MessagingException, IOException { + MimeMultipart multipart = new MimeMultipart("mixed"); + MimeBodyPart body = new MimeBodyPart(); + body.setContent(target.getContent(), target.getContentType()); + multipart.addBodyPart(body); + addAttachments(source, multipart, "", attachmentParts); + if (multipart.getCount() > 1) { + target.setContent(multipart); + } + target.saveChanges(); + } + + /** + * 递归添加邮件附件 + * + * 从邮件副本提取全部附件。 + * + * @param part 原邮件的 MIME 部件 + * @param target 目标 MIME 多部件内容 + * @param path 当前 MIME 路径 + * @param attachmentParts 保留的附件路径,null 时保留全部 + * @throws MessagingException 处理附件结构失败 + * @throws IOException 读取附件内容失败 + */ + private void addAttachments(Part part, MimeMultipart target, String path, List attachmentParts) throws MessagingException, IOException { + if (Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition()) || part.getFileName() != null) { + if (attachmentParts != null && !attachmentParts.contains(path)) { + return; + } + MimeBodyPart copy = new MimeBodyPart(); + copy.setDataHandler(part.getDataHandler()); + copy.setFileName(part.getFileName()); + copy.setDisposition(Part.ATTACHMENT); + target.addBodyPart(copy); + } else if (part.isMimeType(MULTIPART_TYPE)) { + Multipart multipart = (Multipart) part.getContent(); + for (int i = 0; i < multipart.getCount(); i++) { + addAttachments(multipart.getBodyPart(i), target, path.isEmpty() ? String.valueOf(i) : path + "." + i, attachmentParts); + } + } + } + + @Override + public Pair getAttachment(Message message, String path) throws MessagingException, IOException { + // 1. 根据元数据中的 MIME 路径定位附件,不读取外部 URL + Part part = message; + if (path == null || (!path.isEmpty() && !path.matches("[0-9]+(\\.[0-9]+)*"))) { + throw exception(MAIL_ATTACHMENT_NOT_EXISTS); + } + if (!path.isEmpty()) { + for (String index : path.split("\\.")) { + if (!part.isMimeType(MULTIPART_TYPE)) { + throw exception(MAIL_ATTACHMENT_NOT_EXISTS); + } + Multipart multipart = (Multipart) part.getContent(); + int partIndex; + try { + partIndex = Integer.parseInt(index); + } catch (NumberFormatException e) { + throw exception(MAIL_ATTACHMENT_NOT_EXISTS); + } + if (partIndex >= multipart.getCount()) { + throw exception(MAIL_ATTACHMENT_NOT_EXISTS); + } + part = multipart.getBodyPart(partIndex); + } + } + // 2. 只返回附件部件,拒绝正文路径 + if (!Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition()) && part.getFileName() == null) { + throw exception(MAIL_ATTACHMENT_NOT_EXISTS); + } + try (InputStream input = part.getInputStream()) { + return Pair.of(MimeUtility.decodeText(StrUtil.blankToDefault(part.getFileName(), "附件")), IoUtil.readBytes(input)); + } + } + + @Override + public void sendMessage(OaMailAccountDO account, MimeMessage message) throws MessagingException { + OaMailProviderDO.ConnectionConfig config = getProvider(account).getSmtp(); + Properties properties = new Properties(); + properties.setProperty("mail.smtp.auth", "true"); + properties.setProperty("mail.smtp.ssl.enable", config.getSslEnable().toString()); + properties.setProperty("mail.smtp.ssl.checkserveridentity", "true"); + properties.setProperty("mail.smtp.starttls.enable", config.getStarttlsEnable().toString()); + properties.setProperty("mail.smtp.starttls.required", config.getStarttlsEnable().toString()); + properties.setProperty("mail.smtp.connectiontimeout", "10000"); + properties.setProperty("mail.smtp.timeout", "30000"); + properties.setProperty("mail.smtp.writetimeout", "30000"); + try (Transport transport = Session.getInstance(properties).getTransport("smtp")) { + transport.connect(config.getHost(), config.getPort(), account.getUsername(), account.getPassword()); + transport.sendMessage(message, message.getAllRecipients()); + } + } + + /** + * 获得可用的邮箱服务配置 + * + * 校验邮箱账号和服务配置均为启用状态 + * + * @param account 邮箱账号 + * @return 邮箱服务配置 + */ + private OaMailProviderDO getProvider(OaMailAccountDO account) { + if (CommonStatusEnum.isDisable(account.getStatus())) { + throw exception(MAIL_ACCOUNT_DISABLED); + } + OaMailProviderDO provider = mailProviderService.validateMailProviderExists(account.getProviderId()); + if (CommonStatusEnum.isDisable(provider.getStatus())) { + throw exception(MAIL_PROVIDER_DISABLED); + } + return provider; + } + + /** + * 将邮箱地址转换为文本 + * + * @param addresses 邮箱地址数组,允许为空 + * @return 包含显示名称的地址文本,空数组返回空字符串 + */ + private static String addresses(Address[] addresses) { + return addresses == null ? "" : InternetAddress.toUnicodeString(addresses); + } + + // ==================== 协议连接管理 ==================== + + @Override + public Store openImapStore(OaMailProviderDO.ConnectionConfig config, String username, String password) + throws MessagingException { + Properties properties = new Properties(); + properties.setProperty("mail.imap.ssl.enable", config.getSslEnable().toString()); + properties.setProperty("mail.imap.ssl.checkserveridentity", "true"); + properties.setProperty("mail.imap.starttls.enable", config.getStarttlsEnable().toString()); + properties.setProperty("mail.imap.starttls.required", config.getStarttlsEnable().toString()); + properties.setProperty("mail.imap.connectiontimeout", "10000"); + properties.setProperty("mail.imap.timeout", "10000"); + properties.setProperty("mail.imap.writetimeout", "10000"); + properties.setProperty("mail.imap.peek", "true"); + Store store = Session.getInstance(properties).getStore("imap"); + try { + store.connect(config.getHost(), config.getPort(), username, password); + // 支持 ID 扩展的服务端需要客户端身份信息,例如网易在打开收件箱前进行校验 + if (store instanceof IMAPStore && ((IMAPStore) store).hasCapability(OaMailCapabilityEnum.ID.getValue())) { + ((IMAPStore) store).id(Collections.singletonMap("name", "Yudao OA Mail")); + } + return store; + } catch (MessagingException e) { + log.warn("[mail][远端操作失败,异常类型({})]", e.getClass().getSimpleName()); + try { + store.close(); + } catch (MessagingException ignored) { + // 连接尚未建立,不覆盖认证失败原因 + } + throw e; + } + } + + @Override + public boolean testConnection(OaMailProviderDO.ConnectionConfig config, + String username, String password, boolean imap) { + try { + if (imap) { + try (Store store = openImapStore(config, username, password)) { + // 同时验证收件箱可读,避免认证成功但实际读取被服务端拒绝 + try (Folder folder = store.getFolder(OaMailFolderTypeEnum.INBOX.getType())) { + folder.open(Folder.READ_ONLY); + } + } + } else { + String protocol = "smtp"; + Properties properties = new Properties(); + String prefix = "mail." + protocol + "."; + properties.setProperty(prefix + "ssl.enable", config.getSslEnable().toString()); + properties.setProperty(prefix + "ssl.checkserveridentity", "true"); + properties.setProperty(prefix + "starttls.enable", config.getStarttlsEnable().toString()); + properties.setProperty(prefix + "starttls.required", config.getStarttlsEnable().toString()); + properties.setProperty(prefix + "connectiontimeout", "10000"); + properties.setProperty(prefix + "timeout", "10000"); + properties.setProperty(prefix + "writetimeout", "10000"); + properties.setProperty(prefix + "auth", "true"); + Session session = Session.getInstance(properties); + try (Transport transport = session.getTransport(protocol)) { + transport.connect(config.getHost(), config.getPort(), username, password); + } + } + return true; + } catch (Exception e) { + log.warn("[mail][远端操作失败,异常类型({})]", e.getClass().getSimpleName()); + // 不向前端返回可能包含账号、连接参数或凭据的底层异常 + return false; + } + } + + + /** + * 判断目录是否可存邮件 + * + * @param folder 远端目录 + * @return 是否可存邮件 + * @throws MessagingException 读取目录类型失败 + */ + private boolean holdsMessages(Folder folder) throws MessagingException { + return (folder.getType() & Folder.HOLDS_MESSAGES) != 0; + } + + /** + * 将远端地址数组转换为完整地址列表 + * + * @param addresses 远端邮箱地址 + * @return 地址列表 + */ + private static List addressList(Address[] addresses) { + if (ArrayUtil.isEmpty(addresses)) { + return Collections.emptyList(); + } + return convertList(Arrays.asList(addresses), address -> address instanceof InternetAddress + ? ((InternetAddress) address).toUnicodeString() : address.toString()); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailMessageService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailMessageService.java new file mode 100644 index 0000000000..210c5cbde9 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailMessageService.java @@ -0,0 +1,118 @@ +package cn.iocoder.yudao.module.oa.service.mail; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.mail.vo.message.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.mail.*; + +import java.util.List; +import cn.hutool.core.lang.Pair; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.oa.enums.mail.OaMailComposeModeEnum; + +/** + * 企业邮箱邮件 Service 接口 + * + * @author 芋道源码 + */ +public interface OaMailMessageService { + + /** + * 获得回复、转发或草稿预填数据 + * + * @param id 邮件编号 + * @param mode 写信方式 + * @param userId 当前用户编号 + * @return 写信预填数据 + */ + OaMailMessageSaveReqVO getMailMessageCompose(Long id, @InEnum(value = OaMailComposeModeEnum.class, message = "写信方式必须是 {value}") String mode, Long userId); + + /** + * 手动同步本人账号的邮件文件夹和索引 + * + * @param accountId 邮箱账号编号 + * @param userId 当前用户编号 + * @return 同步的索引数量 + */ + int syncMailMessageList(Long accountId, Long userId); + + /** + * 获得当前用户邮箱账号的文件夹列表 + * + * @param accountId 邮箱账号编号 + * @param userId 当前用户编号 + * @return 文件夹列表,包含收件箱对应的未读邮件虚拟入口 + */ + List getMailFolderList(Long accountId, Long userId); + + /** + * 从数据库分页查询邮件索引 + * + * @param reqVO 分页查询条件 + * @param userId 当前用户编号 + * @return 邮件分页 + */ + PageResult getMailMessagePage(OaMailMessagePageReqVO reqVO, Long userId); + + /** + * 获得邮件详情,首次读取远端正文后缓存到本地 + * + * @param id 邮件编号 + * @param userId 当前用户编号 + * @return 邮件详情 + */ + OaMailMessageDO getMailMessage(Long id, Long userId); + + /** + * 下载本人邮箱中的附件 + * + * @param id 邮件编号 + * @param part 附件 MIME 路径 + * @param userId 当前用户编号 + * @return 附件名称和内容 + */ + Pair getMailMessageAttachment(Long id, String part, Long userId); + + /** + * 更新远端及本地已读状态 + * + * @param id 邮件编号 + * @param readStatus 是否已读 + * @param userId 当前用户编号 + */ + void updateMailMessageRead(Long id, Boolean readStatus, Long userId); + + /** + * 移入已删除文件夹,已删除中的邮件执行单封彻底删除 + * + * @param id 邮件编号 + * @param userId 当前用户编号 + */ + void deleteMailMessage(Long id, Long userId); + + /** + * 将已删除邮件恢复到收件箱 + * + * @param id 邮件编号 + * @param userId 当前用户编号 + */ + void restoreMailMessage(Long id, Long userId); + + /** + * 在远端保存草稿,返回新的本地索引编号 + * + * @param reqVO 草稿内容 + * @param userId 当前用户编号 + * @return 邮件编号 + */ + Long saveMailMessageDraft(OaMailMessageSaveReqVO reqVO, Long userId); + + /** + * 发送邮件,返回投递及副本处理结果,不自动重试 + * + * @param reqVO 邮件内容 + * @param userId 当前用户编号 + * @return 投递及副本处理结果 + */ + String sendMailMessage(OaMailMessageSaveReqVO reqVO, Long userId); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailMessageServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailMessageServiceImpl.java new file mode 100644 index 0000000000..91cfd6db5d --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailMessageServiceImpl.java @@ -0,0 +1,678 @@ +package cn.iocoder.yudao.module.oa.service.mail; + +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.map.MapUtil; +import cn.iocoder.yudao.module.oa.enums.mail.OaMailFolderTypeEnum; +import cn.iocoder.yudao.module.oa.enums.mail.OaMailFolderKeyEnum; +import cn.iocoder.yudao.module.oa.enums.mail.OaMailComposeModeEnum; +import cn.iocoder.yudao.module.oa.enums.mail.OaMailCapabilityEnum; +import lombok.extern.slf4j.Slf4j; +import cn.hutool.core.lang.Pair; +import org.springframework.validation.annotation.Validated; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.mail.vo.message.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.mail.*; +import cn.iocoder.yudao.module.oa.dal.mysql.mail.*; +import jakarta.annotation.Resource; +import jakarta.mail.*; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import jakarta.mail.search.HeaderTerm; +import org.eclipse.angus.mail.imap.AppendUID; +import org.eclipse.angus.mail.imap.IMAPFolder; +import org.eclipse.angus.mail.imap.IMAPStore; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Entities; +import org.jsoup.safety.Safelist; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; + +/** + * 企业邮箱邮件 Service 实现类 + * + * 本地保存邮件索引;同步与写操作按账号加锁。远端操作不可回滚,失败后通过再次同步恢复索引。 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class OaMailMessageServiceImpl implements OaMailMessageService { + + private static final Map STANDARD_FOLDER_NAMES = new LinkedHashMap<>(); + private static final List STANDARD_FOLDER_TYPES; + + static { + for (OaMailFolderTypeEnum type : OaMailFolderTypeEnum.values()) { + if (type != OaMailFolderTypeEnum.CUSTOM) { + STANDARD_FOLDER_NAMES.put(type.getType(), type.getName()); + } + } + STANDARD_FOLDER_TYPES = new ArrayList<>(STANDARD_FOLDER_NAMES.keySet()); + } + + @Resource + private OaMailMessageMapper mailMessageMapper; + + @Resource + private OaMailAccountService mailAccountService; + @Resource + private OaMailFolderService mailFolderService; + + @Resource + private OaMailMessageClient mailMessageClient; + + @Override + @Transactional(rollbackFor = Exception.class) + public OaMailMessageSaveReqVO getMailMessageCompose(Long id, String mode, Long userId) { + // 1. 校验模式与归属并读取原邮件 + OaMailMessageDO source = getMailMessage(id, userId); + // 编辑器处于主页面中,引用内容额外移除邮件 CSS,避免样式泄漏和外链资源请求。 + String editableContent = getEditableContent(source.getContent()); + OaMailAccountDO account = mailAccountService.validateMailAccount(source.getAccountId(), userId); + OaMailMessageSaveReqVO result = new OaMailMessageSaveReqVO().setAccountId(source.getAccountId()) + .setMode(OaMailComposeModeEnum.DRAFT.getValue().equals(mode) ? OaMailComposeModeEnum.NEW.getValue() : mode); + if (StrUtil.equalsAny(mode, OaMailComposeModeEnum.DRAFT.getValue(), OaMailComposeModeEnum.FORWARD.getValue())) { + result.setAttachments(source.getAttachments()).setAttachmentParts( + convertList(source.getAttachments(), OaMailMessageDO.Attachment::getPart)); + } + if (OaMailComposeModeEnum.DRAFT.getValue().equals(mode)) { + if (ObjUtil.notEqual(OaMailFolderTypeEnum.DRAFTS.getType(), validateFolder(source).getType())) { + throw exception(MAIL_NOT_DRAFT); + } + return result.setDraftId(id).setRecipients(source.getRecipients()).setCcs(source.getCcs()) + .setSubject(source.getSubject()).setContent(editableContent); + } + + // 2. 回复地址使用 Reply-To;回复全部排除当前邮箱并去重 + result.setSourceId(id).setSubject(source.getSubject()); + if (OaMailComposeModeEnum.FORWARD.getValue().equals(mode)) { + result.setSubject("Fwd: " + source.getSubject()); + } else if (!StrUtil.startWithIgnoreCase(source.getSubject(), "Re:")) { + result.setSubject("Re: " + source.getSubject()); + } + try { + Map recipients = new LinkedHashMap<>(); + Map cc = new LinkedHashMap<>(); + if (ObjUtil.notEqual(OaMailComposeModeEnum.FORWARD.getValue(), mode)) { + addReplyAddresses(recipients, source.getReplyTos(), account.getMail()); + if (MapUtil.isEmpty(recipients)) { + addReplyAddresses(recipients, Collections.singletonList(source.getSender()), account.getMail()); + } + } + if (OaMailComposeModeEnum.REPLY_ALL.getValue().equals(mode)) { + addReplyAddresses(recipients, source.getRecipients(), account.getMail()); + addReplyAddresses(cc, source.getCcs(), account.getMail()); + recipients.keySet().forEach(cc::remove); + } + result.setRecipients(convertList(recipients.values(), InternetAddress::toUnicodeString)) + .setCcs(convertList(cc.values(), InternetAddress::toUnicodeString)); + } catch (MessagingException e) { + log.warn("[mail][远端操作失败,异常类型({})]", e.getClass().getSimpleName()); + throw exception(MAIL_ADDRESS_INVALID); + } + + // 3. 引用正文保留安全 HTML,原始邮件头以文本转义 + return result.setContent("


发件人:" + Entities.escape(source.getSender()) + + "

" + editableContent + "
"); + } + + + @Override + @Transactional(rollbackFor = Exception.class) + public int syncMailMessageList(Long accountId, Long userId) { + // 1. 校验归属并锁定账号,避免多个同步请求覆盖更新结果 + OaMailAccountDO account = mailAccountService.lockMailAccount(accountId, userId); + + // 2. 获取标准文件夹映射,完整成功后才提交本地快照 + try (Store store = mailMessageClient.openStore(account)) { + Map remoteFolders = mailMessageClient.getFolders(store); + int total = 0; + for (Map.Entry entry : remoteFolders.entrySet()) { + total += syncFolder(store, accountId, + entry.getKey().startsWith("CUSTOM:") ? OaMailFolderTypeEnum.CUSTOM.getType() : entry.getKey(), entry.getValue()); + } + // 3. 不再存在的远端文件夹标记不可用,保留旧索引但不再展示 + for (OaMailFolderDO folder : mailFolderService.getMailFolderList(accountId)) { + if (!remoteFolders.containsValue(folder.getName())) { + mailFolderService.updateMailFolder(new OaMailFolderDO().setId(folder.getId()).setAvailable(false)); + } + } + return total; + } catch (MessagingException e) { + log.warn("[mail][远端操作失败,异常类型({})]", e.getClass().getSimpleName()); + throw exception(MAIL_SYNC_FAILED); + } + } + + @Override + public List getMailFolderList(Long accountId, Long userId) { + // 1. 校验账号归属,批量查询目录及未读数量 + mailAccountService.validateMailAccount(accountId, userId); + List folders = mailFolderService.getMailFolderList(accountId); + Map counts = mailMessageMapper.selectCountMapByAccountIdAndReadStatus(accountId, false); + + // 2. 标准目录统一中文展示,自建目录保留完整名称;未读是虚拟入口 + Map names = STANDARD_FOLDER_NAMES; + List types = STANDARD_FOLDER_TYPES; + folders.sort(Comparator.comparingInt((OaMailFolderDO f) -> + types.contains(f.getType()) ? types.indexOf(f.getType()) : types.size()) + .thenComparing(OaMailFolderDO::getName)); + List result = new ArrayList<>(); + for (OaMailFolderDO folder : folders) { + if (Boolean.FALSE.equals(folder.getAvailable())) { + continue; + } + Long unreadCount = counts.getOrDefault(folder.getId(), 0L); + result.add(new OaMailFolderRespVO() + .setKey(names.containsKey(folder.getType()) ? folder.getType() : folder.getId().toString()) + .setName(names.getOrDefault(folder.getType(), folder.getName())).setUnreadCount(unreadCount)); + if (OaMailFolderTypeEnum.INBOX.getType().equals(folder.getType())) { + result.add(new OaMailFolderRespVO().setKey(OaMailFolderKeyEnum.UNREAD.getKey()).setName("未读邮件").setUnreadCount(unreadCount)); + } + } + return result; + } + + @Override + public PageResult getMailMessagePage(OaMailMessagePageReqVO reqVO, Long userId) { + // 1. 校验本人归属,未读入口固定在收件箱内筛选 + mailAccountService.validateMailAccount(reqVO.getAccountId(), userId); + String type = OaMailFolderKeyEnum.UNREAD.getKey().equals(reqVO.getFolderKey()) ? OaMailFolderTypeEnum.INBOX.getType() : reqVO.getFolderKey(); + OaMailFolderDO folder; + if (StrUtil.isNumeric(type)) { + folder = mailFolderService.getMailFolder(Long.valueOf(type)); + if (folder != null && (ObjUtil.notEqual(folder.getAccountId(), reqVO.getAccountId()) + || Boolean.FALSE.equals(folder.getAvailable()))) { + folder = null; + } + } else { + folder = getFolder(reqVO.getAccountId(), type); + } + + // 2.1 尚未同步或远端文件夹不可用时返回空列表 + if (folder == null) { + return new PageResult<>(Collections.emptyList(), 0L); + } + // 2.2 转换未读筛选条件并查询邮件分页 + OaMailMessagePageReqVO pageReqVO = BeanUtils.toBean(reqVO, OaMailMessagePageReqVO.class); + if (OaMailFolderKeyEnum.UNREAD.getKey().equals(reqVO.getFolderKey())) { + pageReqVO.setReadStatus(false); + } + return mailMessageMapper.selectPage(pageReqVO, folder.getId()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public OaMailMessageDO getMailMessage(Long id, Long userId) { + // 1. 校验索引归属和文件夹状态 + OaMailMessageDO index = validateMessage(id, userId); + // 与同步、草稿替换共用账号锁,锁内重查,避免使用已失效的旧索引。 + OaMailAccountDO account = mailAccountService.lockMailAccount(index.getAccountId(), userId); + index = mailMessageMapper.selectByIdForUpdate(id); + if (index == null) { + throw exception(MAIL_MESSAGE_NOT_EXISTS); + } + OaMailFolderDO localFolder = validateFolder(index); + + // 2.1 情况一:命中正文缓存时直接返回,空正文也属于已缓存 + if (index.getContent() != null) { + return index; + } + + // 2.2 情况二:首次读取远端详情,缓存正文及附件目录,不修改远端状态 + try (Store store = mailMessageClient.openStore(account)) { + Folder folder = store.getFolder(localFolder.getName()); + try { + folder.open(Folder.READ_ONLY); + OaMailMessageDO detail = mailMessageClient.getDetail(folder, index); + detail.setContent(detail.getContent() == null ? "" : mailMessageClient.sanitizeHtml(detail.getContent())); + mailMessageMapper.updateById(new OaMailMessageDO().setId(id) + .setContent(detail.getContent()) + .setReplyTos(detail.getReplyTos()).setAttachments(detail.getAttachments())); + return detail; + } finally { + if (folder.isOpen()) { + folder.close(false); + } + } + } catch (MessagingException | IOException e) { + log.warn("[mail][远端操作失败,异常类型({})]", e.getClass().getSimpleName()); + throw exception(MAIL_DETAIL_READ_FAILED); + } + } + + @Override + public Pair getMailMessageAttachment(Long id, String part, Long userId) { + // 1. 校验本人邮件和所属邮箱、文件夹 + OaMailMessageDO message = validateMessage(id, userId); + OaMailAccountDO account = mailAccountService.validateMailAccount(message.getAccountId(), userId); + OaMailFolderDO localFolder = validateFolder(message); + + // 2. 校验远端 UID 并读取附件;文件夹关闭前读取完整内容 + try (Store store = mailMessageClient.openStore(account); + Folder folder = store.getFolder(localFolder.getName())) { + folder.open(Folder.READ_ONLY); + return mailMessageClient.getAttachment(mailMessageClient.getMessage(folder, message), part); + } catch (MessagingException | IOException e) { + log.warn("[mail][附件读取失败,异常类型({})]", e.getClass().getSimpleName()); + throw exception(MAIL_ATTACHMENT_READ_FAILED); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateMailMessageRead(Long id, Boolean readStatus, Long userId) { + // 1. 校验并串行化本人账号上的写操作 + OaMailMessageDO index = validateMessage(id, userId); + OaMailAccountDO account = mailAccountService.lockMailAccount(index.getAccountId(), userId); + OaMailFolderDO localFolder = validateFolder(index); + + // 2. 远端成功后更新索引,关闭文件夹时不清除其他已删除邮件 + try (Store store = mailMessageClient.openStore(account)) { + Folder folder = store.getFolder(localFolder.getName()); + try { + folder.open(Folder.READ_WRITE); + mailMessageClient.getMessage(folder, index).setFlag(Flags.Flag.SEEN, Boolean.TRUE.equals(readStatus)); + mailMessageMapper.updateById(new OaMailMessageDO().setId(id).setReadStatus(readStatus)); + } finally { + if (folder.isOpen()) { + folder.close(false); + } + } + } catch (MessagingException e) { + log.warn("[mail][远端操作失败,异常类型({})]", e.getClass().getSimpleName()); + throw exception(MAIL_READ_STATUS_FAILED); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteMailMessage(Long id, Long userId) { + // 1. 校验归属、文件夹和远端 UID + OaMailMessageDO index = validateMessage(id, userId); + OaMailAccountDO account = mailAccountService.lockMailAccount(index.getAccountId(), userId); + OaMailFolderDO localFolder = validateFolder(index); + + // 2. 普通文件夹移至已删除;已删除中只清除用户指定的一封邮件 + try (Store store = mailMessageClient.openStore(account)) { + Folder folder = store.getFolder(localFolder.getName()); + try { + folder.open(Folder.READ_WRITE); + mailMessageClient.deleteMessage(store, folder, mailMessageClient.getMessage(folder, index), + mailMessageClient.getFolders(store).get(OaMailFolderTypeEnum.TRASH.getType()), OaMailFolderTypeEnum.TRASH.getType().equals(localFolder.getType())); + mailMessageMapper.deleteById(id); + } finally { + if (folder.isOpen()) { + folder.close(false); + } + } + } catch (MessagingException e) { + log.warn("[mail][远端操作失败,异常类型({})]", e.getClass().getSimpleName()); + throw exception(MAIL_DELETE_UNCONFIRMED); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void restoreMailMessage(Long id, Long userId) { + // 1. 校验归属及垃圾箱,仅恢复用户指定的邮件 + OaMailMessageDO index = validateMessage(id, userId); + OaMailAccountDO account = mailAccountService.lockMailAccount(index.getAccountId(), userId); + OaMailFolderDO localFolder = validateFolder(index); + if (ObjUtil.notEqual(OaMailFolderTypeEnum.TRASH.getType(), localFolder.getType())) { + throw exception(MAIL_NOT_TRASH); + } + + // 2. 移回收件箱,旧 UID 索引删除,新索引由同步生成 + try (Store store = mailMessageClient.openStore(account)) { + String inbox = mailMessageClient.getFolders(store).get(OaMailFolderTypeEnum.INBOX.getType()); + if (inbox == null) { + throw exception(MAIL_FOLDER_NOT_AVAILABLE); + } + Folder folder = store.getFolder(localFolder.getName()); + try { + folder.open(Folder.READ_WRITE); + mailMessageClient.moveMessage(store, folder, mailMessageClient.getMessage(folder, index), inbox); + mailMessageMapper.deleteById(id); + } finally { + if (folder.isOpen()) { + folder.close(false); + } + } + } catch (MessagingException e) { + log.warn("[mail][远端恢复失败,异常类型({})]", e.getClass().getSimpleName()); + throw exception(MAIL_RESTORE_UNCONFIRMED); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long saveMailMessageDraft(OaMailMessageSaveReqVO reqVO, Long userId) { + // 1. 校验本人账号及原草稿,不允许将普通邮件当作草稿覆盖 + OaMailAccountDO account = mailAccountService.lockMailAccount(reqVO.getAccountId(), userId); + OaMailMessageDO oldDraft = validateDraft(reqVO, userId); + + // 2. 先追加新草稿,再按 UID 清理旧草稿;不允许全文件夹 expunge + try (Store store = mailMessageClient.openStore(account)) { + // 2.1 校验草稿目录及按 UID 安全替换能力 + String name = mailMessageClient.getFolders(store).get(OaMailFolderTypeEnum.DRAFTS.getType()); + if (name == null) { + throw exception(MAIL_DRAFT_FOLDER_NOT_EXISTS); + } + if (!((IMAPStore) store).hasCapability(OaMailCapabilityEnum.UID_PLUS.getValue())) { + throw exception(MAIL_DRAFT_REPLACE_UNSUPPORTED); + } + // 2.2 构建并追加新草稿 + MimeMessage message = buildOutgoingMessage(account, reqVO, userId, false); + message.setFlag(Flags.Flag.DRAFT, true); + IMAPFolder folder = (IMAPFolder) store.getFolder(name); + long uid; + try { + folder.open(Folder.READ_WRITE); + if (oldDraft != null) { + mailMessageClient.getMessage(folder, oldDraft); + } + AppendUID[] appended = folder.appendUIDMessages(new Message[]{message}); + if (appended == null || appended.length != 1 || appended[0] == null) { + throw exception(MAIL_DRAFT_SAVE_UNCONFIRMED); + } + uid = appended[0].uid; + // 2.3 新草稿成功追加后,才清理指定旧草稿 + if (oldDraft != null) { + mailMessageClient.deleteMessage(store, folder, mailMessageClient.getMessage(folder, oldDraft), null, true); + } + } finally { + if (folder.isOpen()) { + folder.close(false); + } + } + + // 3. 同步草稿索引并返回新编号 + syncFolder(store, account.getId(), OaMailFolderTypeEnum.DRAFTS.getType(), name); + OaMailFolderDO savedFolder = getFolder(account.getId(), OaMailFolderTypeEnum.DRAFTS.getType()); + List drafts = mailMessageMapper.selectListByFolderId(savedFolder.getId()); + OaMailMessageDO savedDraft = CollUtil.findOne(drafts, item -> item.getUid() == uid); + if (savedDraft == null) { + throw exception(MAIL_DRAFT_INDEX_NOT_FOUND); + } + return savedDraft.getId(); + } catch (MessagingException e) { + log.warn("[mail][远端操作失败,异常类型({})]", e.getClass().getSimpleName()); + throw exception(MAIL_DRAFT_SAVE_UNCONFIRMED); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String sendMailMessage(OaMailMessageSaveReqVO reqVO, Long userId) { + // 1. 校验账号、草稿及引用邮件归属 + OaMailAccountDO account = mailAccountService.lockMailAccount(reqVO.getAccountId(), userId); + OaMailMessageDO draft = validateDraft(reqVO, userId); + if (reqVO.getSourceId() != null) { + OaMailMessageDO source = validateMessage(reqVO.getSourceId(), userId); + if (ObjUtil.notEqual(source.getAccountId(), account.getId())) { + throw exception(MAIL_SOURCE_NOT_OWNED); + } + } + + // 2. 发送只执行一次;超时不能视为一定未发出 + MimeMessage message; + try { + message = buildOutgoingMessage(account, reqVO, userId, true); + mailMessageClient.sendMessage(account, message); + } catch (MessagingException e) { + log.warn("[mail][远端操作失败,异常类型({})]", e.getClass().getSimpleName()); + throw exception(MAIL_SEND_UNCONFIRMED); + } + + // 3. SMTP 成功与 IMAP 副本处理分开反馈,副本失败绝不能导致用户重复发信 + try (Store store = mailMessageClient.openStore(account)) { + Map folders = mailMessageClient.getFolders(store); + String sent = folders.get(OaMailFolderTypeEnum.SENT.getType()); + if (sent == null) { + return "服务器已接收邮件;未识别到已发送文件夹,请在原邮箱核实副本"; + } + Folder sentFolder = store.getFolder(sent); + // 部分服务商自动保存已发送副本,按 Message-ID 检查后再追加。 + try { + sentFolder.open(Folder.READ_ONLY); + if (sentFolder.search(new HeaderTerm("Message-ID", message.getMessageID())).length == 0) { + sentFolder.appendMessages(new Message[]{message}); + } + } finally { + if (sentFolder.isOpen()) { + sentFolder.close(false); + } + } + if (draft != null) { + Folder draftFolder = store.getFolder(validateFolder(draft).getName()); + try { + draftFolder.open(Folder.READ_WRITE); + mailMessageClient.deleteMessage(store, draftFolder, mailMessageClient.getMessage(draftFolder, draft), null, true); + mailMessageMapper.deleteById(draft.getId()); + } finally { + if (draftFolder.isOpen()) { + draftFolder.close(false); + } + } + } + return "服务器已接收邮件,请同步查看已发送"; + } catch (Exception e) { + log.warn("[mail][远端操作失败,异常类型({})]", e.getClass().getSimpleName()); + return "服务器已接收邮件;副本保存或草稿清理未完成,请在原邮箱核实,勿重发"; + } + } + + /** + * 同步邮件文件夹及其索引 + * + * 完整抓取成功后对比 UID 快照,保留稳定的本地主键并更新远端状态。 + * + * @param store 已建立的邮箱连接 + * @param accountId 邮箱账号编号 + * @param type 本地文件夹类型 + * @param name 远端文件夹完整名称 + * @return 本次文件夹同步的邮件索引数量 + * @throws MessagingException 读取远端文件夹或邮件失败 + */ + private int syncFolder(Store store, Long accountId, String type, String name) throws MessagingException { + // 1. 查询并定位远端目录对应的本地索引 + List folders = mailFolderService.getMailFolderList(accountId); + OaMailFolderDO localFolder = CollUtil.findOne(folders, folder -> name.equals(folder.getName())); + if (localFolder == null) { + localFolder = new OaMailFolderDO().setAccountId(accountId).setName(name).setType(type).setAvailable(true); + mailFolderService.createMailFolder(localFolder); + } + Folder folder = store.getFolder(name); + try { + folder.open(Folder.READ_ONLY); + List remote = mailMessageClient.getMessageList(folder); + long validity = ((UIDFolder) folder).getUIDValidity(); + Map existing = mailMessageMapper.selectListByFolderId(localFolder.getId()).stream() + .collect(Collectors.toMap(index -> index.getUidValidity() + ":" + index.getUid(), Function.identity())); + for (OaMailMessageDO message : remote) { + OaMailMessageDO old = existing.remove(message.getUidValidity() + ":" + message.getUid()); + message.setAccountId(accountId).setFolderId(localFolder.getId()); + if (old == null) { + mailMessageMapper.insert(message); + } else { + mailMessageMapper.updateById(message.setId(old.getId())); + } + } + // 仅完整快照中缺失的索引失效;实际邮件不会在同步过程中被删除。 + for (OaMailMessageDO removed : existing.values()) { + mailMessageMapper.deleteById(removed.getId()); + } + mailFolderService.updateMailFolder(new OaMailFolderDO().setId(localFolder.getId()).setType(type).setAvailable(true) + .setUidValidity(validity).setSyncTime(LocalDateTime.now())); + return remote.size(); + } finally { + if (folder.isOpen()) { + folder.close(false); + } + } + } + + /** + * 校验邮件索引存在及账号归属 + * + * @param id 本地邮件编号 + * @param userId 当前用户编号 + * @return 邮件索引 + */ + private OaMailMessageDO validateMessage(Long id, Long userId) { + OaMailMessageDO message = mailMessageMapper.selectById(id); + if (message == null) { + throw exception(MAIL_MESSAGE_NOT_EXISTS); + } + mailAccountService.validateMailAccount(message.getAccountId(), userId); + return message; + } + + /** + * 校验邮件所属文件夹可用 + * + * 文件夹须与邮件属于同一邮箱账号。 + * + * @param message 邮件索引 + * @return 邮件文件夹 + */ + private OaMailFolderDO validateFolder(OaMailMessageDO message) { + return mailFolderService.validateMailFolder(message.getFolderId(), message.getAccountId()); + } + + /** + * 获得账号下可用的指定类型文件夹 + * + * @param accountId 邮箱账号编号 + * @param type 文件夹类型 + * @return 邮件文件夹,不存在时返回 null + */ + private OaMailFolderDO getFolder(Long accountId, String type) { + List folders = mailFolderService.getMailFolderList(accountId); + return CollUtil.findOne(folders, folder -> type.equals(folder.getType()) && Boolean.TRUE.equals(folder.getAvailable())); + } + + /** + * 校验待替换草稿存在及账号归属 + * + * @param reqVO 邮件保存信息 + * @param userId 当前用户编号 + * @return 原草稿索引,未指定草稿编号时返回 null + */ + private OaMailMessageDO validateDraft(OaMailMessageSaveReqVO reqVO, Long userId) { + if (reqVO.getDraftId() == null) { + return null; + } + OaMailMessageDO draft = validateMessage(reqVO.getDraftId(), userId); + if (ObjUtil.notEqual(draft.getAccountId(), reqVO.getAccountId()) + || ObjUtil.notEqual(OaMailFolderTypeEnum.DRAFTS.getType(), validateFolder(draft).getType())) { + throw exception(MAIL_DRAFT_NOT_OWNED); + } + return draft; + } + + /** + * 构建待保存或发送的邮件 + * + * 回复时添加线程标识,转发或修改草稿时保留原附件。 + * + * @param account 发件邮箱账号 + * @param reqVO 邮件内容 + * @param userId 当前用户编号 + * @param send 是否用于发送,false 时用于保存草稿 + * @return MIME 邮件 + * @throws MessagingException 构建邮件或读取原邮件失败 + */ + private MimeMessage buildOutgoingMessage(OaMailAccountDO account, OaMailMessageSaveReqVO reqVO, + Long userId, boolean send) throws MessagingException { + // 1.1 构建本次邮件正文 + MimeMessage message = mailMessageClient.createMessage(account, reqVO, send); + Long originalId = ObjUtil.defaultIfNull(reqVO.getDraftId(), reqVO.getSourceId()); + if (originalId == null) { + return message; + } + // 1.2 校验原邮件存在及归属 + OaMailMessageDO source = validateMessage(originalId, userId); + if (ObjUtil.notEqual(source.getAccountId(), account.getId())) { + throw exception(MAIL_SOURCE_NOT_OWNED); + } + + // 2. 读取原邮件并处理转发及回复信息 + try (Store store = mailMessageClient.openStore(account)) { + Folder folder = store.getFolder(validateFolder(source).getName()); + try { + folder.open(Folder.READ_ONLY); + // 2.1 读取原邮件,转发和编辑草稿时保留附件 + MimeMessage original = mailMessageClient.copyMessage(mailMessageClient.getMessage(folder, source)); + if (reqVO.getDraftId() != null || OaMailComposeModeEnum.FORWARD.getValue().equals(reqVO.getMode())) { + mailMessageClient.copyAttachments(message, original, reqVO.getAttachmentParts()); + } + // 2.2 回复时补充线程标识 + if (StrUtil.equalsAny(reqVO.getMode(), OaMailComposeModeEnum.REPLY.getValue(), OaMailComposeModeEnum.REPLY_ALL.getValue())) { + String messageId = original.getMessageID(); + if (messageId != null) { + message.setHeader("In-Reply-To", messageId); + message.setHeader("References", messageId); + } + } + message.saveChanges(); + } finally { + if (folder.isOpen()) { + folder.close(false); + } + } + } catch (IOException e) { + log.warn("[mail][远端操作失败,异常类型({})]", e.getClass().getSimpleName()); + throw exception(MAIL_SOURCE_READ_FAILED); + } + return message; + } + + /** + * 添加回复地址 + * + * 排除本人邮箱,并按忽略大小写的邮箱地址去重。 + * + * @param target 按邮箱地址索引的回复地址映射,新增地址写入此映射 + * @param values 待添加的邮箱地址列表 + * @param self 当前发件账号的邮箱地址 + * @throws MessagingException 邮箱地址解析失败 + */ + private void addReplyAddresses(Map target, List values, String self) + throws MessagingException { + for (InternetAddress address : mailMessageClient.parseAddresses(values)) { + if (!address.getAddress().equalsIgnoreCase(self)) { + target.putIfAbsent(address.getAddress().toLowerCase(Locale.ROOT), address); + } + } + } + + /** + * 获得可放入编辑器的正文 + * + * @param content 邮件正文 + * @return 移除样式和外部资源后的正文 + */ + private String getEditableContent(String content) { + Document editable = Jsoup.parse(StrUtil.nullToEmpty(content)); + editable.select("style").remove(); + return Jsoup.clean(editable.body().html(), Safelist.relaxed().addTags("details", "summary")); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailProviderService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailProviderService.java new file mode 100644 index 0000000000..d9c6e1e723 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailProviderService.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.oa.service.mail; +import cn.iocoder.yudao.module.oa.controller.admin.mail.vo.provider.OaMailProviderSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.mail.OaMailProviderDO; + +import jakarta.validation.Valid; + +import java.util.List; + +/** + * 企业邮箱服务配置 Service 接口 + * + * @author 芋道源码 + */ +public interface OaMailProviderService { + + /** + * 创建邮箱服务配置 + * + * @param reqVO 邮箱服务配置信息 + * @return 邮箱服务配置编号 + */ + Long createMailProvider(@Valid OaMailProviderSaveReqVO reqVO); + + /** + * 更新邮箱服务配置 + * + * @param reqVO 邮箱服务配置信息 + */ + void updateMailProvider(@Valid OaMailProviderSaveReqVO reqVO); + + /** + * 删除邮箱服务配置 + * + * 仅允许删除未被邮箱账号引用的服务配置。 + * + * @param id 邮箱服务配置编号 + */ + void deleteMailProvider(Long id); + + /** + * 校验邮箱服务配置存在 + * + * @param id 邮箱服务配置编号 + * @return 邮箱服务配置 + */ + OaMailProviderDO validateMailProviderExists(Long id); + + /** + * 获得邮箱服务配置列表 + * + * @param status 状态,允许为空,为空时查询全部状态 + * @return 邮箱服务配置列表 + */ + List getMailProviderList(Integer status); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailProviderServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailProviderServiceImpl.java new file mode 100644 index 0000000000..17e140171f --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/mail/OaMailProviderServiceImpl.java @@ -0,0 +1,80 @@ +package cn.iocoder.yudao.module.oa.service.mail; + +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.mail.vo.provider.OaMailProviderSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.mail.OaMailProviderDO; +import cn.iocoder.yudao.module.oa.dal.mysql.mail.OaMailProviderMapper; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; + +/** + * 企业邮箱服务配置 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaMailProviderServiceImpl implements OaMailProviderService { + + @Resource + private OaMailProviderMapper mailProviderMapper; + @Resource + @Lazy + private OaMailAccountService mailAccountService; + + @Override + public Long createMailProvider(OaMailProviderSaveReqVO reqVO) { + // 1. 转换邮箱服务配置 + OaMailProviderDO provider = BeanUtils.toBean(reqVO, OaMailProviderDO.class).setId(null); + + // 2. 新增配置 + mailProviderMapper.insert(provider); + return provider.getId(); + } + + @Override + public void updateMailProvider(OaMailProviderSaveReqVO reqVO) { + // 1.1 校验配置存在 + validateMailProviderExists(reqVO.getId()); + // 1.2 转换邮箱服务配置 + OaMailProviderDO provider = BeanUtils.toBean(reqVO, OaMailProviderDO.class); + + // 2. 修改配置 + mailProviderMapper.updateById(provider); + } + + @Override + public void deleteMailProvider(Long id) { + // 1.1 校验配置存在 + validateMailProviderExists(id); + // 1.2 校验是否被账号引用 + if (mailAccountService.getMailAccountCountByProviderId(id) > 0) { + throw exception(MAIL_PROVIDER_IN_USE); + } + + // 2. 删除配置 + mailProviderMapper.deleteById(id); + } + + @Override + public OaMailProviderDO validateMailProviderExists(Long id) { + OaMailProviderDO provider = mailProviderMapper.selectById(id); + if (provider == null) { + throw exception(MAIL_PROVIDER_NOT_EXISTS); + } + return provider; + } + + @Override + public List getMailProviderList(Integer status) { + return mailProviderMapper.selectListByStatus(status); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomBookingService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomBookingService.java new file mode 100644 index 0000000000..749db71265 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomBookingService.java @@ -0,0 +1,133 @@ +package cn.iocoder.yudao.module.oa.service.meetingroom; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.meetingroom.vo.room.*; +import cn.iocoder.yudao.module.oa.controller.admin.meetingroom.vo.booking.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.meetingroom.*; +import jakarta.validation.Valid; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +/** + * 会议室预定 Service 接口 + * + * @author 芋道源码 + */ +public interface OaMeetingRoomBookingService { + + /** + * 创建预定草稿 + * + * @param createReqVO 预定信息 + * @param userId 当前用户编号 + * @return 预定编号 + */ + Long createMeetingRoomBooking(@Valid OaMeetingRoomBookingSaveReqVO createReqVO, Long userId); + + /** + * 更新预定草稿 + * + * @param updateReqVO 预定信息 + * @param userId 当前用户编号 + */ + void updateMeetingRoomBooking(@Valid OaMeetingRoomBookingSaveReqVO updateReqVO, Long userId); + + /** + * 删除预定草稿 + * + * @param id 预定编号 + * @param userId 当前用户编号 + */ + void deleteMeetingRoomBooking(Long id, Long userId); + + /** + * 提交预定,需要审批时启动流程 + * + * @param id 预定编号 + * @param userId 当前用户编号 + * @return 流程实例编号,免审批时为空 + */ + String submitMeetingRoomBooking(Long id, Long userId); + + /** + * 取消审批中或已通过的待使用预约 + * + * @param id 预定编号 + * @param userId 当前用户编号 + */ + void cancelMeetingRoomBooking(Long id, Long userId); + + /** + * 开始使用会议室 + * + * @param id 预定编号 + * @param userId 当前用户编号 + */ + void startMeetingRoomBooking(Long id, Long userId); + + /** + * 完成会议室使用 + * + * @param id 预定编号 + * @param userId 当前用户编号 + */ + void finishMeetingRoomBooking(Long id, Long userId); + + /** + * 回写审批状态 + * + * @param id 预定编号 + * @param status 审批状态 + */ + void updateMeetingRoomBookingStatus(Long id, Integer status); + + /** + * 获得预定详情 + * + * @param id 预定编号 + * @return 预定 + */ + OaMeetingRoomBookingDO getMeetingRoomBooking(Long id); + + /** + * 获得本人预定分页 + * + * @param userId 当前用户编号 + * @param pageReqVO 查询条件 + * @return 分页结果 + */ + PageResult getMeetingRoomBookingPage(Long userId, OaMeetingRoomBookingPageReqVO pageReqVO); + + /** + * 获得会议室占用时段 + * + * @param reqVO 房间和查询区间 + * @return 有效预约列表 + */ + List getMeetingRoomBookingListBySchedule(OaMeetingRoomBookingScheduleReqVO reqVO); + + /** + * 取消已过结束时间、审批通过但尚未开始的会议预定 + * + * @return 取消的会议数量 + */ + int cancelExpiredMeetingRoomBookings(); + + /** + * 发送已通过且待使用会议的开始提醒 + * + * @return 成功提醒的会议数量 + */ + int sendMeetingRoomBookingReminders(); + + /** + * 判断会议室是否存在尚未结束的有效预约 + * + * @param roomId 会议室编号 + * @return 是否存在 + */ + boolean hasActiveMeetingRoomBooking(Long roomId); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomBookingServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomBookingServiceImpl.java new file mode 100644 index 0000000000..1e78a7f856 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomBookingServiceImpl.java @@ -0,0 +1,445 @@ +package cn.iocoder.yudao.module.oa.service.meetingroom; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.date.DatePattern; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi; +import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.controller.admin.meetingroom.vo.booking.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.meetingroom.*; +import cn.iocoder.yudao.module.oa.dal.mysql.meetingroom.OaMeetingRoomBookingMapper; +import cn.iocoder.yudao.module.oa.dal.redis.no.OaNoRedisDAO; +import cn.iocoder.yudao.module.oa.enums.meetingroom.*; +import cn.iocoder.yudao.module.oa.enums.meetingroom.OaMeetingRoomUseStatusEnum; +import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi; +import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO; +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.springframework.context.annotation.Lazy; +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.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.filterList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.getMaxValue; +import static cn.iocoder.yudao.module.oa.enums.BpmModelConstants.MEETING_ROOM_BOOKING; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.oa.enums.MessageTemplateConstants.MEETING_ROOM_BOOKING_REMINDER; + +/** + * 会议室预定 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class OaMeetingRoomBookingServiceImpl implements OaMeetingRoomBookingService { + + @Resource + private OaMeetingRoomBookingMapper meetingRoomBookingMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private OaMeetingRoomService meetingRoomService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private OaNoRedisDAO noRedisDAO; + @Resource + private BpmProcessInstanceApi processInstanceApi; + @Resource + private NotifyMessageSendApi notifyMessageSendApi; + + @Override + public Long createMeetingRoomBooking(OaMeetingRoomBookingSaveReqVO createReqVO, Long userId) { + // 1.1 校验房间、时间和会议人员 + validateMeetingRoomBooking(createReqVO); + // 1.2 查询申请人所属部门 + AdminUserRespDTO user = adminUserApi.validateUser(userId); + // 1.3 生成单号并校验唯一性 + String no = noRedisDAO.generate(OaNoRedisDAO.MEETING_ROOM_BOOKING_NO_PREFIX); + if (meetingRoomBookingMapper.selectByNo(no) != null) { + throw exception(MEETING_ROOM_BOOKING_NO_DUPLICATE); + } + + // 2. 创建草稿 + OaMeetingRoomBookingDO booking = BeanUtils.toBean(createReqVO, OaMeetingRoomBookingDO.class) + .setNo(no).setReminded(false) + .setDeptId(user.getDeptId()).setStatus(BpmProcessInstanceStatusEnum.NOT_START.getStatus()).setUseStatus(OaMeetingRoomUseStatusEnum.PENDING.getStatus()); + meetingRoomBookingMapper.insert(booking); + return booking.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateMeetingRoomBooking(OaMeetingRoomBookingSaveReqVO updateReqVO, Long userId) { + // 1.1 校验本人草稿 + OaMeetingRoomBookingDO booking = validateBookingExists(updateReqVO.getId()); + validateBookingOwner(booking, userId); + validateBookingDraft(booking); + // 1.2 校验会议内容 + validateMeetingRoomBooking(updateReqVO); + + // 2. 更新草稿 + meetingRoomBookingMapper.updateById(BeanUtils.toBean(updateReqVO, OaMeetingRoomBookingDO.class)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteMeetingRoomBooking(Long id, Long userId) { + // 1.1 校验申请归属 + OaMeetingRoomBookingDO booking = validateBookingExists(id); + validateBookingOwner(booking, userId); + // 1.2 校验草稿状态 + validateBookingDraft(booking); + + // 2. 删除草稿 + meetingRoomBookingMapper.deleteById(id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String submitMeetingRoomBooking(Long id, Long userId) { + // 1.1 校验预定和会议室存在 + OaMeetingRoomBookingDO booking = validateBookingExists(id); + OaMeetingRoomDO room = meetingRoomService.validateMeetingRoomExists(booking.getRoomId()); + // 1.2 校验申请归属与草稿状态,阻止重复提交 + validateBookingOwner(booking, userId); + validateBookingDraft(booking); + // 1.3 校验房间状态与预定成员范围 + if (!Boolean.TRUE.equals(room.getAllowBooking()) + || ObjUtil.notEqual(room.getStatus(), OaMeetingRoomStatusEnum.NORMAL.getStatus()) + || (ObjUtil.equal(room.getBookingScope(), OaMeetingRoomBookingScopeEnum.SPECIFIED.getScope()) + && !CollUtil.contains(room.getBookingUserIds(), userId))) { + throw exception(MEETING_ROOM_NOT_AVAILABLE); + } + // 1.4 提交时校验时间及人员仍有效 + validateMeetingRoomBooking(BeanUtils.toBean(booking, OaMeetingRoomBookingSaveReqVO.class)); + if (booking.getStartTime().isBefore(LocalDateTime.now())) { + throw exception(MEETING_ROOM_BOOKING_TIME_INVALID); + } + // 1.5 检查有效预约,采用左闭右开区间,相邻会议不冲突 + if (CollUtil.isNotEmpty(meetingRoomBookingMapper.selectListByRoomIdAndTimeOverlap(room.getId(), + booking.getStartTime(), booking.getEndTime()))) { + throw exception(MEETING_ROOM_BOOKING_TIME_CONFLICT); + } + + // 2. 更新审批状态,免审批直接通过 + boolean needApproval = Boolean.TRUE.equals(room.getNeedApproval()); + meetingRoomBookingMapper.updateById(new OaMeetingRoomBookingDO().setId(id).setNeedApproval(needApproval) + .setStatus(needApproval ? BpmProcessInstanceStatusEnum.RUNNING.getStatus() : BpmProcessInstanceStatusEnum.APPROVE.getStatus()).setUseStatus(OaMeetingRoomUseStatusEnum.PENDING.getStatus())); + if (!needApproval) { + return null; + } + + // 3.1 发起审批 + String processInstanceId = processInstanceApi.createProcessInstance(userId, + new BpmProcessInstanceCreateReqDTO().setProcessDefinitionKey(MEETING_ROOM_BOOKING) + .setBusinessKey(id.toString()).setVariables(new HashMap<>())); + // 3.2 绑定流程编号 + meetingRoomBookingMapper.updateById(new OaMeetingRoomBookingDO().setId(id).setProcessInstanceId(processInstanceId)); + return processInstanceId; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void cancelMeetingRoomBooking(Long id, Long userId) { + // 1. 校验本人预约 + OaMeetingRoomBookingDO booking = validateBookingExists(id); + validateBookingOwner(booking, userId); + + // 2.1 审批中取消 BPM 流程 + if (ObjUtil.equal(booking.getStatus(), BpmProcessInstanceStatusEnum.RUNNING.getStatus())) { + processInstanceApi.cancelProcessInstanceByStartUser(userId, booking.getProcessInstanceId(), "申请人取消会议室预定"); + return; + } + // 2.2 已通过且待使用时取消使用,保留审批结果 + if (ObjUtil.equal(booking.getStatus(), BpmProcessInstanceStatusEnum.APPROVE.getStatus()) + && ObjUtil.equal(booking.getUseStatus(), OaMeetingRoomUseStatusEnum.PENDING.getStatus())) { + meetingRoomBookingMapper.updateById(new OaMeetingRoomBookingDO().setId(id).setUseStatus(OaMeetingRoomUseStatusEnum.CANCELLED.getStatus())); + return; + } + throw exception(MEETING_ROOM_BOOKING_STATUS_INVALID); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void startMeetingRoomBooking(Long id, Long userId) { + // 1.1 校验申请人或主持人可操作 + OaMeetingRoomBookingDO booking = validateBookingOperator(id, userId); + // 1.2 校验审批通过、待使用及当前处于会议时段 + LocalDateTime now = LocalDateTime.now(); + if (ObjUtil.notEqual(booking.getStatus(), BpmProcessInstanceStatusEnum.APPROVE.getStatus()) + || ObjUtil.notEqual(booking.getUseStatus(), OaMeetingRoomUseStatusEnum.PENDING.getStatus()) + || now.isBefore(booking.getStartTime()) || !now.isBefore(booking.getEndTime())) { + throw exception(MEETING_ROOM_BOOKING_STATUS_INVALID); + } + // 1.3 开始使用前确认会议室仍可用,审批期间可能已停用或维修 + OaMeetingRoomDO room = meetingRoomService.validateMeetingRoomExists(booking.getRoomId()); + if (Boolean.FALSE.equals(room.getAllowBooking()) + || ObjUtil.notEqual(room.getStatus(), OaMeetingRoomStatusEnum.NORMAL.getStatus())) { + throw exception(MEETING_ROOM_NOT_AVAILABLE); + } + + // 2. 开始使用 + meetingRoomBookingMapper.updateById(new OaMeetingRoomBookingDO().setId(id).setUseStatus(OaMeetingRoomUseStatusEnum.IN_USE.getStatus())); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void finishMeetingRoomBooking(Long id, Long userId) { + // 1.1 校验操作人 + OaMeetingRoomBookingDO booking = validateBookingOperator(id, userId); + // 1.2 校验使用中 + if (ObjUtil.notEqual(booking.getStatus(), BpmProcessInstanceStatusEnum.APPROVE.getStatus()) + || ObjUtil.notEqual(booking.getUseStatus(), OaMeetingRoomUseStatusEnum.IN_USE.getStatus())) { + throw exception(MEETING_ROOM_BOOKING_STATUS_INVALID); + } + + // 2. 完成使用并释放剩余时段 + meetingRoomBookingMapper.updateById(new OaMeetingRoomBookingDO().setId(id).setUseStatus(OaMeetingRoomUseStatusEnum.COMPLETED.getStatus())); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateMeetingRoomBookingStatus(Long id, Integer status) { + // 1. 校验申请存在,已处理的审批结果不再重复更新业务状态 + OaMeetingRoomBookingDO booking = validateBookingExists(id); + if (ObjUtil.notEqual(booking.getStatus(), BpmProcessInstanceStatusEnum.RUNNING.getStatus())) { + return; + } + + // 2. 回写结果,驳回、取消或已过结束时间的会议不再待使用 + meetingRoomBookingMapper.updateById(new OaMeetingRoomBookingDO().setId(id).setStatus(status) + .setUseStatus(ObjUtil.equal(status, BpmProcessInstanceStatusEnum.APPROVE.getStatus()) + && booking.getEndTime().isAfter(LocalDateTime.now()) + ? OaMeetingRoomUseStatusEnum.PENDING.getStatus() : OaMeetingRoomUseStatusEnum.CANCELLED.getStatus())); + } + + @Override + public OaMeetingRoomBookingDO getMeetingRoomBooking(Long id) { + return meetingRoomBookingMapper.selectById(id); + } + + @Override + public PageResult getMeetingRoomBookingPage(Long userId, OaMeetingRoomBookingPageReqVO reqVO) { + // 1. 查询关联字段,空匹配直接返回,防止空集合导致筛选失效 + List roomIds = null; + if (StrUtil.isNotBlank(reqVO.getRoomName())) { + roomIds = convertList(meetingRoomService.getMeetingRoomListByName(reqVO.getRoomName()), OaMeetingRoomDO::getId); + if (CollUtil.isEmpty(roomIds)) { + return new PageResult<>(Collections.emptyList(), 0L); + } + } + List moderatorIds = null; + if (StrUtil.isNotBlank(reqVO.getModeratorName())) { + moderatorIds = convertList(adminUserApi.getUserListByNickname(reqVO.getModeratorName()), AdminUserRespDTO::getId); + if (CollUtil.isEmpty(moderatorIds)) { + return new PageResult<>(Collections.emptyList(), 0L); + } + } + // 2. 查询本人预定 + return meetingRoomBookingMapper.selectPage(userId, reqVO, roomIds, moderatorIds); + } + + @Override + public List getMeetingRoomBookingListBySchedule(OaMeetingRoomBookingScheduleReqVO reqVO) { + // 1. 校验房间和查询区间 + meetingRoomService.validateMeetingRoomExists(reqVO.getRoomId()); + validateMeetingTime(reqVO.getStartTime(), reqVO.getEndTime()); + // 2. 查询相交时段的预约 + return meetingRoomBookingMapper.selectListByRoomIdAndTimeOverlap(reqVO.getRoomId(), reqVO.getStartTime(), reqVO.getEndTime()); + } + + @Override + public int cancelExpiredMeetingRoomBookings() { + return meetingRoomBookingMapper.updateUseStatusByExpired(LocalDateTime.now()); + } + + @Override + @SuppressWarnings("DataFlowIssue") + public int sendMeetingRoomBookingReminders() { + // 1. 查询最长提前时间内的待提醒会议,再按各会议的提醒选项判断 + LocalDateTime remindTime = LocalDateTime.now(); + // 不提醒类型没有提前分钟数,不参与扫描窗口计算。 + List reminderTypes = filterList( + Arrays.asList(OaMeetingRoomReminderTypeEnum.values()), type -> type.getMinutes() != null); + if (CollUtil.isEmpty(reminderTypes)) { + return 0; + } + // 一次查询覆盖所有可用提醒选项,具体发送时再检查各会议的提前时间。 + List bookings = meetingRoomBookingMapper.selectListByReminderTypesAndStartTime( + convertList(reminderTypes, OaMeetingRoomReminderTypeEnum::getType), remindTime, + remindTime.plusMinutes(getMaxValue(reminderTypes, OaMeetingRoomReminderTypeEnum::getMinutes))); + + // 2. 每个会议使用独立事务,单条失败不影响其他会议,下轮扫描重试 + int count = 0; + for (OaMeetingRoomBookingDO booking : bookings) { + try { + count += getSelf().sendMeetingRoomBookingReminder(booking.getId(), remindTime) ? 1 : 0; + } catch (Exception ex) { + log.error("[sendMeetingRoomBookingReminders][会议 ({}) 提醒失败]", booking.getId(), ex); + } + } + return count; + } + + /** + * 向申请人、主持人和参会人员发送会议开始提醒 + * + * 保留 public 供自身代理调用,确保发送和提醒标记处于同一事务。 + * + * @param id 预定编号 + * @param remindTime 提醒基准时间 + * @return 是否完成提醒,未到提醒时间或已处理时返回 false + */ + @Transactional(rollbackFor = Exception.class) + public boolean sendMeetingRoomBookingReminder(Long id, LocalDateTime remindTime) { + // 1. 校验提醒窗口,开始后不补发;刚好进入提前时间时允许提醒 + OaMeetingRoomBookingDO booking = meetingRoomBookingMapper.selectById(id); + if (booking == null || Boolean.TRUE.equals(booking.getReminded()) + || ObjUtil.notEqual(booking.getStatus(), BpmProcessInstanceStatusEnum.APPROVE.getStatus()) + || ObjUtil.notEqual(booking.getUseStatus(), OaMeetingRoomUseStatusEnum.PENDING.getStatus())) { + return false; + } + OaMeetingRoomReminderTypeEnum reminderType = OaMeetingRoomReminderTypeEnum.valueOf(booking.getReminderType()); + if (reminderType == null || reminderType.getMinutes() == null + || !booking.getStartTime().isAfter(remindTime) + || booking.getStartTime().isAfter(remindTime.plusMinutes(reminderType.getMinutes()))) { + return false; + } + + // 2.1 合并并去重接收人 + Set userIds = new LinkedHashSet<>(); + userIds.add(NumberUtils.parseLong(booking.getCreator())); + userIds.add(booking.getModeratorUserId()); + CollUtil.addAll(userIds, booking.getAttendeeUserIds()); + userIds.remove(null); + // 2.2 组装会议消息并发送站内信 + OaMeetingRoomDO room = meetingRoomService.validateMeetingRoomExists(booking.getRoomId()); + Map templateParams = new HashMap<>(); + templateParams.put("title", booking.getTitle()); + templateParams.put("roomName", room.getName()); + templateParams.put("location", room.getLocation()); + templateParams.put("startTime", LocalDateTimeUtil.format(booking.getStartTime(), DatePattern.NORM_DATETIME_MINUTE_PATTERN)); + for (Long userId : userIds) { + notifyMessageSendApi.sendSingleMessageToAdmin(new NotifySendSingleToUserReqDTO() + .setUserId(userId).setTemplateCode(MEETING_ROOM_BOOKING_REMINDER).setTemplateParams(templateParams)); + } + + // 3. 标记已通知 + meetingRoomBookingMapper.updateById(new OaMeetingRoomBookingDO().setId(id).setReminded(true)); + return true; + } + + /** + * 获得自身代理,使单条会议提醒的事务生效 + * + * @return 当前 Service 的 Spring 代理 + */ + private OaMeetingRoomBookingServiceImpl getSelf() { + return SpringUtil.getBean(getClass()); + } + + @Override + public boolean hasActiveMeetingRoomBooking(Long roomId) { + return meetingRoomBookingMapper.selectCountByRoomIdAndEndTimeAfter(roomId, LocalDateTime.now()) > 0; + } + + /** + * 校验预定存在 + * + * @param id 预定编号 + * @return 预定 + */ + private OaMeetingRoomBookingDO validateBookingExists(Long id) { + OaMeetingRoomBookingDO booking = meetingRoomBookingMapper.selectById(id); + if (booking == null) { + throw exception(MEETING_ROOM_BOOKING_NOT_EXISTS); + } + return booking; + } + + /** + * 校验会议内容 + * + * @param reqVO 预定信息 + */ + private void validateMeetingRoomBooking(OaMeetingRoomBookingSaveReqVO reqVO) { + // 1. 校验房间存在 + meetingRoomService.validateMeetingRoomExists(reqVO.getRoomId()); + // 2. 校验时间先后顺序 + validateMeetingTime(reqVO.getStartTime(), reqVO.getEndTime()); + // 3. 校验主持人与参会人员有效 + adminUserApi.validateUser(reqVO.getModeratorUserId()); + if (CollUtil.isNotEmpty(reqVO.getAttendeeUserIds())) { + adminUserApi.validateUserList(reqVO.getAttendeeUserIds()); + } + } + + /** + * 校验会议时间 + * + * @param startTime 开始时间 + * @param endTime 结束时间 + */ + private void validateMeetingTime(LocalDateTime startTime, LocalDateTime endTime) { + if (!startTime.isBefore(endTime)) { + throw exception(MEETING_ROOM_BOOKING_TIME_INVALID); + } + } + + /** + * 校验预定归属 + * + * @param booking 预定 + * @param userId 用户编号 + */ + private void validateBookingOwner(OaMeetingRoomBookingDO booking, Long userId) { + if (ObjUtil.notEqual(booking.getCreator(), userId.toString())) { + throw exception(MEETING_ROOM_BOOKING_NOT_OWNER); + } + } + + /** + * 校验预定草稿 + * + * @param booking 预定 + */ + private void validateBookingDraft(OaMeetingRoomBookingDO booking) { + if (ObjUtil.notEqual(booking.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus())) { + throw exception(MEETING_ROOM_BOOKING_STATUS_INVALID); + } + } + + /** + * 校验开始和完成使用的操作人 + * + * @param id 预定编号 + * @param userId 用户编号 + * @return 预定 + */ + private OaMeetingRoomBookingDO validateBookingOperator(Long id, Long userId) { + OaMeetingRoomBookingDO booking = validateBookingExists(id); + if (ObjUtil.notEqual(booking.getCreator(), userId.toString()) && ObjUtil.notEqual(booking.getModeratorUserId(), userId)) { + throw exception(MEETING_ROOM_BOOKING_NOT_OWNER); + } + return booking; + } +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomService.java new file mode 100644 index 0000000000..d62fa65230 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomService.java @@ -0,0 +1,96 @@ +package cn.iocoder.yudao.module.oa.service.meetingroom; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.meetingroom.vo.room.*; +import cn.iocoder.yudao.module.oa.controller.admin.meetingroom.vo.booking.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.meetingroom.*; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * 会议室 Service 接口 + * + * @author 芋道源码 + */ +public interface OaMeetingRoomService { + + /** + * 创建会议室 + * + * @param createReqVO 会议室信息 + * @return 编号 + */ + Long createMeetingRoom(@Valid OaMeetingRoomSaveReqVO createReqVO); + + /** + * 更新会议室 + * + * @param updateReqVO 会议室信息 + */ + void updateMeetingRoom(@Valid OaMeetingRoomSaveReqVO updateReqVO); + + /** + * 删除会议室 + * + * @param id 会议室编号 + */ + void deleteMeetingRoom(Long id); + + /** + * 校验会议室存在 + * + * @param id 会议室编号 + * @return 会议室 + */ + OaMeetingRoomDO validateMeetingRoomExists(Long id); + + /** + * 获得会议室分页 + * + * @param pageReqVO 查询条件 + * @return 分页结果 + */ + PageResult getMeetingRoomPage(OaMeetingRoomPageReqVO pageReqVO); + + /** + * 获得会议室列表 + * + * @param ids 会议室编号 + * @return 会议室列表 + */ + List getMeetingRoomList(Collection ids); + + /** + * 获得会议室 Map + * + * @param ids 会议室编号 + * @return 会议室 Map + */ + default Map getMeetingRoomMap(Collection ids) { + List rooms = getMeetingRoomList(ids); + return convertMap(rooms, OaMeetingRoomDO::getId); + } + + /** + * 按名称查询会议室 + * + * @param name 名称关键字 + * @return 会议室列表 + */ + List getMeetingRoomListByName(String name); + + /** + * 获得本人可预定会议室分页 + * + * @param pageReqVO 查询条件 + * @param userId 用户编号 + * @return 会议室分页 + */ + PageResult getMeetingRoomPageByBookingUserId(OaMeetingRoomPageReqVO pageReqVO, Long userId); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomServiceImpl.java new file mode 100644 index 0000000000..bfebb5ea19 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomServiceImpl.java @@ -0,0 +1,145 @@ +package cn.iocoder.yudao.module.oa.service.meetingroom; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.meetingroom.vo.room.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.meetingroom.OaMeetingRoomDO; +import cn.iocoder.yudao.module.oa.dal.mysql.meetingroom.OaMeetingRoomMapper; +import cn.iocoder.yudao.module.oa.enums.DictTypeConstants; +import cn.iocoder.yudao.module.oa.enums.meetingroom.*; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.dict.DictDataApi; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; + +/** + * 会议室 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaMeetingRoomServiceImpl implements OaMeetingRoomService { + + @Resource + private OaMeetingRoomMapper meetingRoomMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private OaMeetingRoomBookingService meetingRoomBookingService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DictDataApi dictDataApi; + + @Override + public Long createMeetingRoom(OaMeetingRoomSaveReqVO createReqVO) { + // 1. 校验负责人、设备和可预定成员 + validateMeetingRoom(createReqVO); + + // 2. 保存会议室 + OaMeetingRoomDO room = BeanUtils.toBean(createReqVO, OaMeetingRoomDO.class); + meetingRoomMapper.insert(room); + return room.getId(); + } + + @Override + public void updateMeetingRoom(OaMeetingRoomSaveReqVO updateReqVO) { + // 1.1 校验会议室存在 + validateMeetingRoomExists(updateReqVO.getId()); + // 1.2 校验配置 + validateMeetingRoom(updateReqVO); + + // 2. 更新当前配置 + meetingRoomMapper.updateById(BeanUtils.toBean(updateReqVO, OaMeetingRoomDO.class)); + } + + @Override + public void deleteMeetingRoom(Long id) { + // 1.1 校验会议室存在 + validateMeetingRoomExists(id); + // 1.2 校验没有仍有效的预约 + if (meetingRoomBookingService.hasActiveMeetingRoomBooking(id)) { + throw exception(MEETING_ROOM_HAS_BOOKING); + } + + // 2. 删除会议室 + meetingRoomMapper.deleteById(id); + } + + @Override + public OaMeetingRoomDO validateMeetingRoomExists(Long id) { + OaMeetingRoomDO room = meetingRoomMapper.selectById(id); + if (room == null) { + throw exception(MEETING_ROOM_NOT_EXISTS); + } + return room; + } + + @Override + public PageResult getMeetingRoomPage(OaMeetingRoomPageReqVO pageReqVO) { + return getMeetingRoomPageByBookingUserId(pageReqVO, null); + } + + @Override + public PageResult getMeetingRoomPageByBookingUserId(OaMeetingRoomPageReqVO pageReqVO, Long userId) { + // 1. 按负责人姓名查询匹配用户,空结果不继续查询 + List managerIds = null; + if (StrUtil.isNotBlank(pageReqVO.getManagerName())) { + managerIds = convertList(adminUserApi.getUserListByNickname(pageReqVO.getManagerName()), AdminUserRespDTO::getId); + if (CollUtil.isEmpty(managerIds)) { + return new PageResult<>(Collections.emptyList(), 0L); + } + } + // 2. 查询会议室分页 + return meetingRoomMapper.selectPage(pageReqVO, managerIds, userId); + } + + @Override + public List getMeetingRoomList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return meetingRoomMapper.selectByIds(ids); + } + + @Override + public List getMeetingRoomListByName(String name) { + return meetingRoomMapper.selectListByName(name); + } + + /** + * 校验会议室配置 + * + * @param reqVO 会议室配置 + */ + private void validateMeetingRoom(OaMeetingRoomSaveReqVO reqVO) { + // 1. 校验负责人有效 + adminUserApi.validateUser(reqVO.getManagerUserId()); + // 2. 校验设备字典值 + if (CollUtil.isNotEmpty(reqVO.getEquipments())) { + dictDataApi.validateDictDataList(DictTypeConstants.MEETING_ROOM_EQUIPMENT, + convertList(reqVO.getEquipments(), String::valueOf)); + } + // 3. 校验指定成员范围 + if (ObjUtil.equal(reqVO.getBookingScope(), OaMeetingRoomBookingScopeEnum.SPECIFIED.getScope())) { + if (CollUtil.isEmpty(reqVO.getBookingUserIds())) { + throw exception(MEETING_ROOM_SCOPE_INVALID); + } + adminUserApi.validateUserList(reqVO.getBookingUserIds()); + } + } +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/meetingroom/listener/OaMeetingRoomBookingStatusListener.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/meetingroom/listener/OaMeetingRoomBookingStatusListener.java new file mode 100644 index 0000000000..6f2214a790 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/meetingroom/listener/OaMeetingRoomBookingStatusListener.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.oa.service.meetingroom.listener; + +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import cn.iocoder.yudao.module.oa.service.meetingroom.OaMeetingRoomBookingService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +/** + * 会议室预定审批结果监听器 + * + * @author 芋道源码 + */ +@Component +public class OaMeetingRoomBookingStatusListener extends BpmProcessInstanceStatusEventListener { + + @Resource + private OaMeetingRoomBookingService meetingRoomBookingService; + + @Override + protected String getProcessDefinitionKey() { + return BpmModelConstants.MEETING_ROOM_BOOKING; + } + + @Override + protected void onEvent(BpmProcessInstanceStatusEvent event) { + meetingRoomBookingService.updateMeetingRoomBookingStatus(Long.parseLong(event.getBusinessKey()), event.getStatus()); + } +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/note/OaNoteCategoryService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/note/OaNoteCategoryService.java new file mode 100644 index 0000000000..d1deaa972e --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/note/OaNoteCategoryService.java @@ -0,0 +1,89 @@ +package cn.iocoder.yudao.module.oa.service.note; + +import cn.iocoder.yudao.module.oa.controller.admin.note.vo.category.OaNoteCategorySaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.note.OaNoteCategoryDO; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * OA 笔记目录 Service 接口 + * + * @author 芋道源码 + */ +public interface OaNoteCategoryService { + + /** + * 创建笔记目录 + * + * @param createReqVO 创建信息 + * @param userId 用户编号 + * @return 目录编号 + */ + Long createNoteCategory(OaNoteCategorySaveReqVO createReqVO, Long userId); + + /** + * 更新笔记目录 + * + * @param updateReqVO 更新信息 + * @param userId 用户编号 + */ + void updateNoteCategory(OaNoteCategorySaveReqVO updateReqVO, Long userId); + + /** + * 删除笔记目录 + * + * @param id 目录编号 + * @param userId 用户编号 + */ + void deleteNoteCategory(Long id, Long userId); + + /** + * 获得用户的笔记目录列表 + * + * @param userId 用户编号 + * @return 笔记目录列表 + */ + List getNoteCategoryList(Long userId); + + /** + * 获得笔记目录 + * + * @param id 目录编号 + * @param userId 用户编号 + * @return 笔记目录 + */ + OaNoteCategoryDO getNoteCategory(Long id, Long userId); + + /** + * 校验笔记目录归属当前用户 + * + * @param id 目录编号 + * @param userId 用户编号 + * @return 笔记目录 + */ + OaNoteCategoryDO validateNoteCategoryOwner(Long id, Long userId); + + /** + * 获得笔记目录列表 + * + * @param ids 目录编号集合 + * @return 笔记目录列表 + */ + List getNoteCategoryList(Collection ids); + + /** + * 获得笔记目录 Map + * + * @param ids 目录编号集合 + * @return 笔记目录 Map + */ + default Map getNoteCategoryMap(Collection ids) { + List categories = getNoteCategoryList(ids); + return convertMap(categories, OaNoteCategoryDO::getId); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/note/OaNoteCategoryServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/note/OaNoteCategoryServiceImpl.java new file mode 100644 index 0000000000..ad4a436d15 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/note/OaNoteCategoryServiceImpl.java @@ -0,0 +1,115 @@ +package cn.iocoder.yudao.module.oa.service.note; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.note.vo.category.OaNoteCategorySaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.note.OaNoteCategoryDO; +import cn.iocoder.yudao.module.oa.dal.mysql.note.OaNoteCategoryMapper; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +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.oa.enums.ErrorCodeConstants.NOTE_CATEGORY_NAME_DUPLICATE; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.NOTE_CATEGORY_NOT_EXISTS; + +/** + * OA 笔记目录 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaNoteCategoryServiceImpl implements OaNoteCategoryService { + + @Resource + private OaNoteCategoryMapper noteCategoryMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private OaNoteService noteService; + + @Override + public Long createNoteCategory(OaNoteCategorySaveReqVO createReqVO, Long userId) { + // 1. 校验目录名称唯一 + validateNoteCategoryNameUnique(null, userId, createReqVO.getName()); + + // 2. 新增笔记目录 + OaNoteCategoryDO category = BeanUtils.toBean(createReqVO, OaNoteCategoryDO.class).setUserId(userId); + noteCategoryMapper.insert(category); + return category.getId(); + } + + @Override + public void updateNoteCategory(OaNoteCategorySaveReqVO updateReqVO, Long userId) { + // 1.1 校验笔记目录属于当前用户 + validateNoteCategoryOwner(updateReqVO.getId(), userId); + // 1.2 校验目录名称唯一 + validateNoteCategoryNameUnique(updateReqVO.getId(), userId, updateReqVO.getName()); + + // 2. 更新笔记目录 + noteCategoryMapper.updateById(BeanUtils.toBean(updateReqVO, OaNoteCategoryDO.class)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteNoteCategory(Long id, Long userId) { + // 1. 校验笔记目录属于当前用户 + validateNoteCategoryOwner(id, userId); + + // 2.1 删除目录下的笔记 + noteService.deleteNotesByCategoryId(id, userId); + + // 2.2 删除笔记目录 + noteCategoryMapper.deleteById(id); + } + + @Override + public OaNoteCategoryDO getNoteCategory(Long id, Long userId) { + return validateNoteCategoryOwner(id, userId); + } + + @Override + public List getNoteCategoryList(Long userId) { + return noteCategoryMapper.selectListByUserId(userId); + } + + @Override + public OaNoteCategoryDO validateNoteCategoryOwner(Long id, Long userId) { + if (id == null) { + return null; + } + OaNoteCategoryDO category = noteCategoryMapper.selectById(id); + if (category == null || ObjectUtil.notEqual(category.getUserId(), userId)) { + throw exception(NOTE_CATEGORY_NOT_EXISTS); + } + return category; + } + + @Override + public List getNoteCategoryList(Collection ids) { + return CollUtil.isEmpty(ids) ? Collections.emptyList() : noteCategoryMapper.selectByIds(ids); + } + + /** + * 校验同一用户的目录名称唯一 + * + * @param id 当前目录编号,新增时为空 + * @param userId 用户编号 + * @param name 目录名称 + */ + private void validateNoteCategoryNameUnique(Long id, Long userId, String name) { + OaNoteCategoryDO category = noteCategoryMapper.selectByUserIdAndName(userId, name); + if (category != null && ObjectUtil.notEqual(category.getId(), id)) { + throw exception(NOTE_CATEGORY_NAME_DUPLICATE); + } + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/note/OaNoteService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/note/OaNoteService.java new file mode 100644 index 0000000000..7addbfc52a --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/note/OaNoteService.java @@ -0,0 +1,126 @@ +package cn.iocoder.yudao.module.oa.service.note; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.note.vo.OaNotePageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.note.vo.OaNoteSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.note.OaNoteDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.note.OaNoteReceiverDO; +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * OA 笔记 Service 接口 + * + * @author 芋道源码 + */ +public interface OaNoteService { + + /** + * 创建笔记 + * + * @param createReqVO 创建信息 + * @param userId 用户编号 + * @return 笔记编号 + */ + Long createNote(OaNoteSaveReqVO createReqVO, Long userId); + + /** + * 更新笔记 + * + * @param updateReqVO 更新信息 + * @param userId 用户编号 + */ + void updateNote(OaNoteSaveReqVO updateReqVO, Long userId); + + /** + * 删除本人笔记,共享笔记仅移除本人持有关系 + * + * @param id 笔记编号 + * @param userId 用户编号 + */ + void deleteNote(Long id, Long userId); + + /** + * 移除本人收到的共享笔记,不删除正文 + * + * @param id 笔记编号 + * @param userId 接收人用户编号 + */ + void deleteReceivedNote(Long id, Long userId); + + /** + * 获得笔记 + * + * @param id 笔记编号 + * @param userId 用户编号 + * @return 笔记 + */ + OaNoteDO getNote(Long id, Long userId); + + /** + * 获得我的笔记分页 + * + * @param pageReqVO 分页查询 + * @param userId 当前用户编号 + * @return 笔记分页 + */ + PageResult getMyNotePage(OaNotePageReqVO pageReqVO, Long userId); + + /** + * 获得共享给我的笔记分页 + * + * @param pageReqVO 分页查询 + * @param userId 当前用户编号 + * @return 笔记分页 + */ + PageResult getReceivedNotePage(OaNotePageReqVO pageReqVO, Long userId); + + /** + * 更新笔记收藏状态 + * + * @param id 笔记编号 + * @param favorite 是否收藏 + * @param userId 用户编号 + */ + void updateNoteFavorite(Long id, Boolean favorite, Long userId); + + /** + * 更新笔记共享接收人,不修改笔记内容 + * + * @param id 笔记编号 + * @param receiverUserIds 接收人编号,空集合取消对其他人的共享并保留本人持有关系 + * @param userId 操作用户编号 + */ + void updateNoteShare(Long id, List receiverUserIds, Long userId); + + /** + * 获得笔记持有人用户编号 Map,包含创建人 + * + * @param noteIds 笔记编号集合 + * @return 笔记编号与持有人用户编号列表的 Map + */ + default Map> getNoteReceiverUserIdListMap(Collection noteIds) { + List receivers = getNoteReceiverList(noteIds); + return CollectionUtils.convertMultiMap(receivers, OaNoteReceiverDO::getNoteId, OaNoteReceiverDO::getUserId); + } + + /** + * 获得笔记持有关系列表,包含创建人 + * + * @param noteIds 笔记编号集合 + * @return 持有关系列表 + */ + List getNoteReceiverList(Collection noteIds); + + /** + * 删除指定目录下的笔记 + * + * @param categoryId 目录编号 + * @param userId 用户编号 + */ + void deleteNotesByCategoryId(Long categoryId, Long userId); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/note/OaNoteServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/note/OaNoteServiceImpl.java new file mode 100644 index 0000000000..2a7ce7f4c3 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/note/OaNoteServiceImpl.java @@ -0,0 +1,251 @@ +package cn.iocoder.yudao.module.oa.service.note; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.note.vo.OaNotePageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.note.vo.OaNoteSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.note.OaNoteDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.note.OaNoteReceiverDO; +import cn.iocoder.yudao.module.oa.dal.mysql.note.OaNoteMapper; +import cn.iocoder.yudao.module.oa.dal.mysql.note.OaNoteReceiverMapper; +import cn.iocoder.yudao.module.oa.enums.note.OaNoteTypeEnum; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.diffList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.NOTE_ACCESS_DENIED; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.NOTE_NOT_EXISTS; + +/** + * OA 笔记 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaNoteServiceImpl implements OaNoteService { + + @Resource + private OaNoteMapper noteMapper; + @Resource + private OaNoteReceiverMapper noteReceiverMapper; + @Resource + @Lazy + private OaNoteCategoryService noteCategoryService; + @Resource + private AdminUserApi adminUserApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createNote(OaNoteSaveReqVO createReqVO, Long userId) { + // 1. 校验笔记目录属于当前用户 + noteCategoryService.validateNoteCategoryOwner(createReqVO.getCategoryId(), userId); + + // 2. 新增笔记 + OaNoteDO note = BeanUtils.toBean(createReqVO, OaNoteDO.class) + .setFavorite(false); + noteMapper.insert(note); + + // 3. 创建人也通过持有关系访问笔记 + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()).setUserId(userId)); + return note.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateNote(OaNoteSaveReqVO updateReqVO, Long userId) { + // 1.1 校验笔记属于当前用户 + validateNoteOwner(updateReqVO.getId(), userId); + // 1.2 校验笔记目录属于当前用户 + noteCategoryService.validateNoteCategoryOwner(updateReqVO.getCategoryId(), userId); + + // 2. 更新笔记 + noteMapper.updateForSave(BeanUtils.toBean(updateReqVO, OaNoteDO.class)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateNoteShare(Long id, List receiverUserIds, Long userId) { + // 1.1 校验笔记属于当前用户 + validateNoteOwner(id, userId); + // 1.2 校验接收人存在 + adminUserApi.validateUserList(receiverUserIds); + + // 2.1 查询原接收关系并计算差异 + List oldReceivers = noteReceiverMapper.selectListByNoteId(id); + Set holderUserIds = new HashSet<>(receiverUserIds); + holderUserIds.add(userId); // 清空共享名单也保留创建人自己的持有关系 + List newReceivers = convertList(holderUserIds, + receiverUserId -> new OaNoteReceiverDO().setNoteId(id).setUserId(receiverUserId)); + List> diffReceivers = diffList(oldReceivers, newReceivers, + (oldReceiver, newReceiver) -> ObjectUtil.equal(oldReceiver.getUserId(), newReceiver.getUserId())); + // 2.2 新增接收关系 + if (CollUtil.isNotEmpty(diffReceivers.get(0))) { + noteReceiverMapper.insertBatch(diffReceivers.get(0)); + } + // 2.3 删除取消的接收关系,创建人自身关系不参与撤销 + if (CollUtil.isNotEmpty(diffReceivers.get(2))) { + noteReceiverMapper.deleteByNoteIdAndUserIds(id, convertList(diffReceivers.get(2), OaNoteReceiverDO::getUserId)); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteNote(Long id, Long userId) { + // 1. 校验笔记属于当前用户 + OaNoteDO note = validateNoteOwner(id, userId); + + // 2. 共享笔记只退出本人持有,保留正文及其他接收人的访问 + if (ObjectUtil.equal(note.getType(), OaNoteTypeEnum.SHARED.getType()) + || CollUtil.findOne(noteReceiverMapper.selectListByNoteId(id), + receiver -> ObjectUtil.notEqual(receiver.getUserId(), userId)) != null) { + noteReceiverMapper.deleteByNoteIdAndUserIds(id, Collections.singletonList(userId)); + return; + } + + // 3.1 删除笔记接收人 + noteReceiverMapper.deleteByNoteId(id); + // 3.2 删除笔记 + noteMapper.deleteById(id); + } + + @Override + public void deleteReceivedNote(Long id, Long userId) { + // 1.1 创建人通过本人笔记删除入口操作 + OaNoteDO note = validateNoteExists(id); + if (ObjectUtil.equal(NumberUtils.parseLong(note.getCreator()), userId)) { + throw exception(NOTE_ACCESS_DENIED); + } + // 1.2 校验当前用户是共享接收人 + OaNoteReceiverDO receiver = noteReceiverMapper.selectByNoteIdAndUserId(id, userId); + if (receiver == null) { + throw exception(NOTE_ACCESS_DENIED); + } + + // 2. 仅移除本人的接收关系,保留正文及其他用户的访问权限 + noteReceiverMapper.deleteById(receiver.getId()); + } + + @Override + public OaNoteDO getNote(Long id, Long userId) { + // 1. 校验笔记存在 + OaNoteDO note = validateNoteExists(id); + + // 2. 创建人和接收人均须仍持有笔记 + if (noteReceiverMapper.selectByNoteIdAndUserId(id, userId) == null) { + throw exception(NOTE_ACCESS_DENIED); + } + return note; + } + + @Override + public PageResult getMyNotePage(OaNotePageReqVO pageReqVO, Long userId) { + // 1. 查询当前用户仍持有的笔记 + List noteIds = convertList(noteReceiverMapper.selectListByUserId(userId), OaNoteReceiverDO::getNoteId); + if (CollUtil.isEmpty(noteIds)) { + return PageResult.empty(); + } + + // 2. 仅展示本人创建且仍持有的笔记 + return noteMapper.selectMyPage(pageReqVO, userId, noteIds); + } + + @Override + public PageResult getReceivedNotePage(OaNotePageReqVO pageReqVO, Long userId) { + // 1. 查询当前用户的接收关系 + List receivers = noteReceiverMapper.selectListByUserId(userId); + if (CollUtil.isEmpty(receivers)) { + return PageResult.empty(); + } + // 2. 查询共享给本人的笔记 + List noteIds = convertList(receivers, OaNoteReceiverDO::getNoteId); + return noteMapper.selectReceivedPage(pageReqVO, noteIds, userId); + } + + @Override + public void updateNoteFavorite(Long id, Boolean favorite, Long userId) { + // 1. 校验当前用户仍持有笔记 + getNote(id, userId); + + // 2. 持有人共用笔记的收藏状态 + noteMapper.updateById(new OaNoteDO().setId(id).setFavorite(favorite)); + } + + @Override + public List getNoteReceiverList(Collection noteIds) { + if (CollUtil.isEmpty(noteIds)) { + return Collections.emptyList(); + } + return noteReceiverMapper.selectListByNoteIds(noteIds); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteNotesByCategoryId(Long categoryId, Long userId) { + // 1.1 查询本人仍持有的目录笔记,已退出的正文不随目录删除 + List heldNoteIds = convertList(noteReceiverMapper.selectListByUserId(userId), OaNoteReceiverDO::getNoteId); + if (CollUtil.isEmpty(heldNoteIds)) { + return; + } + List notes = noteMapper.selectListByCategoryIdAndIds(categoryId, heldNoteIds); + if (CollUtil.isEmpty(notes)) { + return; + } + // 1.2 校验笔记均属于当前用户 + if (CollUtil.findOne(notes, note -> ObjectUtil.notEqual(NumberUtils.parseLong(note.getCreator()), userId)) != null) { + throw exception(NOTE_ACCESS_DENIED); + } + + // 2.1 删除笔记接收人 + Collection noteIds = convertSet(notes, OaNoteDO::getId); + noteReceiverMapper.deleteByNoteIds(noteIds); + // 2.2 删除笔记 + noteMapper.deleteByIds(noteIds); + } + + /** + * 校验笔记属于指定用户 + * + * @param id 笔记编号 + * @param userId 用户编号 + * @return 笔记 + */ + private OaNoteDO validateNoteOwner(Long id, Long userId) { + OaNoteDO note = getNote(id, userId); + if (ObjectUtil.notEqual(NumberUtils.parseLong(note.getCreator()), userId)) { + throw exception(NOTE_ACCESS_DENIED); + } + return note; + } + + /** + * 校验笔记存在 + * + * @param id 笔记编号 + * @return 笔记 + */ + private OaNoteDO validateNoteExists(Long id) { + OaNoteDO note = noteMapper.selectById(id); + if (note == null) { + throw exception(NOTE_NOT_EXISTS); + } + return note; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocReceiveService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocReceiveService.java new file mode 100644 index 0000000000..86f1a4799b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocReceiveService.java @@ -0,0 +1,97 @@ +package cn.iocoder.yudao.module.oa.service.officialdoc; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.receive.OaOfficialDocReceivePageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.receive.OaOfficialDocReceiveSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.officialdoc.OaOfficialDocReceiveDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.officialdoc.OaOfficialDocSendDO; + +/** + * 公文收文 Service 接口 + * + * @author 芋道源码 + */ +public interface OaOfficialDocReceiveService { + + /** + * 创建公文收文 + * + * @param reqVO 公文收文信息 + * @return 公文收文编号 + */ + Long createOfficialDocReceive(OaOfficialDocReceiveSaveReqVO reqVO); + + /** + * 根据已通过的发文创建部门收文 + * + * @param send 发文 + */ + void createOfficialDocReceiveListByOfficialDocSend(OaOfficialDocSendDO send); + + /** + * 更新公文收文 + * + * @param reqVO 公文收文信息 + * @param userId 操作用户编号 + */ + void updateOfficialDocReceive(OaOfficialDocReceiveSaveReqVO reqVO, Long userId); + + /** + * 签收公文 + * + * @param id 收文编号 + * @param userId 签收人编号 + */ + void claimOfficialDocReceive(Long id, Long userId); + + /** + * 提交公文收文审批 + * + * @param id 单据编号 + * @param userId 操作用户编号 + * @return 流程实例编号 + */ + String submitOfficialDocReceive(Long id, Long userId); + + /** + * 撤销公文收文审批 + * + * @param id 单据编号 + * @param userId 操作用户编号 + */ + void cancelOfficialDocReceive(Long id, Long userId); + + /** + * 更新公文收文审批结果 + * + * @param id 单据编号 + * @param status 审批状态 + */ + void updateOfficialDocReceiveStatus(Long id, Integer status); + + /** + * 删除公文收文 + * + * @param id 公文收文编号 + * @param userId 操作用户编号 + */ + void deleteOfficialDocReceive(Long id, Long userId); + + /** + * 获得公文收文分页 + * + * @param reqVO 分页条件 + * @param userId 操作用户编号 + * @return 公文收文分页 + */ + PageResult getOfficialDocReceivePage(OaOfficialDocReceivePageReqVO reqVO, Long userId); + + /** + * 获得公文收文 + * + * @param id 公文收文编号 + * @return 公文收文 + */ + OaOfficialDocReceiveDO getOfficialDocReceive(Long id); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocReceiveServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocReceiveServiceImpl.java new file mode 100644 index 0000000000..d8222f35be --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocReceiveServiceImpl.java @@ -0,0 +1,288 @@ +package cn.iocoder.yudao.module.oa.service.officialdoc; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +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.framework.common.util.object.ObjectUtils; +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.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.receive.OaOfficialDocReceivePageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.receive.OaOfficialDocReceiveSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.officialdoc.OaOfficialDocReceiveDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.officialdoc.OaOfficialDocSendDO; +import cn.iocoder.yudao.module.oa.dal.mysql.officialdoc.OaOfficialDocReceiveMapper; +import cn.iocoder.yudao.module.oa.dal.redis.no.OaNoRedisDAO; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import cn.iocoder.yudao.module.oa.enums.officialdoc.OaOfficialDocHandleStatusEnum; +import cn.iocoder.yudao.module.oa.enums.officialdoc.OaOfficialDocReceiveTypeEnum; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; + +/** + * 公文收文 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaOfficialDocReceiveServiceImpl implements OaOfficialDocReceiveService { + + @Resource + private OaOfficialDocReceiveMapper officialDocReceiveMapper; + + @Resource + private OaNoRedisDAO noRedisDAO; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private BpmProcessInstanceApi processInstanceApi; + + @Override + public Long createOfficialDocReceive(OaOfficialDocReceiveSaveReqVO reqVO) { + // 1. 生成收文单号并校验唯一 + String no = noRedisDAO.generate(OaNoRedisDAO.OFFICIAL_DOC_RECEIVE_NO_PREFIX); + validateOfficialDocReceiveNoUnique(no); + + // 2. 保存手工收文 + OaOfficialDocReceiveDO receive = BeanUtils.toBean(reqVO, OaOfficialDocReceiveDO.class).setId(null) + .setReceiveType(OaOfficialDocReceiveTypeEnum.MAIN.getType()).setNo(no) + .setStatus(BpmProcessInstanceStatusEnum.NOT_START.getStatus()) + .setHandleStatus(OaOfficialDocHandleStatusEnum.WAIT_CLAIM.getStatus()); + officialDocReceiveMapper.insert(receive); + return receive.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void createOfficialDocReceiveListByOfficialDocSend(OaOfficialDocSendDO send) { + // 1.1 查询已经生成的收文 + List receives = officialDocReceiveMapper.selectListBySendId(send.getId()); + // 1.2 按收文类型整理接收部门 + Map> deptIdsByReceiveType = new LinkedHashMap<>(); + deptIdsByReceiveType.put(OaOfficialDocReceiveTypeEnum.MAIN.getType(), send.getMainDeptIds()); + deptIdsByReceiveType.put(OaOfficialDocReceiveTypeEnum.COPY.getType(), send.getCopyDeptIds()); + + // 2. 分别创建主送、抄送的待签收记录 + deptIdsByReceiveType.forEach((receiveType, deptIds) -> { + if (CollUtil.isEmpty(deptIds)) { + return; + } + Set receivedDeptIds = convertSet(receives, OaOfficialDocReceiveDO::getReceiveDeptId, + receive -> ObjUtil.equal(receive.getReceiveType(), receiveType)); + deptIds.forEach(deptId -> { + // 同一类型已投递的部门不重复生成,主送与抄送互不影响 + if (!receivedDeptIds.add(deptId)) { + return; + } + // 2.1 生成接收部门的收文单号,并校验唯一性 + String no = noRedisDAO.generate(OaNoRedisDAO.OFFICIAL_DOC_RECEIVE_NO_PREFIX); + validateOfficialDocReceiveNoUnique(no); + // 2.2 保存发文快照,初始化为未开始审批、待签收的部门收文 + OaOfficialDocReceiveDO receive = new OaOfficialDocReceiveDO().setSendId(send.getId()) + .setNo(no) + .setReceiveDeptId(deptId).setReceiveType(receiveType).setTitle(send.getTitle()) + .setDocumentNo(send.getDocumentNo()).setSecrecyLevel(send.getSecrecyLevel()) + .setUrgencyLevel(send.getUrgencyLevel()).setFormalFileUrl(send.getFormalFileUrl()) + .setFileUrls(send.getFileUrls()).setStatus(BpmProcessInstanceStatusEnum.NOT_START.getStatus()) + .setHandleStatus(OaOfficialDocHandleStatusEnum.WAIT_CLAIM.getStatus()); + receive.setCreator(""); // 自动投递尚无归属人,避免被当前审批人的通用审计填充占用;签收后再确定归属 + officialDocReceiveMapper.insert(receive); + }); + }); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateOfficialDocReceive(OaOfficialDocReceiveSaveReqVO reqVO, Long userId) { + // 1.1 校验收文存在 + OaOfficialDocReceiveDO receive = validateOfficialDocReceiveExists(reqVO.getId()); + // 1.2 校验收文归属 + validateOfficialDocReceiveOwner(receive, userId); + // 1.3 仅未提交草稿允许修改 + validateOfficialDocReceiveDraft(receive); + + // 2. 更新表单字段,不覆盖流程状态和归属 + OaOfficialDocReceiveDO updateObj = BeanUtils.toBean(reqVO, OaOfficialDocReceiveDO.class) + .setReceiveType(receive.getReceiveType()); + // 自动收文保留投递时的附件和正式公文,不允许通过编辑替换 + if (receive.getSendId() != null) { + updateObj.setFileUrls(receive.getFileUrls()).setFormalFileUrl(receive.getFormalFileUrl()); + } + officialDocReceiveMapper.updateById(updateObj); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void claimOfficialDocReceive(Long id, Long userId) { + // 1.1 校验收文存在 + OaOfficialDocReceiveDO receive = validateOfficialDocReceiveExists(id); + // 1.2 校验接收部门 + if (ObjUtil.notEqual(receive.getReceiveDeptId(), adminUserApi.getUser(userId).getDeptId())) { + throw exception(OFFICIAL_DOC_ACCESS_DENIED); + } + // 1.3 仅未签收的自动投递单据允许认领 + if (receive.getSendId() == null || StrUtil.isNotEmpty(receive.getCreator()) + || ObjUtil.notEqual(receive.getHandleStatus(), OaOfficialDocHandleStatusEnum.WAIT_CLAIM.getStatus())) { + throw exception(OFFICIAL_DOC_STATUS_INVALID); + } + + // 2. 签收仅确定单据归属和办理状态,保留主办人及收文时间 + OaOfficialDocReceiveDO updateObj = new OaOfficialDocReceiveDO().setId(id) + .setHandleStatus(OaOfficialDocHandleStatusEnum.CLAIMED.getStatus()); + updateObj.setCreator(userId.toString()); + officialDocReceiveMapper.updateById(updateObj); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String submitOfficialDocReceive(Long id, Long userId) { + // 1.1 校验收文存在 + OaOfficialDocReceiveDO receive = validateOfficialDocReceiveExists(id); + // 1.2 校验收文归属 + validateOfficialDocReceiveOwner(receive, userId); + // 1.3 校验草稿状态 + validateOfficialDocReceiveDraft(receive); + // 1.4 抄送仅知会,不发起办理审批 + if (ObjUtil.notEqual(receive.getReceiveType(), OaOfficialDocReceiveTypeEnum.MAIN.getType())) { + throw exception(OFFICIAL_DOC_STATUS_INVALID); + } + + // 2. 更新审批状态和办理状态 + officialDocReceiveMapper.updateById(new OaOfficialDocReceiveDO().setId(id).setStatus(BpmProcessInstanceStatusEnum.RUNNING.getStatus()) + .setHandleStatus(OaOfficialDocHandleStatusEnum.PROCESSING.getStatus())); + + // 3.1 发起审批 + String processInstanceId = processInstanceApi.createProcessInstance(userId, + new BpmProcessInstanceCreateReqDTO().setProcessDefinitionKey(BpmModelConstants.OFFICIAL_DOC_RECEIVE) + .setBusinessKey(id.toString()).setVariables(new HashMap<>())); + // 3.2 绑定流程编号 + officialDocReceiveMapper.updateById(new OaOfficialDocReceiveDO().setId(id).setProcessInstanceId(processInstanceId)); + return processInstanceId; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void cancelOfficialDocReceive(Long id, Long userId) { + // 1.1 校验收文存在 + OaOfficialDocReceiveDO receive = validateOfficialDocReceiveExists(id); + // 1.2 校验收文归属 + validateOfficialDocReceiveOwner(receive, userId); + // 1.3 仅审批中的单据允许撤销 + if (ObjUtil.notEqual(receive.getStatus(), BpmProcessInstanceStatusEnum.RUNNING.getStatus())) { + throw exception(OFFICIAL_DOC_STATUS_INVALID); + } + + // 2. 撤销审批流程,审批事件同步单据状态 + processInstanceApi.cancelProcessInstanceByStartUser(userId, receive.getProcessInstanceId(), "申请人撤销公文收文"); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateOfficialDocReceiveStatus(Long id, Integer status) { + // 1. 校验收文存在 + validateOfficialDocReceiveExists(id); + + // 2. 更新审批结果及办理状态 + officialDocReceiveMapper.updateById(new OaOfficialDocReceiveDO().setId(id).setStatus(status) + .setHandleStatus(ObjUtil.equal(status, BpmProcessInstanceStatusEnum.APPROVE.getStatus()) + ? OaOfficialDocHandleStatusEnum.COMPLETED.getStatus() : OaOfficialDocHandleStatusEnum.CLAIMED.getStatus())); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteOfficialDocReceive(Long id, Long userId) { + // 1.1 校验收文存在 + OaOfficialDocReceiveDO receive = validateOfficialDocReceiveExists(id); + // 1.2 校验收文归属 + validateOfficialDocReceiveOwner(receive, userId); + // 1.3 未提交、审批不通过和已取消的单据允许删除 + // TODO @AI:notEqualsAny + if (!ObjectUtils.equalsAny(receive.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus(), + BpmProcessInstanceStatusEnum.REJECT.getStatus(), BpmProcessInstanceStatusEnum.CANCEL.getStatus())) { + throw exception(OFFICIAL_DOC_STATUS_INVALID); + } + + // 2. 删除公文 + officialDocReceiveMapper.deleteById(id); + } + + @Override + public PageResult getOfficialDocReceivePage(OaOfficialDocReceivePageReqVO reqVO, Long userId) { + Long deptId = adminUserApi.getUser(userId).getDeptId(); + if (deptId == null) { + return PageResult.empty(); + } + return officialDocReceiveMapper.selectPage(deptId, reqVO); + } + + @Override + public OaOfficialDocReceiveDO getOfficialDocReceive(Long id) { + return officialDocReceiveMapper.selectById(id); + } + + /** + * 校验收文单据编号唯一 + * + * @param no 单据编号 + */ + private void validateOfficialDocReceiveNoUnique(String no) { + if (officialDocReceiveMapper.selectByNo(no) != null) { + throw exception(OFFICIAL_DOC_RECEIVE_NO_DUPLICATE); + } + } + + /** + * 校验公文收文存在 + * + * @param id 公文收文编号 + * @return 公文收文 + */ + private OaOfficialDocReceiveDO validateOfficialDocReceiveExists(Long id) { + OaOfficialDocReceiveDO receive = officialDocReceiveMapper.selectById(id); + if (receive == null) { + throw exception(OFFICIAL_DOC_NOT_EXISTS); + } + return receive; + } + + /** + * 校验公文收文操作人 + * + * @param receive 公文收文 + * @param userId 操作用户编号 + */ + private void validateOfficialDocReceiveOwner(OaOfficialDocReceiveDO receive, Long userId) { + if (ObjUtil.notEqual(receive.getCreator(), userId.toString())) { + throw exception(OFFICIAL_DOC_ACCESS_DENIED); + } + } + + /** + * 校验公文收文为未提交草稿 + * + * @param receive 公文收文 + */ + private void validateOfficialDocReceiveDraft(OaOfficialDocReceiveDO receive) { + if (ObjUtil.notEqual(receive.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus())) { + throw exception(OFFICIAL_DOC_STATUS_INVALID); + } + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocSendService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocSendService.java new file mode 100644 index 0000000000..f2698ac07a --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocSendService.java @@ -0,0 +1,118 @@ +package cn.iocoder.yudao.module.oa.service.officialdoc; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; +import cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.send.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.officialdoc.*; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 公文发文 Service 接口 + * + * @author 芋道源码 + */ +public interface OaOfficialDocSendService { + + /** + * 创建公文发文草稿 + * + * @param reqVO 公文发文信息 + * @param userId 操作用户编号 + * @return 公文发文编号 + */ + Long createOfficialDocSend(OaOfficialDocSendSaveReqVO reqVO, Long userId); + + /** + * 更新公文发文 + * + * @param reqVO 公文发文信息 + * @param userId 操作用户编号 + */ + void updateOfficialDocSend(OaOfficialDocSendSaveReqVO reqVO, Long userId); + + /** + * 删除公文发文 + * + * @param id 公文发文编号 + * @param userId 操作用户编号 + */ + void deleteOfficialDocSend(Long id, Long userId); + + /** + * 获得公文发文 + * + * @param id 公文发文编号 + * @return 公文发文 + */ + OaOfficialDocSendDO getOfficialDocSend(Long id); + + /** + * 获得公文发文列表 + * + * @param ids 公文发文编号集合 + * @return 公文发文列表 + */ + List getOfficialDocSendList(Collection ids); + + /** + * 获得公文发文 Map + * + * @param ids 公文发文编号集合 + * @return 公文发文 Map + */ + default Map getOfficialDocSendMap(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return new HashMap<>(); + } + return CollectionUtils.convertMap(getOfficialDocSendList(ids), OaOfficialDocSendDO::getId); + } + + /** + * 获得公文发文分页 + * + * @param reqVO 分页条件 + * @param userId 操作用户编号 + * @return 公文发文分页 + */ + PageResult getOfficialDocSendPage(OaOfficialDocSendPageReqVO reqVO, Long userId); + + /** + * 提交公文发文审批 + * + * @param id 单据编号 + * @param userId 操作用户编号 + * @return 流程实例编号 + */ + String submitOfficialDocSend(Long id, Long userId); + + /** + * 撤销公文发文审批 + * + * @param id 单据编号 + * @param userId 操作用户编号 + */ + void cancelOfficialDocSend(Long id, Long userId); + + /** + * 更新公文发文审批结果 + * + * @param id 单据编号 + * @param processInstanceId 流程实例编号 + * @param status 审批状态 + */ + void updateOfficialDocSendStatus(Long id, String processInstanceId, Integer status); + + /** + * 获得引用套红模板的单据数量 + * + * @param id 套红模板编号 + * @return 单据数量 + */ + Long getOfficialDocSendCountByTemplateId(Long id); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocSendServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocSendServiceImpl.java new file mode 100644 index 0000000000..9baacb4f8f --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocSendServiceImpl.java @@ -0,0 +1,266 @@ +package cn.iocoder.yudao.module.oa.service.officialdoc; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +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.framework.common.util.object.ObjectUtils; +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.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.send.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.officialdoc.*; +import cn.iocoder.yudao.module.oa.dal.mysql.officialdoc.OaOfficialDocSendMapper; +import cn.iocoder.yudao.module.oa.dal.redis.no.OaNoRedisDAO; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; + +/** + * 公文发文 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaOfficialDocSendServiceImpl implements OaOfficialDocSendService { + + @Resource + private OaOfficialDocSendMapper officialDocSendMapper; + + @Resource + private OaNoRedisDAO noRedisDAO; + + @Resource + private OaOfficialDocTemplateService officialDocTemplateService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private OaOfficialDocReceiveService officialDocReceiveService; + + @Resource + private BpmProcessInstanceApi processInstanceApi; + @Resource + private DeptApi deptApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createOfficialDocSend(OaOfficialDocSendSaveReqVO reqVO, Long userId) { + // 1. 校验发文信息及关联数据 + validateOfficialDocSend(reqVO, null); + // 1.2 生成单号并校验唯一性 + String no = noRedisDAO.generate(OaNoRedisDAO.OFFICIAL_DOC_SEND_NO_PREFIX); + if (officialDocSendMapper.selectByNo(no) != null) { + throw exception(OFFICIAL_DOC_SEND_NO_DUPLICATE); + } + + // 2. 保存发文草稿,单号及审批状态由服务端生成 + OaOfficialDocSendDO send = BeanUtils.toBean(reqVO, OaOfficialDocSendDO.class).setId(null) + .setNo(no) + .setStatus(BpmProcessInstanceStatusEnum.NOT_START.getStatus()).setDocumentNo(buildOfficialDocSendDocumentNo(reqVO)); + officialDocSendMapper.insert(send); + return send.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateOfficialDocSend(OaOfficialDocSendSaveReqVO reqVO, Long userId) { + // 1.1 校验发文存在 + OaOfficialDocSendDO send = validateOfficialDocSendExists(reqVO.getId()); + // 1.2 校验发文归属 + validateOfficialDocSendOwner(send, userId); + // 1.3 仅未提交草稿允许修改 + validateOfficialDocSendDraft(send); + // 1.4 校验发文信息 + validateOfficialDocSend(reqVO, send.getId()); + + // 2. 更新表单字段,不覆盖流程状态和归属 + officialDocSendMapper.updateById(BeanUtils.toBean(reqVO, OaOfficialDocSendDO.class) + .setDocumentNo(buildOfficialDocSendDocumentNo(reqVO))); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteOfficialDocSend(Long id, Long userId) { + // 1.1 校验发文存在 + OaOfficialDocSendDO send = validateOfficialDocSendExists(id); + // 1.2 校验发文归属 + validateOfficialDocSendOwner(send, userId); + // 1.3 未提交、审批不通过和已取消的单据允许删除 + // TODO @AI:notEqualsAny + if (!ObjectUtils.equalsAny(send.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus(), + BpmProcessInstanceStatusEnum.REJECT.getStatus(), BpmProcessInstanceStatusEnum.CANCEL.getStatus())) { + throw exception(OFFICIAL_DOC_STATUS_INVALID); + } + + // 2. 删除公文 + officialDocSendMapper.deleteById(id); + } + + @Override + public OaOfficialDocSendDO getOfficialDocSend(Long id) { + return officialDocSendMapper.selectById(id); + } + + @Override + public List getOfficialDocSendList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return officialDocSendMapper.selectByIds(ids); + } + + @Override + public PageResult getOfficialDocSendPage(OaOfficialDocSendPageReqVO reqVO, Long userId) { + return officialDocSendMapper.selectPage(userId, reqVO); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String submitOfficialDocSend(Long id, Long userId) { + // 1.1 校验发文存在 + OaOfficialDocSendDO send = validateOfficialDocSendExists(id); + // 1.2 校验发文归属 + validateOfficialDocSendOwner(send, userId); + // 1.3 校验草稿状态 + validateOfficialDocSendDraft(send); + // 1.4 校验发文关联数据 + validateOfficialDocSend(BeanUtils.toBean(send, OaOfficialDocSendSaveReqVO.class), send.getId()); + + // 2. 更新审批状态 + officialDocSendMapper.updateById(new OaOfficialDocSendDO().setId(id).setStatus(BpmProcessInstanceStatusEnum.RUNNING.getStatus())); + + // 3.1 发起审批 + String processInstanceId = processInstanceApi.createProcessInstance(userId, + new BpmProcessInstanceCreateReqDTO().setProcessDefinitionKey(BpmModelConstants.OFFICIAL_DOC_SEND) + .setBusinessKey(id.toString()).setVariables(new HashMap<>())); + // 3.2 绑定流程编号 + officialDocSendMapper.updateById(new OaOfficialDocSendDO().setId(id).setProcessInstanceId(processInstanceId)); + return processInstanceId; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void cancelOfficialDocSend(Long id, Long userId) { + // 1.1 校验发文存在 + OaOfficialDocSendDO send = validateOfficialDocSendExists(id); + // 1.2 校验发文归属 + validateOfficialDocSendOwner(send, userId); + // 1.3 仅审批中的单据允许撤销 + if (ObjUtil.notEqual(send.getStatus(), BpmProcessInstanceStatusEnum.RUNNING.getStatus())) { + throw exception(OFFICIAL_DOC_STATUS_INVALID); + } + + // 2. 撤销审批流程,审批事件同步单据状态 + processInstanceApi.cancelProcessInstanceByStartUser(userId, send.getProcessInstanceId(), "申请人撤销公文发文"); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateOfficialDocSendStatus(Long id, String processInstanceId, Integer status) { + // 1.1 校验发文存在 + OaOfficialDocSendDO send = validateOfficialDocSendExists(id); + // 1.2 校验单据与回调流程一致 + if (StrUtil.isEmpty(processInstanceId) || (send.getProcessInstanceId() != null + && ObjUtil.notEqual(send.getProcessInstanceId(), processInstanceId))) { + throw exception(OFFICIAL_DOC_PROCESS_MISMATCH); + } + // 1.3 已处理的审批结果不再生成收文记录 + if (ObjUtil.notEqual(send.getStatus(), BpmProcessInstanceStatusEnum.RUNNING.getStatus())) { + return; + } + + // 2.1 更新审批结果 + officialDocSendMapper.updateById(new OaOfficialDocSendDO().setId(id).setStatus(status).setProcessInstanceId(processInstanceId)); + // 2.2 审批通过后发送至主送和抄送部门 + if (ObjUtil.equal(status, BpmProcessInstanceStatusEnum.APPROVE.getStatus())) { + officialDocReceiveService.createOfficialDocReceiveListByOfficialDocSend(send); + } + } + + /** + * 校验公文发文存在 + * + * @param id 公文发文编号 + * @return 公文发文 + */ + private OaOfficialDocSendDO validateOfficialDocSendExists(Long id) { + OaOfficialDocSendDO send = officialDocSendMapper.selectById(id); + if (send == null) { + throw exception(OFFICIAL_DOC_NOT_EXISTS); + } + return send; + } + + /** + * 校验公文发文操作人 + * + * @param send 公文发文 + * @param userId 操作用户编号 + */ + private void validateOfficialDocSendOwner(OaOfficialDocSendDO send, Long userId) { + if (ObjUtil.notEqual(send.getCreator(), userId.toString())) { + throw exception(OFFICIAL_DOC_ACCESS_DENIED); + } + } + + /** + * 校验公文发文为未提交草稿 + * + * @param send 公文发文 + */ + private void validateOfficialDocSendDraft(OaOfficialDocSendDO send) { + if (ObjUtil.notEqual(send.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus())) { + throw exception(OFFICIAL_DOC_STATUS_INVALID); + } + } + + /** + * 校验发文关联的模板、部门及发文字号 + * + * @param reqVO 发文信息 + * @param id 当前发文编号,新增时为空 + */ + private void validateOfficialDocSend(OaOfficialDocSendSaveReqVO reqVO, Long id) { + // 1. 校验套红模板可用 + officialDocTemplateService.validateOfficialDocTemplate(reqVO.getTemplateId()); + // 2. 校验发文及接收部门 + deptApi.validateDeptList(Collections.singleton(reqVO.getSendDeptId())); + deptApi.validateDeptList(reqVO.getMainDeptIds()); + if (CollUtil.isNotEmpty(reqVO.getCopyDeptIds())) { + deptApi.validateDeptList(reqVO.getCopyDeptIds()); + } + // 3. 完整发文字号不能重复,尚未编号的草稿不参与查重 + if (StrUtil.isNotBlank(reqVO.getNoPrefix()) && reqVO.getYear() != null && reqVO.getSequence() != null + && officialDocSendMapper.selectCountByDocumentNoAndIdNot(buildOfficialDocSendDocumentNo(reqVO), id) > 0) { + throw exception(OFFICIAL_DOC_SEND_DOCUMENT_NO_DUPLICATE); + } + } + + /** + * 根据字号、年份及序号组合发文字号 + * + * @param reqVO 发文信息 + * @return 发文字号 + */ + private String buildOfficialDocSendDocumentNo(OaOfficialDocSendSaveReqVO reqVO) { + return StrUtil.nullToEmpty(reqVO.getNoPrefix()) + + (reqVO.getYear() == null ? "" : "〔" + reqVO.getYear() + "〕") + + (reqVO.getSequence() == null ? "" : reqVO.getSequence() + "号"); + } + + @Override + public Long getOfficialDocSendCountByTemplateId(Long id) { + return officialDocSendMapper.selectCountByTemplateId(id); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocTemplateService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocTemplateService.java new file mode 100644 index 0000000000..1ec657eef6 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocTemplateService.java @@ -0,0 +1,69 @@ +package cn.iocoder.yudao.module.oa.service.officialdoc; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.template.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.officialdoc.*; +import java.util.List; + +/** + * 套红模板 Service 接口 + * + * @author 芋道源码 + */ +public interface OaOfficialDocTemplateService { + + /** + * 创建套红模板 + * + * @param reqVO 套红模板信息 + * @return 套红模板编号 + */ + Long createOfficialDocTemplate(OaOfficialDocTemplateSaveReqVO reqVO); + + /** + * 更新套红模板 + * + * @param reqVO 套红模板信息 + */ + void updateOfficialDocTemplate(OaOfficialDocTemplateSaveReqVO reqVO); + + /** + * 删除套红模板 + * + * @param id 套红模板编号 + */ + void deleteOfficialDocTemplate(Long id); + + /** + * 获得套红模板 + * + * @param id 套红模板编号 + * @return 套红模板 + */ + OaOfficialDocTemplateDO getOfficialDocTemplate(Long id); + + /** + * 校验套红模板存在且已启用 + * + * @param id 套红模板编号 + * @return 套红模板 + */ + OaOfficialDocTemplateDO validateOfficialDocTemplate(Long id); + + /** + * 获得套红模板分页 + * + * @param reqVO 分页条件 + * @return 套红模板分页 + */ + PageResult getOfficialDocTemplatePage(OaOfficialDocTemplatePageReqVO reqVO); + + /** + * 获得模板列表 + * + * @param status 状态 + * @return 模板列表 + */ + List getOfficialDocTemplateList(Integer status); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocTemplateServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocTemplateServiceImpl.java new file mode 100644 index 0000000000..a9a3c7c33c --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocTemplateServiceImpl.java @@ -0,0 +1,109 @@ +package cn.iocoder.yudao.module.oa.service.officialdoc; + +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.oa.controller.admin.officialdoc.vo.template.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.officialdoc.OaOfficialDocTemplateDO; +import cn.iocoder.yudao.module.oa.dal.mysql.officialdoc.OaOfficialDocTemplateMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.context.annotation.Lazy; +import org.springframework.validation.annotation.Validated; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.OFFICIAL_DOC_TEMPLATE_NOT_EXISTS; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.OFFICIAL_DOC_TEMPLATE_DISABLED; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.OFFICIAL_DOC_TEMPLATE_IN_USE; + +/** + * 套红模板 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaOfficialDocTemplateServiceImpl implements OaOfficialDocTemplateService { + + @Resource + private OaOfficialDocTemplateMapper officialDocTemplateMapper; + + @Resource + @Lazy // 延迟,避免循环依赖报错 + private OaOfficialDocSendService officialDocSendService; + + @Override + public Long createOfficialDocTemplate(OaOfficialDocTemplateSaveReqVO reqVO) { + // 1. 转换套红模板内容 + OaOfficialDocTemplateDO template = BeanUtils.toBean(reqVO, OaOfficialDocTemplateDO.class).setId(null); + + // 2. 保存记录 + officialDocTemplateMapper.insert(template); + return template.getId(); + } + + @Override + public void updateOfficialDocTemplate(OaOfficialDocTemplateSaveReqVO reqVO) { + // 1. 校验模板存在 + validateOfficialDocTemplateExists(reqVO.getId()); + + // 2. 更新模板 + officialDocTemplateMapper.updateById(BeanUtils.toBean(reqVO, OaOfficialDocTemplateDO.class)); + } + + @Override + public void deleteOfficialDocTemplate(Long id) { + // 1.1 校验模板存在 + validateOfficialDocTemplateExists(id); + // 1.2 校验模板未被业务单据引用 + if (officialDocSendService.getOfficialDocSendCountByTemplateId(id) > 0) { + throw exception(OFFICIAL_DOC_TEMPLATE_IN_USE); + } + + // 2. 删除模板,不删除已有关联发文 + officialDocTemplateMapper.deleteById(id); + } + + @Override + public OaOfficialDocTemplateDO getOfficialDocTemplate(Long id) { + return officialDocTemplateMapper.selectById(id); + } + + @Override + public OaOfficialDocTemplateDO validateOfficialDocTemplate(Long id) { + // 1.1 校验模板存在 + OaOfficialDocTemplateDO template = validateOfficialDocTemplateExists(id); + // 2. 校验模板已启用 + if (CommonStatusEnum.isDisable(template.getStatus())) { + throw exception(OFFICIAL_DOC_TEMPLATE_DISABLED); + } + return template; + } + + @Override + public PageResult getOfficialDocTemplatePage(OaOfficialDocTemplatePageReqVO reqVO) { + return officialDocTemplateMapper.selectPage(reqVO); + } + + @Override + public List getOfficialDocTemplateList(Integer status) { + return officialDocTemplateMapper.selectListByStatus(status); + } + + /** + * 校验套红模板存在 + * + * @param id 套红模板编号 + * @return 套红模板 + */ + private OaOfficialDocTemplateDO validateOfficialDocTemplateExists(Long id) { + OaOfficialDocTemplateDO template = officialDocTemplateMapper.selectById(id); + if (template == null) { + throw exception(OFFICIAL_DOC_TEMPLATE_NOT_EXISTS); + } + return template; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/listener/OaOfficialDocReceiveStatusListener.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/listener/OaOfficialDocReceiveStatusListener.java new file mode 100644 index 0000000000..669d898813 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/listener/OaOfficialDocReceiveStatusListener.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.oa.service.officialdoc.listener; + +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import cn.iocoder.yudao.module.oa.service.officialdoc.OaOfficialDocReceiveService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +/** + * 公文收文审批结果监听器 + * + * @author 芋道源码 + */ +@Component +public class OaOfficialDocReceiveStatusListener extends BpmProcessInstanceStatusEventListener { + + @Resource + private OaOfficialDocReceiveService officialDocReceiveService; + + @Override + protected String getProcessDefinitionKey() { + return BpmModelConstants.OFFICIAL_DOC_RECEIVE; + } + + @Override + protected void onEvent(BpmProcessInstanceStatusEvent event) { + officialDocReceiveService.updateOfficialDocReceiveStatus(Long.parseLong(event.getBusinessKey()), event.getStatus()); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/listener/OaOfficialDocSendStatusListener.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/listener/OaOfficialDocSendStatusListener.java new file mode 100644 index 0000000000..9f4c4fd18b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/listener/OaOfficialDocSendStatusListener.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.oa.service.officialdoc.listener; + +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import cn.iocoder.yudao.module.oa.service.officialdoc.OaOfficialDocSendService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +/** + * 公文发文审批结果监听器 + * + * @author 芋道源码 + */ +@Component +public class OaOfficialDocSendStatusListener extends BpmProcessInstanceStatusEventListener { + + @Resource + private OaOfficialDocSendService officialDocSendService; + + @Override + protected String getProcessDefinitionKey() { + return BpmModelConstants.OFFICIAL_DOC_SEND; + } + + @Override + protected void onEvent(BpmProcessInstanceStatusEvent event) { + officialDocSendService.updateOfficialDocSendStatus(Long.parseLong(event.getBusinessKey()), event.getId(), event.getStatus()); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/overtime/OaOvertimeApplyService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/overtime/OaOvertimeApplyService.java new file mode 100644 index 0000000000..6a67e87c33 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/overtime/OaOvertimeApplyService.java @@ -0,0 +1,65 @@ +package cn.iocoder.yudao.module.oa.service.overtime; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.overtime.vo.OaOvertimeApplySaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.overtime.vo.OaOvertimeApplySubmitReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.overtime.vo.OaOvertimeApplyPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.overtime.OaOvertimeApplyDO; + +/** + * 加班申请 Service 接口 + * + * @author 芋道源码 + */ +public interface OaOvertimeApplyService { + + /** + * 创建加班申请草稿 + * + * @param saveReqVO 申请内容 + * @return 申请编号 + */ + Long createOvertimeApply(OaOvertimeApplySaveReqVO saveReqVO); + + /** + * 更新本人草稿 + * + * @param saveReqVO 申请内容 + * @param userId 当前用户编号 + */ + void updateOvertimeApply(OaOvertimeApplySaveReqVO saveReqVO, Long userId); + + /** + * 提交本人草稿并发起审批 + * + * @param submitReqVO 申请编号和自选审批人 + * @param userId 当前用户编号 + */ + void submitOvertimeApply(OaOvertimeApplySubmitReqVO submitReqVO, Long userId); + + /** + * 获得加班申请 + * + * @param id 申请编号 + * @return 加班申请 + */ + OaOvertimeApplyDO getOvertimeApply(Long id); + + /** + * 获得本人加班申请分页 + * + * @param userId 申请人编号 + * @param pageReqVO 分页条件 + * @return 申请分页 + */ + PageResult getOvertimeApplyPage(Long userId, OaOvertimeApplyPageReqVO pageReqVO); + + /** + * 更新加班申请审批状态 + * + * @param id 申请编号 + * @param status 审批状态 + */ + void updateOvertimeApplyStatus(Long id, Integer status); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/overtime/OaOvertimeApplyServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/overtime/OaOvertimeApplyServiceImpl.java new file mode 100644 index 0000000000..602e8f3048 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/overtime/OaOvertimeApplyServiceImpl.java @@ -0,0 +1,146 @@ +package cn.iocoder.yudao.module.oa.service.overtime; + +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils; +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.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.controller.admin.overtime.vo.OaOvertimeApplyPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.overtime.vo.OaOvertimeApplySaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.overtime.vo.OaOvertimeApplySubmitReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.overtime.OaOvertimeApplyDO; +import cn.iocoder.yudao.module.oa.dal.mysql.overtime.OaOvertimeApplyMapper; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.HashMap; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; + +/** + * 加班申请 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaOvertimeApplyServiceImpl implements OaOvertimeApplyService { + + @Resource + private OaOvertimeApplyMapper overtimeApplyMapper; + @Resource + private BpmProcessInstanceApi processInstanceApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createOvertimeApply(OaOvertimeApplySaveReqVO saveReqVO) { + // TODO DONE @AI:天数计算复用 LocalDateTimeUtils,仍保留一位小数并四舍五入。 + // 1. 转换申请内容并计算天数 + OaOvertimeApplyDO overtimeApply = BeanUtils.toBean(saveReqVO, OaOvertimeApplyDO.class) + .setDays(LocalDateTimeUtils.getDaysBetween(saveReqVO.getStartTime(), saveReqVO.getEndTime(), 1)); + + // 2. 保存草稿 + overtimeApply.setId(null).setStatus(BpmProcessInstanceStatusEnum.NOT_START.getStatus()); + overtimeApplyMapper.insert(overtimeApply); + return overtimeApply.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateOvertimeApply(OaOvertimeApplySaveReqVO saveReqVO, Long userId) { + // 1. 校验申请可修改 + validateOvertimeApplyEditable(saveReqVO.getId(), userId); + + // 2. 更新加班申请 + OaOvertimeApplyDO overtimeApply = BeanUtils.toBean(saveReqVO, OaOvertimeApplyDO.class) + .setDays(LocalDateTimeUtils.getDaysBetween(saveReqVO.getStartTime(), saveReqVO.getEndTime(), 1)); + overtimeApplyMapper.updateById(overtimeApply); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void submitOvertimeApply(OaOvertimeApplySubmitReqVO submitReqVO, Long userId) { + // 1. 校验申请可提交 + OaOvertimeApplyDO overtimeApply = validateOvertimeApplyEditable(submitReqVO.getId(), userId); + + // 2. 更新为审批中 + overtimeApplyMapper.updateById(new OaOvertimeApplyDO().setId(overtimeApply.getId()) + .setStatus(BpmProcessInstanceStatusEnum.RUNNING.getStatus())); + + // 3.1 发起审批 + Map variables = new HashMap<>(); + variables.put("days", overtimeApply.getDays()); + String processInstanceId = processInstanceApi.createProcessInstance(userId, + new BpmProcessInstanceCreateReqDTO().setProcessDefinitionKey(BpmModelConstants.OVERTIME_APPLY) + .setBusinessKey(overtimeApply.getId().toString()).setVariables(variables) + .setStartUserSelectAssignees(submitReqVO.getStartUserSelectAssignees())); + // 3.2 绑定流程编号 + overtimeApplyMapper.updateById(new OaOvertimeApplyDO().setId(overtimeApply.getId()).setProcessInstanceId(processInstanceId)); + } + + @Override + public OaOvertimeApplyDO getOvertimeApply(Long id) { + return overtimeApplyMapper.selectById(id); + } + + @Override + public PageResult getOvertimeApplyPage(Long userId, OaOvertimeApplyPageReqVO pageReqVO) { + return overtimeApplyMapper.selectPage(userId, pageReqVO); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateOvertimeApplyStatus(Long id, Integer status) { + // 1. 校验申请存在 + validateOvertimeApplyExists(id); + + // 2. 更新审批状态 + overtimeApplyMapper.updateById(new OaOvertimeApplyDO().setId(id).setStatus(status)); + } + + /** + * 校验申请可修改或提交 + * + * @param id 申请编号 + * @param userId 当前用户编号 + * @return 本人的未提交申请 + */ + private OaOvertimeApplyDO validateOvertimeApplyEditable(Long id, Long userId) { + // 1. 查询申请 + OaOvertimeApplyDO overtimeApply = overtimeApplyMapper.selectById(id); + if (overtimeApply == null) { + throw exception(APPLY_NOT_EXISTS); + } + // 2. 校验本人归属 + if (ObjUtil.notEqual(overtimeApply.getCreator(), userId.toString())) { + throw exception(APPLY_ACCESS_DENIED); + } + // 3. 只有未提交草稿允许修改或提交 + if (ObjUtil.notEqual(overtimeApply.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus())) { + throw exception(APPLY_STATUS_INVALID); + } + return overtimeApply; + } + + /** + * 校验加班申请存在 + * + * @param id 申请编号 + * @return 申请 + */ + private OaOvertimeApplyDO validateOvertimeApplyExists(Long id) { + OaOvertimeApplyDO overtimeApply = overtimeApplyMapper.selectById(id); + if (overtimeApply == null) { + throw exception(APPLY_NOT_EXISTS); + } + return overtimeApply; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/overtime/listener/OaOvertimeApplyStatusListener.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/overtime/listener/OaOvertimeApplyStatusListener.java new file mode 100644 index 0000000000..c258f0c71b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/overtime/listener/OaOvertimeApplyStatusListener.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.oa.service.overtime.listener; + +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import cn.iocoder.yudao.module.oa.service.overtime.OaOvertimeApplyService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +/** + * 加班申请审批结果监听器 + * + * @author 芋道源码 + */ +@Component +public class OaOvertimeApplyStatusListener extends BpmProcessInstanceStatusEventListener { + + @Resource + private OaOvertimeApplyService overtimeApplyService; + + @Override + protected String getProcessDefinitionKey() { + return BpmModelConstants.OVERTIME_APPLY; + } + + @Override + protected void onEvent(BpmProcessInstanceStatusEvent event) { + overtimeApplyService.updateOvertimeApplyStatus(Long.valueOf(event.getBusinessKey()), event.getStatus()); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/plan/OaPlanService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/plan/OaPlanService.java new file mode 100644 index 0000000000..3a0a8d2cd1 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/plan/OaPlanService.java @@ -0,0 +1,81 @@ +package cn.iocoder.yudao.module.oa.service.plan; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.plan.vo.OaPlanPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.plan.vo.OaPlanSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.plan.OaPlanDO; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Map; + +/** + * OA 工作计划 Service 接口 + * + * @author 芋道源码 + */ +public interface OaPlanService { + + /** + * 创建工作计划 + * + * @param createReqVO 创建信息 + * @return 计划编号 + */ + Long createPlan(OaPlanSaveReqVO createReqVO); + + /** + * 更新工作计划 + * + * @param updateReqVO 更新信息 + * @param userId 用户编号 + */ + void updatePlan(OaPlanSaveReqVO updateReqVO, Long userId); + + /** + * 删除工作计划 + * + * @param id 计划编号 + * @param userId 用户编号 + */ + void deletePlan(Long id, Long userId); + + /** + * 获得工作计划 + * + * @param id 计划编号 + * @param userId 用户编号 + * @return 工作计划 + */ + OaPlanDO getPlan(Long id, Long userId); + + /** + * 获得工作计划分页 + * + * @param pageReqVO 分页查询 + * @param userId 用户编号 + * @return 工作计划分页 + */ + PageResult getPlanPage(OaPlanPageReqVO pageReqVO, Long userId); + + /** + * 点评工作计划 + * + * @param id 计划编号 + * @param comment 点评内容 + * @param userId 点评人用户编号 + */ + void addPlanComment(Long id, String comment, Long userId); + + /** + * 获得指定成员在统计周期内的最新工作计划 Map + * + * @param userIds 成员用户编号集合 + * @param type 计划类型 + * @param createTime 创建时间范围 + * @return 以成员用户编号为 Key 的最新工作计划 Map + */ + Map getLatestPlanMap(Collection userIds, Integer type, + LocalDateTime[] createTime); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/plan/OaPlanServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/plan/OaPlanServiceImpl.java new file mode 100644 index 0000000000..b1d1b478c4 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/plan/OaPlanServiceImpl.java @@ -0,0 +1,145 @@ +package cn.iocoder.yudao.module.oa.service.plan; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.hutool.core.util.ObjUtil; +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.framework.common.util.validation.ValidationUtils; +import cn.iocoder.yudao.module.oa.controller.admin.plan.vo.OaPlanCommentReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.plan.vo.OaPlanPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.plan.vo.OaPlanSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.plan.OaPlanDO; +import cn.iocoder.yudao.module.oa.dal.mysql.plan.OaPlanMapper; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.PLAN_ACCESS_DENIED; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.PLAN_NOT_EXISTS; + +/** + * OA 工作计划 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaPlanServiceImpl implements OaPlanService { + + @Resource + private OaPlanMapper planMapper; + + @Resource + private AdminUserApi adminUserApi; + + @Override + public Long createPlan(OaPlanSaveReqVO createReqVO) { + // 1. 转换工作计划内容 + OaPlanDO plan = BeanUtils.toBean(createReqVO, OaPlanDO.class); + + // 2. 保存记录 + planMapper.insert(plan); + return plan.getId(); + } + + @Override + public void updatePlan(OaPlanSaveReqVO updateReqVO, Long userId) { + // 1. 校验工作计划属于当前用户 + validatePlanOwner(updateReqVO.getId(), userId); + + // 2. 更新工作计划 + planMapper.updateById(BeanUtils.toBean(updateReqVO, OaPlanDO.class)); + } + + @Override + public void deletePlan(Long id, Long userId) { + // 1. 校验工作计划属于当前用户 + validatePlanOwner(id, userId); + + // 2. 删除工作计划 + planMapper.deleteById(id); + } + + @Override + public OaPlanDO getPlan(Long id, Long userId) { + return validatePlanOwner(id, userId); + } + + @Override + public PageResult getPlanPage(OaPlanPageReqVO pageReqVO, Long userId) { + return planMapper.selectPage(pageReqVO, userId); + } + + @Override + public void addPlanComment(Long id, String comment, Long userId) { + // 1.1 校验工作计划存在 + OaPlanDO plan = validatePlanExists(id); + // 1.2 校验计划属于当前用户的管理范围 + List subordinateUsers = adminUserApi.getUserListBySubordinate(userId); + if (!convertSet(subordinateUsers, AdminUserRespDTO::getId).contains(NumberUtils.parseLong(plan.getCreator()))) { + throw exception(PLAN_ACCESS_DENIED); + } + + // 2. 追加工作计划点评 + String updatedComment = StrUtil.isEmpty(plan.getComment()) ? comment + : plan.getComment() + System.lineSeparator() + comment; + ValidationUtils.validate(new OaPlanCommentReqVO().setId(id).setComment(updatedComment)); + planMapper.updateById(new OaPlanDO().setId(id).setComment(updatedComment)); + } + + @Override + public Map getLatestPlanMap(Collection userIds, Integer type, LocalDateTime[] createTime) { + if (CollUtil.isEmpty(userIds)) { + return Collections.emptyMap(); + } + // 1. 查询指定成员在统计周期内的工作计划 + List plans = planMapper.selectListByCreatorsAndTypeAndCreateTime( + convertList(userIds, String::valueOf), type, createTime); + // 2. 每位成员只保留创建时间最新的工作计划 + return convertMap(plans, plan -> NumberUtils.parseLong(plan.getCreator())); + } + + /** + * 校验工作计划属于指定用户 + * + * @param id 计划编号 + * @param userId 用户编号 + * @return 工作计划 + */ + private OaPlanDO validatePlanOwner(Long id, Long userId) { + OaPlanDO plan = validatePlanExists(id); + if (ObjUtil.notEqual(plan.getCreator(), userId.toString())) { + throw exception(PLAN_ACCESS_DENIED); + } + return plan; + } + + /** + * 校验工作计划是否存在 + * + * @param id 计划编号 + * @return 工作计划 + */ + private OaPlanDO validatePlanExists(Long id) { + OaPlanDO plan = planMapper.selectById(id); + if (plan == null) { + throw exception(PLAN_NOT_EXISTS); + } + return plan; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/regular/OaRegularApplyService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/regular/OaRegularApplyService.java new file mode 100644 index 0000000000..c02aee2003 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/regular/OaRegularApplyService.java @@ -0,0 +1,65 @@ +package cn.iocoder.yudao.module.oa.service.regular; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.regular.vo.OaRegularApplySaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.regular.vo.OaRegularApplySubmitReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.regular.vo.OaRegularApplyPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.regular.OaRegularApplyDO; + +/** + * 转正申请 Service 接口 + * + * @author 芋道源码 + */ +public interface OaRegularApplyService { + + /** + * 创建转正申请草稿 + * + * @param saveReqVO 申请内容 + * @return 申请编号 + */ + Long createRegularApply(OaRegularApplySaveReqVO saveReqVO); + + /** + * 更新本人草稿 + * + * @param saveReqVO 申请内容 + * @param userId 当前用户编号 + */ + void updateRegularApply(OaRegularApplySaveReqVO saveReqVO, Long userId); + + /** + * 提交本人草稿并发起审批 + * + * @param submitReqVO 申请编号和自选审批人 + * @param userId 当前用户编号 + */ + void submitRegularApply(OaRegularApplySubmitReqVO submitReqVO, Long userId); + + /** + * 获得转正申请 + * + * @param id 申请编号 + * @return 转正申请 + */ + OaRegularApplyDO getRegularApply(Long id); + + /** + * 获得本人转正申请分页 + * + * @param userId 申请人编号 + * @param pageReqVO 分页条件 + * @return 申请分页 + */ + PageResult getRegularApplyPage(Long userId, OaRegularApplyPageReqVO pageReqVO); + + /** + * 更新转正申请审批状态 + * + * @param id 申请编号 + * @param status 审批状态 + */ + void updateRegularApplyStatus(Long id, Integer status); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/regular/OaRegularApplyServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/regular/OaRegularApplyServiceImpl.java new file mode 100644 index 0000000000..17e64e1a1b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/regular/OaRegularApplyServiceImpl.java @@ -0,0 +1,145 @@ +package cn.iocoder.yudao.module.oa.service.regular; + +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils; +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.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.controller.admin.regular.vo.OaRegularApplyPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.regular.vo.OaRegularApplySaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.regular.vo.OaRegularApplySubmitReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.regular.OaRegularApplyDO; +import cn.iocoder.yudao.module.oa.dal.mysql.regular.OaRegularApplyMapper; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.HashMap; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; + +/** + * 转正申请 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaRegularApplyServiceImpl implements OaRegularApplyService { + + @Resource + private OaRegularApplyMapper regularApplyMapper; + @Resource + private BpmProcessInstanceApi processInstanceApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createRegularApply(OaRegularApplySaveReqVO saveReqVO) { + // 1. 转换试用内容并计算天数 + OaRegularApplyDO regularApply = BeanUtils.toBean(saveReqVO, OaRegularApplyDO.class) + .setDays(LocalDateTimeUtils.getDaysBetweenCeiling(saveReqVO.getStartTime(), saveReqVO.getEndTime())); + + // 2. 保存草稿 + regularApply.setId(null).setStatus(BpmProcessInstanceStatusEnum.NOT_START.getStatus()); + regularApplyMapper.insert(regularApply); + return regularApply.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateRegularApply(OaRegularApplySaveReqVO saveReqVO, Long userId) { + // 1. 校验申请可修改 + validateRegularApplyEditable(saveReqVO.getId(), userId); + + // 2. 更新转正申请 + OaRegularApplyDO regularApply = BeanUtils.toBean(saveReqVO, OaRegularApplyDO.class) + .setDays(LocalDateTimeUtils.getDaysBetweenCeiling(saveReqVO.getStartTime(), saveReqVO.getEndTime())); + regularApplyMapper.updateById(regularApply); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void submitRegularApply(OaRegularApplySubmitReqVO submitReqVO, Long userId) { + // 1. 校验申请可提交 + OaRegularApplyDO regularApply = validateRegularApplyEditable(submitReqVO.getId(), userId); + + // 2. 更新为审批中 + regularApplyMapper.updateById(new OaRegularApplyDO().setId(regularApply.getId()) + .setStatus(BpmProcessInstanceStatusEnum.RUNNING.getStatus())); + + // 3.1 发起审批 + Map variables = new HashMap<>(); + variables.put("days", regularApply.getDays()); + String processInstanceId = processInstanceApi.createProcessInstance(userId, + new BpmProcessInstanceCreateReqDTO().setProcessDefinitionKey(BpmModelConstants.REGULAR_APPLY) + .setBusinessKey(regularApply.getId().toString()).setVariables(variables) + .setStartUserSelectAssignees(submitReqVO.getStartUserSelectAssignees())); + // 3.2 绑定流程编号 + regularApplyMapper.updateById(new OaRegularApplyDO().setId(regularApply.getId()).setProcessInstanceId(processInstanceId)); + } + + @Override + public OaRegularApplyDO getRegularApply(Long id) { + return regularApplyMapper.selectById(id); + } + + @Override + public PageResult getRegularApplyPage(Long userId, OaRegularApplyPageReqVO pageReqVO) { + return regularApplyMapper.selectPage(userId, pageReqVO); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateRegularApplyStatus(Long id, Integer status) { + // 1. 校验申请存在 + validateRegularApplyExists(id); + + // 2. 更新审批状态 + regularApplyMapper.updateById(new OaRegularApplyDO().setId(id).setStatus(status)); + } + + /** + * 校验申请可修改或提交 + * + * @param id 申请编号 + * @param userId 当前用户编号 + * @return 本人的未提交申请 + */ + private OaRegularApplyDO validateRegularApplyEditable(Long id, Long userId) { + // 1. 查询申请 + OaRegularApplyDO regularApply = regularApplyMapper.selectById(id); + if (regularApply == null) { + throw exception(APPLY_NOT_EXISTS); + } + // 2. 校验本人归属 + if (ObjUtil.notEqual(regularApply.getCreator(), userId.toString())) { + throw exception(APPLY_ACCESS_DENIED); + } + // 3. 只有未提交草稿允许修改或提交 + if (ObjUtil.notEqual(regularApply.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus())) { + throw exception(APPLY_STATUS_INVALID); + } + return regularApply; + } + + /** + * 校验转正申请存在 + * + * @param id 申请编号 + * @return 申请 + */ + private OaRegularApplyDO validateRegularApplyExists(Long id) { + OaRegularApplyDO regularApply = regularApplyMapper.selectById(id); + if (regularApply == null) { + throw exception(APPLY_NOT_EXISTS); + } + return regularApply; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/regular/listener/OaRegularApplyStatusListener.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/regular/listener/OaRegularApplyStatusListener.java new file mode 100644 index 0000000000..c4fcdd2a72 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/regular/listener/OaRegularApplyStatusListener.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.oa.service.regular.listener; + +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import cn.iocoder.yudao.module.oa.service.regular.OaRegularApplyService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +/** + * 转正申请审批结果监听器 + * + * @author 芋道源码 + */ +@Component +public class OaRegularApplyStatusListener extends BpmProcessInstanceStatusEventListener { + + @Resource + private OaRegularApplyService regularApplyService; + + @Override + protected String getProcessDefinitionKey() { + return BpmModelConstants.REGULAR_APPLY; + } + + @Override + protected void onEvent(BpmProcessInstanceStatusEvent event) { + regularApplyService.updateRegularApplyStatus(Long.valueOf(event.getBusinessKey()), event.getStatus()); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/reimbursement/OaReimbursementService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/reimbursement/OaReimbursementService.java new file mode 100644 index 0000000000..87e3637c0f --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/reimbursement/OaReimbursementService.java @@ -0,0 +1,65 @@ +package cn.iocoder.yudao.module.oa.service.reimbursement; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.reimbursement.vo.OaReimbursementSaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.reimbursement.vo.OaReimbursementSubmitReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.reimbursement.vo.OaReimbursementPageReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.reimbursement.OaReimbursementDO; + +/** + * 费用报销 Service 接口 + * + * @author 芋道源码 + */ +public interface OaReimbursementService { + + /** + * 创建费用报销草稿 + * + * @param saveReqVO 申请内容 + * @return 申请编号 + */ + Long createReimbursement(OaReimbursementSaveReqVO saveReqVO); + + /** + * 更新本人草稿 + * + * @param saveReqVO 申请内容 + * @param userId 当前用户编号 + */ + void updateReimbursement(OaReimbursementSaveReqVO saveReqVO, Long userId); + + /** + * 提交本人草稿并发起审批 + * + * @param submitReqVO 申请编号和自选审批人 + * @param userId 当前用户编号 + */ + void submitReimbursement(OaReimbursementSubmitReqVO submitReqVO, Long userId); + + /** + * 获得费用报销 + * + * @param id 申请编号 + * @return 费用报销 + */ + OaReimbursementDO getReimbursement(Long id); + + /** + * 获得本人费用报销分页 + * + * @param userId 申请人编号 + * @param pageReqVO 分页条件 + * @return 申请分页 + */ + PageResult getReimbursementPage(Long userId, OaReimbursementPageReqVO pageReqVO); + + /** + * 更新费用报销审批状态 + * + * @param id 申请编号 + * @param status 审批状态 + */ + void updateReimbursementStatus(Long id, Integer status); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/reimbursement/OaReimbursementServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/reimbursement/OaReimbursementServiceImpl.java new file mode 100644 index 0000000000..6e194c8269 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/reimbursement/OaReimbursementServiceImpl.java @@ -0,0 +1,166 @@ +package cn.iocoder.yudao.module.oa.service.reimbursement; + +import cn.hutool.core.util.ObjUtil; +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.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.controller.admin.reimbursement.vo.OaReimbursementPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.reimbursement.vo.OaReimbursementSaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.reimbursement.vo.OaReimbursementSubmitReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.reimbursement.OaReimbursementDO; +import cn.iocoder.yudao.module.oa.dal.mysql.reimbursement.OaReimbursementMapper; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.getSumValue; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; + +/** + * 费用报销 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaReimbursementServiceImpl implements OaReimbursementService { + + @Resource + private OaReimbursementMapper reimbursementMapper; + @Resource + private AdminUserApi adminUserApi; + @Resource + private BpmProcessInstanceApi processInstanceApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createReimbursement(OaReimbursementSaveReqVO saveReqVO) { + // 1. 校验证明人存在 + adminUserApi.validateUser(saveReqVO.getWitnessUserId()); + + // 2. 转换申请内容、汇总金额和票据数,保存草稿 + OaReimbursementDO reimbursement = buildReimbursement(saveReqVO) + .setStatus(BpmProcessInstanceStatusEnum.NOT_START.getStatus()); + reimbursementMapper.insert(reimbursement); + return reimbursement.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateReimbursement(OaReimbursementSaveReqVO saveReqVO, Long userId) { + // 1.1 校验申请可修改 + validateReimbursementEditable(saveReqVO.getId(), userId); + // 1.2 校验证明人存在 + adminUserApi.validateUser(saveReqVO.getWitnessUserId()); + + // 2. 更新费用报销 + OaReimbursementDO reimbursement = buildReimbursement(saveReqVO); + reimbursementMapper.updateById(reimbursement); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void submitReimbursement(OaReimbursementSubmitReqVO submitReqVO, Long userId) { + // 1. 校验申请可提交 + OaReimbursementDO reimbursement = validateReimbursementEditable(submitReqVO.getId(), userId); + + // 2. 更新为审批中 + reimbursementMapper.updateById(new OaReimbursementDO().setId(reimbursement.getId()) + .setStatus(BpmProcessInstanceStatusEnum.RUNNING.getStatus())); + + // 3.1 发起审批 + Map variables = new HashMap<>(); + variables.put("totalPrice", reimbursement.getTotalPrice()); + String processInstanceId = processInstanceApi.createProcessInstance(userId, + new BpmProcessInstanceCreateReqDTO().setProcessDefinitionKey(BpmModelConstants.REIMBURSEMENT) + .setBusinessKey(reimbursement.getId().toString()).setVariables(variables) + .setStartUserSelectAssignees(submitReqVO.getStartUserSelectAssignees())); + // 3.2 绑定流程编号 + reimbursementMapper.updateById(new OaReimbursementDO().setId(reimbursement.getId()).setProcessInstanceId(processInstanceId)); + } + + @Override + public OaReimbursementDO getReimbursement(Long id) { + return reimbursementMapper.selectById(id); + } + + @Override + public PageResult getReimbursementPage(Long userId, OaReimbursementPageReqVO pageReqVO) { + return reimbursementMapper.selectPage(userId, pageReqVO); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateReimbursementStatus(Long id, Integer status) { + // 1. 校验申请存在 + validateReimbursementExists(id); + + // 2. 更新审批状态 + reimbursementMapper.updateById(new OaReimbursementDO().setId(id).setStatus(status)); + } + + /** + * 构造申请及汇总字段 + * + * @param saveReqVO 申请内容 + * @return 申请及计算后的汇总字段 + */ + private OaReimbursementDO buildReimbursement(OaReimbursementSaveReqVO saveReqVO) { + // 1. 转换申请及报销明细 + OaReimbursementDO reimbursement = BeanUtils.toBean(saveReqVO, OaReimbursementDO.class); + reimbursement.setItems(BeanUtils.toBean(saveReqVO.getItems(), OaReimbursementDO.Item.class)); + // 2. 汇总金额和票据数 + reimbursement.setTotalPrice(getSumValue(reimbursement.getItems(), OaReimbursementDO.Item::getPrice, BigDecimal::add, BigDecimal.ZERO)) + .setInvoiceCount(getSumValue(reimbursement.getItems(), OaReimbursementDO.Item::getInvoiceCount, Math::addExact, 0)); + return reimbursement; + } + + /** + * 校验申请可修改或提交 + * + * @param id 申请编号 + * @param userId 当前用户编号 + * @return 本人的未提交申请 + */ + private OaReimbursementDO validateReimbursementEditable(Long id, Long userId) { + // 1. 查询申请 + OaReimbursementDO reimbursement = reimbursementMapper.selectById(id); + if (reimbursement == null) { + throw exception(APPLY_NOT_EXISTS); + } + // 2. 校验本人归属 + if (ObjUtil.notEqual(reimbursement.getCreator(), userId.toString())) { + throw exception(APPLY_ACCESS_DENIED); + } + // 3. 只有未提交草稿允许修改或提交 + if (ObjUtil.notEqual(reimbursement.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus())) { + throw exception(APPLY_STATUS_INVALID); + } + return reimbursement; + } + + /** + * 校验费用报销存在 + * + * @param id 申请编号 + * @return 申请 + */ + private OaReimbursementDO validateReimbursementExists(Long id) { + OaReimbursementDO reimbursement = reimbursementMapper.selectById(id); + if (reimbursement == null) { + throw exception(APPLY_NOT_EXISTS); + } + return reimbursement; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/reimbursement/listener/OaReimbursementStatusListener.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/reimbursement/listener/OaReimbursementStatusListener.java new file mode 100644 index 0000000000..395e91efef --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/reimbursement/listener/OaReimbursementStatusListener.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.oa.service.reimbursement.listener; + +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import cn.iocoder.yudao.module.oa.service.reimbursement.OaReimbursementService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +/** + * 费用报销审批结果监听器 + * + * @author 芋道源码 + */ +@Component +public class OaReimbursementStatusListener extends BpmProcessInstanceStatusEventListener { + + @Resource + private OaReimbursementService reimbursementService; + + @Override + protected String getProcessDefinitionKey() { + return BpmModelConstants.REIMBURSEMENT; + } + + @Override + protected void onEvent(BpmProcessInstanceStatusEvent event) { + reimbursementService.updateReimbursementStatus(Long.valueOf(event.getBusinessKey()), event.getStatus()); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/resign/OaResignApplyService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/resign/OaResignApplyService.java new file mode 100644 index 0000000000..83b1b67a77 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/resign/OaResignApplyService.java @@ -0,0 +1,65 @@ +package cn.iocoder.yudao.module.oa.service.resign; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.resign.vo.OaResignApplyPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.resign.vo.OaResignApplySaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.resign.vo.OaResignApplySubmitReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.resign.OaResignApplyDO; + +/** + * 离职申请 Service 接口 + * + * @author 芋道源码 + */ +public interface OaResignApplyService { + + /** + * 创建离职申请草稿 + * + * @param saveReqVO 申请内容 + * @return 申请编号 + */ + Long createResignApply(OaResignApplySaveReqVO saveReqVO); + + /** + * 更新本人离职申请草稿 + * + * @param saveReqVO 申请内容 + * @param userId 当前用户编号 + */ + void updateResignApply(OaResignApplySaveReqVO saveReqVO, Long userId); + + /** + * 提交本人离职申请草稿并发起审批 + * + * @param submitReqVO 申请编号和自选审批人 + * @param userId 当前用户编号 + */ + void submitResignApply(OaResignApplySubmitReqVO submitReqVO, Long userId); + + /** + * 获得离职申请 + * + * @param id 申请编号 + * @return 离职申请 + */ + OaResignApplyDO getResignApply(Long id); + + /** + * 获得本人离职申请分页 + * + * @param userId 申请人编号 + * @param pageReqVO 分页条件 + * @return 离职申请分页 + */ + PageResult getResignApplyPage(Long userId, OaResignApplyPageReqVO pageReqVO); + + /** + * 更新离职申请审批状态 + * + * @param id 申请编号 + * @param status 审批状态 + */ + void updateResignApplyStatus(Long id, Integer status); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/resign/OaResignApplyServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/resign/OaResignApplyServiceImpl.java new file mode 100644 index 0000000000..854bb8d6f9 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/resign/OaResignApplyServiceImpl.java @@ -0,0 +1,149 @@ +package cn.iocoder.yudao.module.oa.service.resign; + +import cn.hutool.core.util.ObjUtil; +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.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.controller.admin.resign.vo.OaResignApplyPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.resign.vo.OaResignApplySaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.resign.vo.OaResignApplySubmitReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.resign.OaResignApplyDO; +import cn.iocoder.yudao.module.oa.dal.mysql.resign.OaResignApplyMapper; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.HashMap; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; + +/** + * 离职申请 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaResignApplyServiceImpl implements OaResignApplyService { + + @Resource + private OaResignApplyMapper resignApplyMapper; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private BpmProcessInstanceApi processInstanceApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createResignApply(OaResignApplySaveReqVO saveReqVO) { + // 1. 校验交接人存在 + adminUserApi.validateUser(saveReqVO.getHandoverUserId()); + + // 2. 保存草稿 + OaResignApplyDO resignApply = BeanUtils.toBean(saveReqVO, OaResignApplyDO.class) + .setStatus(BpmProcessInstanceStatusEnum.NOT_START.getStatus()); + resignApplyMapper.insert(resignApply); + return resignApply.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateResignApply(OaResignApplySaveReqVO saveReqVO, Long userId) { + // 1.1 校验申请可修改 + validateResignApplyEditable(saveReqVO.getId(), userId); + // 1.2 校验交接人存在 + adminUserApi.validateUser(saveReqVO.getHandoverUserId()); + + // 2. 更新草稿 + resignApplyMapper.updateById(BeanUtils.toBean(saveReqVO, OaResignApplyDO.class)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void submitResignApply(OaResignApplySubmitReqVO submitReqVO, Long userId) { + // 1. 校验申请可提交 + OaResignApplyDO resignApply = validateResignApplyEditable(submitReqVO.getId(), userId); + + // 2. 更新为审批中 + resignApplyMapper.updateById(new OaResignApplyDO().setId(resignApply.getId()) + .setStatus(BpmProcessInstanceStatusEnum.RUNNING.getStatus())); + + // 3.1 发起审批 + Map variables = new HashMap<>(); + variables.put("hasPendingReimbursement", resignApply.getHasPendingReimbursement()); // 是否存在未完成报销 + String processInstanceId = processInstanceApi.createProcessInstance(userId, + new BpmProcessInstanceCreateReqDTO().setProcessDefinitionKey(BpmModelConstants.RESIGN_APPLY) + .setBusinessKey(resignApply.getId().toString()).setVariables(variables) + .setStartUserSelectAssignees(submitReqVO.getStartUserSelectAssignees())); + // 3.2 绑定流程编号 + resignApplyMapper.updateById(new OaResignApplyDO().setId(resignApply.getId()).setProcessInstanceId(processInstanceId)); + } + + @Override + public OaResignApplyDO getResignApply(Long id) { + return resignApplyMapper.selectById(id); + } + + @Override + public PageResult getResignApplyPage(Long userId, OaResignApplyPageReqVO pageReqVO) { + return resignApplyMapper.selectPage(userId, pageReqVO); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateResignApplyStatus(Long id, Integer status) { + // 1. 校验申请存在 + validateResignApplyExists(id); + + // 2. 更新审批状态 + resignApplyMapper.updateById(new OaResignApplyDO().setId(id).setStatus(status)); + } + + /** + * 校验申请可修改或提交 + * + * @param id 申请编号 + * @param userId 当前用户编号 + * @return 本人的未提交申请 + */ + private OaResignApplyDO validateResignApplyEditable(Long id, Long userId) { + // 1. 查询申请 + OaResignApplyDO resignApply = resignApplyMapper.selectById(id); + if (resignApply == null) { + throw exception(APPLY_NOT_EXISTS); + } + // 2. 校验本人归属 + if (ObjUtil.notEqual(resignApply.getCreator(), userId.toString())) { + throw exception(APPLY_ACCESS_DENIED); + } + // 3. 只有未提交草稿允许修改或提交 + if (ObjUtil.notEqual(resignApply.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus())) { + throw exception(APPLY_STATUS_INVALID); + } + return resignApply; + } + + /** + * 校验离职申请存在 + * + * @param id 申请编号 + * @return 申请 + */ + @SuppressWarnings("UnusedReturnValue") + private OaResignApplyDO validateResignApplyExists(Long id) { + OaResignApplyDO resignApply = resignApplyMapper.selectById(id); + if (resignApply == null) { + throw exception(APPLY_NOT_EXISTS); + } + return resignApply; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/resign/listener/OaResignApplyStatusListener.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/resign/listener/OaResignApplyStatusListener.java new file mode 100644 index 0000000000..532d50354b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/resign/listener/OaResignApplyStatusListener.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.oa.service.resign.listener; + +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import cn.iocoder.yudao.module.oa.service.resign.OaResignApplyService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +/** + * 离职申请审批结果监听器 + * + * @author 芋道源码 + */ +@Component +public class OaResignApplyStatusListener extends BpmProcessInstanceStatusEventListener { + + @Resource + private OaResignApplyService resignApplyService; + + @Override + protected String getProcessDefinitionKey() { + return BpmModelConstants.RESIGN_APPLY; + } + + @Override + protected void onEvent(BpmProcessInstanceStatusEvent event) { + resignApplyService.updateResignApplyStatus(Long.valueOf(event.getBusinessKey()), event.getStatus()); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/schedule/OaScheduleService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/schedule/OaScheduleService.java new file mode 100644 index 0000000000..476bc2aa60 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/schedule/OaScheduleService.java @@ -0,0 +1,125 @@ +package cn.iocoder.yudao.module.oa.service.schedule; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.schedule.vo.OaSchedulePageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.schedule.vo.OaScheduleSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.schedule.OaScheduleDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.schedule.OaScheduleParticipantDO; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap; + +/** + * OA 日程 Service 接口 + * + * @author 芋道源码 + */ +public interface OaScheduleService { + + /** + * 创建日程 + * + * @param createReqVO 创建信息 + * @param userId 用户编号 + * @return 日程编号 + */ + Long createSchedule(OaScheduleSaveReqVO createReqVO, Long userId); + + /** + * 更新日程 + * + * @param updateReqVO 更新信息 + * @param userId 用户编号 + */ + void updateSchedule(OaScheduleSaveReqVO updateReqVO, Long userId); + + /** + * 删除日程 + * + * @param id 日程编号 + * @param userId 用户编号 + */ + void deleteSchedule(Long id, Long userId); + + /** + * 获得日程 + * + * @param id 日程编号 + * @param userId 用户编号 + * @return 日程 + */ + OaScheduleDO getSchedule(Long id, Long userId); + + /** + * 标记当前参与人已读 + * + * @param id 日程编号 + * @param userId 当前用户编号 + */ + void updateScheduleReadStatus(Long id, Long userId); + + /** + * 获得日程参与人列表 + * + * @param id 日程编号 + * @return 参与人列表 + */ + List getScheduleParticipantList(Long id); + + /** + * 获得我的日程分页 + * + * @param pageReqVO 分页查询 + * @param userId 当前用户编号 + * @return 日程分页 + */ + PageResult getMySchedulePage(OaSchedulePageReqVO pageReqVO, Long userId); + + /** + * 获得所选范围内的日程分页 + * + * @param pageReqVO 分页查询 + * @param userId 当前用户编号 + * @return 日程分页 + */ + PageResult getSchedulePage(OaSchedulePageReqVO pageReqVO, Long userId); + + /** + * 获得共享给我的日程分页 + * + * @param pageReqVO 分页查询 + * @param userId 当前用户编号 + * @return 日程分页 + */ + PageResult getReceivedSchedulePage(OaSchedulePageReqVO pageReqVO, Long userId); + + /** + * 批量发送待提醒日程 + * + * @return 成功提醒的日程数量 + */ + int sendScheduleReminders(); + + /** + * 获得日程参与人列表 + * + * @param scheduleIds 日程编号集合 + * @return 日程参与人列表 + */ + List getScheduleParticipantList(Collection scheduleIds); + + /** + * 获得日程参与人用户编号 Map + * + * @param scheduleIds 日程编号集合 + * @return 日程编号与参与人用户编号列表的 Map + */ + default Map> getScheduleParticipantUserIdListMap(Collection scheduleIds) { + List participants = getScheduleParticipantList(scheduleIds); + return convertMultiMap(participants, OaScheduleParticipantDO::getScheduleId, OaScheduleParticipantDO::getUserId); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/schedule/OaScheduleServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/schedule/OaScheduleServiceImpl.java new file mode 100644 index 0000000000..685238dcf6 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/schedule/OaScheduleServiceImpl.java @@ -0,0 +1,319 @@ +package cn.iocoder.yudao.module.oa.service.schedule; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.date.DatePattern; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.schedule.vo.OaSchedulePageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.schedule.vo.OaScheduleSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.schedule.OaScheduleDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.schedule.OaScheduleParticipantDO; +import cn.iocoder.yudao.module.oa.dal.mysql.schedule.OaScheduleMapper; +import cn.iocoder.yudao.module.oa.dal.mysql.schedule.OaScheduleParticipantMapper; +import cn.iocoder.yudao.module.oa.framework.config.OaProperties; +import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi; +import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import com.google.common.collect.Maps; +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.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.diffList; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.SCHEDULE_ACCESS_DENIED; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.SCHEDULE_NOT_EXISTS; +import static cn.iocoder.yudao.module.oa.enums.MessageTemplateConstants.SCHEDULE_REMINDER; + +/** + * OA 日程 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class OaScheduleServiceImpl implements OaScheduleService { + + @Resource + private OaProperties properties; + + @Resource + private OaScheduleMapper scheduleMapper; + @Resource + private OaScheduleParticipantMapper scheduleParticipantMapper; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private NotifyMessageSendApi notifyMessageSendApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createSchedule(OaScheduleSaveReqVO createReqVO, Long userId) { + // 1. 校验参与人 + adminUserApi.validateUserList(createReqVO.getParticipantUserIds()); + + // 2. 新增日程 + OaScheduleDO schedule = BeanUtils.toBean(createReqVO, OaScheduleDO.class) + .setReminded(false); + scheduleMapper.insert(schedule); + + // 3. 新增参与人 + createScheduleParticipantList(schedule.getId(), createReqVO.getParticipantUserIds()); + return schedule.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateSchedule(OaScheduleSaveReqVO updateReqVO, Long userId) { + // 1.1 校验日程属于当前用户 + validateScheduleOwner(updateReqVO.getId(), userId); + // 1.2 校验参与人存在 + adminUserApi.validateUserList(updateReqVO.getParticipantUserIds()); + + // 2. 修改日程 + scheduleMapper.updateById(BeanUtils.toBean(updateReqVO, OaScheduleDO.class).setReminded(false)); + // 编辑保存后重新进入提醒周期,不清空阅读回执 + + // 3.1 查询参与关系并计算差异 + List oldParticipants = scheduleParticipantMapper.selectListByScheduleId(updateReqVO.getId()); + List newParticipants = convertList(CollUtil.distinct(updateReqVO.getParticipantUserIds()), + user -> new OaScheduleParticipantDO().setScheduleId(updateReqVO.getId()).setUserId(user).setReadStatus(false)); + List> diffParticipants = diffList(oldParticipants, newParticipants, + (oldParticipant, newParticipant) -> ObjectUtil.equal(oldParticipant.getUserId(), newParticipant.getUserId())); + // 3.2 新增参与关系 + if (CollUtil.isNotEmpty(diffParticipants.get(0))) { + scheduleParticipantMapper.insertBatch(diffParticipants.get(0)); + } + // 3.3 删除取消的参与关系 + if (CollUtil.isNotEmpty(diffParticipants.get(2))) { + scheduleParticipantMapper.deleteByScheduleIdAndUserIds(updateReqVO.getId(), + convertList(diffParticipants.get(2), OaScheduleParticipantDO::getUserId)); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteSchedule(Long id, Long userId) { + // 1. 校验日程 + validateScheduleOwner(id, userId); + + // 2.1 删除日程参与人 + scheduleParticipantMapper.deleteByScheduleId(id); + // 2.2 删除日程 + scheduleMapper.deleteById(id); + } + + @Override + public OaScheduleDO getSchedule(Long id, Long userId) { + // 1. 校验日程存在 + OaScheduleDO schedule = validateScheduleExists(id); + + // 2. 创建人可直接访问 + if (ObjectUtil.equal(NumberUtils.parseLong(schedule.getCreator()), userId)) { + return schedule; + } + + // 3. 校验当前用户是日程参与人 + List participants = scheduleParticipantMapper.selectListByScheduleIds( + Collections.singleton(id)); + if (!convertSet(participants, OaScheduleParticipantDO::getUserId).contains(userId)) { + throw exception(SCHEDULE_ACCESS_DENIED); + } + return schedule; + } + + @Override + public PageResult getMySchedulePage(OaSchedulePageReqVO pageReqVO, Long userId) { + return scheduleMapper.selectMyPage(pageReqVO, userId); + } + + @Override + public PageResult getSchedulePage(OaSchedulePageReqVO pageReqVO, Long userId) { + // 1.1 未选择本人日程时,只查询共享日程或返回空列表 + if (Boolean.FALSE.equals(pageReqVO.getIncludeMine())) { + return Boolean.TRUE.equals(pageReqVO.getIncludeReceived()) + ? getReceivedSchedulePage(pageReqVO, userId) : PageResult.empty(); + } + // 1.2 仅选择本人日程时,复用本人分页查询 + if (Boolean.FALSE.equals(pageReqVO.getIncludeReceived())) { + return getMySchedulePage(pageReqVO, userId); + } + + // 2. 查询本人参与关系,没有共享日程时只查本人日程 + List participants = scheduleParticipantMapper.selectListByUserId(userId); + if (CollUtil.isEmpty(participants)) { + return getMySchedulePage(pageReqVO, userId); + } + + // 3. 按创建人或参与关系统一分页,同一日程只返回一次 + return scheduleMapper.selectPageByCreatorOrIds(pageReqVO, userId, + convertList(participants, OaScheduleParticipantDO::getScheduleId)); + } + + @Override + public void updateScheduleReadStatus(Long id, Long userId) { + // 1.1 校验当前用户可访问日程 + getSchedule(id, userId); + // 1.2 查询本人参与关系,创建人未参与或已经阅读时无需更新 + OaScheduleParticipantDO participant = scheduleParticipantMapper.selectByScheduleIdAndUserId(id, userId); + if (participant == null || Boolean.TRUE.equals(participant.getReadStatus())) { + return; + } + + // 2. 标记本人已读,记录阅读时间 + scheduleParticipantMapper.updateById(new OaScheduleParticipantDO().setId(participant.getId()) + .setReadStatus(true).setReadTime(LocalDateTime.now())); + } + + @Override + public List getScheduleParticipantList(Long id) { + return scheduleParticipantMapper.selectListByScheduleId(id); + } + + @Override + public PageResult getReceivedSchedulePage(OaSchedulePageReqVO pageReqVO, Long userId) { + // 1. 查询当前用户的参与关系 + List participants = scheduleParticipantMapper.selectListByUserId(userId); + if (CollUtil.isEmpty(participants)) { + return PageResult.empty(); + } + // 2. 查询共享给本人的日程 + List scheduleIds = convertList(participants, OaScheduleParticipantDO::getScheduleId); + return scheduleMapper.selectReceivedPage(pageReqVO, scheduleIds); + } + + @Override + public int sendScheduleReminders() { + // 1. 查询待提醒日程 + LocalDateTime remindTime = LocalDateTime.now(); + List schedules = scheduleMapper.selectListByRemindAndRemindedAndStartTime(true, false, + new LocalDateTime[]{remindTime, remindTime.plusHours(properties.getSchedule().getRemindBeforeHours())}); + + // 2. 逐条发送,通过代理调用保证每条日程独立事务,失败不影响其他日程 + int count = 0; + for (OaScheduleDO schedule : schedules) { + try { + count += getSelf().sendScheduleReminder(schedule.getId(), remindTime) ? 1 : 0; + } catch (Exception ex) { + log.error("[sendScheduleReminders][日程 ({}) 提醒失败]", schedule.getId(), ex); + } + } + return count; + } + + /** + * 向创建人和参与人发送单条日程提醒 + * + * 保留 public 供自身代理调用,使单条日程的事务生效,不对外暴露 Service 接口。 + * + * @param id 日程编号 + * @param remindTime 提醒基准时间 + * @return 是否完成本日程的提醒,已提醒或不符合条件时返回 false + */ + @Transactional(rollbackFor = Exception.class) + public boolean sendScheduleReminder(Long id, LocalDateTime remindTime) { + // 1. 校验日程当前仍需提醒,编辑或其他执行已处理时不重复发送 + OaScheduleDO schedule = validateScheduleExists(id); + if (Boolean.FALSE.equals(schedule.getRemind()) || Boolean.TRUE.equals(schedule.getReminded()) + || !schedule.getStartTime().isAfter(remindTime) + || !schedule.getStartTime().isBefore( + remindTime.plusHours(properties.getSchedule().getRemindBeforeHours()))) { + return false; + } + + // 2.1 查询参与人并合并创建人,同一用户只接收一次提醒 + List participants = scheduleParticipantMapper.selectListByScheduleId(id); + List userIds = convertList(participants, OaScheduleParticipantDO::getUserId); + userIds.add(NumberUtils.parseLong(schedule.getCreator())); + Map templateParams = Maps.newHashMapWithExpectedSize(3); + templateParams.put("scheduleTitle", schedule.getTitle()); + templateParams.put("startTime", LocalDateTimeUtil.format(schedule.getStartTime(), DatePattern.NORM_DATETIME_MINUTE_PATTERN)); + templateParams.put("route", "/oa/schedule/calendar"); + // 2.2 逐个通知 + for (Long userId : CollUtil.distinct(userIds)) { + notifyMessageSendApi.sendSingleMessageToAdmin(new NotifySendSingleToUserReqDTO() + .setUserId(userId).setTemplateCode(SCHEDULE_REMINDER).setTemplateParams(templateParams)); + } + scheduleMapper.updateById(new OaScheduleDO().setId(id).setReminded(true)); + return true; + } + + /** + * 获得自身代理,保证内部调用的事务生效 + * + * @return 当前 Service 的 Spring 代理 + */ + private OaScheduleServiceImpl getSelf() { + return SpringUtil.getBean(getClass()); + } + + @Override + public List getScheduleParticipantList(Collection scheduleIds) { + if (CollUtil.isEmpty(scheduleIds)) { + return Collections.emptyList(); + } + return scheduleParticipantMapper.selectListByScheduleIds(scheduleIds); + } + + /** + * 校验日程存在且属于当前用户 + * + * @param id 日程编号 + * @param userId 用户编号 + * @return 日程 + */ + private OaScheduleDO validateScheduleOwner(Long id, Long userId) { + OaScheduleDO schedule = validateScheduleExists(id); + if (ObjectUtil.notEqual(NumberUtils.parseLong(schedule.getCreator()), userId)) { + throw exception(SCHEDULE_ACCESS_DENIED); + } + return schedule; + } + + /** + * 校验日程存在 + * + * @param id 日程编号 + * @return 日程 + */ + private OaScheduleDO validateScheduleExists(Long id) { + OaScheduleDO schedule = scheduleMapper.selectById(id); + if (schedule == null) { + throw exception(SCHEDULE_NOT_EXISTS); + } + return schedule; + } + + /** + * 创建日程参与关系 + * + * @param scheduleId 日程编号 + * @param participantUserIds 参与人编号集合 + */ + private void createScheduleParticipantList(Long scheduleId, Collection participantUserIds) { + if (CollUtil.isEmpty(participantUserIds)) { + return; + } + List participants = convertList(CollUtil.distinct(participantUserIds), + participantUserId -> new OaScheduleParticipantDO().setScheduleId(scheduleId).setUserId(participantUserId) + .setReadStatus(false)); + scheduleParticipantMapper.insertBatch(participants); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/seal/OaSealApplyService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/seal/OaSealApplyService.java new file mode 100644 index 0000000000..860d72ab2a --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/seal/OaSealApplyService.java @@ -0,0 +1,90 @@ +package cn.iocoder.yudao.module.oa.service.seal; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.seal.vo.apply.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.seal.OaSealApplyDO; +import jakarta.validation.Valid; + +/** + * 用印申请 Service 接口 + * + * @author 芋道源码 + */ +public interface OaSealApplyService { + + /** + * 创建用印草稿 + * + * @param reqVO 申请信息 + * @param userId 申请人 + * @return 申请编号 + */ + Long createSealApply(@Valid OaSealApplySaveReqVO reqVO, Long userId); + + /** + * 更新本人用印草稿 + * + * @param reqVO 申请信息 + * @param userId 申请人 + */ + void updateSealApply(@Valid OaSealApplySaveReqVO reqVO, Long userId); + + /** + * 删除本人用印草稿 + * + * @param id 申请编号 + * @param userId 申请人 + */ + void deleteSealApply(Long id, Long userId); + + /** + * 提交用印申请 + * + * @param id 申请编号 + * @param userId 申请人 + * @return 流程实例编号 + */ + String submitSealApply(Long id, Long userId); + + /** + * 撤销审批中的用印申请 + * + * @param id 申请编号 + * @param userId 申请人 + */ + void cancelSealApply(Long id, Long userId); + + /** + * 回写用印审批结果 + * + * @param id 申请编号 + * @param status 审批结果 + */ + void updateSealApplyStatus(Long id, Integer status); + + /** + * 获得用印申请 + * + * @param id 申请编号 + * @return 用印申请 + */ + OaSealApplyDO getSealApply(Long id); + + /** + * 获得本人申请分页 + * + * @param userId 申请人 + * @param reqVO 分页条件 + * @return 申请分页 + */ + PageResult getSealApplyPage(Long userId, OaSealApplyPageReqVO reqVO); + + /** + * 获得引用印章的单据数量 + * + * @param id 印章编号 + * @return 单据数量 + */ + Long getSealApplyCountBySealId(Long id); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/seal/OaSealApplyServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/seal/OaSealApplyServiceImpl.java new file mode 100644 index 0000000000..4bf8dbe328 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/seal/OaSealApplyServiceImpl.java @@ -0,0 +1,242 @@ +package cn.iocoder.yudao.module.oa.service.seal; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +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.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.controller.admin.seal.vo.apply.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.seal.*; +import cn.iocoder.yudao.module.oa.dal.mysql.seal.OaSealApplyMapper; +import cn.iocoder.yudao.module.oa.dal.redis.no.OaNoRedisDAO; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import cn.iocoder.yudao.module.oa.enums.seal.*; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.HashMap; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; + +/** + * 用印申请 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaSealApplyServiceImpl implements OaSealApplyService { + + @Resource + private OaSealApplyMapper sealApplyMapper; + @Resource + @Lazy + private OaSealService sealService; + @Resource + private AdminUserApi adminUserApi; + @Resource + private BpmProcessInstanceApi processInstanceApi; + @Resource + private OaNoRedisDAO noRedisDAO; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createSealApply(OaSealApplySaveReqVO reqVO, Long userId) { + // 1.1 校验印章和申请内容,草稿不占用预约时段 + OaSealDO seal = sealService.validateSealExists(reqVO.getSealId()); + // 1.2 申请人与所属部门取自平台账号,不接受客户端指定 + adminUserApi.validateUser(userId); + AdminUserRespDTO user = adminUserApi.getUser(userId); + // 1.3 生成申请单号,并校验唯一性 + String no = noRedisDAO.generate(OaNoRedisDAO.SEAL_APPLY_NO_PREFIX); + if (sealApplyMapper.selectByNo(no) != null) { + throw exception(SEAL_APPLY_NO_DUPLICATE); + } + + // 2. 保存草稿及印章快照,审批状态与业务状态分别维护 + OaSealApplyDO apply = BeanUtils.toBean(reqVO, OaSealApplyDO.class).setId(null) + .setNo(no).setUserId(userId).setDeptId(user.getDeptId()) + .setSealNo(seal.getNo()).setSealName(seal.getName()).setKeeperUserId(seal.getKeeperUserId()) + .setSealType(seal.getType()).setKeeperDeptId(seal.getKeeperDeptId()) + .setStatus(BpmProcessInstanceStatusEnum.NOT_START.getStatus()).setUseStatus(OaSealUseStatusEnum.PENDING.getStatus()); + sealApplyMapper.insert(apply); + return apply.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateSealApply(OaSealApplySaveReqVO reqVO, Long userId) { + // 1.1 校验申请存在及归属 + OaSealApplyDO apply = validateSealApplyExists(reqVO.getId()); + validateOwner(apply, userId); + // 1.2 仅未提交的草稿允许修改 + validateDraft(apply); + // 1.3 重新校验申请内容及印章 + OaSealDO seal = sealService.validateSealExists(reqVO.getSealId()); + + // 2. 更新白名单字段,申请人、流程及用印状态不允许通用更新 + OaSealApplyDO update = BeanUtils.toBean(reqVO, OaSealApplyDO.class) + .setSealNo(seal.getNo()).setSealName(seal.getName()).setKeeperUserId(seal.getKeeperUserId()) + .setSealType(seal.getType()).setKeeperDeptId(seal.getKeeperDeptId()); + if (ObjUtil.notEqual(update.getType(), OaSealApplyTypeEnum.CONTRACT.getType())) { + update.setContractPrice(null).setContractParty(null); + } + if (ObjUtil.notEqual(update.getMode(), OaSealUseModeEnum.BORROW.getMode())) { + update.setExpectedReturnTime(null); + } + sealApplyMapper.updateById(update); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteSealApply(Long id, Long userId) { + // 1.1 校验申请归属 + OaSealApplyDO apply = validateSealApplyExists(id); + validateOwner(apply, userId); + // 1.2 已提交申请保留审批记录 + validateDraft(apply); + + // 2. 删除草稿 + sealApplyMapper.deleteById(id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String submitSealApply(Long id, Long userId) { + // 1.1 查询申请并校验归属 + OaSealApplyDO apply = validateSealApplyExists(id); + validateOwner(apply, userId); + // 1.2 校验草稿状态 + validateDraft(apply); + // 1.3 校验关联印章存在 + OaSealDO seal = sealService.validateSealExists(apply.getSealId()); + // 1.4 校验借用时段冲突 + validateTimeConflict(apply); + + // 2. 更新审批状态,借用申请在审批中参与时段冲突检查 + sealApplyMapper.updateById(new OaSealApplyDO().setId(id).setStatus(BpmProcessInstanceStatusEnum.RUNNING.getStatus()) + .setSealNo(seal.getNo()).setSealName(seal.getName()).setKeeperUserId(seal.getKeeperUserId()) + .setSealType(seal.getType()).setKeeperDeptId(seal.getKeeperDeptId())); + + // 3.1 发起审批 + String processId = processInstanceApi.createProcessInstance(userId, + new BpmProcessInstanceCreateReqDTO().setProcessDefinitionKey(BpmModelConstants.SEAL_APPLY) + .setBusinessKey(id.toString()).setVariables(new HashMap<>())); + // 3.2 绑定流程编号 + sealApplyMapper.updateById(new OaSealApplyDO().setId(id).setProcessInstanceId(processId)); + return processId; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void cancelSealApply(Long id, Long userId) { + // 1.1 查询申请,台账删除不影响历史申请撤销 + OaSealApplyDO apply = validateSealApplyExists(id); + // 1.2 校验本人申请及审批中状态 + validateOwner(apply, userId); + if (ObjUtil.notEqual(apply.getStatus(), BpmProcessInstanceStatusEnum.RUNNING.getStatus())) { + throw exception(SEAL_APPLY_STATUS_INVALID); + } + + // 2. 撤销流程,由审批事件释放预约 + processInstanceApi.cancelProcessInstanceByStartUser(userId, apply.getProcessInstanceId(), "申请人撤销用印申请"); + } + + /** + * 接收 BPM 内部审批结果,仅校验申请存在并更新状态 + * + * @param id 申请编号 + * @param status 审批结果 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void updateSealApplyStatus(Long id, Integer status) { + // 1. 校验申请存在 + validateSealApplyExists(id); + + // 2. 更新审批状态,通过仅表示允许用印 + sealApplyMapper.updateById(new OaSealApplyDO().setId(id).setStatus(status)); + } + + @Override + public OaSealApplyDO getSealApply(Long id) { + return sealApplyMapper.selectById(id); + } + + @Override + public PageResult getSealApplyPage(Long userId, OaSealApplyPageReqVO reqVO) { + return sealApplyMapper.selectPage(userId, reqVO); + } + + /** + * 校验借用时段冲突,时间边界使用闭区间 + * + * @param apply 待提交的申请 + */ + private void validateTimeConflict(OaSealApplyDO apply) { + // 1. 现场用印及未填写完整借用时间的申请不检查时段冲突 + if (ObjUtil.notEqual(apply.getMode(), OaSealUseModeEnum.BORROW.getMode()) + || apply.getExpectedUseTime() == null || apply.getExpectedReturnTime() == null) { + return; + } + if (apply.getExpectedUseTime().isAfter(apply.getExpectedReturnTime())) { + throw exception(SEAL_APPLY_TIME_INVALID); + } + // 2. 审批中或审批通过的外借申请占用预计时段,不依赖用印登记状态 + if (CollUtil.isNotEmpty(sealApplyMapper.selectListBySealIdAndBorrowTimeConflict( + apply.getSealId(), apply.getId(), apply.getExpectedUseTime(), apply.getExpectedReturnTime()))) { + throw exception(SEAL_APPLY_TIME_CONFLICT); + } + } + + /** + * 校验申请存在 + * + * @param id 申请编号 + * @return 用印申请 + */ + private OaSealApplyDO validateSealApplyExists(Long id) { + OaSealApplyDO apply = sealApplyMapper.selectById(id); + if (apply == null) { + throw exception(SEAL_APPLY_NOT_EXISTS); + } + return apply; + } + + /** + * 校验申请归属 + * + * @param apply 用印申请 + * @param userId 申请人 + */ + private void validateOwner(OaSealApplyDO apply, Long userId) { + if (ObjUtil.notEqual(apply.getUserId(), userId)) { + throw exception(SEAL_APPLY_NOT_OWNER); + } + } + + /** + * 校验申请未提交 + * + * @param apply 用印申请 + */ + private void validateDraft(OaSealApplyDO apply) { + if (ObjUtil.notEqual(apply.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus())) { + throw exception(SEAL_APPLY_STATUS_INVALID); + } + } + + @Override + public Long getSealApplyCountBySealId(Long id) { + return sealApplyMapper.selectCountBySealId(id); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/seal/OaSealService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/seal/OaSealService.java new file mode 100644 index 0000000000..a6106eedaa --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/seal/OaSealService.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.oa.service.seal; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.seal.vo.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.seal.OaSealDO; +import jakarta.validation.Valid; + +/** + * 印章 Service 接口 + * + * @author 芋道源码 + */ +public interface OaSealService { + + /** + * 创建印章 + * + * @param createReqVO 印章信息 + * @return 编号 + */ + Long createSeal(@Valid OaSealSaveReqVO createReqVO); + + /** + * 更新印章 + * + * @param updateReqVO 印章信息 + */ + void updateSeal(@Valid OaSealSaveReqVO updateReqVO); + + /** + * 删除印章 + * + * @param id 印章编号 + */ + void deleteSeal(Long id); + + /** + * 获得印章分页 + * + * @param pageReqVO 分页查询 + * @return 印章分页 + */ + PageResult getSealPage(OaSealPageReqVO pageReqVO); + + /** + * 校验印章存在 + * + * @param id 编号 + * @return 印章 + */ + OaSealDO validateSealExists(Long id); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/seal/OaSealServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/seal/OaSealServiceImpl.java new file mode 100644 index 0000000000..b1deb4cea4 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/seal/OaSealServiceImpl.java @@ -0,0 +1,120 @@ +package cn.iocoder.yudao.module.oa.service.seal; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.seal.vo.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.seal.OaSealDO; +import cn.iocoder.yudao.module.oa.dal.mysql.seal.OaSealMapper; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.oa.dal.redis.no.OaNoRedisDAO; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.context.annotation.Lazy; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static java.util.Collections.singleton; + +/** + * 印章 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaSealServiceImpl implements OaSealService { + + @Resource + private OaNoRedisDAO noRedisDAO; + + @Resource + private OaSealMapper sealMapper; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + + @Resource + @Lazy // 延迟,避免循环依赖报错 + private OaSealApplyService sealApplyService; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createSeal(OaSealSaveReqVO createReqVO) { + // 1.1 校验保管人有效 + adminUserApi.validateUser(createReqVO.getKeeperUserId()); + // 1.2 校验保管部门有效 + deptApi.validateDeptList(singleton(createReqVO.getKeeperDeptId())); + // 1.3 校验所属部门有效,允许与保管部门不同 + deptApi.validateDeptList(singleton(createReqVO.getDeptId())); + // 1.4 生成印章编号,并校验唯一性 + String no = noRedisDAO.generate(OaNoRedisDAO.SEAL_NO_PREFIX); + validateSealNoUnique(no); + + // 2. 新增印章,状态及日期按表单保存 + OaSealDO seal = BeanUtils.toBean(createReqVO, OaSealDO.class).setNo(no).setId(null); + sealMapper.insert(seal); + return seal.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateSeal(OaSealSaveReqVO updateReqVO) { + // 1.1 校验印章存在 + validateSealExists(updateReqVO.getId()); + // 1.2 校验保管人有效 + adminUserApi.validateUser(updateReqVO.getKeeperUserId()); + // 1.3 校验保管部门有效 + deptApi.validateDeptList(singleton(updateReqVO.getKeeperDeptId())); + // 1.4 校验所属部门有效,允许与保管部门不同 + deptApi.validateDeptList(singleton(updateReqVO.getDeptId())); + + // 2. 更新印章及保管信息 + OaSealDO updateObj = BeanUtils.toBean(updateReqVO, OaSealDO.class); + sealMapper.updateById(updateObj); + } + + @Override + public void deleteSeal(Long id) { + // 1.1 校验印章存在 + validateSealExists(id); + // 1.2 校验印章未被业务单据引用 + if (sealApplyService.getSealApplyCountBySealId(id) > 0) { + throw exception(SEAL_IN_USE); + } + + // 2. 逻辑删除台账 + sealMapper.deleteById(id); + } + + @Override + public PageResult getSealPage(OaSealPageReqVO pageReqVO) { + return sealMapper.selectPage(pageReqVO); + } + + @Override + public OaSealDO validateSealExists(Long id) { + OaSealDO seal = sealMapper.selectById(id); + if (seal == null) { + throw exception(SEAL_NOT_EXISTS); + } + return seal; + } + + /** + * 校验印章编号唯一 + * + * @param no 业务编号 + */ + private void validateSealNoUnique(String no) { + OaSealDO seal = sealMapper.selectByNo(no); + if (seal != null) { + throw exception(SEAL_NO_DUPLICATE); + } + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/seal/listener/OaSealApplyStatusListener.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/seal/listener/OaSealApplyStatusListener.java new file mode 100644 index 0000000000..f74c472002 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/seal/listener/OaSealApplyStatusListener.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.oa.service.seal.listener; + +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import cn.iocoder.yudao.module.oa.service.seal.OaSealApplyService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +/** + * 用印申请审批结果监听器 + * + * @author 芋道源码 + */ +@Component +public class OaSealApplyStatusListener extends BpmProcessInstanceStatusEventListener { + + @Resource + private OaSealApplyService sealApplyService; + + @Override + protected String getProcessDefinitionKey() { + return BpmModelConstants.SEAL_APPLY; + } + + @Override + protected void onEvent(BpmProcessInstanceStatusEvent event) { + sealApplyService.updateSealApplyStatus(Long.parseLong(event.getBusinessKey()), event.getStatus()); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyApplyService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyApplyService.java new file mode 100644 index 0000000000..5c8973d0c7 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyApplyService.java @@ -0,0 +1,131 @@ +package cn.iocoder.yudao.module.oa.service.supply; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.apply.*; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.issue.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.supply.*; +import jakarta.validation.Valid; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * 用品领用申请 Service 接口 + * + * @author 芋道源码 + */ +public interface OaSupplyApplyService { + + /** + * 获得物品借出未归还的明细数量 + * + * @param itemId 物品编号 + * @return 借出未归还的明细数量 + */ + Long getUnreturnedSupplyItemCount(Long itemId); + + /** + * 获得物品待发放的明细数量 + * + * @param itemId 物品编号 + * @return 待发放的明细数量 + */ + Long getPendingSupplyItemCount(Long itemId); + + /** + * 创建用品领用申请 + * + * @param createReqVO 创建信息 + * @param userId 操作人编号 + * @return 用品领用申请编号 + */ + Long createSupplyApply(@Valid OaSupplyApplySaveReqVO createReqVO, Long userId); + + /** + * 更新用品领用申请 + * + * @param updateReqVO 更新信息 + * @param userId 操作人编号 + */ + void updateSupplyApply(@Valid OaSupplyApplySaveReqVO updateReqVO, Long userId); + + /** + * 删除用品领用申请 + * + * @param id 编号 + * @param userId 操作人编号 + */ + void deleteSupplyApply(Long id, Long userId); + + /** + * 提交用品领用申请 + * + * @param id 编号 + * @param userId 操作人编号 + * @return 流程实例编号 + */ + String submitSupplyApply(Long id, Long userId); + + /** + * 取消用品领用申请 + * + * @param id 编号 + * @param userId 操作人编号 + */ + void cancelSupplyApply(Long id, Long userId); + + /** + * 回写用品申请审批结果 + * + * @param id 编号 + * @param processInstanceId 流程实例编号 + * @param status 审批状态 + */ + void updateSupplyApplyStatus(Long id, String processInstanceId, Integer status); + + /** + * 获得用品领用申请 + * + * @param id 编号 + * @return 用品领用申请 + */ + OaSupplyApplyDO getSupplyApply(Long id); + + /** + * 获得本人用品领用申请分页 + * + * @param reqVO 请求参数 + * @param userId 操作人编号 + * @return 本人用品领用申请分页 + */ + PageResult getSupplyApplyPage(OaSupplyApplyPageReqVO reqVO, Long userId); + + /** + * 获得用品申请列表 + * + * @param ids 编号集合 + * @return 用品申请列表 + */ + List getSupplyApplyList(Collection ids); + + /** + * 获得用品申请映射 + * + * @param ids 编号集合 + * @return 用品申请映射 + */ + default Map getSupplyApplyMap(Collection ids) { + List applies = getSupplyApplyList(ids); + return convertMap(applies, OaSupplyApplyDO::getId); + } + + /** + * 获得用品申请明细列表 + * + * @param applyId 申请编号 + * @return 用品申请明细列表 + */ + List getSupplyApplyItemList(Long applyId); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyApplyServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyApplyServiceImpl.java new file mode 100644 index 0000000000..21e34cc87b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyApplyServiceImpl.java @@ -0,0 +1,286 @@ +package cn.iocoder.yudao.module.oa.service.supply; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +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.framework.common.util.object.ObjectUtils; +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.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.apply.*; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.issue.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.supply.*; +import cn.iocoder.yudao.module.oa.dal.mysql.supply.*; +import cn.iocoder.yudao.module.oa.dal.redis.no.OaNoRedisDAO; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import cn.iocoder.yudao.module.oa.enums.supply.*; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; + +/** + * 用品领用申请 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaSupplyApplyServiceImpl implements OaSupplyApplyService { + + @Resource + private OaSupplyApplyMapper supplyApplyMapper; + @Resource + private OaSupplyApplyItemMapper supplyApplyItemMapper; + @Resource + private OaSupplyItemService supplyItemService; + @Resource + private AdminUserApi adminUserApi; + @Resource + private BpmProcessInstanceApi processInstanceApi; + @Resource + private OaNoRedisDAO noRedisDAO; + + @Override + public Long getUnreturnedSupplyItemCount(Long itemId) { + return supplyApplyItemMapper.selectCountByItemIdAndUnreturned(itemId, OaSupplyManageTypeEnum.BORROWABLE.getType()); + } + + @Override + public Long getPendingSupplyItemCount(Long itemId) { + return supplyApplyItemMapper.selectCountByItemIdAndStatus(itemId, OaSupplyApplyItemStatusEnum.PENDING.getStatus()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createSupplyApply(OaSupplyApplySaveReqVO createReqVO, Long userId) { + // 1.1 校验领用物品有效性,必填信息由保存 VO 校验 + List applyItems = BeanUtils.toBean(createReqVO.getItems(), OaSupplyApplyItemDO.class); + Map items = validateSupplyApplyItems(applyItems); + // 1.2 查询申请人部门 + AdminUserRespDTO user = adminUserApi.validateUser(userId); + // 1.3 生成并校验申请单号 + String no = noRedisDAO.generate(OaNoRedisDAO.SUPPLY_APPLY_NO_PREFIX); + if (supplyApplyMapper.selectByNo(no) != null) { + throw exception(SUPPLY_APPLY_NO_DUPLICATE); + } + + // 2.1 保存草稿,创建人由公共审计字段填写 + OaSupplyApplyDO apply = BeanUtils.toBean(createReqVO, OaSupplyApplyDO.class).setId(null) + .setNo(no).setDeptId(user.getDeptId()).setStatus(BpmProcessInstanceStatusEnum.NOT_START.getStatus()); + supplyApplyMapper.insert(apply); + // 2.2 保存领用明细 + saveSupplyApplyItems(apply.getId(), applyItems, items); + return apply.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateSupplyApply(OaSupplyApplySaveReqVO updateReqVO, Long userId) { + // 1.1 校验本人可编辑申请 + OaSupplyApplyDO apply = validateSupplyApplyExists(updateReqVO.getId()); + validateSupplyApplyOwner(apply, userId); + validateSupplyApplyEditable(apply); + // 1.2 校验新的领用明细 + List applyItems = BeanUtils.toBean(updateReqVO.getItems(), OaSupplyApplyItemDO.class); + Map items = validateSupplyApplyItems(applyItems); + + // 2.1 更新申请内容 + supplyApplyMapper.updateById(BeanUtils.toBean(updateReqVO, OaSupplyApplyDO.class)); + // 2.2 重新保存草稿明细 + supplyApplyItemMapper.deleteByApplyId(apply.getId()); + saveSupplyApplyItems(apply.getId(), applyItems, items); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteSupplyApply(Long id, Long userId) { + // 1.1 校验申请归属 + OaSupplyApplyDO apply = validateSupplyApplyExists(id); + validateSupplyApplyOwner(apply, userId); + // 1.2 校验申请可编辑 + validateSupplyApplyEditable(apply); + + // 2. 删除申请及明细 + supplyApplyItemMapper.deleteByApplyId(id); + supplyApplyMapper.deleteById(id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String submitSupplyApply(Long id, Long userId) { + // 1.1 校验申请归属及状态 + OaSupplyApplyDO apply = validateSupplyApplyExists(id); + validateSupplyApplyOwner(apply, userId); + validateSupplyApplyEditable(apply); + // 1.2 校验领用物品仍然有效,必填信息已在保存时校验,提交不扣减库存 + List items = supplyApplyItemMapper.selectListByApplyId(id); + validateSupplyApplyItems(items); + + // 2. 更新审批中状态 + int count = supplyApplyMapper.updateStatusAndClearProcessInstanceId(id, apply.getStatus(), BpmProcessInstanceStatusEnum.RUNNING.getStatus()); + if (count == 0) { + throw exception(SUPPLY_APPLY_STATUS_INVALID); + } + + // 3.1 发起审批 + String processInstanceId = processInstanceApi.createProcessInstance(userId, + new BpmProcessInstanceCreateReqDTO().setProcessDefinitionKey(BpmModelConstants.SUPPLY_APPLY) + .setBusinessKey(id.toString()).setVariables(new HashMap<>())); + // 3.2 绑定流程编号 + supplyApplyMapper.updateById(new OaSupplyApplyDO().setId(id).setProcessInstanceId(processInstanceId)); + return processInstanceId; + } + + @Override + public void cancelSupplyApply(Long id, Long userId) { + // 1.1 校验申请归属 + OaSupplyApplyDO apply = validateSupplyApplyExists(id); + validateSupplyApplyOwner(apply, userId); + // 1.2 校验审批中状态 + if (ObjUtil.notEqual(apply.getStatus(), BpmProcessInstanceStatusEnum.RUNNING.getStatus())) { + throw exception(SUPPLY_APPLY_STATUS_INVALID); + } + + // 2. 取消流程,由监听器回写结果 + processInstanceApi.cancelProcessInstanceByStartUser(userId, apply.getProcessInstanceId(), "申请人取消用品申请"); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateSupplyApplyStatus(Long id, String processInstanceId, Integer status) { + // 1. 校验审批结果属于当前申请 + OaSupplyApplyDO apply = validateSupplyApplyExists(id); + // 忽略不属于当前流程的审批结果 + if (apply.getProcessInstanceId() != null && ObjUtil.notEqual(apply.getProcessInstanceId(), processInstanceId)) { + return; + } + + // 2.1 保存审批状态 + supplyApplyMapper.updateById(new OaSupplyApplyDO().setId(id).setStatus(status)); + // 2.2 审批通过后允许发放,重复回调不重置已发放明细 + if (ObjUtil.equal(status, BpmProcessInstanceStatusEnum.APPROVE.getStatus()) && ObjUtil.notEqual(apply.getStatus(), status)) { + supplyApplyItemMapper.updateStatusByApplyId(id, OaSupplyApplyItemStatusEnum.PENDING.getStatus()); + } + } + + @Override + public OaSupplyApplyDO getSupplyApply(Long id) { + return supplyApplyMapper.selectById(id); + } + + @Override + public PageResult getSupplyApplyPage(OaSupplyApplyPageReqVO reqVO, Long userId) { + return supplyApplyMapper.selectPage(reqVO, userId); + } + + @Override + public List getSupplyApplyList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return supplyApplyMapper.selectByIds(ids); + } + + @Override + public List getSupplyApplyItemList(Long applyId) { + return supplyApplyItemMapper.selectListByApplyId(applyId); + } + + + /** + * 校验用品申请存在 + * + * @param id 编号 + * @return 用品申请 + */ + private OaSupplyApplyDO validateSupplyApplyExists(Long id) { + OaSupplyApplyDO apply = supplyApplyMapper.selectById(id); + if (apply == null) { + throw exception(SUPPLY_APPLY_NOT_EXISTS); + } + return apply; + } + + /** + * 校验申请归属 + * + * @param apply 申请 + * @param userId 操作人编号 + */ + private void validateSupplyApplyOwner(OaSupplyApplyDO apply, Long userId) { + if (ObjUtil.notEqual(apply.getCreator(), userId.toString())) { + throw exception(SUPPLY_APPLY_ACCESS_DENIED); + } + } + + /** + * 校验申请可编辑 + * + * @param apply 申请 + */ + private void validateSupplyApplyEditable(OaSupplyApplyDO apply) { + // 草稿、驳回和取消的申请允许编辑 + if (ObjectUtils.notEqualsAny(apply.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus(), + BpmProcessInstanceStatusEnum.REJECT.getStatus(), BpmProcessInstanceStatusEnum.CANCEL.getStatus())) { + throw exception(SUPPLY_APPLY_STATUS_INVALID); + } + } + + /** + * 校验领用物品有效且不重复 + * + * @param items 领用明细 + * @return 物品映射 + */ + private Map validateSupplyApplyItems(List items) { + if (CollUtil.isEmpty(items)) { + return Collections.emptyMap(); + } + Set ids = convertSet(items, OaSupplyApplyItemDO::getItemId); + Map itemMap = supplyItemService.getSupplyItemMap(ids); + if (ids.size() != items.size() || itemMap.size() != ids.size()) { + throw exception(SUPPLY_APPLY_ITEM_INVALID); + } + for (OaSupplyApplyItemDO item : items) { + // TODO DONE @AI:用品启用状态使用 CommonStatusEnum,避免魔法值 + if (ObjUtil.notEqual(itemMap.get(item.getItemId()).getStatus(), CommonStatusEnum.ENABLE.getStatus())) { + throw exception(SUPPLY_APPLY_ITEM_INVALID); + } + } + return itemMap; + } + + /** + * 保存申请明细 + * + * @param applyId 申请编号 + * @param applyItems 领用明细 + * @param items 物品映射 + */ + private void saveSupplyApplyItems(Long applyId, List applyItems, + Map items) { + if (CollUtil.isEmpty(applyItems)) { + return; + } + for (OaSupplyApplyItemDO applyItem : applyItems) { + OaSupplyItemDO item = items.get(applyItem.getItemId()); + applyItem.setApplyId(applyId).setItemName(item.getName()).setModel(item.getModel()) + .setUnit(item.getUnit()).setManageType(item.getManageType()) + .setIssuedQuantity(0).setReturnedQuantity(0).setStatus(OaSupplyApplyItemStatusEnum.APPLYING.getStatus()); + supplyApplyItemMapper.insert(applyItem); + } + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyIssueService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyIssueService.java new file mode 100644 index 0000000000..b7cee4b9cd --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyIssueService.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.oa.service.supply; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.issue.OaSupplyIssueReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.issue.OaSupplyIssuePageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.issue.OaSupplyReturnReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.supply.OaSupplyApplyItemDO; + +/** + * 用品领用发放 Service 接口 + * + * @author 芋道源码 + */ +public interface OaSupplyIssueService { + + /** + * 获得领用发放分页 + * + * @param reqVO 请求参数 + * @return 领用发放分页 + */ + PageResult getSupplyIssuePage(OaSupplyIssuePageReqVO reqVO); + + /** + * 发放用品 + * + * @param reqVO 请求参数 + * @param userId 操作人编号 + */ + void issueSupply(OaSupplyIssueReqVO reqVO, Long userId); + + /** + * 确认归还用品 + * + * @param reqVO 请求参数 + */ + void returnSupply(OaSupplyReturnReqVO reqVO); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyIssueServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyIssueServiceImpl.java new file mode 100644 index 0000000000..dc01670d09 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyIssueServiceImpl.java @@ -0,0 +1,136 @@ +package cn.iocoder.yudao.module.oa.service.supply; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.issue.OaSupplyIssueReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.issue.OaSupplyIssuePageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.issue.OaSupplyReturnReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.supply.OaSupplyApplyDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.supply.OaSupplyApplyItemDO; +import cn.iocoder.yudao.module.oa.dal.mysql.supply.OaSupplyApplyItemMapper; +import cn.iocoder.yudao.module.oa.enums.supply.OaSupplyApplyItemStatusEnum; +import cn.iocoder.yudao.module.oa.enums.supply.OaSupplyManageTypeEnum; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; + +/** + * 用品领用发放 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaSupplyIssueServiceImpl implements OaSupplyIssueService { + + @Resource + private OaSupplyApplyItemMapper supplyApplyItemMapper; + + @Resource + private OaSupplyApplyService supplyApplyService; + @Resource + private OaSupplyItemService supplyItemService; + + @Resource + private AdminUserApi adminUserApi; + + @Override + public PageResult getSupplyIssuePage(OaSupplyIssuePageReqVO reqVO) { + // 1. 查询申请人筛选范围 + List creators = null; + if (StrUtil.isNotBlank(reqVO.getCreatorName())) { + List users = adminUserApi.getUserListByNickname(reqVO.getCreatorName()); + if (CollUtil.isEmpty(users)) { + return PageResult.empty(); + } + creators = convertList(users, user -> user.getId().toString()); + } + + // 2. 查询领用明细分页 + return supplyApplyItemMapper.selectPage(reqVO, creators); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void issueSupply(OaSupplyIssueReqVO reqVO, Long userId) { + // 1.1 校验明细待发放 + OaSupplyApplyItemDO item = validateSupplyApplyItemExists(reqVO.getId()); + if (ObjUtil.notEqual(item.getStatus(), OaSupplyApplyItemStatusEnum.PENDING.getStatus())) { + throw exception(SUPPLY_APPLY_STATUS_INVALID); + } + // 1.2 校验申请已审批通过 + OaSupplyApplyDO apply = supplyApplyService.getSupplyApply(item.getApplyId()); + if (apply == null) { + throw exception(SUPPLY_APPLY_NOT_EXISTS); + } + if (ObjUtil.notEqual(apply.getStatus(), BpmProcessInstanceStatusEnum.APPROVE.getStatus())) { + throw exception(SUPPLY_APPLY_STATUS_INVALID); + } + + // 2.1 一次发放后,借用品转为待归还,其他用品转为已领用 + Integer status = ObjUtil.equal(item.getManageType(), OaSupplyManageTypeEnum.BORROWABLE.getType()) + ? OaSupplyApplyItemStatusEnum.RETURN_PENDING.getStatus() : OaSupplyApplyItemStatusEnum.RECEIVED.getStatus(); + int count = supplyApplyItemMapper.updateByIdAndStatus(new OaSupplyApplyItemDO().setId(item.getId()) + .setIssuedQuantity(reqVO.getIssuedQuantity()).setIssueRemark(reqVO.getIssueRemark()) + .setIssueUserId(userId).setIssueTime(LocalDateTime.now()).setStatus(status), item.getStatus()); + if (count == 0) { + throw exception(SUPPLY_APPLY_STATUS_INVALID); + } + // 2.2 扣减实际发放数量,库存不足时一并回滚 + supplyItemService.updateSupplyItemStockQuantity(item.getItemId(), -reqVO.getIssuedQuantity()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void returnSupply(OaSupplyReturnReqVO reqVO) { + // 1.1 校验明细待归还 + OaSupplyApplyItemDO item = validateSupplyApplyItemExists(reqVO.getId()); + if (ObjUtil.notEqual(item.getStatus(), OaSupplyApplyItemStatusEnum.RETURN_PENDING.getStatus())) { + throw exception(SUPPLY_APPLY_STATUS_INVALID); + } + // 1.2 本次归还不能超过剩余数量 + int quantity = item.getReturnedQuantity() + reqVO.getQuantity(); + if (quantity > item.getIssuedQuantity()) { + throw exception(SUPPLY_APPLY_QTY_INVALID); + } + + // 2.1 累计归还数量,全部归还后结束 + int count = supplyApplyItemMapper.updateByIdAndReturnedQuantity(new OaSupplyApplyItemDO().setId(item.getId()) + .setReturnedQuantity(quantity).setReturnRemark(reqVO.getReturnRemark()) + .setStatus(quantity == item.getIssuedQuantity() ? OaSupplyApplyItemStatusEnum.RETURNED.getStatus() + : OaSupplyApplyItemStatusEnum.RETURN_PENDING.getStatus()), item.getReturnedQuantity()); + if (count == 0) { + throw exception(SUPPLY_APPLY_STATUS_INVALID); + } + // 2.2 将本次归还数量补回库存 + supplyItemService.updateSupplyItemStockQuantity(item.getItemId(), reqVO.getQuantity()); + } + + /** + * 校验领用明细存在 + * + * @param id 明细编号 + * @return 领用明细 + */ + private OaSupplyApplyItemDO validateSupplyApplyItemExists(Long id) { + OaSupplyApplyItemDO item = supplyApplyItemMapper.selectById(id); + if (item == null) { + throw exception(SUPPLY_ISSUE_NOT_EXISTS); + } + return item; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyItemService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyItemService.java new file mode 100644 index 0000000000..9ed8f2a3a7 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyItemService.java @@ -0,0 +1,90 @@ +package cn.iocoder.yudao.module.oa.service.supply; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.item.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.supply.*; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * 办公用品 Service 接口 + * + * @author 芋道源码 + */ +public interface OaSupplyItemService { + + /** + * 创建办公用品 + * + * @param createReqVO 创建信息 + * @return 办公用品编号 + */ + Long createSupplyItem(OaSupplyItemSaveReqVO createReqVO); + + /** + * 更新办公用品 + * + * @param updateReqVO 更新信息 + */ + void updateSupplyItem(OaSupplyItemSaveReqVO updateReqVO); + + /** + * 删除办公用品 + * + * @param id 编号 + */ + void deleteSupplyItem(Long id); + + /** + * 获得办公用品 + * + * @param id 编号 + * @return 办公用品 + */ + OaSupplyItemDO getSupplyItem(Long id); + + /** + * 获得办公用品分页 + * + * @param pageReqVO 分页条件 + * @return 办公用品分页 + */ + PageResult getSupplyItemPage(OaSupplyItemPageReqVO pageReqVO); + + /** + * 获得办公用品列表 + * + * @param ids 编号集合 + * @return 办公用品列表 + */ + List getSupplyItemList(Collection ids); + + /** + * 获得办公用品映射 + * + * @param ids 编号集合 + * @return 办公用品映射 + */ + default Map getSupplyItemMap(Collection ids) { + List items = getSupplyItemList(ids); + return convertMap(items, OaSupplyItemDO::getId); + } + + /** + * 办公用品入库 + * + * @param reqVO 请求参数 + */ + void stockInSupplyItem(OaSupplyItemStockInReqVO reqVO); + + /** + * 增减办公用品库存 + * + * @param id 编号 + * @param quantity 库存增量,扣减时为负数 + */ + void updateSupplyItemStockQuantity(Long id, Integer quantity); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyItemServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyItemServiceImpl.java new file mode 100644 index 0000000000..2efb5090cd --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyItemServiceImpl.java @@ -0,0 +1,153 @@ +package cn.iocoder.yudao.module.oa.service.supply; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +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.oa.controller.admin.supply.vo.item.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.supply.*; +import cn.iocoder.yudao.module.oa.dal.mysql.supply.*; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.context.annotation.Lazy; +import org.springframework.validation.annotation.Validated; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; + +/** + * 办公用品 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaSupplyItemServiceImpl implements OaSupplyItemService { + + @Resource + private OaSupplyItemMapper supplyItemMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private OaSupplyApplyService supplyApplyService; + + @Resource + private DeptApi deptApi; + + @Override + public Long createSupplyItem(OaSupplyItemSaveReqVO createReqVO) { + // 1.1 校验物品编码 + validateSupplyItemNoUnique(null, createReqVO.getNo()); + // 1.2 校验所属部门 + deptApi.validateDeptList(Collections.singletonList(createReqVO.getDeptId())); + + // 2. 保存物品 + OaSupplyItemDO item = BeanUtils.toBean(createReqVO, OaSupplyItemDO.class); + supplyItemMapper.insert(item); + return item.getId(); + } + + @Override + public void updateSupplyItem(OaSupplyItemSaveReqVO updateReqVO) { + // 1.1 校验物品存在 + validateSupplyItemExists(updateReqVO.getId()); + // 1.2 校验物品编码 + validateSupplyItemNoUnique(updateReqVO.getId(), updateReqVO.getNo()); + // 1.3 校验所属部门 + deptApi.validateDeptList(Collections.singletonList(updateReqVO.getDeptId())); + + // 2. 更新物品 + supplyItemMapper.updateById(BeanUtils.toBean(updateReqVO, OaSupplyItemDO.class)); + } + + @Override + public void deleteSupplyItem(Long id) { + // 1. 校验物品存在 + validateSupplyItemExists(id); + // 1.2 借出的物品归还完成后才允许删除 + if (supplyApplyService.getUnreturnedSupplyItemCount(id) > 0) { + throw exception(SUPPLY_ITEM_NOT_RETURNED); + } + // 1.3 待发放的物品完成发放处理后才允许删除 + if (supplyApplyService.getPendingSupplyItemCount(id) > 0) { + throw exception(SUPPLY_ITEM_PENDING_ISSUE); + } + + // 2. 删除物品 + supplyItemMapper.deleteById(id); + } + + @Override + public OaSupplyItemDO getSupplyItem(Long id) { + return supplyItemMapper.selectById(id); + } + + @Override + public PageResult getSupplyItemPage(OaSupplyItemPageReqVO pageReqVO) { + return supplyItemMapper.selectPage(pageReqVO); + } + + @Override + public List getSupplyItemList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return supplyItemMapper.selectByIds(ids); + } + + @Override + public void stockInSupplyItem(OaSupplyItemStockInReqVO reqVO) { + // 1. 校验物品存在 + validateSupplyItemExists(reqVO.getId()); + + // 2. 增加库存 + updateSupplyItemStockQuantity(reqVO.getId(), reqVO.getQuantity()); + } + + @Override + public void updateSupplyItemStockQuantity(Long id, Integer quantity) { + // 1. 校验物品存在 + validateSupplyItemExists(id); + + // 2. 原子增减库存,扣减时不允许负库存 + if (supplyItemMapper.updateStockQuantity(id, quantity) == 0) { + throw exception(SUPPLY_STOCK_INSUFFICIENT); + } + } + + /** + * 校验办公用品存在 + * + * @param id 编号 + * @return 办公用品 + */ + @SuppressWarnings("UnusedReturnValue") + private OaSupplyItemDO validateSupplyItemExists(Long id) { + OaSupplyItemDO item = supplyItemMapper.selectById(id); + if (item == null) { + throw exception(SUPPLY_ITEM_NOT_EXISTS); + } + return item; + } + + /** + * 校验物品编码唯一 + * + * @param id 物品编号 + * @param no 物品编码 + */ + private void validateSupplyItemNoUnique(Long id, String no) { + if (StrUtil.isBlank(no)) { + return; + } + OaSupplyItemDO item = supplyItemMapper.selectByNo(no); + if (item != null && ObjUtil.notEqual(item.getId(), id)) { + throw exception(SUPPLY_ITEM_NO_DUPLICATE); + } + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/listener/OaSupplyApplyStatusListener.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/listener/OaSupplyApplyStatusListener.java new file mode 100644 index 0000000000..e1f4cd5417 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/supply/listener/OaSupplyApplyStatusListener.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.oa.service.supply.listener; + +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import cn.iocoder.yudao.module.oa.service.supply.OaSupplyApplyService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +// TODO DONE @AI:审批监听器统一放在业务模块的 listener 子包 +/** + * 用品申请审批结果监听器 + * + * @author 芋道源码 + */ +@Component +public class OaSupplyApplyStatusListener extends BpmProcessInstanceStatusEventListener { + + @Resource + private OaSupplyApplyService supplyApplyService; + + @Override + protected String getProcessDefinitionKey() { + return BpmModelConstants.SUPPLY_APPLY; + } + + @Override + protected void onEvent(BpmProcessInstanceStatusEvent event) { + supplyApplyService.updateSupplyApplyStatus(Long.parseLong(event.getBusinessKey()), event.getId(), event.getStatus()); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/task/OaTaskService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/task/OaTaskService.java new file mode 100644 index 0000000000..dd371710d0 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/task/OaTaskService.java @@ -0,0 +1,133 @@ +package cn.iocoder.yudao.module.oa.service.task; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.task.vo.OaTaskFeedbackReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.task.vo.OaTaskPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.task.vo.OaTaskSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.task.OaTaskDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.task.OaTaskLogDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.task.OaTaskReceiverDO; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap; + +/** + * OA 任务 Service 接口 + * + * @author 芋道源码 + */ +public interface OaTaskService { + + /** + * 创建任务 + * + * @param createReqVO 新增信息 + * @param userId 发布人用户编号 + * @return 任务编号 + */ + Long createTask(OaTaskSaveReqVO createReqVO, Long userId); + + /** + * 更新任务 + * + * @param updateReqVO 修改信息 + * @param userId 发布人用户编号 + */ + void updateTask(OaTaskSaveReqVO updateReqVO, Long userId); + + /** + * 删除发布的任务 + * + * @param id 任务编号 + * @param userId 发布人用户编号 + */ + void deleteTask(Long id, Long userId); + + /** + * 删除接收的任务 + * + * @param id 任务编号 + * @param userId 接收人用户编号 + */ + void deleteReceivedTask(Long id, Long userId); + + /** + * 获得任务详情 + * + * @param id 任务编号 + * @param userId 用户编号 + * @return 任务 + */ + OaTaskDO getTask(Long id, Long userId); + + /** + * 获得我发布的任务分页 + * + * @param pageReqVO 分页查询 + * @param userId 发布人用户编号 + * @return 任务分页 + */ + PageResult getPublishedTaskPage(OaTaskPageReqVO pageReqVO, Long userId); + + /** + * 获得我的任务分页 + * + * @param pageReqVO 分页查询 + * @param userId 接收人用户编号 + * @return 任务分页 + */ + PageResult getReceivedTaskPage(OaTaskPageReqVO pageReqVO, Long userId); + + /** + * 创建任务反馈 + * + * @param createReqVO 反馈信息 + * @param userId 反馈人用户编号 + */ + void feedbackTask(OaTaskFeedbackReqVO createReqVO, Long userId); + + /** + * 获得任务接收人列表 + * + * @param taskIds 任务编号集合 + * @return 任务接收人列表 + */ + List getTaskReceiverList(Collection taskIds); + + /** + * 获得按任务编号分组的接收人列表 Map + * + * @param taskIds 任务编号集合 + * @return 任务编号与接收人列表的 Map + */ + default Map> getTaskReceiverListMap(Collection taskIds) { + return convertMultiMap(getTaskReceiverList(taskIds), OaTaskReceiverDO::getTaskId); + } + + /** + * 获得任务反馈日志列表 + * + * @param taskId 任务编号 + * @return 任务反馈日志列表 + */ + List getTaskLogList(Long taskId); + + /** + * 获得当前用户各任务状态的数量 Map + * + * @param userId 用户编号 + * @return 任务状态与数量的 Map + */ + Map getTaskStatusCountMap(Long userId); + + /** + * 获得各发布人已完成任务数量排行 Map + * + * @return 发布人用户编号与总体已完成任务数量的 Map + */ + Map getCompletedTaskCountMapByUserId(); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/task/OaTaskServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/task/OaTaskServiceImpl.java new file mode 100644 index 0000000000..8abbfc4bc5 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/task/OaTaskServiceImpl.java @@ -0,0 +1,277 @@ +package cn.iocoder.yudao.module.oa.service.task; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.task.vo.OaTaskFeedbackReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.task.vo.OaTaskPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.task.vo.OaTaskSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.task.OaTaskDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.task.OaTaskLogDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.task.OaTaskReceiverDO; +import cn.iocoder.yudao.module.oa.dal.mysql.task.OaTaskLogMapper; +import cn.iocoder.yudao.module.oa.dal.mysql.task.OaTaskMapper; +import cn.iocoder.yudao.module.oa.dal.mysql.task.OaTaskReceiverMapper; +import cn.iocoder.yudao.module.oa.enums.task.OaTaskStatusEnum; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.diffList; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.TASK_ACCESS_DENIED; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.TASK_NOT_EXISTS; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.TASK_RECEIVER_NOT_EXISTS; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.TASK_RECEIVED_DELETE_DENIED; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.TASK_STATUS_TRANSITION_INVALID; + +/** + * OA 任务 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaTaskServiceImpl implements OaTaskService { + + @Resource + private OaTaskMapper taskMapper; + @Resource + private OaTaskReceiverMapper taskReceiverMapper; + @Resource + private OaTaskLogMapper taskLogMapper; + + @Resource + private AdminUserApi adminUserApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createTask(OaTaskSaveReqVO createReqVO, Long userId) { + // 1. 校验任务接收人 + adminUserApi.validateUserList(createReqVO.getReceiverUserIds()); + + // 2. 新增任务 + OaTaskDO task = BeanUtils.toBean(createReqVO, OaTaskDO.class).setPublishTime(LocalDateTime.now()); + taskMapper.insert(task); + + // 3. 新增任务接收人,初始进度与发布人选择的任务状态一致 + taskReceiverMapper.insertBatch(convertList(createReqVO.getReceiverUserIds(), receiverUserId -> new OaTaskReceiverDO() + .setTaskId(task.getId()).setUserId(receiverUserId).setStatus(task.getStatus()))); + return task.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateTask(OaTaskSaveReqVO updateReqVO, Long userId) { + // 1.1 校验任务属于当前发布人 + OaTaskDO task = validateTaskPublisher(updateReqVO.getId(), userId); + // 1.2 校验任务接收人 + adminUserApi.validateUserList(updateReqVO.getReceiverUserIds()); + + // 2. 按发布人表单更新任务信息及总体状态 + taskMapper.updateById(BeanUtils.toBean(updateReqVO, OaTaskDO.class).setPublishTime(LocalDateTime.now())); + + // 3. 更新任务接收人,移除不再接收任务的关系 + List existingReceivers = taskReceiverMapper.selectListByTaskId(task.getId()); + List> diff = diffList(convertList(existingReceivers, OaTaskReceiverDO::getUserId), + updateReqVO.getReceiverUserIds(), ObjUtil::equal); + if (CollUtil.isNotEmpty(diff.get(2))) { + taskReceiverMapper.deleteByTaskIdAndUserIds(task.getId(), diff.get(2)); + } + List addedReceivers = convertList(diff.get(0), + receiverUserId -> new OaTaskReceiverDO().setTaskId(task.getId()).setUserId(receiverUserId) + .setStatus(updateReqVO.getStatus())); + if (CollUtil.isNotEmpty(addedReceivers)) { + taskReceiverMapper.insertBatch(addedReceivers); + } + + // 4. 表单状态同步覆盖全部当前接收人的进度 + taskReceiverMapper.updateStatusByTaskId(task.getId(), updateReqVO.getStatus()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteTask(Long id, Long userId) { + // 1. 校验任务属于当前发布人 + validateTaskPublisher(id, userId); + + // 2. 删除反馈、接收人和任务 + taskLogMapper.deleteByTaskId(id); + taskReceiverMapper.deleteByTaskId(id); + taskMapper.deleteById(id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteReceivedTask(Long id, Long userId) { + // 1.1 校验当前用户是任务接收人 + OaTaskDO task = validateTaskExists(id); + validateTaskReceiver(id, userId); + // 1.2 只有发布人已取消任务后,接收人才可删除自己的接收关系 + if (Boolean.FALSE.equals(task.getCanceled())) { + throw exception(TASK_RECEIVED_DELETE_DENIED); + } + + // 2. 仅删除当前用户的接收关系,不影响其他接收人和任务本身 + taskReceiverMapper.deleteByTaskIdAndUserId(id, userId); + + // 3. 根据剩余接收人的最低进度更新任务总体状态 + updateTaskOverallStatus(id); + } + + @Override + public OaTaskDO getTask(Long id, Long userId) { + OaTaskDO task = validateTaskExists(id); + if (ObjUtil.notEqual(task.getCreator(), userId.toString()) + && taskReceiverMapper.selectByTaskIdAndUserId(id, userId) == null) { + throw exception(TASK_ACCESS_DENIED); + } + return task; + } + + @Override + public PageResult getPublishedTaskPage(OaTaskPageReqVO pageReqVO, Long userId) { + return taskMapper.selectPublishedPage(pageReqVO, userId); + } + + @Override + public PageResult getReceivedTaskPage(OaTaskPageReqVO pageReqVO, Long userId) { + List receivers = taskReceiverMapper.selectListByUserIdAndStatus( + userId, pageReqVO.getStatus()); + if (CollUtil.isEmpty(receivers)) { + return PageResult.empty(); + } + return taskMapper.selectReceivedPage(pageReqVO, + convertList(receivers, OaTaskReceiverDO::getTaskId)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void feedbackTask(OaTaskFeedbackReqVO createReqVO, Long userId) { + // 1. 校验任务存在且未取消 + OaTaskDO task = validateTaskExists(createReqVO.getTaskId()); + if (Boolean.TRUE.equals(task.getCanceled())) { + throw exception(TASK_STATUS_TRANSITION_INVALID); + } + + // 2. 发布人可调整总体状态;接收人只能在提交前反馈,完成由发布人确认 + if (Boolean.TRUE.equals(createReqVO.getPublisher())) { + if (ObjUtil.notEqual(task.getCreator(), userId.toString())) { + throw exception(TASK_ACCESS_DENIED); + } + taskMapper.updateById(new OaTaskDO().setId(task.getId()).setStatus(createReqVO.getStatus())); + taskReceiverMapper.updateStatusByTaskId(task.getId(), createReqVO.getStatus()); + } else { + OaTaskReceiverDO receiver = validateTaskReceiver(task.getId(), userId); + if (receiver.getStatus() >= OaTaskStatusEnum.SUBMITTED.getStatus() + || createReqVO.getStatus() < OaTaskStatusEnum.NEW.getStatus() + || createReqVO.getStatus() > OaTaskStatusEnum.SUBMITTED.getStatus()) { + throw exception(TASK_STATUS_TRANSITION_INVALID); + } + taskReceiverMapper.updateById(receiver.setStatus(createReqVO.getStatus())); + updateTaskOverallStatus(task.getId()); + } + + // 3. 记录状态变化和反馈内容 + taskLogMapper.insert(BeanUtils.toBean(createReqVO, OaTaskLogDO.class).setUserId(userId)); + } + + @Override + public List getTaskReceiverList(Collection taskIds) { + if (CollUtil.isEmpty(taskIds)) { + return Collections.emptyList(); + } + return taskReceiverMapper.selectListByTaskIds(taskIds); + } + + @Override + public List getTaskLogList(Long taskId) { + return taskLogMapper.selectListByTaskId(taskId); + } + + @Override + public Map getTaskStatusCountMap(Long userId) { + return taskReceiverMapper.selectCountMapByUserIdGroupByStatus(userId); + } + + @Override + public Map getCompletedTaskCountMapByUserId() { + Map counts = taskMapper.selectCountMapByStatusGroupByCreator( + OaTaskStatusEnum.COMPLETED.getStatus()); + Map countMap = new LinkedHashMap<>(); + counts.entrySet().stream().sorted(Map.Entry.comparingByValue().reversed() + .thenComparing(Map.Entry.comparingByKey())) + .limit(5).forEach(entry -> countMap.put(entry.getKey(), entry.getValue())); + return countMap; + } + + /** + * 根据各接收人的最低进度更新任务总体状态 + * + * @param taskId 任务编号 + */ + private void updateTaskOverallStatus(Long taskId) { + List receivers = taskReceiverMapper.selectListByTaskId(taskId); + if (CollUtil.isEmpty(receivers)) { + return; + } + Integer overallStatus = Collections.min(convertList(receivers, OaTaskReceiverDO::getStatus)); + taskMapper.updateById(new OaTaskDO().setId(taskId).setStatus(overallStatus)); + } + + /** + * 校验任务属于指定发布人 + * + * @param id 任务编号 + * @param userId 发布人用户编号 + * @return 任务 + */ + private OaTaskDO validateTaskPublisher(Long id, Long userId) { + OaTaskDO task = validateTaskExists(id); + if (ObjUtil.notEqual(task.getCreator(), userId.toString())) { + throw exception(TASK_ACCESS_DENIED); + } + return task; + } + + /** + * 校验当前用户是任务接收人 + * + * @param taskId 任务编号 + * @param userId 用户编号 + * @return 任务接收人 + */ + private OaTaskReceiverDO validateTaskReceiver(Long taskId, Long userId) { + OaTaskReceiverDO receiver = taskReceiverMapper.selectByTaskIdAndUserId(taskId, userId); + if (receiver == null) { + throw exception(TASK_RECEIVER_NOT_EXISTS); + } + return receiver; + } + + /** + * 校验任务是否存在 + * + * @param id 任务编号 + * @return 任务 + */ + private OaTaskDO validateTaskExists(Long id) { + OaTaskDO task = taskMapper.selectById(id); + if (task == null) { + throw exception(TASK_NOT_EXISTS); + } + return task; + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelApplyService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelApplyService.java new file mode 100644 index 0000000000..973ea3b135 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelApplyService.java @@ -0,0 +1,138 @@ +package cn.iocoder.yudao.module.oa.service.travel; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import jakarta.validation.Valid; +import cn.iocoder.yudao.module.oa.controller.admin.travel.vo.apply.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.travel.OaTravelApplyDO; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * 出差申请 Service 接口 + * + * @author 芋道源码 + */ +public interface OaTravelApplyService { + + /** + * 创建出差申请草稿 + * + * @param reqVO 单据内容 + * @param userId 当前用户编号 + * @return 单据编号 + */ + Long createTravelApply(@Valid OaTravelApplySaveReqVO reqVO, Long userId); + + /** + * 更新出差申请草稿 + * + * @param reqVO 单据内容 + * @param userId 当前用户编号 + */ + void updateTravelApply(@Valid OaTravelApplySaveReqVO reqVO, Long userId); + + /** + * 提交已保存的出差申请 + * + * @param submitReqVO 单据编号及发起人选择的审批人 + * @param userId 当前用户编号 + */ + void submitTravelApply(OaTravelApplySubmitReqVO submitReqVO, Long userId); + + /** + * 撤回本人的审批中出差申请 + * + * @param id 单据编号 + * @param userId 当前用户编号 + */ + void cancelTravelApply(Long id, Long userId); + + /** + * 删除本人允许删除状态的出差申请 + * + * @param id 单据编号 + * @param userId 当前用户编号 + */ + void deleteTravelApply(Long id, Long userId); + + /** + * 获得出差申请详情 + * + * @param id 单据编号 + * @return 单据 + */ + OaTravelApplyDO getTravelApply(Long id); + + /** + * 获得本人出差申请分页 + * + * @param userId 当前用户编号 + * @param reqVO 分页条件 + * @return 本人单据分页 + */ + PageResult getTravelApplyPage(Long userId, OaTravelApplyPageReqVO reqVO); + + /** + * 获得用户审批通过的出差天数合计 + * + * @param userIds 用户编号集合 + * @param startTime 申请开始时间范围,包含边界;跨月申请整笔计入开始月份 + * @return 用户编号与出差天数的映射,无申请的用户不返回 + */ + Map getApprovedTravelDaysMap(Collection userIds, LocalDateTime[] startTime); + + /** + * 更新出差申请审批状态 + * + * @param id 单据编号 + * @param status 审批状态 + */ + void updateTravelApplyStatus(Long id, Integer status); + + /** + * 获得本人审批通过的出差申请 + * + * @param userId 当前用户编号 + * @return 可关联的出差申请列表 + */ + List getApprovedTravelApplyList(Long userId); + + /** + * 校验所选出差申请属于本人且已通过 + * + * @param id 申请编号 + * @param userId 当前用户编号 + * @return 出差申请 + */ + OaTravelApplyDO validateApprovedTravelApply(Long id, Long userId); + + /** + * 批量查询出差申请用于展示单号 + * + * @param ids 申请编号集合 + * @return 出差申请列表 + */ + List getTravelApplyList(Collection ids); + + /** + * 获得出差申请 Map + * + * @param ids 申请编号集合 + * @return 申请编号与申请的映射 + */ + default Map getTravelApplyMap(Collection ids) { + return convertMap(getTravelApplyList(ids), OaTravelApplyDO::getId); + } + + /** + * 关联报销审批通过后标记出差已报销 + * + * @param id 申请编号 + */ + void updateTravelApplyReimburseStatus(Long id); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelApplyServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelApplyServiceImpl.java new file mode 100644 index 0000000000..9f7d7e2b0c --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelApplyServiceImpl.java @@ -0,0 +1,257 @@ +package cn.iocoder.yudao.module.oa.service.travel; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; +import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils; +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.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.controller.admin.travel.vo.apply.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.travel.OaTravelApplyDO; +import cn.iocoder.yudao.module.oa.dal.mysql.travel.OaTravelApplyMapper; +import cn.iocoder.yudao.module.oa.dal.redis.no.OaNoRedisDAO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import cn.iocoder.yudao.module.oa.service.attendance.OaAttendanceService; +import cn.iocoder.yudao.module.oa.enums.attendance.OaAttendanceTypeEnum; +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.module.oa.enums.BpmModelConstants.*; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; + +/** + * 出差申请 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaTravelApplyServiceImpl implements OaTravelApplyService { + + @Resource + @Lazy + private OaAttendanceService attendanceService; + + @Resource + private OaTravelApplyMapper travelApplyMapper; + + @Resource + private OaNoRedisDAO noRedisDAO; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private BpmProcessInstanceApi processInstanceApi; + + @Override + public Map getApprovedTravelDaysMap(Collection userIds, LocalDateTime[] startTime) { + if (CollUtil.isEmpty(userIds)) { + return Collections.emptyMap(); + } + return travelApplyMapper.selectDaysMapByCreatorsAndStatusAndStartTime( + userIds, BpmProcessInstanceStatusEnum.APPROVE.getStatus(), startTime); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createTravelApply(OaTravelApplySaveReqVO reqVO, Long userId) { + // 1.1 查询并校验申请人 + AdminUserRespDTO user = adminUserApi.validateUser(userId); + // 1.2 生成单号并校验唯一性 + String no = noRedisDAO.generate(OaNoRedisDAO.TRAVEL_APPLY_NO_PREFIX); + if (travelApplyMapper.selectByNo(no) != null) { + throw exception(TRAVEL_NO_DUPLICATE); + } + + // 2. 创建草稿 + OaTravelApplyDO record = BeanUtils.toBean(reqVO, OaTravelApplyDO.class) + .setDays(LocalDateTimeUtils.getDaysBetweenCeiling(reqVO.getStartTime(), reqVO.getEndTime())).setNo(no) + .setDeptId(user.getDeptId()).setStatus(BpmProcessInstanceStatusEnum.NOT_START.getStatus()) + .setReimburseStatus(false); + travelApplyMapper.insert(record); + return record.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateTravelApply(OaTravelApplySaveReqVO reqVO, Long userId) { + // 1.1 查询并校验单据存在 + OaTravelApplyDO record = validateTravelApplyExists(reqVO.getId()); + // 1.2 校验本人归属 + validateTravelApplyOwner(record, userId); + // 1.3 校验可编辑状态 + validateTravelApplyEditable(record); + + // 2. 更新单据 + OaTravelApplyDO update = BeanUtils.toBean(reqVO, OaTravelApplyDO.class) + .setDays(LocalDateTimeUtils.getDaysBetweenCeiling(reqVO.getStartTime(), reqVO.getEndTime())); + travelApplyMapper.updateById(update); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void submitTravelApply(OaTravelApplySubmitReqVO submitReqVO, Long userId) { + // 1.1 查询并校验单据存在 + OaTravelApplyDO record = validateTravelApplyExists(submitReqVO.getId()); + // 1.2 校验本人归属 + validateTravelApplyOwner(record, userId); + // 1.3 校验可提交状态 + validateTravelApplyEditable(record); + + // 2. 更新为审批中,兼容流程创建时的同步回调 + travelApplyMapper.updateById(new OaTravelApplyDO().setId(record.getId()) + .setStatus(BpmProcessInstanceStatusEnum.RUNNING.getStatus()).setProcessInstanceId("")); + + // 3.1 发起审批 + Map variables = new HashMap<>(); + variables.put("days", record.getDays()); + String processInstanceId = processInstanceApi.createProcessInstance(userId, + new BpmProcessInstanceCreateReqDTO().setProcessDefinitionKey(TRAVEL_APPLY) + .setBusinessKey(record.getId().toString()).setVariables(variables) + .setStartUserSelectAssignees(submitReqVO.getStartUserSelectAssignees())); + // 3.2 绑定流程编号 + travelApplyMapper.updateById(new OaTravelApplyDO().setId(record.getId()).setProcessInstanceId(processInstanceId)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void cancelTravelApply(Long id, Long userId) { + // 1.1 校验单据存在 + OaTravelApplyDO record = validateTravelApplyExists(id); + // 1.2 校验本人归属 + validateTravelApplyOwner(record, userId); + // 1.3 仅审批中可以撤回 + if (ObjUtil.notEqual(record.getStatus(), BpmProcessInstanceStatusEnum.RUNNING.getStatus())) { + throw exception(TRAVEL_STATUS_INVALID); + } + + // 2. 使用 BPM 取消流程,由事件回写单据状态 + processInstanceApi.cancelProcessInstanceByStartUser(userId, record.getProcessInstanceId(), "申请人撤回出差申请"); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteTravelApply(Long id, Long userId) { + // 1.1 校验单据存在 + OaTravelApplyDO record = validateTravelApplyExists(id); + // 1.2 校验本人归属 + validateTravelApplyOwner(record, userId); + // 1.3 校验可删除状态 + validateTravelApplyEditable(record); + + // 2. 删除本人单据 + travelApplyMapper.deleteById(id); + } + + @Override + public OaTravelApplyDO getTravelApply(Long id) { + return travelApplyMapper.selectById(id); + } + + @Override + public PageResult getTravelApplyPage(Long userId, OaTravelApplyPageReqVO reqVO) { + return travelApplyMapper.selectPage(userId, reqVO); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateTravelApplyStatus(Long id, Integer status) { + // 1. 校验申请存在 + OaTravelApplyDO apply = validateTravelApplyExists(id); + + // 2. 更新审批状态 + travelApplyMapper.updateById(new OaTravelApplyDO().setId(id).setStatus(status)); + + // 3. 审批通过后生成考勤明细 + if (ObjUtil.equal(status, BpmProcessInstanceStatusEnum.APPROVE.getStatus())) { + attendanceService.createApplyAttendance(Long.valueOf(apply.getCreator()), + OaAttendanceTypeEnum.TRAVEL, apply.getStartTime()); + } + } + + @Override + public List getApprovedTravelApplyList(Long userId) { + return travelApplyMapper.selectListByCreatorAndStatus(userId, BpmProcessInstanceStatusEnum.APPROVE.getStatus()); + } + + @Override + public OaTravelApplyDO validateApprovedTravelApply(Long id, Long userId) { + // 1. 校验申请存在 + OaTravelApplyDO record = validateTravelApplyExists(id); + // 2. 校验申请归属 + validateTravelApplyOwner(record, userId); + // 3. 校验申请已通过审批 + if (ObjUtil.notEqual(record.getStatus(), BpmProcessInstanceStatusEnum.APPROVE.getStatus())) { + throw exception(TRAVEL_APPLY_NOT_APPROVED); + } + // 4. 已报销的行程不再创建或提交报销单 + if (Boolean.TRUE.equals(record.getReimburseStatus())) { + throw exception(TRAVEL_APPLY_ALREADY_REIMBURSED); + } + return record; + } + + @Override + public List getTravelApplyList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return travelApplyMapper.selectByIds(ids); + } + + @Override + public void updateTravelApplyReimburseStatus(Long id) { + travelApplyMapper.updateById(new OaTravelApplyDO().setId(id) + .setReimburseStatus(true)); + } + + /** + * 校验单据存在 + * + * @param id 单据编号 + * @return 单据 + */ + private OaTravelApplyDO validateTravelApplyExists(Long id) { + OaTravelApplyDO record = travelApplyMapper.selectById(id); + if (record == null) { + throw exception(TRAVEL_NOT_EXISTS); + } + return record; + } + + /** + * 校验本人单据 + * + * @param record 单据 + * @param userId 用户编号 + */ + private void validateTravelApplyOwner(OaTravelApplyDO record, Long userId) { + if (ObjUtil.notEqual(record.getCreator(), userId.toString())) { + throw exception(TRAVEL_ACCESS_DENIED); + } + } + + /** + * 校验可编辑状态 + * + * @param record 单据 + */ + private void validateTravelApplyEditable(OaTravelApplyDO record) { + if (ObjectUtils.notEqualsAny(record.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus(), + BpmProcessInstanceStatusEnum.REJECT.getStatus(), BpmProcessInstanceStatusEnum.CANCEL.getStatus())) { + throw exception(TRAVEL_STATUS_INVALID); + } + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelReimbursementService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelReimbursementService.java new file mode 100644 index 0000000000..aa60db6f5b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelReimbursementService.java @@ -0,0 +1,81 @@ +package cn.iocoder.yudao.module.oa.service.travel; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import jakarta.validation.Valid; +import cn.iocoder.yudao.module.oa.controller.admin.travel.vo.reimbursement.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.travel.OaTravelReimbursementDO; + +/** + * 出差报销 Service 接口 + * + * @author 芋道源码 + */ +public interface OaTravelReimbursementService { + + /** + * 创建出差报销草稿 + * + * @param reqVO 单据内容 + * @param userId 当前用户编号 + * @return 单据编号 + */ + Long createTravelReimbursement(@Valid OaTravelReimbursementSaveReqVO reqVO, Long userId); + + /** + * 更新出差报销草稿 + * + * @param reqVO 单据内容 + * @param userId 当前用户编号 + */ + void updateTravelReimbursement(@Valid OaTravelReimbursementSaveReqVO reqVO, Long userId); + + /** + * 提交已保存的出差报销 + * + * @param submitReqVO 单据编号及发起人选择的审批人 + * @param userId 当前用户编号 + */ + void submitTravelReimbursement(OaTravelReimbursementSubmitReqVO submitReqVO, Long userId); + + /** + * 撤回本人的审批中出差报销 + * + * @param id 单据编号 + * @param userId 当前用户编号 + */ + void cancelTravelReimbursement(Long id, Long userId); + + /** + * 删除本人允许删除状态的出差报销 + * + * @param id 单据编号 + * @param userId 当前用户编号 + */ + void deleteTravelReimbursement(Long id, Long userId); + + /** + * 获得出差报销详情 + * + * @param id 单据编号 + * @return 单据 + */ + OaTravelReimbursementDO getTravelReimbursement(Long id); + + /** + * 获得本人出差报销分页 + * + * @param userId 当前用户编号 + * @param reqVO 分页条件 + * @return 本人单据分页 + */ + PageResult getTravelReimbursementPage(Long userId, OaTravelReimbursementPageReqVO reqVO); + + /** + * 更新出差报销审批状态 + * + * @param id 单据编号 + * @param status 审批状态 + */ + void updateTravelReimbursementStatus(Long id, Integer status); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelReimbursementServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelReimbursementServiceImpl.java new file mode 100644 index 0000000000..fe111122a3 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelReimbursementServiceImpl.java @@ -0,0 +1,243 @@ +package cn.iocoder.yudao.module.oa.service.travel; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; +import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils; +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.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.controller.admin.travel.vo.reimbursement.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.travel.OaTravelReimbursementDO; +import cn.iocoder.yudao.module.oa.dal.mysql.travel.OaTravelReimbursementMapper; +import cn.iocoder.yudao.module.oa.dal.redis.no.OaNoRedisDAO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +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.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.oa.enums.BpmModelConstants.*; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; + +/** + * 出差报销 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaTravelReimbursementServiceImpl implements OaTravelReimbursementService { + + @Resource + private OaTravelReimbursementMapper travelReimbursementMapper; + + @Resource + private OaNoRedisDAO noRedisDAO; + + @Resource + private OaTravelApplyService travelApplyService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private BpmProcessInstanceApi processInstanceApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createTravelReimbursement(OaTravelReimbursementSaveReqVO reqVO, Long userId) { + // 1.1 校验所选申请 + if (reqVO.getTravelApplyId() != null) { + travelApplyService.validateApprovedTravelApply(reqVO.getTravelApplyId(), userId); + } + // 1.2 查询并校验申请人 + AdminUserRespDTO user = adminUserApi.validateUser(userId); + // 1.3 生成单号并校验唯一性 + String no = noRedisDAO.generate(OaNoRedisDAO.TRAVEL_REIMBURSEMENT_NO_PREFIX); + if (travelReimbursementMapper.selectByNo(no) != null) { + throw exception(TRAVEL_NO_DUPLICATE); + } + + // 2. 创建草稿 + OaTravelReimbursementDO record = buildTravelReimbursement(reqVO) + .setNo(no) + .setDeptId(user.getDeptId()).setStatus(BpmProcessInstanceStatusEnum.NOT_START.getStatus()) + .setPayStatus(false); + travelReimbursementMapper.insert(record); + return record.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateTravelReimbursement(OaTravelReimbursementSaveReqVO reqVO, Long userId) { + // 1.1 查询并校验单据存在 + OaTravelReimbursementDO record = validateTravelReimbursementExists(reqVO.getId()); + // 1.2 校验本人归属 + validateTravelReimbursementOwner(record, userId); + // 1.3 校验可编辑状态 + validateTravelReimbursementEditable(record); + // 1.4 校验所选申请 + if (reqVO.getTravelApplyId() != null) { + travelApplyService.validateApprovedTravelApply(reqVO.getTravelApplyId(), userId); + } + + // 2. 更新单据 + travelReimbursementMapper.updateById(buildTravelReimbursement(reqVO)); + } + + /** + * 转换草稿业务字段,统一计算天数和报销总金额 + * + * @param reqVO 单据内容 + * @return 单据业务字段 + */ + private OaTravelReimbursementDO buildTravelReimbursement(OaTravelReimbursementSaveReqVO reqVO) { + OaTravelReimbursementDO record = BeanUtils.toBean(reqVO, OaTravelReimbursementDO.class); + record.setDays(LocalDateTimeUtils.getDaysBetweenCeiling(reqVO.getStartTime(), reqVO.getEndTime())); + BigDecimal totalPrice = BigDecimal.ZERO; + if (CollUtil.isNotEmpty(record.getItems())) { + for (OaTravelReimbursementDO.Item item : record.getItems()) { + if (item.getPrice() != null) { + totalPrice = totalPrice.add(item.getPrice()); + } + } + } + record.setTotalPrice(record.getItems() == null ? null : totalPrice.setScale(2, RoundingMode.HALF_UP)); + return record; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void submitTravelReimbursement(OaTravelReimbursementSubmitReqVO submitReqVO, Long userId) { + // 1.1 查询并校验单据存在 + OaTravelReimbursementDO record = validateTravelReimbursementExists(submitReqVO.getId()); + // 1.2 校验本人归属 + validateTravelReimbursementOwner(record, userId); + // 1.3 校验可提交状态 + validateTravelReimbursementEditable(record); + // 1.4 校验所选申请仍可关联 + if (record.getTravelApplyId() != null) { + travelApplyService.validateApprovedTravelApply(record.getTravelApplyId(), userId); + } + + // 2. 更新为审批中,兼容流程创建时的同步回调 + travelReimbursementMapper.updateById(new OaTravelReimbursementDO().setId(record.getId()) + .setStatus(BpmProcessInstanceStatusEnum.RUNNING.getStatus()).setProcessInstanceId("")); + + // 3.1 发起审批 + Map variables = new HashMap<>(); + variables.put("days", record.getDays()); + String processInstanceId = processInstanceApi.createProcessInstance(userId, + new BpmProcessInstanceCreateReqDTO().setProcessDefinitionKey(TRAVEL_REIMBURSEMENT) + .setBusinessKey(record.getId().toString()).setVariables(variables) + .setStartUserSelectAssignees(submitReqVO.getStartUserSelectAssignees())); + // 3.2 绑定流程编号 + travelReimbursementMapper.updateById(new OaTravelReimbursementDO().setId(record.getId()).setProcessInstanceId(processInstanceId)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void cancelTravelReimbursement(Long id, Long userId) { + // 1.1 校验单据存在 + OaTravelReimbursementDO record = validateTravelReimbursementExists(id); + // 1.2 校验本人归属 + validateTravelReimbursementOwner(record, userId); + // 1.3 仅审批中可以撤回 + if (ObjUtil.notEqual(record.getStatus(), BpmProcessInstanceStatusEnum.RUNNING.getStatus())) { + throw exception(TRAVEL_STATUS_INVALID); + } + + // 2. 使用 BPM 取消流程,由事件回写单据状态 + processInstanceApi.cancelProcessInstanceByStartUser(userId, record.getProcessInstanceId(), "申请人撤回出差报销"); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteTravelReimbursement(Long id, Long userId) { + // 1.1 校验单据存在 + OaTravelReimbursementDO record = validateTravelReimbursementExists(id); + // 1.2 校验本人归属 + validateTravelReimbursementOwner(record, userId); + // 1.3 校验可删除状态 + validateTravelReimbursementEditable(record); + + // 2. 删除本人单据 + travelReimbursementMapper.deleteById(id); + } + + @Override + public OaTravelReimbursementDO getTravelReimbursement(Long id) { + return travelReimbursementMapper.selectById(id); + } + + @Override + public PageResult getTravelReimbursementPage(Long userId, OaTravelReimbursementPageReqVO reqVO) { + return travelReimbursementMapper.selectPage(userId, reqVO); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateTravelReimbursementStatus(Long id, Integer status) { + // 1. 校验报销单存在 + OaTravelReimbursementDO record = validateTravelReimbursementExists(id); + // 重复结果不再触发关联申请的报销状态更新 + if (ObjUtil.equal(record.getStatus(), status)) { + return; + } + + // 2. 更新审批状态 + travelReimbursementMapper.updateById(new OaTravelReimbursementDO().setId(id).setStatus(status)); + + // 3. 审批通过时标记关联申请已报销,不修改支付状态 + if (ObjUtil.equal(status, BpmProcessInstanceStatusEnum.APPROVE.getStatus()) && record.getTravelApplyId() != null) { + travelApplyService.updateTravelApplyReimburseStatus(record.getTravelApplyId()); + } + } + + /** + * 校验单据存在 + * + * @param id 单据编号 + * @return 单据 + */ + private OaTravelReimbursementDO validateTravelReimbursementExists(Long id) { + OaTravelReimbursementDO record = travelReimbursementMapper.selectById(id); + if (record == null) { + throw exception(TRAVEL_NOT_EXISTS); + } + return record; + } + + /** + * 校验本人单据 + * + * @param record 单据 + * @param userId 用户编号 + */ + private void validateTravelReimbursementOwner(OaTravelReimbursementDO record, Long userId) { + if (ObjUtil.notEqual(record.getCreator(), userId.toString())) { + throw exception(TRAVEL_ACCESS_DENIED); + } + } + + /** + * 校验可编辑状态 + * + * @param record 单据 + */ + private void validateTravelReimbursementEditable(OaTravelReimbursementDO record) { + if (ObjectUtils.notEqualsAny(record.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus(), + BpmProcessInstanceStatusEnum.REJECT.getStatus(), BpmProcessInstanceStatusEnum.CANCEL.getStatus())) { + throw exception(TRAVEL_STATUS_INVALID); + } + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/listener/OaTravelApplyStatusListener.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/listener/OaTravelApplyStatusListener.java new file mode 100644 index 0000000000..6f289e230b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/listener/OaTravelApplyStatusListener.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.oa.service.travel.listener; + +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import cn.iocoder.yudao.module.oa.service.travel.OaTravelApplyService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +/** + * 出差申请审批结果监听器 + * + * @author 芋道源码 + */ +@Component +public class OaTravelApplyStatusListener extends BpmProcessInstanceStatusEventListener { + + @Resource + private OaTravelApplyService travelApplyService; + + @Override + protected String getProcessDefinitionKey() { + return BpmModelConstants.TRAVEL_APPLY; + } + + @Override + protected void onEvent(BpmProcessInstanceStatusEvent event) { + travelApplyService.updateTravelApplyStatus(Long.parseLong(event.getBusinessKey()), event.getStatus()); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/listener/OaTravelReimbursementStatusListener.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/listener/OaTravelReimbursementStatusListener.java new file mode 100644 index 0000000000..29c87b6c5b --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/travel/listener/OaTravelReimbursementStatusListener.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.oa.service.travel.listener; + +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import cn.iocoder.yudao.module.oa.service.travel.OaTravelReimbursementService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +/** + * 出差报销审批结果监听器 + * + * @author 芋道源码 + */ +@Component +public class OaTravelReimbursementStatusListener extends BpmProcessInstanceStatusEventListener { + + @Resource + private OaTravelReimbursementService travelReimbursementService; + + @Override + protected String getProcessDefinitionKey() { + return BpmModelConstants.TRAVEL_REIMBURSEMENT; + } + + @Override + protected void onEvent(BpmProcessInstanceStatusEvent event) { + travelReimbursementService.updateTravelReimbursementStatus(Long.parseLong(event.getBusinessKey()), event.getStatus()); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleApplyService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleApplyService.java new file mode 100644 index 0000000000..2c1df9fd87 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleApplyService.java @@ -0,0 +1,133 @@ +package cn.iocoder.yudao.module.oa.service.vehicle; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.apply.OaVehicleApplyPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.apply.OaVehicleApplySaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.OaVehicleApplyDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * 用车申请 Service 接口 + * + * @author 芋道源码 + */ +public interface OaVehicleApplyService { + + /** + * 创建用车申请草稿 + * + * @param createReqVO 创建信息 + * @param userId 当前用户编号 + * @return 申请编号 + */ + Long createVehicleApply(@Valid OaVehicleApplySaveReqVO createReqVO, Long userId); + + /** + * 更新本人用车申请草稿 + * + * @param updateReqVO 修改信息 + * @param userId 当前用户编号 + */ + void updateVehicleApply(@Valid OaVehicleApplySaveReqVO updateReqVO, Long userId); + + /** + * 删除本人用车申请草稿 + * + * @param id 申请编号 + * @param userId 当前用户编号 + */ + void deleteVehicleApply(Long id, Long userId); + + /** + * 提交用车申请,并占用预约时段 + * + * @param id 申请编号 + * @param userId 当前用户编号 + * @return 流程实例编号 + */ + String submitVehicleApply(Long id, Long userId); + + /** + * 撤销本人审批中的用车申请 + * + * @param id 申请编号 + * @param userId 当前用户编号 + */ + void cancelVehicleApply(Long id, Long userId); + + /** + * 更新已批准用车申请的还车状态 + * + * @param id 申请编号 + * @param expectedReturnStatus 原还车状态 + * @param returnStatus 新还车状态 + */ + void updateVehicleApplyReturnStatus(Long id, Integer expectedReturnStatus, Integer returnStatus); + + /** + * 回写用车申请审批结果 + * + * @param id 申请编号 + * @param status BPM 审批结果 + */ + void updateVehicleApplyStatus(Long id, Integer status); + + /** + * 获得用车申请详情 + * + * @param id 申请编号 + * @return 用车申请 + */ + OaVehicleApplyDO getVehicleApply(Long id); + + /** + * 获得本人用车申请分页 + * + * @param userId 当前用户编号 + * @param pageReqVO 分页条件 + * @return 用车申请分页 + */ + PageResult getVehicleApplyPage(Long userId, OaVehicleApplyPageReqVO pageReqVO); + + /** + * 获得指定编号的用车申请列表 + * + * @param ids 申请编号集合 + * @return 用车申请列表 + */ + List getVehicleApplyList(Collection ids); + + /** + * 获得用车申请 Map + * + * @param ids 申请编号集合 + * @return 用车申请 Map + */ + default Map getVehicleApplyMap(Collection ids) { + List applies = getVehicleApplyList(ids); + return convertMap(applies, OaVehicleApplyDO::getId); + } + + /** + * 查询并校验用车申请存在 + * + * @param id 申请编号 + * @return 用车申请 + */ + OaVehicleApplyDO validateVehicleApplyExists(Long id); + + /** + * 获得引用车辆的单据数量 + * + * @param id 车辆编号 + * @return 单据数量 + */ + Long getVehicleApplyCountByVehicleId(Long id); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleApplyServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleApplyServiceImpl.java new file mode 100644 index 0000000000..5df189cd23 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleApplyServiceImpl.java @@ -0,0 +1,263 @@ +package cn.iocoder.yudao.module.oa.service.vehicle; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +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.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.apply.OaVehicleApplyPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.apply.OaVehicleApplySaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.OaVehicleApplyDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.OaVehicleDO; +import cn.iocoder.yudao.module.oa.dal.mysql.vehicle.OaVehicleApplyMapper; +import cn.iocoder.yudao.module.oa.dal.redis.no.OaNoRedisDAO; +import cn.iocoder.yudao.module.oa.enums.vehicle.OaVehicleReturnStatusEnum; +import cn.iocoder.yudao.module.oa.enums.vehicle.OaVehicleStatusEnum; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.oa.enums.BpmModelConstants.VEHICLE_APPLY; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; + +/** + * 用车申请 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaVehicleApplyServiceImpl implements OaVehicleApplyService { + + @Resource + private OaNoRedisDAO noRedisDAO; + + @Resource + private OaVehicleApplyMapper vehicleApplyMapper; + + @Resource + private OaVehicleService vehicleService; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private BpmProcessInstanceApi processInstanceApi; + + @Override + public Long createVehicleApply(OaVehicleApplySaveReqVO createReqVO, Long userId) { + // 1.1 校验用车信息,草稿不占用预约时段 + OaVehicleDO vehicle = validateVehicleApply(createReqVO); + // 1.2 校验申请人有效 + AdminUserRespDTO user = adminUserApi.validateUser(userId); + // 1.3 生成业务单号,并校验唯一性 + String no = noRedisDAO.generate(OaNoRedisDAO.VEHICLE_APPLY_NO_PREFIX); + validateVehicleApplyNoUnique(no); + + // 2. 创建草稿 + OaVehicleApplyDO apply = BeanUtils.toBean(createReqVO, OaVehicleApplyDO.class).setNo(no).setId(null) + .setVehicleNo(vehicle.getNo()).setUserId(userId).setDeptId(user.getDeptId()) + .setStatus(BpmProcessInstanceStatusEnum.NOT_START.getStatus()).setReturnStatus(OaVehicleReturnStatusEnum.NOT_EFFECTIVE.getStatus()); + vehicleApplyMapper.insert(apply); + return apply.getId(); + } + + @Override + public void updateVehicleApply(OaVehicleApplySaveReqVO updateReqVO, Long userId) { + // 1.1 查询草稿并校验归属,草稿允许调整车辆 + OaVehicleApplyDO apply = validateVehicleApplyExists(updateReqVO.getId()); + validateVehicleApplyOwner(apply, userId); + // 1.2 只有未提交申请允许编辑 + validateVehicleApplyDraft(apply); + // 1.3 校验车辆和用车时间 + OaVehicleDO vehicle = validateVehicleApply(updateReqVO); + + // 2. 更新草稿 + vehicleApplyMapper.updateById(BeanUtils.toBean(updateReqVO, OaVehicleApplyDO.class) + .setVehicleNo(vehicle.getNo())); + } + + @Override + public void deleteVehicleApply(Long id, Long userId) { + // 1.1 校验申请归属 + OaVehicleApplyDO apply = validateVehicleApplyExists(id); + validateVehicleApplyOwner(apply, userId); + // 1.2 已提交记录保留审批历史,不允许删除 + validateVehicleApplyDraft(apply); + + // 2. 删除草稿 + vehicleApplyMapper.deleteById(id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String submitVehicleApply(Long id, Long userId) { + // 1.1 查询申请并校验归属 + OaVehicleApplyDO apply = validateVehicleApplyExists(id); + validateVehicleApplyOwner(apply, userId); + // 1.2 仅未提交申请可以发起流程 + validateVehicleApplyDraft(apply); + OaVehicleDO vehicle = vehicleService.validateVehicleExists(apply.getVehicleId()); + // 1.3 提交时校验车辆空闲状态 + if (ObjUtil.notEqual(vehicle.getStatus(), OaVehicleStatusEnum.IDLE.getStatus())) { + throw exception(VEHICLE_NOT_ENABLED); + } + // 1.4 检查同一车辆时段冲突,审批中也占用预约 + Long count = vehicleApplyMapper.selectCountByVehicleIdAndTimeOverlap(vehicle.getId(), id, + apply.getStartTime(), apply.getEndTime()); + if (count > 0) { + throw exception(VEHICLE_APPLY_TIME_CONFLICT); + } + + // 2. 保存提交时的车辆快照并占用预约时段 + vehicleApplyMapper.updateById(new OaVehicleApplyDO().setId(id).setVehicleNo(vehicle.getNo()) + .setStatus(BpmProcessInstanceStatusEnum.RUNNING.getStatus())); + + // 3.1 发起审批 + String processInstanceId = processInstanceApi.createProcessInstance(userId, + new BpmProcessInstanceCreateReqDTO().setProcessDefinitionKey(VEHICLE_APPLY) + .setBusinessKey(id.toString()).setVariables(new HashMap<>())); + // 3.2 绑定流程编号 + vehicleApplyMapper.updateById(new OaVehicleApplyDO().setId(id).setProcessInstanceId(processInstanceId)); + return processInstanceId; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void cancelVehicleApply(Long id, Long userId) { + // 1.1 校验本人申请 + OaVehicleApplyDO apply = validateVehicleApplyExists(id); + validateVehicleApplyOwner(apply, userId); + // 1.2 仅审批中允许取消 + if (ObjUtil.notEqual(apply.getStatus(), BpmProcessInstanceStatusEnum.RUNNING.getStatus())) { + throw exception(VEHICLE_APPLY_STATUS_INVALID); + } + + // 2. 取消流程,事件回写后释放预约 + processInstanceApi.cancelProcessInstanceByStartUser(userId, apply.getProcessInstanceId(), "申请人取消用车申请"); + } + + @Override + public void updateVehicleApplyReturnStatus(Long id, Integer expectedReturnStatus, Integer returnStatus) { + // 1. 校验原申请已通过且还车状态未发生变化 + OaVehicleApplyDO apply = validateVehicleApplyExists(id); + if (ObjUtil.notEqual(apply.getStatus(), BpmProcessInstanceStatusEnum.APPROVE.getStatus()) + || ObjUtil.notEqual(apply.getReturnStatus(), expectedReturnStatus)) { + throw exception(VEHICLE_RETURN_STATUS_INVALID); + } + + // 2. 按原状态回写,防止多个还车草稿同时提交成功 + int updateCount = vehicleApplyMapper.updateReturnStatusByIdAndReturnStatus(id, expectedReturnStatus, returnStatus); + if (updateCount == 0) { + throw exception(VEHICLE_RETURN_STATUS_INVALID); + } + } + + @Override + public void updateVehicleApplyStatus(Long id, Integer status) { + // 1. 校验申请存在,已处理的审批结果不再重复更新业务状态 + OaVehicleApplyDO apply = validateVehicleApplyExists(id); + if (ObjUtil.notEqual(apply.getStatus(), BpmProcessInstanceStatusEnum.RUNNING.getStatus())) { + return; + } + + // 2. 通过后等待还车;拒绝、取消后不再占用预约时段 + vehicleApplyMapper.updateById(new OaVehicleApplyDO().setId(id).setStatus(status) + .setReturnStatus(ObjUtil.equal(status, BpmProcessInstanceStatusEnum.APPROVE.getStatus()) + ? OaVehicleReturnStatusEnum.PENDING_RETURN.getStatus() : OaVehicleReturnStatusEnum.NOT_EFFECTIVE.getStatus())); + } + + @Override + public OaVehicleApplyDO getVehicleApply(Long id) { + return vehicleApplyMapper.selectById(id); + } + + @Override + public PageResult getVehicleApplyPage(Long userId, OaVehicleApplyPageReqVO pageReqVO) { + return vehicleApplyMapper.selectPage(userId, pageReqVO); + } + + @Override + public List getVehicleApplyList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return vehicleApplyMapper.selectByIds(ids); + } + + @Override + public OaVehicleApplyDO validateVehicleApplyExists(Long id) { + OaVehicleApplyDO apply = vehicleApplyMapper.selectById(id); + if (apply == null) { + throw exception(VEHICLE_APPLY_NOT_EXISTS); + } + return apply; + } + + /** + * 校验车辆和预约时间 + * + * @param reqVO 申请信息 + * @return 车辆 + */ + private OaVehicleDO validateVehicleApply(OaVehicleApplySaveReqVO reqVO) { + // 1. 校验车辆存在,空闲状态仅在提交时校验 + OaVehicleDO vehicle = vehicleService.validateVehicleExists(reqVO.getVehicleId()); + // 2. 用车时间采用左闭右开区间,结束必须晚于开始 + if (!reqVO.getStartTime().isBefore(reqVO.getEndTime())) { + throw exception(VEHICLE_APPLY_TIME_INVALID); + } + return vehicle; + } + + /** + * 校验申请存在且属于当前用户 + * + * @param apply 用车申请 + * @param userId 当前用户编号 + */ + private void validateVehicleApplyOwner(OaVehicleApplyDO apply, Long userId) { + if (ObjUtil.notEqual(apply.getUserId(), userId)) { + throw exception(VEHICLE_APPLY_NOT_OWNER); + } + } + + /** + * 校验申请处于草稿状态 + * + * @param apply 用车申请 + */ + private void validateVehicleApplyDraft(OaVehicleApplyDO apply) { + if (ObjUtil.notEqual(apply.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus())) { + throw exception(VEHICLE_APPLY_STATUS_INVALID); + } + } + + /** + * 校验申请单号唯一 + * + * @param no 申请单号 + */ + private void validateVehicleApplyNoUnique(String no) { + OaVehicleApplyDO apply = vehicleApplyMapper.selectByNo(no); + if (apply != null) { + throw exception(VEHICLE_APPLY_NO_DUPLICATE); + } + } + + @Override + public Long getVehicleApplyCountByVehicleId(Long id) { + return vehicleApplyMapper.selectCountByVehicleId(id); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleReturnService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleReturnService.java new file mode 100644 index 0000000000..5c375b3a69 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleReturnService.java @@ -0,0 +1,83 @@ +package cn.iocoder.yudao.module.oa.service.vehicle; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.returning.OaVehicleReturnPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.returning.OaVehicleReturnSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.OaVehicleReturnDO; +import jakarta.validation.Valid; + +/** + * 还车申请 Service 接口 + * + * @author 芋道源码 + */ +public interface OaVehicleReturnService { + + /** + * 创建本人还车申请草稿 + * + * @param createReqVO 创建信息 + * @param userId 当前用户编号 + * @return 申请编号 + */ + Long createVehicleReturn(@Valid OaVehicleReturnSaveReqVO createReqVO, Long userId); + + /** + * 更新本人还车申请草稿 + * + * @param updateReqVO 修改信息 + * @param userId 当前用户编号 + */ + void updateVehicleReturn(@Valid OaVehicleReturnSaveReqVO updateReqVO, Long userId); + + /** + * 删除本人还车申请草稿 + * + * @param id 申请编号 + * @param userId 当前用户编号 + */ + void deleteVehicleReturn(Long id, Long userId); + + /** + * 提交本人还车申请 + * + * @param id 申请编号 + * @param userId 当前用户编号 + * @return 流程实例编号 + */ + String submitVehicleReturn(Long id, Long userId); + + /** + * 取消本人审批中的还车申请 + * + * @param id 申请编号 + * @param userId 当前用户编号 + */ + void cancelVehicleReturn(Long id, Long userId); + + /** + * 回写还车审批结果并更新车辆及用车申请 + * + * @param id 还车申请编号 + * @param status 审批结果 + */ + void updateVehicleReturnStatus(Long id, Integer status); + + /** + * 获得还车申请详情 + * + * @param id 还车申请编号 + * @return 还车申请 + */ + OaVehicleReturnDO getVehicleReturn(Long id); + + /** + * 获得本人还车申请分页 + * + * @param userId 当前用户编号 + * @param pageReqVO 分页条件 + * @return 还车申请分页 + */ + PageResult getVehicleReturnPage(Long userId, OaVehicleReturnPageReqVO pageReqVO); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleReturnServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleReturnServiceImpl.java new file mode 100644 index 0000000000..1648f417df --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleReturnServiceImpl.java @@ -0,0 +1,254 @@ +package cn.iocoder.yudao.module.oa.service.vehicle; + +import cn.hutool.core.util.ObjUtil; +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.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.returning.OaVehicleReturnPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.returning.OaVehicleReturnSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.OaVehicleApplyDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.OaVehicleReturnDO; +import cn.iocoder.yudao.module.oa.dal.mysql.vehicle.OaVehicleReturnMapper; +import cn.iocoder.yudao.module.oa.dal.redis.no.OaNoRedisDAO; +import cn.iocoder.yudao.module.oa.enums.vehicle.OaVehicleReturnStatusEnum; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.HashMap; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.oa.enums.BpmModelConstants.VEHICLE_RETURN; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; + +/** + * 还车申请 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaVehicleReturnServiceImpl implements OaVehicleReturnService { + + @Resource + private OaNoRedisDAO noRedisDAO; + + @Resource + private OaVehicleReturnMapper vehicleReturnMapper; + + @Resource + private OaVehicleApplyService vehicleApplyService; + + @Resource + private BpmProcessInstanceApi processInstanceApi; + + @Override + public Long createVehicleReturn(OaVehicleReturnSaveReqVO createReqVO, Long userId) { + // 1.1 查询关联用车申请 + OaVehicleApplyDO apply = vehicleApplyService.validateVehicleApplyExists(createReqVO.getApplyId()); + // 1.2 校验申请归属及待还车状态 + validateVehicleApplyReturnable(apply, userId); + // 1.3 校验实际回车时间,同一用车申请允许保存多个草稿 + validateVehicleReturn(createReqVO); + // 1.4 生成业务单号,并校验唯一性 + String no = noRedisDAO.generate(OaNoRedisDAO.VEHICLE_RETURN_NO_PREFIX); + validateVehicleReturnNoUnique(no); + + // 2. 创建草稿 + OaVehicleReturnDO vehicleReturn = BeanUtils.toBean(createReqVO, OaVehicleReturnDO.class).setNo(no) + .setVehicleId(apply.getVehicleId()).setUserId(userId).setDeptId(apply.getDeptId()) + .setStatus(BpmProcessInstanceStatusEnum.NOT_START.getStatus()); + vehicleReturnMapper.insert(vehicleReturn); + return vehicleReturn.getId(); + } + + @Override + public void updateVehicleReturn(OaVehicleReturnSaveReqVO updateReqVO, Long userId) { + // 1.1 校验还车申请存在 + OaVehicleReturnDO vehicleReturn = validateVehicleReturnExists(updateReqVO.getId()); + // 1.2 校验本人归属 + validateVehicleReturnOwner(vehicleReturn, userId); + // 1.3 只能编辑草稿 + validateVehicleReturnDraft(vehicleReturn); + // 1.4 校验本次选择的用车申请存在 + OaVehicleApplyDO apply = vehicleApplyService.validateVehicleApplyExists(updateReqVO.getApplyId()); + // 1.5 校验申请归属及待还车状态 + validateVehicleApplyReturnable(apply, userId); + // 1.6 校验实际回车时间 + validateVehicleReturn(updateReqVO); + + // 2. 保存草稿 + vehicleReturnMapper.updateById(BeanUtils.toBean(updateReqVO, OaVehicleReturnDO.class) + .setVehicleId(apply.getVehicleId()).setUserId(userId).setDeptId(apply.getDeptId())); + } + + @Override + public void deleteVehicleReturn(Long id, Long userId) { + // 1.1 校验还车申请存在 + OaVehicleReturnDO vehicleReturn = validateVehicleReturnExists(id); + // 1.2 校验本人归属 + validateVehicleReturnOwner(vehicleReturn, userId); + // 1.3 只允许删除草稿,已提交记录保留审批历史 + validateVehicleReturnDraft(vehicleReturn); + + // 2. 删除草稿,原用车单仍然待还车 + vehicleReturnMapper.deleteById(id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String submitVehicleReturn(Long id, Long userId) { + // 1.1 校验还车申请存在 + OaVehicleReturnDO vehicleReturn = validateVehicleReturnExists(id); + // 1.2 校验本人归属 + validateVehicleReturnOwner(vehicleReturn, userId); + // 1.3 仅未提交申请允许发起 + validateVehicleReturnDraft(vehicleReturn); + // 1.4 校验原用车申请存在 + OaVehicleApplyDO apply = vehicleApplyService.validateVehicleApplyExists(vehicleReturn.getApplyId()); + // 1.5 校验原申请归属及待还车状态 + validateVehicleApplyReturnable(apply, userId); + // 1.6 重新校验实际回车时间 + OaVehicleReturnSaveReqVO reqVO = BeanUtils.toBean(vehicleReturn, OaVehicleReturnSaveReqVO.class); + validateVehicleReturn(reqVO); + + // 2.1 原用车申请切换为还车中,仍然占用车辆 + vehicleApplyService.updateVehicleApplyReturnStatus(apply.getId(), OaVehicleReturnStatusEnum.PENDING_RETURN.getStatus(), OaVehicleReturnStatusEnum.RETURNING.getStatus()); + // 2.2 更新还车申请为审批中 + vehicleReturnMapper.updateById(new OaVehicleReturnDO().setId(id).setStatus(BpmProcessInstanceStatusEnum.RUNNING.getStatus())); + + // 3.1 发起审批 + String processInstanceId = processInstanceApi.createProcessInstance(userId, + new BpmProcessInstanceCreateReqDTO().setProcessDefinitionKey(VEHICLE_RETURN) + .setBusinessKey(id.toString()).setVariables(new HashMap<>())); + // 3.2 绑定流程编号 + vehicleReturnMapper.updateById(new OaVehicleReturnDO().setId(id).setProcessInstanceId(processInstanceId)); + return processInstanceId; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void cancelVehicleReturn(Long id, Long userId) { + // 1.1 校验还车申请存在 + OaVehicleReturnDO vehicleReturn = validateVehicleReturnExists(id); + // 1.2 校验本人归属 + validateVehicleReturnOwner(vehicleReturn, userId); + // 1.3 仅允许取消审批中的还车申请 + if (ObjUtil.notEqual(vehicleReturn.getStatus(), BpmProcessInstanceStatusEnum.RUNNING.getStatus())) { + throw exception(VEHICLE_RETURN_STATUS_INVALID); + } + + // 2. 取消流程,结果事件将原用车单恢复为待还车 + processInstanceApi.cancelProcessInstanceByStartUser(userId, vehicleReturn.getProcessInstanceId(), + "申请人取消还车申请"); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateVehicleReturnStatus(Long id, Integer status) { + // 1. 校验申请存在,已处理的审批结果不再重复更新业务状态 + OaVehicleReturnDO vehicleReturn = validateVehicleReturnExists(id); + if (ObjUtil.notEqual(vehicleReturn.getStatus(), BpmProcessInstanceStatusEnum.RUNNING.getStatus())) { + return; + } + + // 2.1 还车通过后结束占用;拒绝或取消则恢复为待还车,允许再次发起 + vehicleApplyService.updateVehicleApplyReturnStatus(vehicleReturn.getApplyId(), OaVehicleReturnStatusEnum.RETURNING.getStatus(), + ObjUtil.equal(status, BpmProcessInstanceStatusEnum.APPROVE.getStatus()) ? OaVehicleReturnStatusEnum.RETURNED.getStatus() : OaVehicleReturnStatusEnum.PENDING_RETURN.getStatus()); + // 2.2 保存审批结果,与原用车单更新共同提交或回滚 + vehicleReturnMapper.updateById(new OaVehicleReturnDO().setId(id).setStatus(status)); + } + + @Override + public OaVehicleReturnDO getVehicleReturn(Long id) { + return vehicleReturnMapper.selectById(id); + } + + @Override + public PageResult getVehicleReturnPage(Long userId, OaVehicleReturnPageReqVO pageReqVO) { + return vehicleReturnMapper.selectPage(userId, pageReqVO); + } + + /** + * 校验还车申请单号唯一 + * + * @param no 还车申请单号 + */ + private void validateVehicleReturnNoUnique(String no) { + OaVehicleReturnDO vehicleReturn = vehicleReturnMapper.selectByNo(no); + if (vehicleReturn != null) { + throw exception(VEHICLE_RETURN_NO_DUPLICATE); + } + } + + /** + * 校验还车申请存在 + * + * @param id 还车申请编号 + * @return 还车申请 + */ + private OaVehicleReturnDO validateVehicleReturnExists(Long id) { + OaVehicleReturnDO vehicleReturn = vehicleReturnMapper.selectById(id); + if (vehicleReturn == null) { + throw exception(VEHICLE_RETURN_NOT_EXISTS); + } + return vehicleReturn; + } + + /** + * 校验还车申请属于当前用户 + * + * @param vehicleReturn 还车申请 + * @param userId 当前用户编号 + */ + private void validateVehicleReturnOwner(OaVehicleReturnDO vehicleReturn, Long userId) { + if (ObjUtil.notEqual(vehicleReturn.getUserId(), userId)) { + throw exception(VEHICLE_RETURN_NOT_OWNER); + } + } + + /** + * 校验还车申请为草稿 + * + * @param vehicleReturn 还车申请 + */ + private void validateVehicleReturnDraft(OaVehicleReturnDO vehicleReturn) { + if (ObjUtil.notEqual(vehicleReturn.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus())) { + throw exception(VEHICLE_RETURN_STATUS_INVALID); + } + } + + /** + * 校验原用车申请允许本人还车 + * + * @param apply 用车申请 + * @param userId 当前用户编号 + */ + private void validateVehicleApplyReturnable(OaVehicleApplyDO apply, Long userId) { + if (ObjUtil.notEqual(apply.getUserId(), userId)) { + throw exception(VEHICLE_RETURN_NOT_OWNER); + } + if (ObjUtil.notEqual(apply.getStatus(), BpmProcessInstanceStatusEnum.APPROVE.getStatus()) + || ObjUtil.notEqual(apply.getReturnStatus(), OaVehicleReturnStatusEnum.PENDING_RETURN.getStatus())) { + throw exception(VEHICLE_RETURN_STATUS_INVALID); + } + } + + /** + * 校验实际回车时间 + * + * @param reqVO 还车信息 + */ + private void validateVehicleReturn(OaVehicleReturnSaveReqVO reqVO) { + if (reqVO.getActualStartTime() == null + || reqVO.getActualReturnTime().isBefore(reqVO.getActualStartTime()) + || reqVO.getActualReturnTime().isAfter(LocalDateTime.now())) { + throw exception(VEHICLE_RETURN_TIME_INVALID); + } + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleService.java new file mode 100644 index 0000000000..302e647b28 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleService.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.oa.service.vehicle; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.OaVehicleDO; +import jakarta.validation.Valid; + +/** + * 车辆 Service 接口 + * + * @author 芋道源码 + */ +public interface OaVehicleService { + + /** + * 创建车辆 + * + * @param createReqVO 车辆信息 + * @return 编号 + */ + Long createVehicle(@Valid OaVehicleSaveReqVO createReqVO); + + /** + * 更新车辆 + * + * @param updateReqVO 车辆信息 + */ + void updateVehicle(@Valid OaVehicleSaveReqVO updateReqVO); + + /** + * 删除车辆 + * + * @param id 编号 + */ + void deleteVehicle(Long id); + + /** + * 获得车辆分页 + * + * @param pageReqVO 分页查询 + * @return 车辆分页 + */ + PageResult getVehiclePage(OaVehiclePageReqVO pageReqVO); + + /** + * 校验车辆存在 + * + * @param id 编号 + * @return 车辆 + */ + OaVehicleDO validateVehicleExists(Long id); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleServiceImpl.java new file mode 100644 index 0000000000..7ce5006b9f --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleServiceImpl.java @@ -0,0 +1,109 @@ +package cn.iocoder.yudao.module.oa.service.vehicle; + +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.OaVehicleDO; +import cn.iocoder.yudao.module.oa.dal.mysql.vehicle.OaVehicleMapper; +import jakarta.annotation.Resource; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import org.springframework.stereotype.Service; +import org.springframework.context.annotation.Lazy; +import org.springframework.validation.annotation.Validated; + +import java.util.Collections; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; + +/** + * 车辆 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaVehicleServiceImpl implements OaVehicleService { + + @Resource + private OaVehicleMapper vehicleMapper; + + @Resource + private DeptApi deptApi; + + @Resource + @Lazy // 延迟,避免循环依赖报错 + private OaVehicleApplyService vehicleApplyService; + + @Override + public Long createVehicle(OaVehicleSaveReqVO createReqVO) { + // 1.1 校验车牌号唯一性 + validateVehicleNoUnique(null, createReqVO.getNo()); + // 1.2 校验所属部门有效 + if (createReqVO.getDeptId() != null) { + deptApi.validateDeptList(Collections.singleton(createReqVO.getDeptId())); + } + + // 2. 保存车辆信息 + OaVehicleDO vehicle = BeanUtils.toBean(createReqVO, OaVehicleDO.class); + vehicleMapper.insert(vehicle); + return vehicle.getId(); + } + + @Override + public void updateVehicle(OaVehicleSaveReqVO updateReqVO) { + // 1.1 校验车辆存在 + validateVehicleExists(updateReqVO.getId()); + // 1.2 校验车牌号唯一性 + validateVehicleNoUnique(updateReqVO.getId(), updateReqVO.getNo()); + // 1.3 校验所属部门有效 + if (updateReqVO.getDeptId() != null) { + deptApi.validateDeptList(Collections.singleton(updateReqVO.getDeptId())); + } + + // 2. 保存基础信息和车辆状态 + vehicleMapper.updateById(BeanUtils.toBean(updateReqVO, OaVehicleDO.class)); + } + + @Override + public PageResult getVehiclePage(OaVehiclePageReqVO pageReqVO) { + return vehicleMapper.selectPage(pageReqVO); + } + + @Override + public void deleteVehicle(Long id) { + // 1.1 校验车辆存在 + validateVehicleExists(id); + // 1.2 校验车辆未被业务单据引用 + if (vehicleApplyService.getVehicleApplyCountByVehicleId(id) > 0) { + throw exception(VEHICLE_IN_USE); + } + + // 2. 删除车辆信息 + vehicleMapper.deleteById(id); + } + + @Override + public OaVehicleDO validateVehicleExists(Long id) { + OaVehicleDO vehicle = vehicleMapper.selectById(id); + if (vehicle == null) { + throw exception(VEHICLE_NOT_EXISTS); + } + return vehicle; + } + + /** + * 校验车辆编号唯一 + * + * @param id 当前编号,新增时为空 + * @param no 业务编号 + */ + private void validateVehicleNoUnique(Long id, String no) { + OaVehicleDO vehicle = vehicleMapper.selectByNo(no); + if (vehicle != null && ObjUtil.notEqual(vehicle.getId(), id)) { + throw exception(VEHICLE_NO_DUPLICATE); + } + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/listener/OaVehicleApplyStatusListener.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/listener/OaVehicleApplyStatusListener.java new file mode 100644 index 0000000000..e1c6335f26 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/listener/OaVehicleApplyStatusListener.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.oa.service.vehicle.listener; + +import cn.iocoder.yudao.module.oa.service.vehicle.OaVehicleApplyService; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; + +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener; + +/** + * 用车申请审批结果监听器 + * + * @author 芋道源码 + */ +@Component +public class OaVehicleApplyStatusListener extends BpmProcessInstanceStatusEventListener { + + @Resource + private OaVehicleApplyService vehicleApplyService; + + @Override + protected String getProcessDefinitionKey() { + return BpmModelConstants.VEHICLE_APPLY; + } + + @Override + protected void onEvent(BpmProcessInstanceStatusEvent event) { + vehicleApplyService.updateVehicleApplyStatus(Long.parseLong(event.getBusinessKey()), event.getStatus()); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/listener/OaVehicleReturnStatusListener.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/listener/OaVehicleReturnStatusListener.java new file mode 100644 index 0000000000..0de9be4462 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/vehicle/listener/OaVehicleReturnStatusListener.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.oa.service.vehicle.listener; + +import cn.iocoder.yudao.module.oa.service.vehicle.OaVehicleReturnService; + +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener; + +/** + * 还车申请审批结果监听器 + * + * @author 芋道源码 + */ +@Component +public class OaVehicleReturnStatusListener extends BpmProcessInstanceStatusEventListener { + + @Resource + private OaVehicleReturnService vehicleReturnService; + + @Override + protected String getProcessDefinitionKey() { + return BpmModelConstants.VEHICLE_RETURN; + } + + @Override + protected void onEvent(BpmProcessInstanceStatusEvent event) { + vehicleReturnService.updateVehicleReturnStatus(Long.parseLong(event.getBusinessKey()), event.getStatus()); + } + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/workreport/OaWorkReportService.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/workreport/OaWorkReportService.java new file mode 100644 index 0000000000..742096db69 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/workreport/OaWorkReportService.java @@ -0,0 +1,86 @@ +package cn.iocoder.yudao.module.oa.service.workreport; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.oa.controller.admin.workreport.vo.OaWorkReportPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.workreport.vo.OaWorkReportSaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.workreport.vo.OaWorkReportStatisticsReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.workreport.vo.OaWorkReportStatisticsRespVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.workreport.OaWorkReportDO; +import jakarta.validation.Valid; + +/** + * OA 工作汇报 Service 接口 + * + * @author 芋道源码 + */ +public interface OaWorkReportService { + + /** + * 创建工作汇报草稿 + * + * @param createReqVO 创建信息 + * @param userId 用户编号 + * @return 汇报编号 + */ + Long createWorkReport(@Valid OaWorkReportSaveReqVO createReqVO, Long userId); + + /** + * 更新工作汇报草稿 + * + * @param updateReqVO 更新信息 + * @param userId 用户编号 + */ + void updateWorkReport(@Valid OaWorkReportSaveReqVO updateReqVO, Long userId); + + /** + * 删除工作汇报草稿 + * + * @param id 汇报编号 + * @param userId 用户编号 + */ + void deleteWorkReport(Long id, Long userId); + + /** + * 提交工作汇报 + * + * @param id 汇报编号 + * @param userId 用户编号 + */ + void submitWorkReport(Long id, Long userId); + + /** + * 取消提交工作汇报 + * + * @param id 汇报编号 + * @param userId 用户编号 + */ + void cancelWorkReport(Long id, Long userId); + + /** + * 获得工作汇报 + * + * @param id 汇报编号 + * @param userId 当前用户编号 + * @return 工作汇报 + */ + OaWorkReportDO getWorkReport(Long id, Long userId); + + /** + * 获得我的工作汇报分页 + * + * @param pageReqVO 分页条件 + * @param userId 用户编号 + * @return 工作汇报分页 + */ + PageResult getWorkReportPage(OaWorkReportPageReqVO pageReqVO, Long userId); + + /** + * 获得工作汇报统计 + * + * @param reqVO 统计条件 + * @param userId 当前用户编号 + * @return 工作汇报统计 + */ + OaWorkReportStatisticsRespVO getWorkReportStatistics(@Valid OaWorkReportStatisticsReqVO reqVO, Long userId); + +} diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/workreport/OaWorkReportServiceImpl.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/workreport/OaWorkReportServiceImpl.java new file mode 100644 index 0000000000..99db721987 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/workreport/OaWorkReportServiceImpl.java @@ -0,0 +1,358 @@ +package cn.iocoder.yudao.module.oa.service.workreport; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +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.date.LocalDateTimeUtils; +import cn.iocoder.yudao.framework.common.util.number.NumberUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.oa.controller.admin.workreport.vo.OaWorkReportPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.workreport.vo.OaWorkReportSaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.workreport.vo.OaWorkReportStatisticsReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.workreport.vo.OaWorkReportStatisticsRespVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.workreport.OaWorkReportDO; +import cn.iocoder.yudao.module.oa.dal.mysql.workreport.OaWorkReportMapper; +import cn.iocoder.yudao.module.oa.dal.redis.no.OaNoRedisDAO; +import cn.iocoder.yudao.module.oa.enums.workreport.OaWorkReportStatusEnum; +import cn.iocoder.yudao.module.oa.enums.workreport.OaWorkReportTypeEnum; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.time.DayOfWeek; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.filterList; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.WORK_REPORT_ACCESS_DENIED; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.WORK_REPORT_NOT_EXISTS; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.WORK_REPORT_NO_DUPLICATE; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.WORK_REPORT_STATUS_INVALID; + +/** + * OA 工作汇报 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class OaWorkReportServiceImpl implements OaWorkReportService { + + /** + * 工作汇报统计权限 + */ + private static final String WORK_REPORT_STATISTICS_PERMISSION = "oa:work-report:statistics"; + + @Resource + private OaWorkReportMapper workReportMapper; + @Resource + private OaNoRedisDAO noRedisDAO; + + @Resource + private AdminUserApi adminUserApi; + @Resource + private DeptApi deptApi; + @Resource + private PermissionApi permissionApi; + + @Override + public Long createWorkReport(OaWorkReportSaveReqVO createReqVO, Long userId) { + // 1. 查询汇报人信息 + AdminUserRespDTO user = adminUserApi.validateUser(userId); + // 1.2 生成单号并校验唯一性 + String no = noRedisDAO.generate(OaNoRedisDAO.WORK_REPORT_NO_PREFIX); + if (workReportMapper.selectByNo(no) != null) { + throw exception(WORK_REPORT_NO_DUPLICATE); + } + + // 2. 创建工作汇报草稿 + OaWorkReportDO workReport = BeanUtils.toBean(createReqVO, OaWorkReportDO.class) + .setNo(no) + .setTitle(buildTitle(createReqVO.getTitle(), createReqVO.getType(), createReqVO.getStartTime())) + .setStatus(OaWorkReportStatusEnum.DRAFT.getStatus()) + .setDeptId(user != null ? user.getDeptId() : null); + workReportMapper.insert(workReport); + return workReport.getId(); + } + + @Override + public void updateWorkReport(OaWorkReportSaveReqVO updateReqVO, Long userId) { + // 1.1 校验工作汇报属于当前用户 + OaWorkReportDO workReport = validateWorkReportOwner(updateReqVO.getId(), userId); + // 1.2 校验工作汇报处于草稿状态 + validateWorkReportStatus(workReport, OaWorkReportStatusEnum.DRAFT); + + // 2. 更新工作汇报草稿 + workReportMapper.updateById(BeanUtils.toBean(updateReqVO, OaWorkReportDO.class) + .setTitle(buildTitle(updateReqVO.getTitle(), updateReqVO.getType(), updateReqVO.getStartTime()))); + } + + @Override + public void deleteWorkReport(Long id, Long userId) { + // 1.1 校验工作汇报属于当前用户 + OaWorkReportDO workReport = validateWorkReportOwner(id, userId); + // 1.2 校验工作汇报处于草稿状态 + validateWorkReportStatus(workReport, OaWorkReportStatusEnum.DRAFT); + + // 2. 删除工作汇报草稿 + workReportMapper.deleteById(id); + } + + @Override + public void submitWorkReport(Long id, Long userId) { + // 1.1 校验工作汇报属于当前用户 + OaWorkReportDO workReport = validateWorkReportOwner(id, userId); + // 1.2 校验工作汇报处于草稿状态 + validateWorkReportStatus(workReport, OaWorkReportStatusEnum.DRAFT); + + // 2. 提交工作汇报 + workReportMapper.updateById(new OaWorkReportDO().setId(id) + .setStatus(OaWorkReportStatusEnum.SUBMITTED.getStatus())); + } + + @Override + public void cancelWorkReport(Long id, Long userId) { + // 1.1 校验工作汇报属于当前用户 + OaWorkReportDO workReport = validateWorkReportOwner(id, userId); + // 1.2 校验工作汇报处于已提交状态 + validateWorkReportStatus(workReport, OaWorkReportStatusEnum.SUBMITTED); + + // 2. 取消提交工作汇报 + workReportMapper.updateById(new OaWorkReportDO().setId(id) + .setStatus(OaWorkReportStatusEnum.DRAFT.getStatus())); + } + + @Override + public OaWorkReportDO getWorkReport(Long id, Long userId) { + // 1. 校验工作汇报存在 + OaWorkReportDO workReport = validateWorkReportExists(id); + if (ObjUtil.equal(workReport.getCreator(), userId.toString())) { + return workReport; + } + + // 2. 管理范围只开放已提交的汇报,不公开员工草稿 + List subordinateUsers = adminUserApi.getUserListBySubordinate(userId); + Set subordinateUserIds = convertSet(subordinateUsers, AdminUserRespDTO::getId); + if (!permissionApi.hasAnyPermissions(userId, WORK_REPORT_STATISTICS_PERMISSION) + || ObjUtil.notEqual(workReport.getStatus(), OaWorkReportStatusEnum.SUBMITTED.getStatus()) + || !subordinateUserIds.contains(NumberUtils.parseLong(workReport.getCreator()))) { + throw exception(WORK_REPORT_ACCESS_DENIED); + } + return workReport; + } + + @Override + public PageResult getWorkReportPage(OaWorkReportPageReqVO pageReqVO, Long userId) { + return workReportMapper.selectPage(pageReqVO, userId); + } + + @Override + public OaWorkReportStatisticsRespVO getWorkReportStatistics(OaWorkReportStatisticsReqVO reqVO, Long userId) { + // 1.1 按所选日期范围生成应填周期,包含范围内的未来日期 + List expectedPeriodKeys = buildPeriodKeys(reqVO.getType(), reqVO.getStartTime(), reqVO.getEndTime()); + // 1.2 查询管理范围内的员工 + List users = getStatisticsUsers(reqVO, userId); + + // 2. 按完整自然周期查询,避免统计从周中或月中开始时漏掉汇报 + List workReports = CollUtil.isEmpty(users) + ? Collections.emptyList() + : workReportMapper.selectListByCreatorsAndTypeAndStatusAndStartTimeBetween( + convertList(users, user -> user.getId().toString()), reqVO.getType(), + OaWorkReportStatusEnum.SUBMITTED.getStatus(), + reqVO.getQueryStartTime(), reqVO.getQueryEndTime()); + + // 3. 汇总每位员工的已填、未填及汇报明细 + Map> userWorkReportMap = convertMultiMap(workReports, + report -> NumberUtils.parseLong(report.getCreator())); + Map deptMap = deptApi.getDeptMap(convertSet(users, AdminUserRespDTO::getDeptId)); + List userStatisticsList = convertList(users, user -> + buildUserStatistics(user, deptMap.get(user.getDeptId()), userWorkReportMap.getOrDefault(user.getId(), Collections.emptyList()), expectedPeriodKeys)); + + // 4. 汇总整体统计 + int expectedCount = userStatisticsList.stream().mapToInt(OaWorkReportStatisticsRespVO.UserStatistics::getExpectedCount).sum(); + int submittedCount = userStatisticsList.stream().mapToInt(OaWorkReportStatisticsRespVO.UserStatistics::getSubmittedCount).sum(); + return new OaWorkReportStatisticsRespVO() + .setUserCount(users.size()).setExpectedCount(expectedCount) + .setSubmittedCount(submittedCount).setMissingCount(Math.max(0, expectedCount - submittedCount)) + .setUsers(userStatisticsList); + } + + /** + * 获得汇报统计范围内的员工 + * + * @param reqVO 统计条件 + * @param userId 当前用户编号 + * @return 管理范围内符合部门和启用状态条件的员工列表 + */ + private List getStatisticsUsers(OaWorkReportStatisticsReqVO reqVO, Long userId) { + // 1. 查询所选部门及子部门范围,未选部门时不限制部门 + Set deptIds = null; + if (reqVO.getDeptId() != null) { + List childDepts = deptApi.getChildDeptList(reqVO.getDeptId()); + deptIds = convertSet(childDepts, DeptRespDTO::getId); + deptIds.add(reqVO.getDeptId()); + } + + // 2.1 查询当前用户管理范围内的员工 + List subordinateUsers = adminUserApi.getUserListBySubordinate(userId); + // 2.2 保留启用且属于所选部门的员工,按用户编号稳定排序 + Set finalDeptIds = deptIds; + List users = filterList(subordinateUsers, user -> + CommonStatusEnum.ENABLE.getStatus().equals(user.getStatus()) + && (finalDeptIds == null || finalDeptIds.contains(user.getDeptId()))); + users.sort(Comparator.comparing(AdminUserRespDTO::getId)); + return users; + } + + /** + * 创建员工汇报统计 + * + * @param user 员工信息 + * @param dept 部门信息 + * @param workReports 员工已提交的汇报列表 + * @param expectedPeriodKeys 应填周期列表 + * @return 员工汇报统计及明细 + */ + private OaWorkReportStatisticsRespVO.UserStatistics buildUserStatistics(AdminUserRespDTO user, DeptRespDTO dept, + List workReports, + List expectedPeriodKeys) { + // 1. 按周期去重统计已填数量,并计算未填周期 + Set submittedPeriodKeys = convertSet(workReports, report -> OaWorkReportTypeEnum.valueOf(report.getType()) + .formatPeriod(report.getStartTime())); + List missingPeriodKeys = filterList(expectedPeriodKeys, periodKey -> !submittedPeriodKeys.contains(periodKey)); + int submittedCount = expectedPeriodKeys.size() - missingPeriodKeys.size(); + + // 2. 转换已提交汇报明细,同一周期的多份汇报均保留展示 + List submittedReports = convertList(workReports, workReport -> + new OaWorkReportStatisticsRespVO.Report().setId(workReport.getId()).setNo(workReport.getNo()) + .setTitle(workReport.getTitle()).setStatus(workReport.getStatus()) + .setStartTime(workReport.getStartTime()).setCreateTime(workReport.getCreateTime()) + .setPeriodKey(OaWorkReportTypeEnum.valueOf(workReport.getType()).formatPeriod(workReport.getStartTime()))); + + // 3. 拼接员工、部门及统计数量 + return new OaWorkReportStatisticsRespVO.UserStatistics() + .setUserId(user.getId()).setUserName(user.getNickname()) + .setDeptId(user.getDeptId()).setDeptName(dept != null ? dept.getName() : null) + .setExpectedCount(expectedPeriodKeys.size()).setSubmittedCount(submittedCount) + .setMissingCount(Math.max(0, expectedPeriodKeys.size() - submittedCount)) + .setSubmittedReports(submittedReports).setMissingPeriodKeys(missingPeriodKeys); + } + + /** + * 生成统计范围内的周期标识 + * + * @param type 汇报类型 + * @param startTime 统计开始时间 + * @param endTime 统计结束时间 + * @return 去重后的应填周期列表,日报不包含周末 + */ + private List buildPeriodKeys(Integer type, LocalDateTime startTime, LocalDateTime endTime) { + // 1. 按自然日统一到零点,避免时分秒不同导致末日漏计 + LocalDateTime dayStartTime = LocalDateTimeUtils.getDayBeginTime(startTime); + LocalDateTime dayEndTime = LocalDateTimeUtils.getDayBeginTime(endTime); + if (dayStartTime.isAfter(dayEndTime)) { + return Collections.emptyList(); + } + + // 2. 遍历自然日,日报排除周末,周报、月报按所属周期去重 + Set periodKeys = new LinkedHashSet<>(); + for (LocalDateTime dateTime = dayStartTime; !dateTime.isAfter(dayEndTime); dateTime = dateTime.plusDays(1)) { + if (OaWorkReportTypeEnum.DAILY.getType().equals(type)) { + if (dateTime.getDayOfWeek() != DayOfWeek.SATURDAY && dateTime.getDayOfWeek() != DayOfWeek.SUNDAY) { + periodKeys.add(dateTime.toLocalDate().toString()); + } + } else if (OaWorkReportTypeEnum.WEEKLY.getType().equals(type)) { + periodKeys.add(buildWeekPeriodKey(dateTime)); + } else { + periodKeys.add(YearMonth.from(dateTime).toString()); + } + } + return new ArrayList<>(periodKeys); + } + + /** + * 生成工作汇报的自然年周次 + * + * @param dateTime 汇报时间 + * @return 自然年周次 + */ + private String buildWeekPeriodKey(LocalDateTime dateTime) { + return OaWorkReportTypeEnum.WEEKLY.formatPeriod(dateTime); + } + + /** + * 生成默认汇报标题 + * + * @param title 自定义标题 + * @param type 汇报类型 + * @param startTime 汇报开始时间 + * @return 自定义标题,未填写时返回所属周期及汇报类型 + */ + private String buildTitle(String title, Integer type, LocalDateTime startTime) { + if (StrUtil.isNotBlank(title)) { + return title; + } + OaWorkReportTypeEnum typeEnum = OaWorkReportTypeEnum.valueOf(type); + return typeEnum.formatPeriod(startTime) + " 工作" + typeEnum.getName(); + } + + /** + * 校验工作汇报属于指定用户 + * + * @param id 汇报编号 + * @param userId 用户编号 + * @return 工作汇报 + */ + private OaWorkReportDO validateWorkReportOwner(Long id, Long userId) { + OaWorkReportDO workReport = validateWorkReportExists(id); + if (ObjUtil.notEqual(workReport.getCreator(), userId.toString())) { + throw exception(WORK_REPORT_ACCESS_DENIED); + } + return workReport; + } + + /** + * 校验工作汇报存在 + * + * @param id 汇报编号 + * @return 工作汇报 + */ + private OaWorkReportDO validateWorkReportExists(Long id) { + OaWorkReportDO workReport = workReportMapper.selectById(id); + if (workReport == null) { + throw exception(WORK_REPORT_NOT_EXISTS); + } + return workReport; + } + + /** + * 校验工作汇报状态 + * + * @param workReport 工作汇报 + * @param status 预期状态 + */ + private void validateWorkReportStatus(OaWorkReportDO workReport, OaWorkReportStatusEnum status) { + if (ObjUtil.notEqual(workReport.getStatus(), status.getStatus())) { + throw exception(WORK_REPORT_STATUS_INVALID); + } + } + +} diff --git a/yudao-module-oa/src/main/resources/bpm/oa_leave_apply.json b/yudao-module-oa/src/main/resources/bpm/oa_leave_apply.json new file mode 100644 index 0000000000..875103c033 --- /dev/null +++ b/yudao-module-oa/src/main/resources/bpm/oa_leave_apply.json @@ -0,0 +1,49 @@ +{ + "key": "oa_leave_apply", + "name": "请假申请", + "description": "本人发起,由发起人部门负责人审批,使用 BPM 内置候选人策略。", + "type": 20, + "formType": 20, + "formCustomCreatePath": "/oa/process/leave", + "formCustomViewPath": "/oa/leave/detail/index.vue", + "visible": false, + "managerUserIds": [], + "startUserIds": [], + "startDeptIds": [], + "allowCancelRunningProcess": true, + "allowWithdrawTask": false, + "autoApprovalType": 0, + "simpleModel": { + "id": "StartUserNode", + "type": 10, + "name": "申请人", + "childNode": { + "id": "SuperiorApprove", + "type": 11, + "name": "上级审批", + "showText": "发起人部门负责人", + "candidateStrategy": 37, + "candidateParam": "1", + "approveType": 1, + "approveMethod": 1, + "signEnable": false, + "reasonRequire": true, + "rejectHandler": { + "type": 1 + }, + "assignStartUserHandlerType": 1, + "assignEmptyHandler": { + "type": 2 + }, + "timeoutHandler": { + "enable": false + }, + "childNode": { + "id": "EndEvent", + "type": 1, + "name": "结束" + } + } + }, + "category": "OA" +} diff --git a/yudao-module-oa/src/main/resources/bpm/oa_meeting_room_booking.json b/yudao-module-oa/src/main/resources/bpm/oa_meeting_room_booking.json new file mode 100644 index 0000000000..10b3c74330 --- /dev/null +++ b/yudao-module-oa/src/main/resources/bpm/oa_meeting_room_booking.json @@ -0,0 +1,49 @@ +{ + "key": "oa_meeting_room_booking", + "name": "会议室预定", + "description": "发起人提交,由发起人部门负责人审批", + "type": 20, + "formType": 20, + "formCustomCreatePath": "/oa/meetingroom/booking", + "formCustomViewPath": "/oa/meetingroom/booking/detail/index.vue", + "visible": false, + "managerUserIds": [], + "startUserIds": [], + "startDeptIds": [], + "allowCancelRunningProcess": true, + "allowWithdrawTask": false, + "autoApprovalType": 0, + "simpleModel": { + "id": "StartUserNode", + "type": 10, + "name": "申请人", + "childNode": { + "id": "SuperiorApprove", + "type": 11, + "name": "上级审批", + "showText": "发起人部门负责人", + "candidateStrategy": 37, + "candidateParam": "1", + "approveType": 1, + "approveMethod": 1, + "signEnable": false, + "reasonRequire": true, + "rejectHandler": { + "type": 1 + }, + "assignStartUserHandlerType": 1, + "assignEmptyHandler": { + "type": 2 + }, + "timeoutHandler": { + "enable": false + }, + "childNode": { + "id": "EndEvent", + "type": 1, + "name": "结束" + } + } + }, + "category": "OA" +} diff --git a/yudao-module-oa/src/main/resources/bpm/oa_official_doc_receive.json b/yudao-module-oa/src/main/resources/bpm/oa_official_doc_receive.json new file mode 100644 index 0000000000..51deeb0dc9 --- /dev/null +++ b/yudao-module-oa/src/main/resources/bpm/oa_official_doc_receive.json @@ -0,0 +1,49 @@ +{ + "key": "oa_official_doc_receive", + "name": "公文收文", + "category": "OA", + "description": "申请人提交后,由部门层级中最近的有效上级负责人审批。申请人本人不可自批;未配置有效上级时阻止提交。", + "type": 20, + "formType": 20, + "formCustomCreatePath": "/oa/officialdoc/receive", + "formCustomViewPath": "/oa/officialdoc/receive/detail/index.vue", + "visible": false, + "managerUserIds": [], + "startUserIds": [], + "startDeptIds": [], + "allowCancelRunningProcess": true, + "allowWithdrawTask": false, + "autoApprovalType": 0, + "simpleModel": { + "id": "StartUserNode", + "type": 10, + "name": "申请人", + "childNode": { + "id": "SuperiorApprove", + "type": 11, + "name": "上级审批", + "showText": "部门层级中最近的非本人有效负责人", + "candidateStrategy": 37, + "candidateParam": "1", + "approveType": 1, + "approveMethod": 1, + "signEnable": false, + "reasonRequire": true, + "rejectHandler": { + "type": 1 + }, + "assignStartUserHandlerType": 1, + "assignEmptyHandler": { + "type": 2 + }, + "timeoutHandler": { + "enable": false + }, + "childNode": { + "id": "EndEvent", + "type": 1, + "name": "结束" + } + } + } +} diff --git a/yudao-module-oa/src/main/resources/bpm/oa_official_doc_send.json b/yudao-module-oa/src/main/resources/bpm/oa_official_doc_send.json new file mode 100644 index 0000000000..75a1e8f761 --- /dev/null +++ b/yudao-module-oa/src/main/resources/bpm/oa_official_doc_send.json @@ -0,0 +1,49 @@ +{ + "key": "oa_official_doc_send", + "name": "公文发文", + "category": "OA", + "description": "申请人提交后,由部门层级中最近的有效上级负责人审批。申请人本人不可自批;未配置有效上级时阻止提交。", + "type": 20, + "formType": 20, + "formCustomCreatePath": "/oa/officialdoc/send", + "formCustomViewPath": "/oa/officialdoc/send/detail/index.vue", + "visible": false, + "managerUserIds": [], + "startUserIds": [], + "startDeptIds": [], + "allowCancelRunningProcess": true, + "allowWithdrawTask": false, + "autoApprovalType": 0, + "simpleModel": { + "id": "StartUserNode", + "type": 10, + "name": "申请人", + "childNode": { + "id": "SuperiorApprove", + "type": 11, + "name": "上级审批", + "showText": "部门层级中最近的非本人有效负责人", + "candidateStrategy": 37, + "candidateParam": "1", + "approveType": 1, + "approveMethod": 1, + "signEnable": false, + "reasonRequire": true, + "rejectHandler": { + "type": 1 + }, + "assignStartUserHandlerType": 1, + "assignEmptyHandler": { + "type": 2 + }, + "timeoutHandler": { + "enable": false + }, + "childNode": { + "id": "EndEvent", + "type": 1, + "name": "结束" + } + } + } +} diff --git a/yudao-module-oa/src/main/resources/bpm/oa_overtime_apply.json b/yudao-module-oa/src/main/resources/bpm/oa_overtime_apply.json new file mode 100644 index 0000000000..1974fd9c39 --- /dev/null +++ b/yudao-module-oa/src/main/resources/bpm/oa_overtime_apply.json @@ -0,0 +1,49 @@ +{ + "key": "oa_overtime_apply", + "name": "加班申请", + "description": "本人发起,由发起人部门负责人审批,使用 BPM 内置候选人策略。", + "type": 20, + "formType": 20, + "formCustomCreatePath": "/oa/process/overtime", + "formCustomViewPath": "/oa/overtime/detail/index.vue", + "visible": false, + "managerUserIds": [], + "startUserIds": [], + "startDeptIds": [], + "allowCancelRunningProcess": true, + "allowWithdrawTask": false, + "autoApprovalType": 0, + "simpleModel": { + "id": "StartUserNode", + "type": 10, + "name": "申请人", + "childNode": { + "id": "SuperiorApprove", + "type": 11, + "name": "上级审批", + "showText": "发起人部门负责人", + "candidateStrategy": 37, + "candidateParam": "1", + "approveType": 1, + "approveMethod": 1, + "signEnable": false, + "reasonRequire": true, + "rejectHandler": { + "type": 1 + }, + "assignStartUserHandlerType": 1, + "assignEmptyHandler": { + "type": 2 + }, + "timeoutHandler": { + "enable": false + }, + "childNode": { + "id": "EndEvent", + "type": 1, + "name": "结束" + } + } + }, + "category": "OA" +} diff --git a/yudao-module-oa/src/main/resources/bpm/oa_regular_apply.json b/yudao-module-oa/src/main/resources/bpm/oa_regular_apply.json new file mode 100644 index 0000000000..555a344e7f --- /dev/null +++ b/yudao-module-oa/src/main/resources/bpm/oa_regular_apply.json @@ -0,0 +1,49 @@ +{ + "key": "oa_regular_apply", + "name": "转正申请", + "description": "本人发起,由发起人部门负责人审批,使用 BPM 内置候选人策略。", + "type": 20, + "formType": 20, + "formCustomCreatePath": "/oa/process/regular", + "formCustomViewPath": "/oa/regular/detail/index.vue", + "visible": false, + "managerUserIds": [], + "startUserIds": [], + "startDeptIds": [], + "allowCancelRunningProcess": true, + "allowWithdrawTask": false, + "autoApprovalType": 0, + "simpleModel": { + "id": "StartUserNode", + "type": 10, + "name": "申请人", + "childNode": { + "id": "SuperiorApprove", + "type": 11, + "name": "上级审批", + "showText": "发起人部门负责人", + "candidateStrategy": 37, + "candidateParam": "1", + "approveType": 1, + "approveMethod": 1, + "signEnable": false, + "reasonRequire": true, + "rejectHandler": { + "type": 1 + }, + "assignStartUserHandlerType": 1, + "assignEmptyHandler": { + "type": 2 + }, + "timeoutHandler": { + "enable": false + }, + "childNode": { + "id": "EndEvent", + "type": 1, + "name": "结束" + } + } + }, + "category": "OA" +} diff --git a/yudao-module-oa/src/main/resources/bpm/oa_reimbursement.json b/yudao-module-oa/src/main/resources/bpm/oa_reimbursement.json new file mode 100644 index 0000000000..66e8b059bb --- /dev/null +++ b/yudao-module-oa/src/main/resources/bpm/oa_reimbursement.json @@ -0,0 +1,49 @@ +{ + "key": "oa_reimbursement", + "name": "费用报销", + "description": "本人发起,由发起人部门负责人审批,使用 BPM 内置候选人策略。", + "type": 20, + "formType": 20, + "formCustomCreatePath": "/oa/process/reimbursement", + "formCustomViewPath": "/oa/reimbursement/detail/index.vue", + "visible": false, + "managerUserIds": [], + "startUserIds": [], + "startDeptIds": [], + "allowCancelRunningProcess": true, + "allowWithdrawTask": false, + "autoApprovalType": 0, + "simpleModel": { + "id": "StartUserNode", + "type": 10, + "name": "申请人", + "childNode": { + "id": "SuperiorApprove", + "type": 11, + "name": "上级审批", + "showText": "发起人部门负责人", + "candidateStrategy": 37, + "candidateParam": "1", + "approveType": 1, + "approveMethod": 1, + "signEnable": false, + "reasonRequire": true, + "rejectHandler": { + "type": 1 + }, + "assignStartUserHandlerType": 1, + "assignEmptyHandler": { + "type": 2 + }, + "timeoutHandler": { + "enable": false + }, + "childNode": { + "id": "EndEvent", + "type": 1, + "name": "结束" + } + } + }, + "category": "OA" +} diff --git a/yudao-module-oa/src/main/resources/bpm/oa_resign_apply.json b/yudao-module-oa/src/main/resources/bpm/oa_resign_apply.json new file mode 100644 index 0000000000..a108e3b9b1 --- /dev/null +++ b/yudao-module-oa/src/main/resources/bpm/oa_resign_apply.json @@ -0,0 +1,49 @@ +{ + "key": "oa_resign_apply", + "name": "离职申请", + "description": "本人发起,由发起人部门负责人审批,使用 BPM 内置候选人策略。", + "type": 20, + "formType": 20, + "formCustomCreatePath": "/oa/process/resign", + "formCustomViewPath": "/oa/resign/detail/index.vue", + "visible": false, + "managerUserIds": [], + "startUserIds": [], + "startDeptIds": [], + "allowCancelRunningProcess": true, + "allowWithdrawTask": false, + "autoApprovalType": 0, + "simpleModel": { + "id": "StartUserNode", + "type": 10, + "name": "申请人", + "childNode": { + "id": "SuperiorApprove", + "type": 11, + "name": "上级审批", + "showText": "发起人部门负责人", + "candidateStrategy": 37, + "candidateParam": "1", + "approveType": 1, + "approveMethod": 1, + "signEnable": false, + "reasonRequire": true, + "rejectHandler": { + "type": 1 + }, + "assignStartUserHandlerType": 1, + "assignEmptyHandler": { + "type": 2 + }, + "timeoutHandler": { + "enable": false + }, + "childNode": { + "id": "EndEvent", + "type": 1, + "name": "结束" + } + } + }, + "category": "OA" +} diff --git a/yudao-module-oa/src/main/resources/bpm/oa_seal_apply.json b/yudao-module-oa/src/main/resources/bpm/oa_seal_apply.json new file mode 100644 index 0000000000..87ab33248b --- /dev/null +++ b/yudao-module-oa/src/main/resources/bpm/oa_seal_apply.json @@ -0,0 +1,49 @@ +{ + "key": "oa_seal_apply", + "name": "用印申请", + "category": "OA", + "description": "本人发起,由发起人部门负责人审批,使用 BPM 内置候选人策略。", + "type": 20, + "formType": 20, + "formCustomCreatePath": "/oa/seal/apply", + "formCustomViewPath": "/oa/seal/apply/detail/index.vue", + "visible": false, + "managerUserIds": [], + "startUserIds": [], + "startDeptIds": [], + "allowCancelRunningProcess": true, + "allowWithdrawTask": false, + "autoApprovalType": 0, + "simpleModel": { + "id": "StartUserNode", + "type": 10, + "name": "申请人", + "childNode": { + "id": "SuperiorApprove", + "type": 11, + "name": "上级审批", + "showText": "发起人部门负责人", + "candidateStrategy": 37, + "candidateParam": "1", + "approveType": 1, + "approveMethod": 1, + "signEnable": false, + "reasonRequire": true, + "rejectHandler": { + "type": 1 + }, + "assignStartUserHandlerType": 1, + "assignEmptyHandler": { + "type": 2 + }, + "timeoutHandler": { + "enable": false + }, + "childNode": { + "id": "EndEvent", + "type": 1, + "name": "结束" + } + } + } +} diff --git a/yudao-module-oa/src/main/resources/bpm/oa_supply_apply.json b/yudao-module-oa/src/main/resources/bpm/oa_supply_apply.json new file mode 100644 index 0000000000..17838a05df --- /dev/null +++ b/yudao-module-oa/src/main/resources/bpm/oa_supply_apply.json @@ -0,0 +1,49 @@ +{ + "key": "oa_supply_apply", + "name": "用品领用申请", + "description": "本人发起,由发起人部门负责人审批,使用 BPM 内置候选人策略。", + "type": 20, + "formType": 20, + "formCustomCreatePath": "/oa/supply/apply", + "formCustomViewPath": "/oa/supply/apply/detail/index.vue", + "visible": false, + "managerUserIds": [], + "startUserIds": [], + "startDeptIds": [], + "allowCancelRunningProcess": true, + "allowWithdrawTask": false, + "autoApprovalType": 0, + "simpleModel": { + "id": "StartUserNode", + "type": 10, + "name": "申请人", + "childNode": { + "id": "SuperiorApprove", + "type": 11, + "name": "上级审批", + "showText": "发起人部门负责人", + "candidateStrategy": 37, + "candidateParam": "1", + "approveType": 1, + "approveMethod": 1, + "signEnable": false, + "reasonRequire": true, + "rejectHandler": { + "type": 1 + }, + "assignStartUserHandlerType": 1, + "assignEmptyHandler": { + "type": 2 + }, + "timeoutHandler": { + "enable": false + }, + "childNode": { + "id": "EndEvent", + "type": 1, + "name": "结束" + } + } + }, + "category": "OA" +} diff --git a/yudao-module-oa/src/main/resources/bpm/oa_travel_apply.json b/yudao-module-oa/src/main/resources/bpm/oa_travel_apply.json new file mode 100644 index 0000000000..04e150e65a --- /dev/null +++ b/yudao-module-oa/src/main/resources/bpm/oa_travel_apply.json @@ -0,0 +1,49 @@ +{ + "key": "oa_travel_apply", + "name": "出差申请", + "description": "本人发起,由发起人部门负责人审批,使用 BPM 内置候选人策略。", + "type": 20, + "formType": 20, + "formCustomCreatePath": "/oa/travel/apply", + "formCustomViewPath": "/oa/travel/apply/detail/index.vue", + "visible": false, + "managerUserIds": [], + "startUserIds": [], + "startDeptIds": [], + "allowCancelRunningProcess": true, + "allowWithdrawTask": false, + "autoApprovalType": 0, + "simpleModel": { + "id": "StartUserNode", + "type": 10, + "name": "申请人", + "childNode": { + "id": "SuperiorApprove", + "type": 11, + "name": "上级审批", + "showText": "发起人部门负责人", + "candidateStrategy": 37, + "candidateParam": "1", + "approveType": 1, + "approveMethod": 1, + "signEnable": false, + "reasonRequire": true, + "rejectHandler": { + "type": 1 + }, + "assignStartUserHandlerType": 1, + "assignEmptyHandler": { + "type": 2 + }, + "timeoutHandler": { + "enable": false + }, + "childNode": { + "id": "EndEvent", + "type": 1, + "name": "结束" + } + } + }, + "category": "OA" +} diff --git a/yudao-module-oa/src/main/resources/bpm/oa_travel_reimbursement.json b/yudao-module-oa/src/main/resources/bpm/oa_travel_reimbursement.json new file mode 100644 index 0000000000..e361021d0d --- /dev/null +++ b/yudao-module-oa/src/main/resources/bpm/oa_travel_reimbursement.json @@ -0,0 +1,49 @@ +{ + "key": "oa_travel_reimbursement", + "name": "出差报销", + "description": "本人发起,由发起人部门负责人审批,使用 BPM 内置候选人策略。", + "type": 20, + "formType": 20, + "formCustomCreatePath": "/oa/travel/reimbursement", + "formCustomViewPath": "/oa/travel/reimbursement/detail/index.vue", + "visible": false, + "managerUserIds": [], + "startUserIds": [], + "startDeptIds": [], + "allowCancelRunningProcess": true, + "allowWithdrawTask": false, + "autoApprovalType": 0, + "simpleModel": { + "id": "StartUserNode", + "type": 10, + "name": "申请人", + "childNode": { + "id": "SuperiorApprove", + "type": 11, + "name": "上级审批", + "showText": "发起人部门负责人", + "candidateStrategy": 37, + "candidateParam": "1", + "approveType": 1, + "approveMethod": 1, + "signEnable": false, + "reasonRequire": true, + "rejectHandler": { + "type": 1 + }, + "assignStartUserHandlerType": 1, + "assignEmptyHandler": { + "type": 2 + }, + "timeoutHandler": { + "enable": false + }, + "childNode": { + "id": "EndEvent", + "type": 1, + "name": "结束" + } + } + }, + "category": "OA" +} diff --git a/yudao-module-oa/src/main/resources/bpm/oa_vehicle_apply.json b/yudao-module-oa/src/main/resources/bpm/oa_vehicle_apply.json new file mode 100644 index 0000000000..bb875f7d46 --- /dev/null +++ b/yudao-module-oa/src/main/resources/bpm/oa_vehicle_apply.json @@ -0,0 +1,49 @@ +{ + "key": "oa_vehicle_apply", + "name": "用车申请", + "description": "本人发起,由发起人部门负责人审批,使用 BPM 内置候选人策略。", + "type": 20, + "formType": 20, + "formCustomCreatePath": "/oa/vehicle/apply", + "formCustomViewPath": "/oa/vehicle/apply/detail/index.vue", + "visible": false, + "managerUserIds": [], + "startUserIds": [], + "startDeptIds": [], + "allowCancelRunningProcess": true, + "allowWithdrawTask": false, + "autoApprovalType": 0, + "simpleModel": { + "id": "StartUserNode", + "type": 10, + "name": "申请人", + "childNode": { + "id": "SuperiorApprove", + "type": 11, + "name": "上级审批", + "showText": "发起人部门负责人", + "candidateStrategy": 37, + "candidateParam": "1", + "approveType": 1, + "approveMethod": 1, + "signEnable": false, + "reasonRequire": true, + "rejectHandler": { + "type": 1 + }, + "assignStartUserHandlerType": 1, + "assignEmptyHandler": { + "type": 2 + }, + "timeoutHandler": { + "enable": false + }, + "childNode": { + "id": "EndEvent", + "type": 1, + "name": "结束" + } + } + }, + "category": "OA" +} diff --git a/yudao-module-oa/src/main/resources/bpm/oa_vehicle_return.json b/yudao-module-oa/src/main/resources/bpm/oa_vehicle_return.json new file mode 100644 index 0000000000..9820513120 --- /dev/null +++ b/yudao-module-oa/src/main/resources/bpm/oa_vehicle_return.json @@ -0,0 +1,49 @@ +{ + "key": "oa_vehicle_return", + "name": "还车申请", + "description": "本人发起,由发起人部门负责人审批,使用 BPM 内置候选人策略。", + "type": 20, + "formType": 20, + "formCustomCreatePath": "/oa/vehicle/return", + "formCustomViewPath": "/oa/vehicle/return/detail/index.vue", + "visible": false, + "managerUserIds": [], + "startUserIds": [], + "startDeptIds": [], + "allowCancelRunningProcess": true, + "allowWithdrawTask": false, + "autoApprovalType": 0, + "simpleModel": { + "id": "StartUserNode", + "type": 10, + "name": "申请人", + "childNode": { + "id": "SuperiorApprove", + "type": 11, + "name": "上级审批", + "showText": "发起人部门负责人", + "candidateStrategy": 37, + "candidateParam": "1", + "approveType": 1, + "approveMethod": 1, + "signEnable": false, + "reasonRequire": true, + "rejectHandler": { + "type": 1 + }, + "assignStartUserHandlerType": 1, + "assignEmptyHandler": { + "type": 2 + }, + "timeoutHandler": { + "enable": false + }, + "childNode": { + "id": "EndEvent", + "type": 1, + "name": "结束" + } + } + }, + "category": "OA" +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/announcement/OaAnnouncementServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/announcement/OaAnnouncementServiceImplTest.java new file mode 100644 index 0000000000..ce7fc8d54b --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/announcement/OaAnnouncementServiceImplTest.java @@ -0,0 +1,533 @@ +package cn.iocoder.yudao.module.oa.service.announcement; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.controller.admin.announcement.vo.OaAnnouncementPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.announcement.vo.OaAnnouncementSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.announcement.OaAnnouncementDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.announcement.OaAnnouncementReceiverDO; +import cn.iocoder.yudao.module.oa.dal.mysql.announcement.OaAnnouncementMapper; +import cn.iocoder.yudao.module.oa.dal.mysql.announcement.OaAnnouncementReceiverMapper; +import cn.iocoder.yudao.module.oa.enums.announcement.OaAnnouncementTypeEnum; +import cn.iocoder.yudao.module.oa.enums.schedule.OaPriorityEnum; +import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.ANNOUNCEMENT_ACCESS_DENIED; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.ANNOUNCEMENT_ALREADY_FORWARDED; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.ANNOUNCEMENT_RECEIVER_NOT_EXISTS; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.ANNOUNCEMENT_UNREAD_DELETE_DENIED; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * {@link OaAnnouncementServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaAnnouncementServiceImpl.class) +public class OaAnnouncementServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaAnnouncementServiceImpl announcementService; + + @Resource + private OaAnnouncementMapper announcementMapper; + + @Resource + private OaAnnouncementReceiverMapper announcementReceiverMapper; + + @MockitoBean + private AdminUserApi adminUserApi; + @MockitoBean + private NotifyMessageSendApi notifyMessageSendApi; + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void testCreateAnnouncement_success() { + // 准备参数 + Long userId = randomLongId(); + Long firstReceiverUserId = randomLongId(); + Long secondReceiverUserId = randomLongId(); + OaAnnouncementSaveReqVO reqVO = buildAnnouncementSaveReqVO(); + when(adminUserApi.getUserListBySubordinate(userId)).thenReturn(Arrays.asList( + new AdminUserRespDTO().setId(firstReceiverUserId), + new AdminUserRespDTO().setId(secondReceiverUserId))); + + // 调用 + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(userId), new MockHttpServletRequest()); + Long announcementId = announcementService.createAnnouncement(reqVO, userId); + + // 断言公告和去重后的接收关系 + OaAnnouncementDO announcement = announcementMapper.selectById(announcementId); + assertEquals(userId.toString(), announcement.getCreator()); + assertEquals(userId.toString(), announcement.getUpdater()); + assertEquals(reqVO.getTitle(), announcement.getTitle()); + assertEquals(2, announcementReceiverMapper.selectListByAnnouncementId(announcementId).size()); + verify(adminUserApi).getUserListBySubordinate(userId); + verifyNoInteractions(notifyMessageSendApi); + } + + @Test + public void testUpdateAnnouncement_preservePublisherAndCreateTimeAndNullFields() { + // mock 数据 + Long userId = randomLongId(); + LocalDateTime createTime = LocalDateTime.now().minusDays(1); + OaAnnouncementDO announcement = buildAnnouncementDO(userId) + .setContent("原内容").setUrl("https://old.example.com"); + announcement.setCreateTime(createTime); + announcementMapper.insert(announcement); + Long existingReceiverUserId = randomLongId(); + announcementReceiverMapper.insert(buildReceiverDO(announcement.getId(), existingReceiverUserId, true)); + // 准备参数 + OaAnnouncementSaveReqVO reqVO = buildAnnouncementSaveReqVO().setId(announcement.getId()) + .setTitle("更新后的公告").setContent(null).setUrl(null); + + // 调用 + announcementService.updateAnnouncement(reqVO, userId); + + // 断言 + OaAnnouncementDO updatedAnnouncement = announcementMapper.selectById(announcement.getId()); + assertEquals(userId.toString(), updatedAnnouncement.getCreator()); + assertEquals(createTime, updatedAnnouncement.getCreateTime()); + assertEquals(reqVO.getTitle(), updatedAnnouncement.getTitle()); + // 可选字段传 null 时保留原值,不提供显式清空能力 + assertEquals(announcement.getContent(), updatedAnnouncement.getContent()); + assertEquals(announcement.getUrl(), updatedAnnouncement.getUrl()); + List receivers = announcementReceiverMapper + .selectListByAnnouncementId(announcement.getId()); + assertEquals(1, receivers.size()); + assertTrue(announcementReceiverMapper.selectByAnnouncementIdAndReceiverUserId( + announcement.getId(), existingReceiverUserId).getReadStatus()); + } + + @Test + public void testUpdateAnnouncement_notPublisher() { + // mock 数据 + OaAnnouncementDO announcement = buildAnnouncementDO(randomLongId()); + announcementMapper.insert(announcement); + // 准备参数 + OaAnnouncementSaveReqVO reqVO = buildAnnouncementSaveReqVO().setId(announcement.getId()); + + // 调用,并断言异常 + assertServiceException(() -> announcementService.updateAnnouncement(reqVO, randomLongId()), + ANNOUNCEMENT_ACCESS_DENIED); + } + + @Test + public void testDeletePublishedAnnouncement_success() { + // mock 数据 + Long userId = randomLongId(); + OaAnnouncementDO announcement = buildAnnouncementDO(userId); + announcementMapper.insert(announcement); + announcementReceiverMapper.insert(buildReceiverDO(announcement.getId(), randomLongId(), false)); + + // 调用 + announcementService.deleteAnnouncement(announcement.getId(), userId); + + // 断言 + assertNull(announcementMapper.selectById(announcement.getId())); + assertTrue(announcementReceiverMapper.selectListByAnnouncementId(announcement.getId()).isEmpty()); + } + + @Test + public void testDeleteReceivedAnnouncement_success() { + // mock 数据 + Long userId = randomLongId(); + OaAnnouncementDO announcement = buildAnnouncementDO(randomLongId()); + announcementMapper.insert(announcement); + announcementReceiverMapper.insert(buildReceiverDO(announcement.getId(), userId, true)); + announcementReceiverMapper.insert(buildReceiverDO(announcement.getId(), randomLongId(), false)); + + // 调用 + announcementService.deleteReceivedAnnouncement(announcement.getId(), userId); + + // 断言仅删除当前用户的接收关系 + assertNull(announcementReceiverMapper.selectByAnnouncementIdAndReceiverUserId( + announcement.getId(), userId)); + assertEquals(1, announcementReceiverMapper.selectListByAnnouncementId(announcement.getId()).size()); + assertEquals(announcement.getId(), announcementMapper.selectById(announcement.getId()).getId()); + } + + @Test + public void testDeleteReceivedAnnouncement_unread() { + // mock 数据 + Long userId = randomLongId(); + OaAnnouncementDO announcement = buildAnnouncementDO(randomLongId()); + announcementMapper.insert(announcement); + announcementReceiverMapper.insert(buildReceiverDO(announcement.getId(), userId, false)); + + // 调用,并断言未读公告不能移除 + assertServiceException(() -> announcementService.deleteReceivedAnnouncement(announcement.getId(), userId), + ANNOUNCEMENT_UNREAD_DELETE_DENIED); + assertEquals(1, announcementReceiverMapper.selectListByAnnouncementId(announcement.getId()).size()); + } + + @Test + public void testDeleteReceivedAnnouncement_notReceiver() { + // mock 数据 + OaAnnouncementDO announcement = buildAnnouncementDO(randomLongId()); + announcementMapper.insert(announcement); + Long receiverUserId = randomLongId(); + announcementReceiverMapper.insert(buildReceiverDO(announcement.getId(), receiverUserId, false)); + + // 调用,并断言无权移除其他人的接收关系 + assertServiceException(() -> announcementService.deleteReceivedAnnouncement( + announcement.getId(), randomLongId()), ANNOUNCEMENT_RECEIVER_NOT_EXISTS); + assertEquals(1, announcementReceiverMapper.selectListByAnnouncementId(announcement.getId()).size()); + } + + @Test + public void testUpdateAnnouncementReadStatus_notReceiver() { + // mock 数据 + OaAnnouncementDO announcement = buildAnnouncementDO(randomLongId()); + announcementMapper.insert(announcement); + Long receiverUserId = randomLongId(); + announcementReceiverMapper.insert(buildReceiverDO(announcement.getId(), receiverUserId, false)); + + // 调用,并断言无权修改其他人的阅读状态 + assertServiceException(() -> announcementService.updateAnnouncementReadStatus( + announcement.getId(), randomLongId()), ANNOUNCEMENT_RECEIVER_NOT_EXISTS); + assertEquals(false, announcementReceiverMapper.selectByAnnouncementIdAndReceiverUserId( + announcement.getId(), receiverUserId).getReadStatus()); + } + + @Test + public void testGetAnnouncement_accessDenied() { + // mock 数据 + OaAnnouncementDO announcement = buildAnnouncementDO(randomLongId()); + announcementMapper.insert(announcement); + + // 调用,并断言异常 + assertServiceException(() -> announcementService.getAnnouncement(announcement.getId(), randomLongId()), + ANNOUNCEMENT_ACCESS_DENIED); + } + + @Test + public void testUpdateAnnouncementReadStatus_success() { + // mock 数据 + Long userId = randomLongId(); + OaAnnouncementDO announcement = buildAnnouncementDO(randomLongId()); + announcementMapper.insert(announcement); + announcementReceiverMapper.insert(buildReceiverDO(announcement.getId(), userId, false)); + + // 调用 + announcementService.updateAnnouncementReadStatus(announcement.getId(), userId); + + // 断言 + assertTrue(announcementReceiverMapper.selectByAnnouncementIdAndReceiverUserId( + announcement.getId(), userId).getReadStatus()); + } + + @Test + public void testUpdateAnnouncementReadStatus_alreadyRead() { + // mock 数据 + Long userId = randomLongId(); + OaAnnouncementDO announcement = buildAnnouncementDO(randomLongId()); + announcementMapper.insert(announcement); + OaAnnouncementReceiverDO receiver = buildReceiverDO(announcement.getId(), userId, true); + receiver.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 0)); + announcementReceiverMapper.insert(receiver); + + // 调用 + announcementService.updateAnnouncementReadStatus(announcement.getId(), userId); + + // 断言 + OaAnnouncementReceiverDO updatedReceiver = announcementReceiverMapper.selectById(receiver.getId()); + assertTrue(updatedReceiver.getReadStatus()); + assertEquals(receiver.getUpdateTime(), updatedReceiver.getUpdateTime()); + } + + @Test + public void testGetAnnouncementReceiverListMap() { + // mock 数据 + announcementReceiverMapper.insert(buildReceiverDO(1L, 10L, false)); + announcementReceiverMapper.insert(buildReceiverDO(1L, 11L, true)); + announcementReceiverMapper.insert(buildReceiverDO(2L, 12L, false)); + + // 调用 + Map> receiverListMap = + announcementService.getAnnouncementReceiverListMap(Collections.singletonList(1L)); + + // 断言 + assertEquals(1, receiverListMap.size()); + assertEquals(2, receiverListMap.get(1L).size()); + } + + @Test + public void testGetAnnouncementReceiverListMap_empty() { + + // 调用 + Map> receiverListMap = + announcementService.getAnnouncementReceiverListMap(Collections.emptyList()); + + // 断言 + assertTrue(CollUtil.isEmpty(receiverListMap)); + } + + @Test + public void testForwardAnnouncement_alreadyForwarded() { + // mock 数据 + Long userId = randomLongId(); + Long existingSubordinateUserId = randomLongId(); + Long newSubordinateUserId = randomLongId(); + OaAnnouncementDO announcement = buildAnnouncementDO(randomLongId()); + announcementMapper.insert(announcement); + announcementReceiverMapper.insert(buildReceiverDO(announcement.getId(), userId, false)); + announcementReceiverMapper.insert(buildReceiverDO(announcement.getId(), existingSubordinateUserId, false)); + when(adminUserApi.getUserListBySubordinate(userId)).thenReturn(Arrays.asList( + new AdminUserRespDTO().setId(existingSubordinateUserId), + new AdminUserRespDTO().setId(newSubordinateUserId))); + + // 调用,并断言任一下属已接收时拒绝转发,也不补发给新增下属 + assertServiceException(() -> announcementService.forwardAnnouncement(announcement.getId(), userId), + ANNOUNCEMENT_ALREADY_FORWARDED); + assertEquals(2, announcementReceiverMapper.selectListByAnnouncementId(announcement.getId()).size()); + verifyNoInteractions(notifyMessageSendApi); + } + + @Test + public void testForwardAnnouncement_successThenRejectRepeat() { + // mock 数据 + Long userId = randomLongId(); + OaAnnouncementDO announcement = buildAnnouncementDO(randomLongId()); + announcementMapper.insert(announcement); + announcementReceiverMapper.insert(buildReceiverDO(announcement.getId(), userId, false)); + // mock 方法 + when(adminUserApi.getUserListBySubordinate(userId)).thenReturn(Arrays.asList( + new AdminUserRespDTO().setId(randomLongId()), + new AdminUserRespDTO().setId(randomLongId()))); + + // 调用 + assertEquals(2, announcementService.forwardAnnouncement(announcement.getId(), userId)); + + // 断言重复请求被拒绝,接收关系没有增加 + assertServiceException(() -> announcementService.forwardAnnouncement(announcement.getId(), userId), + ANNOUNCEMENT_ALREADY_FORWARDED); + assertEquals(3, announcementReceiverMapper.selectListByAnnouncementId(announcement.getId()).size()); + verifyNoInteractions(notifyMessageSendApi); + } + + @Test + public void testForwardAnnouncement_concurrentRequests() throws Exception { + // mock 数据 + Long userId = randomLongId(); + OaAnnouncementDO announcement = buildAnnouncementDO(randomLongId()); + announcementMapper.insert(announcement); + announcementReceiverMapper.insert(buildReceiverDO(announcement.getId(), userId, false)); + // mock 方法 + when(adminUserApi.getUserListBySubordinate(userId)).thenReturn(Collections.singletonList( + new AdminUserRespDTO().setId(randomLongId()))); + + // 调用:两个独立线程同时转发同一公告 + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch startLatch = new CountDownLatch(1); + try { + Callable forwardTask = () -> { + startLatch.await(); + try { + return announcementService.forwardAnnouncement(announcement.getId(), userId); + } catch (ServiceException ex) { + assertEquals(ANNOUNCEMENT_ALREADY_FORWARDED.getCode(), ex.getCode()); + return 0; + } + }; + Future firstResult = executor.submit(forwardTask); + Future secondResult = executor.submit(forwardTask); + startLatch.countDown(); + + // 断言仅一个请求成功,另一个请求返回已转发业务错误 + assertEquals(1, firstResult.get(10, TimeUnit.SECONDS) + secondResult.get(10, TimeUnit.SECONDS)); + assertEquals(2, announcementReceiverMapper.selectListByAnnouncementId(announcement.getId()).size()); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void testForwardAnnouncement_noSubordinates() { + // mock 数据 + Long userId = randomLongId(); + OaAnnouncementDO announcement = buildAnnouncementDO(randomLongId()); + announcementMapper.insert(announcement); + announcementReceiverMapper.insert(buildReceiverDO(announcement.getId(), userId, false)); + // mock 方法 + when(adminUserApi.getUserListBySubordinate(userId)).thenReturn(Collections.emptyList()); + + // 调用,并断言没有新增接收关系 + assertEquals(0, announcementService.forwardAnnouncement(announcement.getId(), userId)); + assertEquals(1, announcementReceiverMapper.selectListByAnnouncementId(announcement.getId()).size()); + verifyNoInteractions(notifyMessageSendApi); + } + + @Test + public void testForwardAnnouncement_notReceiver() { + // mock 数据 + OaAnnouncementDO announcement = buildAnnouncementDO(randomLongId()); + announcementMapper.insert(announcement); + + // 调用,并断言异常 + assertServiceException(() -> announcementService.forwardAnnouncement(announcement.getId(), randomLongId()), + ANNOUNCEMENT_RECEIVER_NOT_EXISTS); + } + + @Test + public void testGetReceivedAnnouncementPage_filterReadStatusAndDefaultOrder() { + // mock 数据 + Long userId = randomLongId(); + OaAnnouncementDO readAnnouncement = buildAnnouncementDO(randomLongId()).setTitle("已读公告") + .setPriority(OaPriorityEnum.URGENT.getPriority()).setTop(false); + readAnnouncement.setCreateTime(LocalDateTime.now().minusDays(2)); + announcementMapper.insert(readAnnouncement); + announcementReceiverMapper.insert(buildReceiverDO(readAnnouncement.getId(), userId, true)); + OaAnnouncementDO unreadAnnouncement = buildAnnouncementDO(randomLongId()).setTitle("未读置顶公告") + .setTop(true); + unreadAnnouncement.setCreateTime(LocalDateTime.now().minusDays(1)); + announcementMapper.insert(unreadAnnouncement); + announcementReceiverMapper.insert(buildReceiverDO(unreadAnnouncement.getId(), userId, false)); + + // 调用默认排序 + PageResult allResult = announcementService.getReceivedAnnouncementPage( + new OaAnnouncementPageReqVO(), userId); + + // 断言置顶公告优先 + assertEquals(2, allResult.getTotal()); + assertEquals(unreadAnnouncement.getId(), allResult.getList().get(0).getId()); + + // 调用未读筛选,并断言 + OaAnnouncementPageReqVO pageReqVO = new OaAnnouncementPageReqVO().setReadStatus(false); + PageResult unreadResult = announcementService + .getReceivedAnnouncementPage(pageReqVO, userId); + assertEquals(1, unreadResult.getTotal()); + assertEquals(unreadAnnouncement.getId(), unreadResult.getList().get(0).getId()); + + } + + @Test + public void testUpdateAnnouncement_preserveCreateTime() { + // mock 数据 + Long userId = randomLongId(); + LocalDateTime createTime = LocalDateTime.now().minusDays(3).withNano(0); + OaAnnouncementDO announcement = buildAnnouncementDO(userId); + announcement.setCreateTime(createTime); + announcementMapper.insert(announcement); + + // 准备参数 + OaAnnouncementSaveReqVO reqVO = buildAnnouncementSaveReqVO().setId(announcement.getId()) + .setTitle("修改标题但不改变发布时间"); + + // 调用 + announcementService.updateAnnouncement(reqVO, userId); + + // 断言 + assertEquals(createTime, announcementMapper.selectById(announcement.getId()).getCreateTime()); + assertEquals(userId.toString(), announcementMapper.selectById(announcement.getId()).getCreator()); + } + + @Test + public void testGetPublishedAnnouncementPage_filterCreateTime() { + // mock 数据 + Long userId = randomLongId(); + LocalDateTime createTime = LocalDateTime.now().minusDays(3).withNano(0); + OaAnnouncementDO announcement = buildAnnouncementDO(userId); + announcement.setCreateTime(createTime); + announcementMapper.insert(announcement); + OaAnnouncementDO otherAnnouncement = buildAnnouncementDO(userId); + otherAnnouncement.setCreateTime(createTime.plusDays(1)); + announcementMapper.insert(otherAnnouncement); + OaAnnouncementDO otherPublisherAnnouncement = buildAnnouncementDO(randomLongId()); + otherPublisherAnnouncement.setCreateTime(createTime); + announcementMapper.insert(otherPublisherAnnouncement); + + // 准备参数 + OaAnnouncementPageReqVO reqVO = new OaAnnouncementPageReqVO() + .setCreateTime(new LocalDateTime[]{createTime, createTime}); + + // 调用 + PageResult page = announcementService.getPublishedAnnouncementPage(reqVO, userId); + + // 断言 + assertEquals(Collections.singletonList(announcement.getId()), + convertList(page.getList(), OaAnnouncementDO::getId)); + } + + // ========== 随机对象 ========== + + /** + * 构造普通优先级的公告保存参数。 + * + * @return 保存参数 + */ + private static OaAnnouncementSaveReqVO buildAnnouncementSaveReqVO() { + return new OaAnnouncementSaveReqVO().setType(OaAnnouncementTypeEnum.ANNOUNCEMENT.getType()) + .setPriority(OaPriorityEnum.NORMAL.getPriority()).setTitle("国庆节放假通知") + .setContent("请各部门提前安排好假期值班工作").setUrl("https://example.com/announcement") + .setTop(false); + } + + /** + * 构造普通优先级、未置顶的公告。 + * + * @param publisherUserId 发布人编号 + * @return 未入库的测试对象 + */ + private static OaAnnouncementDO buildAnnouncementDO(Long publisherUserId) { + OaAnnouncementDO announcement = new OaAnnouncementDO() + .setType(OaAnnouncementTypeEnum.ANNOUNCEMENT.getType()) + .setPriority(OaPriorityEnum.NORMAL.getPriority()).setTitle("国庆节放假通知") + .setContent("请各部门提前安排好假期值班工作").setTop(false); + announcement.setCreator(publisherUserId.toString()); + announcement.setCreateTime(LocalDateTime.now()); + return announcement; + } + + /** + * 构造具有指定阅读状态的公告接收关系。 + * + * @param announcementId 公告编号 + * @param receiverUserId 接收人编号 + * @param readStatus 是否已读 + * @return 未入库的测试对象 + */ + private static OaAnnouncementReceiverDO buildReceiverDO(Long announcementId, Long receiverUserId, + Boolean readStatus) { + return new OaAnnouncementReceiverDO().setAnnouncementId(announcementId) + .setReceiverUserId(receiverUserId).setReadStatus(readStatus); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/attendance/OaAttendanceServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/attendance/OaAttendanceServiceImplTest.java new file mode 100644 index 0000000000..ee2c52ee6d --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/attendance/OaAttendanceServiceImplTest.java @@ -0,0 +1,537 @@ +package cn.iocoder.yudao.module.oa.service.attendance; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceMonthReportReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceMonthReportRespVO; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendancePageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceUpdateReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceWeekReportReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.attendance.vo.OaAttendanceWeekReportRespVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.attendance.OaAttendanceDO; +import cn.iocoder.yudao.module.oa.dal.mysql.attendance.OaAttendanceMapper; +import cn.iocoder.yudao.module.oa.enums.attendance.OaAttendanceStatusEnum; +import cn.iocoder.yudao.module.oa.enums.attendance.OaAttendanceTypeEnum; +import cn.iocoder.yudao.module.oa.framework.config.OaProperties; +import cn.iocoder.yudao.module.oa.service.leave.OaLeaveApplyService; +import cn.iocoder.yudao.module.oa.service.travel.OaTravelApplyService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.MockedStatic; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.getDateTimeRange; +import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.getMonthDateTimeRange; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.ATTENDANCE_CLOCK_TIME_INVALID; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.ATTENDANCE_NOT_EXISTS; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.ATTENDANCE_STATUS_INVALID; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link OaAttendanceServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import({OaAttendanceServiceImpl.class, OaProperties.class}) +public class OaAttendanceServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaAttendanceServiceImpl attendanceService; + + @Resource + private OaAttendanceMapper attendanceMapper; + @Resource + private OaProperties properties; + + @MockitoBean + private AdminUserApi adminUserApi; + @MockitoBean + private OaLeaveApplyService leaveApplyService; + @MockitoBean + private OaTravelApplyService travelApplyService; + + private MockedStatic dateTimeMock; + + @BeforeEach + public void before() { + properties.getAttendance().setClockBeginTime(LocalTime.of(5, 0)) + .setWorkBeginTime(LocalTime.of(8, 0)).setWorkEndTime(LocalTime.of(17, 0)).setMonthlyWorkDays(22); + // 固定打卡时钟,避免测试依赖实际执行时间 + LocalDateTime clockTime = LocalDateTime.of(2026, 9, 5, 10, 0); + dateTimeMock = mockStatic(LocalDateTime.class, CALLS_REAL_METHODS); + dateTimeMock.when(LocalDateTime::now).thenReturn(clockTime); + } + + @AfterEach + public void tearDown() { + dateTimeMock.close(); + } + + @Test + public void testCreateApplyAttendance_detailWithoutDuplicateMonthDays() { + // mock 方法 + when(adminUserApi.getUserListBySubordinate(10L)) + .thenReturn(Collections.singletonList(new AdminUserRespDTO().setId(1L))); + when(leaveApplyService.getApprovedLeaveDaysMap(Collections.singletonList(1L), getMonthDateTimeRange(2026, 9))) + .thenReturn(Collections.singletonMap(1L, 2)); + when(travelApplyService.getApprovedTravelDaysMap(Collections.singletonList(1L), getMonthDateTimeRange(2026, 9))) + .thenReturn(Collections.singletonMap(1L, 3)); + + // 调用 + attendanceService.createApplyAttendance(1L, OaAttendanceTypeEnum.LEAVE, LocalDateTime.of(2026, 9, 1, 8, 0)); + attendanceService.createApplyAttendance(1L, OaAttendanceTypeEnum.TRAVEL, LocalDateTime.of(2026, 9, 3, 8, 0)); + // 断言:明细可见;月报仍只统计一次申请天数 + PageResult page = attendanceService.getMyAttendancePage(new OaAttendancePageReqVO(), 1L); + assertEquals(2L, page.getTotal()); + assertEquals(OaAttendanceStatusEnum.TRAVEL.getStatus(), CollUtil.getFirst(page.getList()).getStatus()); + OaAttendanceMonthReportRespVO report = CollUtil.getFirst(attendanceService.getAttendanceMonthReport( + new OaAttendanceMonthReportReqVO().setYear(2026).setMonth(9), 10L)); + assertEquals(2, report.getLeaveDays()); + assertEquals(3, report.getTravelDays()); + assertEquals(0, report.getClockInCount()); + assertEquals(0, report.getClockOutCount()); + assertEquals(0, report.getNormalCount()); + } + + @Test + public void testGetAttendanceMonthReport_approvedDaysWithoutClockRecords() { + // mock 方法:管理范围内的用户没有打卡,但有审批通过的申请 + when(adminUserApi.getUserListBySubordinate(10L)).thenReturn(Arrays.asList( + new AdminUserRespDTO().setId(1L), new AdminUserRespDTO().setId(2L))); + List userIds = Arrays.asList(1L, 2L); + LocalDateTime[] monthTime = getMonthDateTimeRange(2026, 9); + when(leaveApplyService.getApprovedLeaveDaysMap(userIds, monthTime)).thenReturn(Collections.singletonMap(1L, 3)); + when(travelApplyService.getApprovedTravelDaysMap(userIds, monthTime)).thenReturn(Collections.singletonMap(1L, 5)); + // 准备参数 + OaAttendanceMonthReportReqVO reqVO = new OaAttendanceMonthReportReqVO().setYear(2026).setMonth(9); + + // 调用 + List reports = attendanceService.getAttendanceMonthReport(reqVO, 10L); + + // 断言:无打卡用户仍展示,未发生请假和出差时填零 + assertEquals(2, reports.size()); + OaAttendanceMonthReportRespVO first = CollUtil.findOne(reports, report -> report.getUserId().equals(1L)); + assertEquals(0, first.getClockInCount()); + assertEquals(3, first.getLeaveDays()); + assertEquals(5, first.getTravelDays()); + OaAttendanceMonthReportRespVO second = CollUtil.findOne(reports, report -> report.getUserId().equals(2L)); + assertEquals(0, second.getLeaveDays()); + assertEquals(0, second.getTravelDays()); + verify(leaveApplyService).getApprovedLeaveDaysMap(userIds, monthTime); + verify(travelApplyService).getApprovedTravelDaysMap(userIds, monthTime); + } + + @Test + public void testClockAttendance_clockInNormal() { + // 准备参数 + Long userId = 1L; + properties.getAttendance().setClockBeginTime(LocalTime.MIN).setWorkBeginTime(LocalTime.MAX); + LocalDateTime beginTime = LocalDateTime.now().withNano(0); + + // 调用 + Long attendanceId = attendanceService.clockAttendance(userId, "127.0.0.1"); + + // 断言 + OaAttendanceDO attendance = attendanceMapper.selectById(attendanceId); + assertEquals(OaAttendanceTypeEnum.CLOCK_IN.getType(), attendance.getType()); + assertEquals(OaAttendanceStatusEnum.NORMAL.getStatus(), attendance.getStatus()); + assertFalse(attendance.getAttendanceTime().isBefore(beginTime)); + assertFalse(attendance.getAttendanceTime().isAfter(LocalDateTime.now().withNano(0))); + } + + @Test + public void testClockAttendance_clockInLate() { + // 准备参数 + properties.getAttendance().setClockBeginTime(LocalTime.MIN).setWorkBeginTime(LocalTime.MIN); + + // 调用 + Long attendanceId = attendanceService.clockAttendance(1L, "127.0.0.1"); + + // 断言 + assertEquals(OaAttendanceStatusEnum.LATE.getStatus(), + attendanceMapper.selectById(attendanceId).getStatus()); + } + + @Test + public void testClockAttendance_refreshClockOut() { + // mock 数据 + Long userId = 1L; + properties.getAttendance().setClockBeginTime(LocalTime.MIN).setWorkEndTime(LocalTime.MAX); + attendanceService.clockAttendance(userId, "127.0.0.1"); + Long clockOutId = attendanceService.clockAttendance(userId, "127.0.0.1"); + OaAttendanceDO clockOut = attendanceMapper.selectById(clockOutId).setRemark("外出后返回打卡"); + attendanceMapper.updateById(clockOut); + + // 调用 + Long refreshedClockOutId = attendanceService.clockAttendance(userId, "192.168.1.10"); + + // 断言 + assertEquals(clockOutId, refreshedClockOutId); + List attendances = attendanceMapper.selectListByUserIdAndAttendanceTime( + userId, getDateTimeRange(LocalDateTime.now().toLocalDate(), LocalDateTime.now().toLocalDate())); + assertEquals(2, attendances.size()); + clockOut = attendanceMapper.selectById(clockOutId); + assertEquals(OaAttendanceStatusEnum.EARLY.getStatus(), clockOut.getStatus()); + assertEquals("外出后返回打卡", clockOut.getRemark()); + assertEquals("192.168.1.10", clockOut.getAttendanceIp()); + } + + @Test + public void testClockAttendance_beforeClockBeginTime() { + // 准备参数 + properties.getAttendance().setClockBeginTime(LocalTime.MAX); + + // 调用,并断言异常 + assertServiceException(() -> attendanceService.clockAttendance(1L, "127.0.0.1"), + ATTENDANCE_CLOCK_TIME_INVALID, LocalTime.MAX, LocalTime.of(17, 0)); + } + + @ParameterizedTest + @CsvSource({"04:59:59,false", "05:00:00,false", "05:00:01,true", + "16:59:59,true", "17:00:00,false", "17:00:01,false", "23:59:59,false"}) + public void testClockAttendance_timeBoundary(String time, boolean allowed) { + // 准备参数 + LocalDateTime clockTime = LocalDate.of(2026, 9, 5).atTime(LocalTime.parse(time)); + dateTimeMock.when(LocalDateTime::now).thenReturn(clockTime); + + // 调用,并断言边界外不新增打卡记录 + if (!allowed) { + assertServiceException(() -> attendanceService.clockAttendance(1L, "127.0.0.1"), + ATTENDANCE_CLOCK_TIME_INVALID, LocalTime.of(5, 0), LocalTime.of(17, 0)); + assertEquals(0L, attendanceMapper.selectCount()); + return; + } + Long id = attendanceService.clockAttendance(1L, "127.0.0.1"); + assertEquals(clockTime, attendanceMapper.selectById(id).getAttendanceTime()); + } + + @Test + public void testClockAttendance_afterEndTimeDoesNotRefreshClockOut() { + // mock 数据 + OaAttendanceDO clockIn = randomAttendanceDO(1L, OaAttendanceTypeEnum.CLOCK_IN, + OaAttendanceStatusEnum.NORMAL, LocalDateTime.of(2026, 9, 5, 8, 0)); + OaAttendanceDO clockOut = randomAttendanceDO(1L, OaAttendanceTypeEnum.CLOCK_OUT, + OaAttendanceStatusEnum.EARLY, LocalDateTime.of(2026, 9, 5, 16, 0)); + attendanceMapper.insert(clockIn); + attendanceMapper.insert(clockOut); + // mock 方法 + LocalDateTime clockTime = LocalDateTime.of(2026, 9, 5, 18, 0); + dateTimeMock.when(LocalDateTime::now).thenReturn(clockTime); + + // 调用,并断言原有下班记录不被修改 + assertServiceException(() -> attendanceService.clockAttendance(1L, "192.168.1.10"), + ATTENDANCE_CLOCK_TIME_INVALID, LocalTime.of(5, 0), LocalTime.of(17, 0)); + OaAttendanceDO result = attendanceMapper.selectById(clockOut.getId()); + assertEquals(clockOut.getAttendanceTime(), result.getAttendanceTime()); + assertEquals(clockOut.getAttendanceIp(), result.getAttendanceIp()); + assertEquals(clockOut.getStatus(), result.getStatus()); + assertEquals(2L, attendanceMapper.selectCount()); + } + + @Test + public void testUpdateAttendance_invalidStatus() { + // mock 方法 + when(adminUserApi.getUserListBySubordinate(10L)) + .thenReturn(Collections.singletonList(new AdminUserRespDTO().setId(1L))); + + // mock 数据 + OaAttendanceDO attendance = randomAttendanceDO(1L, OaAttendanceTypeEnum.CLOCK_IN, + OaAttendanceStatusEnum.NORMAL, LocalDateTime.of(2026, 9, 1, 8, 0)); + attendanceMapper.insert(attendance); + // 准备参数 + OaAttendanceUpdateReqVO reqVO = new OaAttendanceUpdateReqVO().setId(attendance.getId()) + .setStatus(OaAttendanceStatusEnum.EARLY.getStatus()); + + // 调用,并断言异常 + assertServiceException(() -> attendanceService.updateAttendance(reqVO, 10L), ATTENDANCE_STATUS_INVALID); + } + + @Test + public void testUpdateAttendance_success() { + // mock 方法 + when(adminUserApi.getUserListBySubordinate(10L)) + .thenReturn(Collections.singletonList(new AdminUserRespDTO().setId(1L))); + + // mock 数据 + OaAttendanceDO attendance = randomAttendanceDO(1L, OaAttendanceTypeEnum.CLOCK_IN, + OaAttendanceStatusEnum.LATE, LocalDateTime.of(2026, 9, 1, 8, 10)).setRemark("旧备注"); + attendanceMapper.insert(attendance); + // 准备参数 + OaAttendanceUpdateReqVO reqVO = new OaAttendanceUpdateReqVO().setId(attendance.getId()) + .setStatus(OaAttendanceStatusEnum.NORMAL.getStatus()).setRemark("新备注"); + + // 调用 + attendanceService.updateAttendance(reqVO, 10L); + + // 断言 + OaAttendanceDO updatedAttendance = attendanceMapper.selectById(attendance.getId()); + assertEquals(OaAttendanceStatusEnum.NORMAL.getStatus(), updatedAttendance.getStatus()); + assertEquals("新备注", updatedAttendance.getRemark()); + } + + @Test + public void testGetAttendanceMonthReport_success() { + // mock 方法 + when(adminUserApi.getUserListBySubordinate(10L)) + .thenReturn(Collections.singletonList(new AdminUserRespDTO().setId(1L))); + + // mock 数据 + Long userId = 1L; + attendanceMapper.insert(randomAttendanceDO(userId, OaAttendanceTypeEnum.CLOCK_IN, + OaAttendanceStatusEnum.NORMAL, LocalDateTime.of(2026, 9, 1, 8, 0))); + attendanceMapper.insert(randomAttendanceDO(userId, OaAttendanceTypeEnum.CLOCK_OUT, + OaAttendanceStatusEnum.EARLY, LocalDateTime.of(2026, 9, 1, 16, 30))); + attendanceMapper.insert(randomAttendanceDO(userId, OaAttendanceTypeEnum.CLOCK_IN, + OaAttendanceStatusEnum.LATE, LocalDateTime.of(2026, 9, 2, 8, 10))); + // 准备参数 + OaAttendanceMonthReportReqVO reqVO = new OaAttendanceMonthReportReqVO() + .setYear(2026).setMonth(9).setUserId(userId); + + // 调用 + OaAttendanceMonthReportRespVO report = CollUtil.getFirst( + attendanceService.getAttendanceMonthReport(reqVO, 10L)); + + // 断言 + assertEquals(2, report.getClockInCount()); + assertEquals(1, report.getClockOutCount()); + assertEquals(1, report.getNormalCount()); + assertEquals(1, report.getLateCount()); + assertEquals(1, report.getEarlyCount()); + assertEquals(0, report.getAbsentDays()); + } + + + @ParameterizedTest + @CsvSource({"2026, 8, 0, 22, 22", "2026, 8, 1, 22, 21", "2026, 8, 23, 22, -1", + "2026, 9, 0, 22, 0", "2026, 10, 0, 22, 0", "2026, 8, 1, 20, 19"}) + public void testGetAttendanceMonthReport_absentDays(int year, int month, int clockOutCount, + int monthlyWorkDays, int expectedDays) { + // mock 数据:旷工天数按每月工作天数减下班次数计算,结果允许为负数 + properties.getAttendance().setMonthlyWorkDays(monthlyWorkDays); + for (int i = 0; i < clockOutCount; i++) { + attendanceMapper.insert(randomAttendanceDO(1L, OaAttendanceTypeEnum.CLOCK_OUT, + OaAttendanceStatusEnum.NORMAL, LocalDateTime.of(year, month, i + 1, 17, 0))); + } + // mock 方法 + when(adminUserApi.getUserListBySubordinate(10L)) + .thenReturn(Collections.singletonList(new AdminUserRespDTO().setId(1L))); + + // 调用 + OaAttendanceMonthReportRespVO report = CollUtil.getFirst(attendanceService.getAttendanceMonthReport( + new OaAttendanceMonthReportReqVO().setYear(year).setMonth(month), 10L)); + // 断言 + assertEquals(expectedDays, report.getAbsentDays()); + assertEquals(clockOutCount, report.getClockOutCount()); + } + + @Test + public void testGetAttendancePage_managedUsersOnly() { + // mock 数据 + for (Long userId : Arrays.asList(1L, 2L, 10L)) { + attendanceMapper.insert(randomAttendanceDO(userId, OaAttendanceTypeEnum.CLOCK_IN, + OaAttendanceStatusEnum.NORMAL, LocalDateTime.of(2026, 9, 1, 8, 0))); + } + // mock 方法 + when(adminUserApi.getUserListBySubordinate(10L)) + .thenReturn(Collections.singletonList(new AdminUserRespDTO().setId(1L))); + + // 调用 + PageResult result = attendanceService.getAttendancePage(new OaAttendancePageReqVO(), 10L); + + // 断言 + assertEquals(1L, result.getTotal()); + assertEquals(1L, CollUtil.getFirst(result.getList()).getUserId()); + assertEquals(0L, attendanceService.getAttendancePage( + new OaAttendancePageReqVO().setUserId(2L), 10L).getTotal()); + assertEquals(0L, attendanceService.getAttendancePage( + new OaAttendancePageReqVO().setUserId(10L), 10L).getTotal()); + } + + @Test + public void testAttendanceManagement_outsideScope() { + // mock 数据 + OaAttendanceDO attendance = randomAttendanceDO(2L, OaAttendanceTypeEnum.CLOCK_IN, + OaAttendanceStatusEnum.NORMAL, LocalDateTime.of(2026, 9, 1, 8, 0)); + attendanceMapper.insert(attendance); + // mock 方法 + when(adminUserApi.getUserListBySubordinate(10L)) + .thenReturn(Collections.singletonList(new AdminUserRespDTO().setId(1L))); + // 准备参数 + OaAttendanceUpdateReqVO reqVO = new OaAttendanceUpdateReqVO().setId(attendance.getId()) + .setStatus(OaAttendanceStatusEnum.LATE.getStatus()); + + // 调用,并断言异常 + assertServiceException(() -> attendanceService.getAttendance(attendance.getId(), 10L), + ATTENDANCE_NOT_EXISTS); + assertServiceException(() -> attendanceService.updateAttendance(reqVO, 10L), ATTENDANCE_NOT_EXISTS); + assertServiceException(() -> attendanceService.deleteAttendance(attendance.getId(), 10L), + ATTENDANCE_NOT_EXISTS); + assertEquals(OaAttendanceStatusEnum.NORMAL.getStatus(), + attendanceMapper.selectById(attendance.getId()).getStatus()); + } + + @Test + public void testGetAndDeleteAttendance_success() { + // mock 数据 + OaAttendanceDO attendance = randomAttendanceDO(1L, OaAttendanceTypeEnum.CLOCK_IN, + OaAttendanceStatusEnum.NORMAL, LocalDateTime.of(2026, 9, 1, 8, 0)); + attendanceMapper.insert(attendance); + // mock 方法 + when(adminUserApi.getUserListBySubordinate(10L)) + .thenReturn(Collections.singletonList(new AdminUserRespDTO().setId(1L))); + + // 调用,并断言 + assertEquals(attendance.getId(), attendanceService.getAttendance(attendance.getId(), 10L).getId()); + attendanceService.deleteAttendance(attendance.getId(), 10L); + assertNull(attendanceMapper.selectById(attendance.getId())); + } + + @Test + public void testAttendanceReports_includeUsersWithoutRecords() { + // mock 数据 + attendanceMapper.insert(randomAttendanceDO(1L, OaAttendanceTypeEnum.CLOCK_IN, + OaAttendanceStatusEnum.NORMAL, LocalDateTime.of(2026, 9, 1, 8, 0))); + attendanceMapper.insert(randomAttendanceDO(3L, OaAttendanceTypeEnum.CLOCK_IN, + OaAttendanceStatusEnum.LATE, LocalDateTime.of(2026, 9, 1, 9, 0))); + // mock 方法 + when(adminUserApi.getUserListBySubordinate(10L)).thenReturn(Arrays.asList( + new AdminUserRespDTO().setId(1L), new AdminUserRespDTO().setId(2L))); + // 准备参数 + OaAttendanceWeekReportReqVO weekReqVO = new OaAttendanceWeekReportReqVO() + .setStartDate(LocalDate.of(2026, 9, 1)); + OaAttendanceMonthReportReqVO monthReqVO = new OaAttendanceMonthReportReqVO().setYear(2026).setMonth(9); + + // 调用 + List weeks = attendanceService.getAttendanceWeekReport(weekReqVO, 10L); + List months = attendanceService.getAttendanceMonthReport(monthReqVO, 10L); + + // 断言 + assertEquals(2, weeks.size()); + assertEquals(2, months.size()); + OaAttendanceWeekReportRespVO emptyWeek = CollUtil.findOne(weeks, row -> row.getUserId().equals(2L)); + assertEquals(7, emptyWeek.getDailyAttendances().size()); + emptyWeek.getDailyAttendances().forEach(day -> { + assertNull(day.getClockInId()); + assertNull(day.getClockOutId()); + assertNull(day.getClockInTime()); + assertNull(day.getClockOutTime()); + }); + assertEquals(0, CollUtil.findOne(months, row -> row.getUserId().equals(2L)).getClockInCount()); + assertEquals(1, CollUtil.findOne(months, row -> row.getUserId().equals(1L)).getClockInCount()); + assertTrue(attendanceService.getAttendanceWeekReport(weekReqVO.setUserId(3L), 10L).isEmpty()); + assertTrue(attendanceService.getAttendanceMonthReport(monthReqVO.setUserId(3L), 10L).isEmpty()); + assertEquals(1, attendanceService.getAttendanceWeekReport(weekReqVO.setUserId(2L), 10L).size()); + } + + @Test + public void testAttendanceManagement_noSubordinates() { + // mock 数据 + attendanceMapper.insert(randomAttendanceDO(1L, OaAttendanceTypeEnum.CLOCK_IN, + OaAttendanceStatusEnum.NORMAL, LocalDateTime.of(2026, 9, 1, 8, 0))); + // mock 方法 + when(adminUserApi.getUserListBySubordinate(10L)).thenReturn(Collections.emptyList()); + + // 调用,并断言 + assertEquals(0L, attendanceService.getAttendancePage(new OaAttendancePageReqVO(), 10L).getTotal()); + assertTrue(attendanceService.getAttendanceWeekReport( + new OaAttendanceWeekReportReqVO().setStartDate(LocalDate.of(2026, 9, 1)), 10L).isEmpty()); + assertTrue(attendanceService.getAttendanceMonthReport( + new OaAttendanceMonthReportReqVO().setYear(2026).setMonth(9), 10L).isEmpty()); + } + + @Test + public void testGetMyAttendancePage_ignoresOtherUserFilter() { + // mock 数据 + attendanceMapper.insert(randomAttendanceDO(1L, OaAttendanceTypeEnum.CLOCK_IN, + OaAttendanceStatusEnum.NORMAL, LocalDateTime.of(2026, 9, 1, 8, 0))); + attendanceMapper.insert(randomAttendanceDO(2L, OaAttendanceTypeEnum.CLOCK_IN, + OaAttendanceStatusEnum.NORMAL, LocalDateTime.of(2026, 9, 1, 8, 0))); + + // 调用 + PageResult result = attendanceService.getMyAttendancePage( + new OaAttendancePageReqVO().setUserId(2L), 1L); + + // 断言 + assertEquals(1L, result.getTotal()); + assertEquals(1L, CollUtil.getFirst(result.getList()).getUserId()); + } + + @Test + public void testAttendanceWeekReport_updateRecord() { + // mock 数据 + OaAttendanceDO clockIn = randomAttendanceDO(1L, OaAttendanceTypeEnum.CLOCK_IN, + OaAttendanceStatusEnum.LATE, LocalDateTime.of(2026, 9, 1, 8, 10)); + OaAttendanceDO clockOut = randomAttendanceDO(1L, OaAttendanceTypeEnum.CLOCK_OUT, + OaAttendanceStatusEnum.NORMAL, LocalDateTime.of(2026, 9, 1, 18, 0)); + attendanceMapper.insert(clockIn); + attendanceMapper.insert(clockOut); + // mock 方法 + when(adminUserApi.getUserListBySubordinate(10L)) + .thenReturn(Collections.singletonList(new AdminUserRespDTO().setId(1L))); + // 准备参数 + OaAttendanceWeekReportReqVO reqVO = new OaAttendanceWeekReportReqVO() + .setStartDate(LocalDate.of(2026, 9, 1)); + + // 调用 + OaAttendanceWeekReportRespVO report = CollUtil.getFirst( + attendanceService.getAttendanceWeekReport(reqVO, 10L)); + OaAttendanceWeekReportRespVO.DailyAttendance day = CollUtil.findOne( + report.getDailyAttendances(), item -> item.getDate().equals(LocalDate.of(2026, 9, 1))); + + // 断言:周报返回对应记录编号,复用修改接口后再次查询可见最新状态 + assertEquals(clockIn.getId(), day.getClockInId()); + assertEquals(clockOut.getId(), day.getClockOutId()); + attendanceService.updateAttendance(new OaAttendanceUpdateReqVO().setId(day.getClockInId()) + .setStatus(OaAttendanceStatusEnum.NORMAL.getStatus()).setRemark("已核实"), 10L); + OaAttendanceWeekReportRespVO updatedReport = CollUtil.getFirst( + attendanceService.getAttendanceWeekReport(reqVO, 10L)); + assertEquals(OaAttendanceStatusEnum.NORMAL.getStatus(), CollUtil.findOne( + updatedReport.getDailyAttendances(), item -> item.getDate().equals(day.getDate())).getClockInStatus()); + assertEquals("已核实", attendanceMapper.selectById(clockIn.getId()).getRemark()); + assertEquals(clockOut.getAttendanceTime(), attendanceMapper.selectById(clockOut.getId()).getAttendanceTime()); + } + + // ========== 随机对象 ========== + + /** + * 构造具有指定类型、状态和打卡时间的考勤记录。 + * + * @param userId 用户编号 + * @param type 业务类型 + * @param status 业务状态 + * @param attendanceTime 打卡时间 + * @return 未入库的测试对象 + */ + private static OaAttendanceDO randomAttendanceDO(Long userId, OaAttendanceTypeEnum type, + OaAttendanceStatusEnum status, + LocalDateTime attendanceTime) { + return randomPojo(OaAttendanceDO.class, attendance -> attendance.setId(null).setUserId(userId) + .setType(type.getType()).setStatus(status.getStatus()).setAttendanceTime(attendanceTime) + .setAttendanceIp("127.0.0.1").setRemark(null)); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/contact/OaContactCategoryServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/contact/OaContactCategoryServiceImplTest.java new file mode 100644 index 0000000000..cff5bade80 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/contact/OaContactCategoryServiceImplTest.java @@ -0,0 +1,114 @@ +package cn.iocoder.yudao.module.oa.service.contact; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.controller.admin.contact.vo.category.OaContactCategorySaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.contact.OaContactCategoryDO; +import cn.iocoder.yudao.module.oa.dal.mysql.contact.OaContactCategoryMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.CONTACT_CATEGORY_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * {@link OaContactCategoryServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaContactCategoryServiceImpl.class) +public class OaContactCategoryServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaContactCategoryServiceImpl contactCategoryService; + + @Resource + private OaContactCategoryMapper contactCategoryMapper; + + @MockitoBean + private OaContactService contactService; + + @Test + public void testUpdateContactCategory_noAssignmentUpdate() { + // mock 数据 + Long userId = randomLongId(); + OaContactCategoryDO category = new OaContactCategoryDO().setUserId(userId).setName("客户").setSort(1); + contactCategoryMapper.insert(category); + // 准备参数 + OaContactCategorySaveReqVO reqVO = new OaContactCategorySaveReqVO().setId(category.getId()) + .setName("重点客户").setSort(2); + + // 调用 + contactCategoryService.updateContactCategory(reqVO, userId); + + // 断言 + assertEquals("重点客户", contactCategoryMapper.selectById(category.getId()).getName()); + verifyNoInteractions(contactService); + } + + @Test + public void testValidateContactCategory_notOwner() { + // mock 数据 + OaContactCategoryDO category = new OaContactCategoryDO().setUserId(1L).setName("客户").setSort(1); + contactCategoryMapper.insert(category); + + // 调用,并断言异常 + assertServiceException( + () -> contactCategoryService.validateContactCategory(category.getId(), 2L), + CONTACT_CATEGORY_NOT_EXISTS); + } + + @Test + public void testValidateContactCategory_null() { + + // 调用,并断言:分类校验不能放过缺失的编号 + assertServiceException(() -> contactCategoryService.validateContactCategory(null, 1L), + CONTACT_CATEGORY_NOT_EXISTS); + + // 断言 + assertEquals(0L, contactCategoryMapper.selectCount()); + } + + @Test + public void testGetContactCategory_ownerOnly() { + // mock 数据 + OaContactCategoryDO category = new OaContactCategoryDO().setUserId(1L).setName("合作伙伴").setSort(0); + contactCategoryMapper.insert(category); + + // 调用,并断言 + assertEquals(category.getId(), contactCategoryService.getContactCategory(category.getId(), 1L).getId()); + assertServiceException(() -> contactCategoryService.getContactCategory(category.getId(), 2L), + CONTACT_CATEGORY_NOT_EXISTS); + } + + @Test + public void testDeleteContactCategory_null() { + + // 调用,并断言:缺失分类编号时不能执行分类清空 + assertServiceException(() -> contactCategoryService.deleteContactCategory(null, 1L), + CONTACT_CATEGORY_NOT_EXISTS); + verifyNoInteractions(contactService); + } + + @Test + public void testDeleteContactCategory_clearsExistingAssignments() { + // mock 数据 + Long userId = randomLongId(); + OaContactCategoryDO category = new OaContactCategoryDO().setUserId(userId).setName("供应商").setSort(1); + contactCategoryMapper.insert(category); + + // 调用 + contactCategoryService.deleteContactCategory(category.getId(), userId); + + // 断言 + verify(contactService).clearContactCategoryId(category.getId()); + assertNull(contactCategoryMapper.selectById(category.getId())); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/contact/OaContactServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/contact/OaContactServiceImplTest.java new file mode 100644 index 0000000000..2e4987eedf --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/contact/OaContactServiceImplTest.java @@ -0,0 +1,614 @@ +package cn.iocoder.yudao.module.oa.service.contact; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.controller.admin.contact.vo.OaContactPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.contact.vo.OaContactSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.contact.OaContactDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.contact.OaContactShareDO; +import cn.iocoder.yudao.module.oa.dal.mysql.contact.OaContactMapper; +import cn.iocoder.yudao.module.oa.dal.mysql.contact.OaContactShareMapper; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.CONTACT_ACCESS_DENIED; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.CONTACT_CATEGORY_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.doThrow; + +/** + * {@link OaContactServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaContactServiceImpl.class) +public class OaContactServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaContactServiceImpl contactService; + + @Resource + private OaContactMapper contactMapper; + @Resource + private OaContactShareMapper contactShareMapper; + + @MockitoBean + private AdminUserApi adminUserApi; + @MockitoBean + private OaContactCategoryService contactCategoryService; + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void testCreateContact_autoFillCreator() { + // 准备参数 + Long userId = randomLongId(); + OaContactSaveReqVO reqVO = new OaContactSaveReqVO().setName("张三"); + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(userId), new MockHttpServletRequest()); + + // 调用 + Long id = contactService.createContact(reqVO, userId); + + // 断言:创建人由平台填充,随后可按创建人查询和访问 + assertEquals(userId.toString(), contactMapper.selectById(id).getCreator()); + assertEquals(id, contactService.getContact(id, userId).getId()); + assertEquals(id, CollUtil.getFirst(contactMapper.selectListByCreator(userId.toString())).getId()); + } + + @Test + public void testDeleteContact_notCreator() { + // mock 数据 + OaContactDO contact = randomContactDO(randomLongId()); + contactMapper.insert(contact); + contactShareMapper.insert(randomPojo(OaContactShareDO.class, share -> { + share.setId(null).setContactId(contact.getId()) + .setUserId(Long.valueOf(contact.getCreator())).setCategoryId(contact.getCategoryId()) + .setHandleStatus(true); + share.setCreator(contact.getCreator()); + })); + + // 调用,并断言异常 + assertServiceException(() -> contactService.deleteContact(contact.getId(), randomLongId()), + CONTACT_ACCESS_DENIED); + assertEquals(contact.getCreator(), contactMapper.selectById(contact.getId()).getCreator()); + } + + @Test + public void testUpdateContact_clearNullableFields() { + // mock 数据 + Long userId = randomLongId(); + OaContactDO contact = randomContactDO(userId).setCategoryId(10L).setMobile("15600000000") + .setRemark("重点客户"); + contactMapper.insert(contact); + contactShareMapper.insert(randomPojo(OaContactShareDO.class, share -> { + share.setId(null).setContactId(contact.getId()) + .setUserId(Long.valueOf(contact.getCreator())).setCategoryId(contact.getCategoryId()) + .setHandleStatus(true); + share.setCreator(contact.getCreator()); + })); + // 准备参数 + OaContactSaveReqVO reqVO = new OaContactSaveReqVO().setId(contact.getId()).setName("李四"); + + // 调用 + contactService.updateContact(reqVO, userId); + + // 断言 + OaContactDO updatedContact = contactMapper.selectById(contact.getId()); + assertEquals("李四", updatedContact.getName()); + assertEquals(userId.toString(), updatedContact.getCreator()); + assertNull(updatedContact.getCategoryId()); + assertNull(updatedContact.getMobile()); + assertNull(updatedContact.getRemark()); + } + + @Test + public void testShareContact_onlyCreatesNewReceivers() { + // mock 数据 + Long userId = randomLongId(); + Long existingUserId = randomLongId(); + Long newUserId = randomLongId(); + OaContactDO contact = randomContactDO(userId); + contactMapper.insert(contact); + contactShareMapper.insert(randomPojo(OaContactShareDO.class, share -> { + share.setId(null).setContactId(contact.getId()) + .setUserId(Long.valueOf(contact.getCreator())).setCategoryId(contact.getCategoryId()) + .setHandleStatus(true); + share.setCreator(contact.getCreator()); + })); + contactShareMapper.insert(new OaContactShareDO().setContactId(contact.getId()) + .setUserId(existingUserId).setHandleStatus(false)); + + // 调用 + contactService.shareContact(contact.getId(), Arrays.asList(existingUserId, newUserId), userId); + + // 断言 + List shares = contactShareMapper.selectListByContactIds( + Collections.singleton(contact.getId())); + assertEquals(3, shares.size()); + } + + @Test + public void testHandleContactShare_success() { + // mock 数据 + Long receiverUserId = randomLongId(); + OaContactDO contact = randomContactDO(randomLongId()); + contactMapper.insert(contact); + contactShareMapper.insert(randomPojo(OaContactShareDO.class, share -> { + share.setId(null).setContactId(contact.getId()) + .setUserId(Long.valueOf(contact.getCreator())).setCategoryId(contact.getCategoryId()) + .setHandleStatus(true); + share.setCreator(contact.getCreator()); + })); + OaContactShareDO share = new OaContactShareDO().setContactId(contact.getId()) + .setUserId(receiverUserId).setHandleStatus(false); + contactShareMapper.insert(share); + + // 调用 + contactService.handleContactShare(contact.getId(), 20L, receiverUserId); + + // 断言 + OaContactShareDO updatedShare = contactShareMapper.selectById(share.getId()); + assertTrue(updatedShare.getHandleStatus()); + assertEquals(20L, updatedShare.getCategoryId()); + } + + @Test + public void testDeleteReceivedContact_success() { + // mock 数据 + Long receiverUserId = randomLongId(); + OaContactDO contact = randomContactDO(randomLongId()); + contactMapper.insert(contact); + contactShareMapper.insert(randomPojo(OaContactShareDO.class, share -> { + share.setId(null).setContactId(contact.getId()) + .setUserId(Long.valueOf(contact.getCreator())).setCategoryId(contact.getCategoryId()) + .setHandleStatus(true); + share.setCreator(contact.getCreator()); + })); + contactShareMapper.insert(new OaContactShareDO().setContactId(contact.getId()) + .setUserId(receiverUserId).setHandleStatus(true)); + + // 调用 + contactService.deleteReceivedContact(contact.getId(), receiverUserId); + + // 断言 + assertNull(contactShareMapper.selectByContactIdAndUserId(contact.getId(), receiverUserId)); + assertEquals(contact.getName(), contactMapper.selectById(contact.getId()).getName()); + } + + @Test + public void testGetContactPage_mineIncludesHandledReceivedContact() { + // mock 数据 + Long userId = randomLongId(); + OaContactDO ownedContact = randomContactDO(userId); + contactMapper.insert(ownedContact); + contactShareMapper.insert(randomPojo(OaContactShareDO.class, share -> { + share.setId(null).setContactId(ownedContact.getId()) + .setUserId(Long.valueOf(ownedContact.getCreator())).setCategoryId(ownedContact.getCategoryId()) + .setHandleStatus(true); + share.setCreator(ownedContact.getCreator()); + })); + OaContactDO receivedContact = randomContactDO(randomLongId()); + contactMapper.insert(receivedContact); + contactShareMapper.insert(randomPojo(OaContactShareDO.class, share -> { + share.setId(null).setContactId(receivedContact.getId()) + .setUserId(Long.valueOf(receivedContact.getCreator())).setCategoryId(receivedContact.getCategoryId()) + .setHandleStatus(true); + share.setCreator(receivedContact.getCreator()); + })); + contactShareMapper.insert(new OaContactShareDO().setContactId(receivedContact.getId()) + .setUserId(userId).setHandleStatus(true)); + OaContactDO pendingContact = randomContactDO(randomLongId()); + contactMapper.insert(pendingContact); + contactShareMapper.insert(randomPojo(OaContactShareDO.class, share -> { + share.setId(null).setContactId(pendingContact.getId()) + .setUserId(Long.valueOf(pendingContact.getCreator())).setCategoryId(pendingContact.getCategoryId()) + .setHandleStatus(true); + share.setCreator(pendingContact.getCreator()); + })); + contactShareMapper.insert(new OaContactShareDO().setContactId(pendingContact.getId()) + .setUserId(userId).setHandleStatus(false)); + + // 准备参数 + OaContactPageReqVO pageReqVO = new OaContactPageReqVO(); + + // 调用 + List contacts = contactService.getMyContactPage(pageReqVO, userId).getList(); + + // 断言 + assertEquals(2, contacts.size()); + assertTrue(contacts.stream().anyMatch(contact -> contact.getId().equals(ownedContact.getId()))); + assertTrue(contacts.stream().anyMatch(contact -> contact.getId().equals(receivedContact.getId()))); + assertFalse(contacts.stream().anyMatch(contact -> contact.getId().equals(pendingContact.getId()))); + } + + @Test + public void testGetSharedContactPage_receiverRowsAndFilters() { + // mock 数据:同一联系人发给两个人,以及他人转发的关系 + Long ownerId = randomLongId(); + OaContactDO contact = randomContactDO(ownerId).setName("Alice").setPinyin("alice"); + contactMapper.insert(contact); + OaContactShareDO ownerShare = randomPojo(OaContactShareDO.class, o -> { + o.setId(null).setContactId(contact.getId()).setUserId(ownerId).setCategoryId(null).setHandleStatus(true); + o.setCreator(ownerId.toString()); + }); + contactShareMapper.insert(ownerShare); + OaContactShareDO first = randomPojo(OaContactShareDO.class, o -> { + o.setId(null).setContactId(contact.getId()).setUserId(randomLongId()).setCategoryId(null).setHandleStatus(false); + o.setCreator(ownerId.toString()); + }); + OaContactShareDO second = randomPojo(OaContactShareDO.class, o -> { + o.setId(null).setContactId(contact.getId()).setUserId(randomLongId()).setCategoryId(null).setHandleStatus(true); + o.setCreator(ownerId.toString()); + }); + contactShareMapper.insert(first); + contactShareMapper.insert(second); + OaContactShareDO otherShare = randomPojo(OaContactShareDO.class, o -> { + o.setId(null).setContactId(contact.getId()).setUserId(randomLongId()).setCategoryId(null).setHandleStatus(false); + o.setCreator(randomLongId().toString()); + }); + contactShareMapper.insert(otherShare); + // 准备参数 + OaContactPageReqVO reqVO = new OaContactPageReqVO().setKeyword("Alice").setAlphabet("A"); + reqVO.setPageSize(1); + + // 调用 + PageResult page = + contactService.getSharedContactPage(reqVO, ownerId); + + // 断言:按关系分页,同一联系人保留两行,排除本人初始化行和他人转发 + assertEquals(2L, page.getTotal()); + assertEquals(1, page.getList().size()); + Long firstPageId = CollUtil.getFirst(page.getList()).getId(); + reqVO.setPageNo(2); + assertFalse(firstPageId.equals(CollUtil.getFirst(contactService.getSharedContactPage(reqVO, ownerId).getList()).getId())); + reqVO.setPageNo(1); + reqVO.setHandleStatus(false); + assertEquals(first.getId(), CollUtil.getFirst(contactService.getSharedContactPage(reqVO, ownerId).getList()).getId()); + reqVO.setKeyword("不存在"); + assertEquals(0L, contactService.getSharedContactPage(reqVO, ownerId).getTotal()); + reqVO.setKeyword("Alice"); + contactMapper.deleteById(contact.getId()); + assertEquals(0L, contactService.getSharedContactPage(reqVO, ownerId).getTotal()); + } + + @Test + public void testGetContactPage_sentOnlyIncludesSharedContacts() { + // mock 数据 + Long userId = randomLongId(); + OaContactDO sharedContact = randomContactDO(userId); + contactMapper.insert(sharedContact); + contactShareMapper.insert(randomPojo(OaContactShareDO.class, share -> { + share.setId(null).setContactId(sharedContact.getId()) + .setUserId(Long.valueOf(sharedContact.getCreator())).setCategoryId(sharedContact.getCategoryId()) + .setHandleStatus(true); + share.setCreator(sharedContact.getCreator()); + })); + contactShareMapper.insert(randomPojo(OaContactShareDO.class, share -> { + share.setId(null).setContactId(sharedContact.getId()).setUserId(randomLongId()).setHandleStatus(false); + share.setCreator(userId.toString()); + })); + OaContactDO notSharedContact = randomContactDO(userId); + contactMapper.insert(notSharedContact); + contactShareMapper.insert(randomPojo(OaContactShareDO.class, share -> { + share.setId(null).setContactId(notSharedContact.getId()) + .setUserId(Long.valueOf(notSharedContact.getCreator())).setCategoryId(notSharedContact.getCategoryId()) + .setHandleStatus(true); + share.setCreator(notSharedContact.getCreator()); + })); + + // 准备参数 + OaContactPageReqVO pageReqVO = new OaContactPageReqVO(); + + // 调用 + List contacts = contactService.getSharedContactPage(pageReqVO, userId).getList(); + + // 断言 + assertEquals(1, contacts.size()); + assertEquals(sharedContact.getId(), CollUtil.getFirst(contacts).getContactId()); + } + + @Test + public void testGetContactPage_receivedWithoutHandleStatus() { + // mock 数据 + Long userId = randomLongId(); + OaContactDO pendingContact = randomContactDO(randomLongId()); + contactMapper.insert(pendingContact); + contactShareMapper.insert(randomPojo(OaContactShareDO.class, share -> { + share.setId(null).setContactId(pendingContact.getId()) + .setUserId(Long.valueOf(pendingContact.getCreator())).setCategoryId(pendingContact.getCategoryId()) + .setHandleStatus(true); + share.setCreator(pendingContact.getCreator()); + })); + contactShareMapper.insert(new OaContactShareDO().setContactId(pendingContact.getId()) + .setUserId(userId).setHandleStatus(false)); + OaContactDO handledContact = randomContactDO(randomLongId()); + contactMapper.insert(handledContact); + contactShareMapper.insert(randomPojo(OaContactShareDO.class, share -> { + share.setId(null).setContactId(handledContact.getId()) + .setUserId(Long.valueOf(handledContact.getCreator())).setCategoryId(handledContact.getCategoryId()) + .setHandleStatus(true); + share.setCreator(handledContact.getCreator()); + })); + contactShareMapper.insert(new OaContactShareDO().setContactId(handledContact.getId()) + .setUserId(userId).setHandleStatus(true)); + + // 准备参数:未选择处理状态时应查询全部收到的共享 + OaContactPageReqVO pageReqVO = new OaContactPageReqVO(); + + // 调用 + List contacts = contactService.getReceivedContactPage(pageReqVO, userId).getList(); + + // 断言 + assertEquals(2, contacts.size()); + assertTrue(contacts.stream().anyMatch(contact -> contact.getId().equals(pendingContact.getId()))); + assertTrue(contacts.stream().anyMatch(contact -> contact.getId().equals(handledContact.getId()))); + } + + @Test + public void testGetContactShareListMap_success() { + // mock 数据 + OaContactShareDO share = new OaContactShareDO().setContactId(10L).setUserId(1L).setHandleStatus(false); + contactShareMapper.insert(share); + contactShareMapper.insert(new OaContactShareDO().setContactId(20L).setUserId(2L).setHandleStatus(false)); + + // 调用 + Map> shares = contactService.getContactShareListMap(Collections.singleton(10L)); + + // 断言 + assertEquals(1, shares.size()); + assertEquals(share.getId(), CollUtil.getFirst(shares.get(10L)).getId()); + } + + @Test + public void testGetContactShareListMap_empty() { + // mock 数据 + contactShareMapper.insert(new OaContactShareDO().setContactId(10L).setUserId(1L).setHandleStatus(false)); + + // 调用,并断言:空编号集合不能返回全部共享记录 + assertTrue(CollUtil.isEmpty(contactService.getContactShareListMap(Collections.emptyList()))); + } + + @Test + public void testHandleContactShare_clearCategory() { + // mock 数据 + Long userId = randomLongId(); + OaContactDO contact = randomContactDO(randomLongId()); + contactMapper.insert(contact); + contactShareMapper.insert(randomPojo(OaContactShareDO.class, share -> { + share.setId(null).setContactId(contact.getId()) + .setUserId(Long.valueOf(contact.getCreator())).setCategoryId(contact.getCategoryId()) + .setHandleStatus(true); + share.setCreator(contact.getCreator()); + })); + OaContactShareDO share = new OaContactShareDO().setContactId(contact.getId()) + .setUserId(userId).setCategoryId(10L).setHandleStatus(true); + contactShareMapper.insert(share); + + // 调用 + contactService.handleContactShare(contact.getId(), null, userId); + + // 断言 + assertNull(contactShareMapper.selectById(share.getId()).getCategoryId()); + assertTrue(contactShareMapper.selectById(share.getId()).getHandleStatus()); + } + + @Test + public void testClearContactCategoryId_onlySpecifiedCategory() { + // mock 数据 + Long userId = randomLongId(); + OaContactDO contact = randomContactDO(userId).setCategoryId(10L); + contactMapper.insert(contact); + contactShareMapper.insert(randomPojo(OaContactShareDO.class, share -> { + share.setId(null).setContactId(contact.getId()) + .setUserId(Long.valueOf(contact.getCreator())).setCategoryId(contact.getCategoryId()) + .setHandleStatus(true); + share.setCreator(contact.getCreator()); + })); + OaContactDO otherContact = randomContactDO(randomLongId()).setCategoryId(11L); + contactMapper.insert(otherContact); + contactShareMapper.insert(randomPojo(OaContactShareDO.class, share -> { + share.setId(null).setContactId(otherContact.getId()) + .setUserId(Long.valueOf(otherContact.getCreator())).setCategoryId(otherContact.getCategoryId()) + .setHandleStatus(true); + share.setCreator(otherContact.getCreator()); + })); + OaContactShareDO share = new OaContactShareDO().setContactId(otherContact.getId()) + .setUserId(userId).setCategoryId(10L).setHandleStatus(true); + contactShareMapper.insert(share); + OaContactShareDO otherShare = new OaContactShareDO().setContactId(contact.getId()) + .setUserId(randomLongId()).setCategoryId(11L).setHandleStatus(true); + contactShareMapper.insert(otherShare); + + // 调用 + contactService.clearContactCategoryId(10L); + + // 断言 + assertNull(contactMapper.selectById(contact.getId()).getCategoryId()); + assertNull(contactShareMapper.selectById(share.getId()).getCategoryId()); + assertEquals(11L, contactMapper.selectById(otherContact.getId()).getCategoryId()); + assertEquals(11L, contactShareMapper.selectById(otherShare.getId()).getCategoryId()); + } + + @Test + public void testShareContact_notOwner() { + // mock 数据 + OaContactDO contact = randomContactDO(randomLongId()); + contactMapper.insert(contact); + contactShareMapper.insert(randomPojo(OaContactShareDO.class, share -> { + share.setId(null).setContactId(contact.getId()) + .setUserId(Long.valueOf(contact.getCreator())).setCategoryId(contact.getCategoryId()) + .setHandleStatus(true); + share.setCreator(contact.getCreator()); + })); + + // 调用,并断言异常 + assertServiceException(() -> contactService.shareContact(contact.getId(), + Collections.singleton(randomLongId()), randomLongId()), CONTACT_ACCESS_DENIED); + } + + @Test + public void testHandleContactShare_ownerCannotChangeOnlyRelation() { + // mock 数据 + Long userId = randomLongId(); + OaContactDO contact = randomContactDO(userId).setCategoryId(10L); + contactMapper.insert(contact); + OaContactShareDO share = new OaContactShareDO().setContactId(contact.getId()) + .setUserId(userId).setCategoryId(10L).setHandleStatus(true); + contactShareMapper.insert(share); + + // 调用,并断言:本人分类应走联系人编辑,主表与关系表均不变 + assertServiceException(() -> contactService.handleContactShare(contact.getId(), 20L, userId), + CONTACT_ACCESS_DENIED); + assertEquals(10L, contactMapper.selectById(contact.getId()).getCategoryId()); + assertEquals(10L, contactShareMapper.selectById(share.getId()).getCategoryId()); + } + + @Test + public void testHandleContactShare_notReceiver() { + // mock 数据 + OaContactDO contact = randomContactDO(randomLongId()); + contactMapper.insert(contact); + contactShareMapper.insert(randomPojo(OaContactShareDO.class, share -> { + share.setId(null).setContactId(contact.getId()) + .setUserId(Long.valueOf(contact.getCreator())).setCategoryId(contact.getCategoryId()) + .setHandleStatus(true); + share.setCreator(contact.getCreator()); + })); + + // 调用,并断言异常 + assertServiceException(() -> contactService.handleContactShare(contact.getId(), + 10L, randomLongId()), CONTACT_ACCESS_DENIED); + } + + @Test + public void testHandleContactShare_invalidCategory() { + // mock 数据 + Long userId = randomLongId(); + Long categoryId = randomLongId(); + OaContactDO contact = randomContactDO(randomLongId()); + contactMapper.insert(contact); + OaContactShareDO share = new OaContactShareDO().setContactId(contact.getId()) + .setUserId(userId).setHandleStatus(false); + contactShareMapper.insert(share); + // mock 方法 + doThrow(exception( + CONTACT_CATEGORY_NOT_EXISTS)) + .when(contactCategoryService).validateContactCategory(categoryId, userId); + + // 调用,并断言异常 + assertServiceException(() -> contactService.handleContactShare(share.getContactId(), categoryId, userId), + CONTACT_CATEGORY_NOT_EXISTS); + assertFalse(contactShareMapper.selectById(share.getId()).getHandleStatus()); + assertNull(contactShareMapper.selectById(share.getId()).getCategoryId()); + } + + @Test + public void testGetContactPage_receivedUsesReceiverCategory() { + // mock 数据:创建人与接收人分别选择自己的分类 + Long userId = randomLongId(); + OaContactDO contact = randomContactDO(randomLongId()).setCategoryId(10L); + contactMapper.insert(contact); + contactShareMapper.insert(randomPojo(OaContactShareDO.class, share -> { + share.setId(null).setContactId(contact.getId()) + .setUserId(Long.valueOf(contact.getCreator())).setCategoryId(contact.getCategoryId()) + .setHandleStatus(true); + share.setCreator(contact.getCreator()); + })); + contactShareMapper.insert(new OaContactShareDO().setContactId(contact.getId()) + .setUserId(userId).setCategoryId(20L).setHandleStatus(true)); + // 准备参数 + OaContactPageReqVO reqVO = new OaContactPageReqVO(); + reqVO.setCategoryId(20L); + + // 调用,并断言 + assertEquals(1L, contactService.getReceivedContactPage(reqVO, userId).getTotal()); + reqVO.setCategoryId(10L); + assertEquals(0L, contactService.getReceivedContactPage(reqVO, userId).getTotal()); + } + + @Test + public void testDeleteContact_preserveOtherHolderAndDeleteLastHolder() { + // 准备参数 + Long ownerId = 101L; + Long receiverId = 102L; + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(ownerId), new MockHttpServletRequest()); + Long id = contactService.createContact(new OaContactSaveReqVO().setName("共享联系人"), ownerId); + contactService.shareContact(id, Collections.singleton(receiverId), ownerId); + + // 调用:创建人退出,不影响其他持有人 + contactService.deleteContact(id, ownerId); + + // 断言 + assertServiceException(() -> contactService.getContact(id, ownerId), CONTACT_ACCESS_DENIED); + assertEquals(id, contactService.getContact(id, receiverId).getId()); + assertEquals(ownerId.toString(), contactMapper.selectById(id).getCreator()); + contactService.deleteReceivedContact(id, receiverId); + assertNull(contactMapper.selectById(id)); + } + + @Test + public void testShareContact_receiverCanShareAndCreatorFilledAutomatically() { + // 准备参数 + Long ownerId = 101L; + Long receiverId = 102L; + Long nextReceiverId = 103L; + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(ownerId), new MockHttpServletRequest()); + Long id = contactService.createContact(new OaContactSaveReqVO().setName("共享联系人"), ownerId); + assertEquals(0L, contactService.getReceivedContactPage(new OaContactPageReqVO(), ownerId).getTotal()); + assertEquals(0L, contactService.getSharedContactPage(new OaContactPageReqVO(), ownerId).getTotal()); + contactService.shareContact(id, Collections.singleton(receiverId), ownerId); + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(receiverId), new MockHttpServletRequest()); + + // 调用 + contactService.shareContact(id, Collections.singleton(nextReceiverId), receiverId); + + // 断言:关系 creator 是本次共享人,联系人 creator 不变 + assertEquals(receiverId.toString(), contactShareMapper.selectByContactIdAndUserId(id, nextReceiverId).getCreator()); + assertEquals(ownerId.toString(), contactMapper.selectById(id).getCreator()); + assertEquals(1L, contactService.getSharedContactPage(new OaContactPageReqVO(), receiverId).getTotal()); + contactService.deleteContact(id, ownerId); + contactService.shareContact(id, Collections.singleton(ownerId), receiverId); + assertEquals(id, contactService.getContact(id, ownerId).getId()); + assertEquals(1L, contactService.getReceivedContactPage(new OaContactPageReqVO(), ownerId).getTotal()); + } + + // ========== 随机对象 ========== + + /** + * 构造性别在合法范围内的联系人。 + * + * @param ownerUserId 创建人编号 + * @return 未入库的测试对象 + */ + private static OaContactDO randomContactDO(Long ownerUserId) { + OaContactDO contact = randomPojo(OaContactDO.class, o -> o.setId(null) + .setName("张三").setPinyin("zhangsan").setSex(1)); + contact.setCreator(ownerUserId.toString()); + return contact; + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionLikeServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionLikeServiceImplTest.java new file mode 100644 index 0000000000..fd4b59046b --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionLikeServiceImplTest.java @@ -0,0 +1,117 @@ +package cn.iocoder.yudao.module.oa.service.discussion; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionLikeDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionReplyDO; +import cn.iocoder.yudao.module.oa.dal.mysql.discussion.OaDiscussionLikeMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Collections; +import javax.sql.DataSource; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaDiscussionLikeServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaDiscussionLikeServiceImpl.class) +public class OaDiscussionLikeServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaDiscussionLikeServiceImpl discussionLikeService; + + @Resource + private OaDiscussionLikeMapper discussionLikeMapper; + @Resource + private DataSource dataSource; + + @MockitoBean + private OaDiscussionService discussionService; + @MockitoBean + private OaDiscussionReplyService discussionReplyService; + + @Test + public void testCreateAndDeleteDiscussionLike_reply() { + // 准备参数 + Long replyId = randomLongId(); + Long userId = randomLongId(); + + // mock 方法 + when(discussionReplyService.validateDiscussionReplyExists(replyId)) + .thenReturn(new OaDiscussionReplyDO().setId(replyId) + .setParentId(OaDiscussionReplyDO.PARENT_ID_ROOT)); + + // 调用 + discussionLikeService.createDiscussionLike(null, replyId, userId); + discussionLikeService.createDiscussionLike(null, replyId, userId); + // 断言 + assertEquals(1L, discussionLikeMapper.selectCount()); + discussionLikeService.deleteDiscussionLike(null, replyId, userId); + discussionLikeService.deleteDiscussionLike(null, replyId, userId); + assertEquals(0L, discussionLikeMapper.selectCount()); + verify(discussionReplyService, times(2)).validateDiscussionReplyExists(replyId); + verifyNoInteractions(discussionService); + } + + @Test + public void testDeleteDiscussionLikesByReplyIds_empty() { + // mock 数据 + OaDiscussionLikeDO like = new OaDiscussionLikeDO().setDiscussionId(randomLongId()).setUserId(randomLongId()); + discussionLikeMapper.insert(like); + + // 调用 + discussionLikeService.deleteDiscussionLikesByReplyIds(Collections.emptyList()); + + // 断言 + assertNotNull(discussionLikeMapper.selectById(like.getId())); + } + + @Test + public void testCreateAndDeleteDiscussionLike_preservesHistory() { + // 准备参数 + Long replyId = randomLongId(); + Long userId = randomLongId(); + + // mock 方法 + when(discussionReplyService.validateDiscussionReplyExists(replyId)) + .thenReturn(new OaDiscussionReplyDO().setId(replyId) + .setParentId(OaDiscussionReplyDO.PARENT_ID_ROOT)); + + // 调用:连续两轮点赞和取消,逻辑删除历史不应触发唯一键冲突 + for (int i = 0; i < 2; i++) { + discussionLikeService.createDiscussionLike(null, replyId, userId); + discussionLikeService.deleteDiscussionLike(null, replyId, userId); + } + + // 断言:业务查询不可见,物理记录仍保留 + assertEquals(0L, discussionLikeMapper.selectCount()); + assertEquals(2, new JdbcTemplate(dataSource).queryForObject( + "SELECT COUNT(*) FROM oa_discussion_like WHERE reply_id = ? AND deleted = TRUE", Integer.class, replyId)); + } + + @Test + public void testCreateDiscussionLike_childReply() { + // 准备参数 + Long replyId = randomLongId(); + + // mock 方法 + when(discussionReplyService.validateDiscussionReplyExists(replyId)) + .thenReturn(new OaDiscussionReplyDO().setId(replyId).setParentId(randomLongId())); + + // 调用,并断言异常 + assertServiceException(() -> discussionLikeService.createDiscussionLike(null, replyId, randomLongId()), + DISCUSSION_LIKE_TARGET_INVALID); + assertEquals(0L, discussionLikeMapper.selectCount()); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionReplyServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionReplyServiceImplTest.java new file mode 100644 index 0000000000..50f28e771c --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionReplyServiceImplTest.java @@ -0,0 +1,123 @@ +package cn.iocoder.yudao.module.oa.service.discussion; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.controller.admin.discussion.vo.reply.OaDiscussionReplyCreateReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionReplyDO; +import cn.iocoder.yudao.module.oa.dal.mysql.discussion.OaDiscussionReplyMapper; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Collections; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaDiscussionReplyServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaDiscussionReplyServiceImpl.class) +public class OaDiscussionReplyServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaDiscussionReplyServiceImpl discussionReplyService; + + @Resource + private OaDiscussionReplyMapper discussionReplyMapper; + + @MockitoBean + private OaDiscussionService discussionService; + @MockitoBean + private OaDiscussionLikeService discussionLikeService; + @MockitoBean + private PermissionApi permissionApi; + + @Test + public void testCreateDiscussionReply_parentFromOtherDiscussion() { + // mock 数据 + OaDiscussionReplyDO parent = new OaDiscussionReplyDO().setDiscussionId(randomLongId()) + .setUserId(randomLongId()).setParentId(OaDiscussionReplyDO.PARENT_ID_ROOT).setContent("父回复"); + discussionReplyMapper.insert(parent); + + // 准备参数 + Long discussionId = randomLongId(); + OaDiscussionReplyCreateReqVO reqVO = new OaDiscussionReplyCreateReqVO() + .setDiscussionId(discussionId).setParentId(parent.getId()).setContent("回复"); + + // mock 方法 + when(discussionService.validateDiscussionExists(discussionId)).thenReturn(new OaDiscussionDO().setId(discussionId)); + + // 调用,并断言异常 + assertServiceException(() -> discussionReplyService.createDiscussionReply(reqVO, randomLongId()), + DISCUSSION_REPLY_NOT_EXISTS); + assertEquals(1L, discussionReplyMapper.selectCount()); + } + + @Test + public void testCreateDiscussionReply_longRootContent() { + // 准备参数:根回复对齐源端 TEXT,不再受原来 2000 字限制 + Long discussionId = randomLongId(); + String content = StrUtil.repeat("回复正文", 600); + OaDiscussionReplyCreateReqVO reqVO = new OaDiscussionReplyCreateReqVO() + .setDiscussionId(discussionId).setParentId(OaDiscussionReplyDO.PARENT_ID_ROOT).setContent(content); + // mock 方法 + when(discussionService.validateDiscussionExists(discussionId)) + .thenReturn(new OaDiscussionDO().setId(discussionId)); + + // 调用 + Long id = discussionReplyService.createDiscussionReply(reqVO, randomLongId()); + // 断言 + assertEquals(content, discussionReplyMapper.selectById(id).getContent()); + } + + @Test + public void testDeleteDiscussionRepliesByDiscussionId() { + // mock 数据 + Long discussionId = randomLongId(); + OaDiscussionReplyDO reply = new OaDiscussionReplyDO().setDiscussionId(discussionId) + .setUserId(randomLongId()).setParentId(OaDiscussionReplyDO.PARENT_ID_ROOT).setContent("回复"); + discussionReplyMapper.insert(reply); + + // 调用 + discussionReplyService.deleteDiscussionRepliesByDiscussionId(discussionId); + + // 断言 + assertNull(discussionReplyMapper.selectById(reply.getId())); + verify(discussionLikeService).deleteDiscussionLikesByReplyIds(Collections.singleton(reply.getId())); + } + + @Test + public void testDeleteDiscussionReply_cyclicParents() { + // mock 数据:两条回复错误地互为父回复 + Long discussionId = randomLongId(); + Long userId = randomLongId(); + OaDiscussionReplyDO root = new OaDiscussionReplyDO().setDiscussionId(discussionId) + .setUserId(userId).setParentId(OaDiscussionReplyDO.PARENT_ID_ROOT).setContent("根回复"); + discussionReplyMapper.insert(root); + OaDiscussionReplyDO child = new OaDiscussionReplyDO().setDiscussionId(discussionId) + .setUserId(userId).setParentId(root.getId()).setContent("子回复"); + discussionReplyMapper.insert(child); + discussionReplyMapper.updateById(new OaDiscussionReplyDO().setId(root.getId()).setParentId(child.getId())); + + // mock 方法 + when(discussionService.validateDiscussionExists(discussionId)) + .thenReturn(new OaDiscussionDO().setId(discussionId).setUserId(userId)); + + // 调用 + discussionReplyService.deleteDiscussionReply(root.getId(), userId); + + // 断言:循环关联不会导致死循环,也不会漏掉子回复 + assertNull(discussionReplyMapper.selectById(root.getId())); + assertNull(discussionReplyMapper.selectById(child.getId())); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionServiceImplTest.java new file mode 100644 index 0000000000..a826987f02 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionServiceImplTest.java @@ -0,0 +1,74 @@ +package cn.iocoder.yudao.module.oa.service.discussion; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionDO; +import cn.iocoder.yudao.module.oa.dal.mysql.discussion.OaDiscussionMapper; +import cn.iocoder.yudao.module.oa.enums.discussion.OaDiscussionTypeEnum; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaDiscussionServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaDiscussionServiceImpl.class) +public class OaDiscussionServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaDiscussionServiceImpl discussionService; + + @Resource + private OaDiscussionMapper discussionMapper; + + @MockitoBean + private OaDiscussionReplyService discussionReplyService; + @MockitoBean + private OaDiscussionLikeService discussionLikeService; + @MockitoBean + private OaDiscussionVoteService discussionVoteService; + @MockitoBean + private PermissionApi permissionApi; + + @Test + public void testGetDiscussion_notExists() { + + // 调用,并断言:纯查询不抛出异常 + assertNull(discussionService.getDiscussion(randomLongId())); + } + + @Test + public void testValidateDiscussionExists_notExists() { + + // 调用,并断言:存在性校验抛出业务异常 + assertServiceException(() -> discussionService.validateDiscussionExists(randomLongId()), DISCUSSION_NOT_EXISTS); + } + + @Test + public void testDeleteDiscussion_cascadeThroughServices() { + // mock 数据 + Long userId = randomLongId(); + OaDiscussionDO discussion = new OaDiscussionDO().setUserId(userId) + .setType(OaDiscussionTypeEnum.DISCUSSION.getType()).setTitle("讨论主题").setVisitCount(0); + discussionMapper.insert(discussion); + + // 调用 + discussionService.deleteDiscussion(discussion.getId(), userId); + + // 断言 + assertNull(discussionMapper.selectById(discussion.getId())); + verify(discussionReplyService).deleteDiscussionRepliesByDiscussionId(discussion.getId()); + verify(discussionLikeService).deleteDiscussionLikesByDiscussionId(discussion.getId()); + verify(discussionVoteService).deleteDiscussionVotesByDiscussionId(discussion.getId()); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionVoteServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionVoteServiceImplTest.java new file mode 100644 index 0000000000..63cc083041 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/discussion/OaDiscussionVoteServiceImplTest.java @@ -0,0 +1,129 @@ +package cn.iocoder.yudao.module.oa.service.discussion; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaDiscussionDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.discussion.OaVoteOptionDO; +import cn.iocoder.yudao.module.oa.dal.mysql.discussion.OaVoteOptionMapper; +import cn.iocoder.yudao.module.oa.dal.mysql.discussion.OaVoteRecordMapper; +import cn.iocoder.yudao.module.oa.enums.discussion.OaDiscussionTypeEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaDiscussionVoteServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaDiscussionVoteServiceImpl.class) +public class OaDiscussionVoteServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaDiscussionVoteServiceImpl discussionVoteService; + + @Resource + private OaVoteOptionMapper voteOptionMapper; + @Resource + private OaVoteRecordMapper voteRecordMapper; + + @MockitoBean + private OaDiscussionService discussionService; + + @Test + public void testVoteDiscussion_expired() { + // 准备参数 + Long discussionId = randomLongId(); + OaDiscussionDO discussion = new OaDiscussionDO().setId(discussionId) + .setType(OaDiscussionTypeEnum.VOTE.getType()).setVoteMultiple(false) + .setVoteStartTime(LocalDateTime.now().minusDays(2)).setVoteEndTime(LocalDateTime.now().minusDays(1)); + + // mock 方法 + when(discussionService.validateDiscussionExists(discussionId)).thenReturn(discussion); + + // 调用,并断言异常 + assertServiceException(() -> discussionVoteService.voteDiscussion(discussionId, + Collections.singleton(randomLongId()), randomLongId()), DISCUSSION_VOTE_EXPIRED); + assertEquals(0L, voteRecordMapper.selectCount()); + } + + @Test + public void testVoteDiscussion_optionFromOtherDiscussion() { + // mock 数据 + OaVoteOptionDO option = new OaVoteOptionDO().setDiscussionId(randomLongId()).setTitle("其他讨论").setSort(1); + voteOptionMapper.insert(option); + + // 准备参数 + Long discussionId = randomLongId(); + OaDiscussionDO discussion = new OaDiscussionDO().setId(discussionId) + .setType(OaDiscussionTypeEnum.VOTE.getType()).setVoteMultiple(false) + .setVoteStartTime(LocalDateTime.now().minusDays(1)).setVoteEndTime(LocalDateTime.now().plusDays(1)); + + // mock 方法 + when(discussionService.validateDiscussionExists(discussionId)).thenReturn(discussion); + + // 调用,并断言异常 + assertServiceException(() -> discussionVoteService.voteDiscussion(discussionId, + Collections.singleton(option.getId()), randomLongId()), DISCUSSION_VOTE_OPTION_NOT_EXISTS); + assertEquals(0L, voteRecordMapper.selectCount()); + } + + @Test + public void testVoteDiscussion_multipleCanAppend() { + // mock 数据 + Long discussionId = randomLongId(); + Long userId = randomLongId(); + OaDiscussionDO discussion = new OaDiscussionDO().setId(discussionId) + .setType(OaDiscussionTypeEnum.VOTE.getType()).setVoteMultiple(true) + .setVoteStartTime(LocalDateTime.now().minusDays(1)).setVoteEndTime(LocalDateTime.now().plusDays(1)); + OaVoteOptionDO firstOption = new OaVoteOptionDO().setDiscussionId(discussionId).setTitle("方案 A").setSort(1); + OaVoteOptionDO secondOption = new OaVoteOptionDO().setDiscussionId(discussionId).setTitle("方案 B").setSort(2); + voteOptionMapper.insert(firstOption); + voteOptionMapper.insert(secondOption); + + // mock 方法 + when(discussionService.validateDiscussionExists(discussionId)).thenReturn(discussion); + + // 调用 + discussionVoteService.voteDiscussion(discussionId, Collections.singleton(firstOption.getId()), userId); + discussionVoteService.voteDiscussion(discussionId, Collections.singleton(secondOption.getId()), userId); + + // 断言 + assertEquals(2, voteRecordMapper.selectListByDiscussionIdAndUserId(discussionId, userId).size()); + } + + @Test + public void testVoteDiscussion_multipleCannotRepeatOption() { + // mock 数据 + Long discussionId = randomLongId(); + Long userId = randomLongId(); + OaDiscussionDO discussion = new OaDiscussionDO().setId(discussionId) + .setType(OaDiscussionTypeEnum.VOTE.getType()).setVoteMultiple(true) + .setVoteStartTime(LocalDateTime.now().minusDays(1)).setVoteEndTime(LocalDateTime.now().plusDays(1)); + OaVoteOptionDO firstOption = new OaVoteOptionDO().setDiscussionId(discussionId).setTitle("方案 A").setSort(1); + OaVoteOptionDO secondOption = new OaVoteOptionDO().setDiscussionId(discussionId).setTitle("方案 B").setSort(2); + voteOptionMapper.insert(firstOption); + voteOptionMapper.insert(secondOption); + + // mock 方法 + when(discussionService.validateDiscussionExists(discussionId)).thenReturn(discussion); + + // 调用 + discussionVoteService.voteDiscussion(discussionId, Collections.singleton(firstOption.getId()), userId); + // 断言 + assertServiceException(() -> discussionVoteService.voteDiscussion(discussionId, + Arrays.asList(firstOption.getId(), secondOption.getId()), userId), DISCUSSION_VOTE_DUPLICATE); + assertEquals(1, voteRecordMapper.selectListByDiscussionIdAndUserId(discussionId, userId).size()); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/file/OaFileFavoriteServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/file/OaFileFavoriteServiceImplTest.java new file mode 100644 index 0000000000..4653745836 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/file/OaFileFavoriteServiceImplTest.java @@ -0,0 +1,132 @@ +package cn.iocoder.yudao.module.oa.service.file; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.dal.dataobject.file.OaFileFavoriteDO; +import cn.iocoder.yudao.module.oa.dal.mysql.file.OaFileFavoriteMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaFileFavoriteServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaFileFavoriteServiceImpl.class) +public class OaFileFavoriteServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaFileFavoriteService fileFavoriteService; + + @Resource + private OaFileFavoriteMapper fileFavoriteMapper; + + @MockitoBean + private OaFileNodeService fileNodeService; + + @Test + public void testGetFileFavoriteListByUserIdAndNodeIds() { + // mock 数据:仅返回本人、当前页、未逻辑删除的收藏 + OaFileFavoriteDO matched = randomFileFavoriteDO(10L, 1L); + fileFavoriteMapper.insert(matched); + fileFavoriteMapper.insert(randomFileFavoriteDO(10L, 2L)); + fileFavoriteMapper.insert(randomFileFavoriteDO(11L, 1L)); + OaFileFavoriteDO deleted = randomFileFavoriteDO(12L, 1L); + fileFavoriteMapper.insert(deleted); + fileFavoriteMapper.deleteById(deleted.getId()); + + // 调用 + List result = fileFavoriteService.getFileFavoriteListByUserIdAndNodeIds(1L, Arrays.asList(10L, 12L)); + // 断言 + assertEquals(1, result.size()); + assertEquals(matched.getId(), CollUtil.getFirst(result).getId()); + } + + @Test + public void testGetFileFavoriteListByUserIdAndNodeIds_empty() { + // mock 数据 + fileFavoriteMapper.insert(randomFileFavoriteDO(10L, 1L)); + + // 调用 + List result = fileFavoriteService.getFileFavoriteListByUserIdAndNodeIds(1L, Collections.emptyList()); + // 断言:空页不能退化成查询全部收藏 + assertTrue(CollUtil.isEmpty(result)); + } + + @Test + public void testCreateFileFavorite_repeatAndUserIsolation() { + // mock 数据 + OaFileFavoriteDO other = randomFileFavoriteDO(10L, 2L); + fileFavoriteMapper.insert(other); + + // 调用 + fileFavoriteService.createFileFavorite(10L, 1L); + fileFavoriteService.createFileFavorite(10L, 1L); + // 断言 + assertEquals(2, fileFavoriteMapper.selectList().size()); + assertNotNull(fileFavoriteMapper.selectByUserIdAndNodeId(1L, 10L)); + assertNotNull(fileFavoriteMapper.selectById(other.getId())); + verify(fileNodeService, times(2)).validateFileNodePermission(10L, 1L, 1); + } + + @Test + public void testDeleteFileFavorite_onlyCurrentUser() { + // mock 数据 + OaFileFavoriteDO own = randomFileFavoriteDO(10L, 1L); + OaFileFavoriteDO other = randomFileFavoriteDO(10L, 2L); + fileFavoriteMapper.insert(own); + fileFavoriteMapper.insert(other); + + // 调用 + fileFavoriteService.deleteFileFavorite(10L, 1L); + // 断言 + assertNull(fileFavoriteMapper.selectById(own.getId())); + assertNotNull(fileFavoriteMapper.selectById(other.getId())); + } + + @Test + public void testDeleteFileFavoritesByNodeIds() { + // mock 数据 + OaFileFavoriteDO first = randomFileFavoriteDO(10L, 1L); + OaFileFavoriteDO second = randomFileFavoriteDO(11L, 2L); + OaFileFavoriteDO retained = randomFileFavoriteDO(12L, 1L); + fileFavoriteMapper.insert(first); + fileFavoriteMapper.insert(second); + fileFavoriteMapper.insert(retained); + + // 调用 + fileFavoriteService.deleteFileFavoritesByNodeIds(Collections.emptyList()); + fileFavoriteService.deleteFileFavoritesByNodeIds(Arrays.asList(10L, 11L)); + // 断言 + assertNull(fileFavoriteMapper.selectById(first.getId())); + assertNull(fileFavoriteMapper.selectById(second.getId())); + assertNotNull(fileFavoriteMapper.selectById(retained.getId())); + } + + // ========== 随机对象 ========== + + /** + * 构造文件收藏关系。 + * + * @param nodeId 文件节点编号 + * @param userId 用户编号 + * @return 未入库的测试对象 + */ + private static OaFileFavoriteDO randomFileFavoriteDO(Long nodeId, Long userId) { + return randomPojo(OaFileFavoriteDO.class, favorite -> { + favorite.setNodeId(nodeId); + favorite.setUserId(userId); + }); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/file/OaFileNodeServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/file/OaFileNodeServiceImplTest.java new file mode 100644 index 0000000000..d91ddd9546 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/file/OaFileNodeServiceImplTest.java @@ -0,0 +1,1272 @@ +package cn.iocoder.yudao.module.oa.service.file; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.infra.api.file.FileApi; +import cn.iocoder.yudao.module.oa.controller.admin.file.vo.node.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.file.OaFileFavoriteDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.file.OaFileNodeDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.file.OaFilePermissionDO; +import cn.iocoder.yudao.module.oa.dal.mysql.file.OaFileNodeMapper; +import cn.iocoder.yudao.module.oa.framework.config.OaProperties; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaFileNodeServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import({OaFileNodeServiceImpl.class, OaProperties.class, ValidationAutoConfiguration.class}) +public class OaFileNodeServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaFileNodeService fileNodeService; + + @Resource + private OaFileNodeMapper fileNodeMapper; + @Resource + private OaProperties properties; + + @MockitoBean + private OaFilePermissionService filePermissionService; + @MockitoBean + private OaFileFavoriteService fileFavoriteService; + @MockitoBean + private FileApi fileApi; + @MockitoBean + private AdminUserApi adminUserApi; + + @BeforeEach + public void before() { + // mock 当前已登录账号,用户必须存在,部门允许为空 + when(adminUserApi.getUser(anyLong())).thenAnswer(invocation -> + new AdminUserRespDTO().setId(invocation.getArgument(0))); + } + + @Test + public void testCreateFileNode_duplicateName() { + // mock 数据:同目录忽略大小写,其他用户和回收站不占用当前名称 + OaFileNodeDO first = randomFileNodeDO(1L, 0L, 0).setType(1).setName("Report.PDF"); + first.setCreator("10"); + fileNodeMapper.insert(first); + OaFileNodeDO second = randomFileNodeDO(2L, 0L, 0).setType(1).setName("report(1).pdf"); + second.setCreator("10"); + fileNodeMapper.insert(second); + OaFileNodeDO other = randomFileNodeDO(3L, 0L, 0).setType(1).setName("report(2).pdf"); + other.setCreator("20"); + fileNodeMapper.insert(other); + // 准备参数 + OaFileNodeSaveReqVO reqVO = new OaFileNodeSaveReqVO().setParentId(0L).setType(1) + .setName("report.pdf").setUrl("/files/report.pdf").setSize(1L); + + // 调用 + Long id = fileNodeService.createFileNode(reqVO, 10L); + // 断言 + assertEquals("report(2).pdf", fileNodeMapper.selectById(id).getName()); + assertEquals("/files/report.pdf", fileNodeMapper.selectById(id).getUrl()); + } + + @Test + public void testCreateFileNode_duplicateFolderName() { + // mock 数据 + OaFileNodeDO folder = randomFileNodeDO(1L, 0L, 0).setName("资料.v1"); + folder.setCreator("10"); + fileNodeMapper.insert(folder); + + // 调用 + Long id = fileNodeService.createFileNode(new OaFileNodeSaveReqVO().setParentId(0L) + .setType(0).setName("资料.v1"), 10L); + // 断言:目录名称中的点不视为扩展名 + assertEquals("资料.v1(1)", fileNodeMapper.selectById(id).getName()); + } + + @Test + public void testUpdateFileNodeName_duplicateAndSelf() { + // mock 数据 + OaFileNodeDO node = randomFileNodeDO(1L, 0L, 0).setType(1).setName("原文件.txt").setUrl("/files/old.txt"); + node.setCreator("10"); + fileNodeMapper.insert(node); + OaFileNodeDO existing = randomFileNodeDO(2L, 0L, 0).setType(1).setName("报告.pdf"); + existing.setCreator("10"); + fileNodeMapper.insert(existing); + + // 调用 + fileNodeService.updateFileNodeName(new OaFileNodeRenameReqVO().setId(1L).setName("报告.pdf"), 10L); + fileNodeService.updateFileNodeName(new OaFileNodeRenameReqVO().setId(1L).setName("报告(1).pdf"), 10L); + // 断言:再次提交原名称不追加序号,文件内容地址不改变 + OaFileNodeDO result = fileNodeMapper.selectById(1L); + assertEquals("报告(1).pdf", result.getName()); + assertEquals("pdf", result.getExtension()); + assertEquals("/files/old.txt", result.getUrl()); + } + + @Test + public void testUpdateFileNodeParent_duplicateNameAndSameParent() { + // mock 数据 + OaFileNodeDO target = randomFileNodeDO(1L, 0L, 0).setName("目标目录"); + target.setCreator("10"); + fileNodeMapper.insert(target); + OaFileNodeDO existing = randomFileNodeDO(2L, 1L, 0).setType(1).setName("报告.pdf"); + existing.setCreator("10"); + fileNodeMapper.insert(existing); + OaFileNodeDO moving = randomFileNodeDO(3L, 0L, 0).setType(1).setName("报告.pdf").setUrl("/files/report.pdf"); + moving.setCreator("10"); + fileNodeMapper.insert(moving); + + // 调用 + fileNodeService.updateFileNodeParent(new OaFileNodeMoveReqVO().setId(3L).setParentId(1L), 10L); + fileNodeService.updateFileNodeParent(new OaFileNodeMoveReqVO().setId(3L).setParentId(1L), 10L); + // 断言 + assertEquals(1L, fileNodeMapper.selectById(3L).getParentId()); + assertEquals("报告(1).pdf", fileNodeMapper.selectById(3L).getName()); + assertEquals("/files/report.pdf", fileNodeMapper.selectById(3L).getUrl()); + } + + @Test + public void testRestoreFileNode_duplicateName() { + // mock 数据:本人两个同名候选,其他人的名称不占用个人根目录 + OaFileNodeDO existing = randomFileNodeDO(1L, 0L, 0).setName("报告.pdf").setType(1); + existing.setCreator("10"); + fileNodeMapper.insert(existing); + OaFileNodeDO second = randomFileNodeDO(2L, 0L, 0).setName("报告(1).pdf").setType(1); + second.setCreator("10"); + fileNodeMapper.insert(second); + OaFileNodeDO other = randomFileNodeDO(3L, 0L, 0).setName("报告(2).pdf").setType(1); + other.setCreator("20"); + fileNodeMapper.insert(other); + OaFileNodeDO recycled = randomFileNodeDO(4L, 999L, 1).setName("报告.pdf").setType(1); + recycled.setCreator("10"); + fileNodeMapper.insert(recycled); + + // 调用 + fileNodeService.restoreFileNode(4L, 10L); + // 断言 + assertEquals("报告(2).pdf", fileNodeMapper.selectById(4L).getName()); + assertEquals(0L, fileNodeMapper.selectById(4L).getParentId()); + assertEquals(0, fileNodeMapper.selectById(4L).getStatus()); + } + + @Test + public void testCopyFileNode_directoryKeepsSourceAndSkipsRecycled() { + // mock 数据 + OaFileNodeDO root = randomFileNodeDO(1L, 0L, 0).setName("资料"); root.setCreator("10"); + OaFileNodeDO folder = randomFileNodeDO(2L, 1L, 0).setName("子目录"); folder.setCreator("10"); + OaFileNodeDO file = randomFileNodeDO(3L, 2L, 0).setType(1).setName("说明.pdf") + .setUrl("/files/a.pdf").setSize(100L); file.setCreator("10"); + OaFileNodeDO recycled = randomFileNodeDO(4L, 1L, 1); recycled.setCreator("10"); + OaFileNodeDO siblingFile = randomFileNodeDO(5L, 1L, 0).setType(1).setName("附件.txt"); + siblingFile.setCreator("10"); + fileNodeMapper.insert(root); fileNodeMapper.insert(folder); fileNodeMapper.insert(file); fileNodeMapper.insert(recycled); + fileNodeMapper.insert(siblingFile); + + // 调用 + Long id = fileNodeService.copyFileNode(new OaFileNodeCopyReqVO().setId(1L).setParentId(0L), 10L); + // 断言 + assertEquals("资料(1)", fileNodeMapper.selectById(id).getName()); + List copiedChildren = fileNodeMapper.selectListByParentId(id); + assertEquals(2, copiedChildren.size()); + OaFileNodeDO copiedFolder = CollUtil.findOne(copiedChildren, node -> node.getType() == 0); + assertNotNull(CollUtil.findOne(copiedChildren, node -> "附件.txt".equals(node.getName()))); + OaFileNodeDO copiedFile = CollUtil.getFirst(fileNodeMapper.selectListByParentId(copiedFolder.getId())); + assertEquals("说明.pdf", copiedFile.getName()); + assertEquals(file.getUrl(), copiedFile.getUrl()); + assertEquals(9L, fileNodeMapper.selectCount()); + assertNotNull(fileNodeMapper.selectById(1L)); + assertEquals(2L, fileNodeMapper.selectById(3L).getParentId()); + verifyNoInteractions(fileApi, fileFavoriteService); + } + + @Test + public void testCopyFileNode_duplicateNameAndDescendant() { + // mock 数据 + OaFileNodeDO root = randomFileNodeDO(1L, 0L, 0).setName("资料"); root.setCreator("10"); + OaFileNodeDO existing = randomFileNodeDO(2L, 0L, 0).setName("资料(1)"); existing.setCreator("10"); + OaFileNodeDO child = randomFileNodeDO(3L, 1L, 0); child.setCreator("10"); + fileNodeMapper.insert(root); fileNodeMapper.insert(existing); fileNodeMapper.insert(child); + + // 调用,并断言:不能复制到自身下级 + assertServiceException(() -> fileNodeService.copyFileNode(new OaFileNodeCopyReqVO().setId(1L) + .setParentId(3L), 10L), FILE_NODE_PATH_INVALID); + Long id = fileNodeService.copyFileNode(new OaFileNodeCopyReqVO().setId(1L).setParentId(0L), 10L); + assertEquals("资料(2)", fileNodeMapper.selectById(id).getName()); + } + + @Test + public void testCopyFileNode_otherUserWithoutPermission() { + // mock 数据 + OaFileNodeDO root = randomFileNodeDO(1L, 0L, 0); root.setCreator("20"); + fileNodeMapper.insert(root); + + // 调用,并断言异常 + assertServiceException(() -> fileNodeService.copyFileNode(new OaFileNodeCopyReqVO().setId(1L) + .setParentId(0L), 10L), FILE_NODE_ACCESS_DENIED); + assertEquals(1L, fileNodeMapper.selectCount()); + } + + @Test + public void testUpdateFileNodeParent_concurrentMutualMove() throws Exception { + // mock 数据:同一用户的两个根目录 + OaFileNodeDO first = randomFileNodeDO(1L, 0L, 0).setName("目录 A"); + first.setCreator("10"); + OaFileNodeDO second = randomFileNodeDO(2L, 0L, 0).setName("目录 B"); + second.setCreator("10"); + fileNodeMapper.insert(first); + fileNodeMapper.insert(second); + // 准备参数:两个独立事务同时互相移动 + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future firstMove = executor.submit(() -> { + start.await(); + try { + fileNodeService.updateFileNodeParent(new OaFileNodeMoveReqVO().setId(1L).setParentId(2L), 10L); + return true; + } catch (ServiceException e) { + assertEquals(FILE_NODE_MOVE_INVALID.getCode(), e.getCode()); + return false; + } + }); + Future secondMove = executor.submit(() -> { + start.await(); + try { + fileNodeService.updateFileNodeParent(new OaFileNodeMoveReqVO().setId(2L).setParentId(1L), 10L); + return true; + } catch (ServiceException e) { + assertEquals(FILE_NODE_MOVE_INVALID.getCode(), e.getCode()); + return false; + } + }); + // 调用,并断言:仅一个移动成功,最终仍有一个根目录 + start.countDown(); + assertNotEquals(firstMove.get(10, TimeUnit.SECONDS), secondMove.get(10, TimeUnit.SECONDS)); + assertTrue(fileNodeMapper.selectById(1L).getParentId() == 0L + || fileNodeMapper.selectById(2L).getParentId() == 0L); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void testUpdateFileNodeName_preserveUrl() { + // mock 数据 + OaFileNodeDO node = randomFileNodeDO(1L, 0L, 0).setType(1).setName("原名称.txt") + .setUrl("https://example.com/files/sample.txt"); + node.setCreator("1"); + fileNodeMapper.insert(node); + + // 准备参数:名称允许包含路径分隔符,但不参与文件地址拼接 + OaFileNodeRenameReqVO reqVO = new OaFileNodeRenameReqVO().setId(1L).setName("研发/说明.txt"); + + // 调用 + fileNodeService.updateFileNodeName(reqVO, 1L); + + // 断言 + OaFileNodeDO result = fileNodeMapper.selectById(1L); + assertEquals("研发/说明.txt", result.getName()); + assertEquals(node.getUrl(), result.getUrl()); + verifyNoInteractions(fileApi); + } + + @Test + public void testGetFileStorage_configuredSize() { + // 准备参数 + Long originalSize = properties.getFile().getStorageSize(); + properties.getFile().setStorageSize(10L * 1024 * 1024 * 1024); + try { + // 调用 + OaFileStorageRespVO result = fileNodeService.getFileStorage(1L); + + // 断言 + assertEquals(10L * 1024 * 1024 * 1024, result.getTotalSize()); + assertEquals(0L, result.getUsedSize()); + } finally { + properties.getFile().setStorageSize(originalSize); + } + } + + @Test + public void testGetFileNode() { + // mock 数据 + OaFileNodeDO node = randomFileNodeDO(1L, 0L, 0).setType(1) + .setUrl("https://example.com/files/sample.txt"); + node.setCreator("1"); + fileNodeMapper.insert(node); + + // mock 方法 + when(fileApi.presignGetUrl(node.getUrl(), 300)).thenReturn("https://example.com/files/download.txt"); + when(fileFavoriteService.getFileFavoriteListByUserIdAndNodeIds(eq(1L), anyCollection())) + .thenReturn(Collections.singletonList(new OaFileFavoriteDO().setNodeId(node.getId()))); + + // 调用 + OaFileNodeRespVO result = fileNodeService.getFileNode(node.getId(), 1L); + + // 断言 + assertEquals(node.getId(), result.getId()); + assertEquals(node.getName(), result.getName()); + assertEquals(node.getExtension(), result.getExtension()); + assertEquals(4, result.getLevel()); + assertTrue(result.getFavorite()); + assertEquals("https://example.com/files/download.txt", result.getUrl()); + verify(fileApi).presignGetUrl(node.getUrl(), 300); + } + + @Test + public void testGetFileNode_readOnly() { + // mock 数据 + OaFileNodeDO node = randomFileNodeDO(1L, 0L, 0).setType(1).setUrl("https://example.com/private.txt"); + node.setCreator("2"); + fileNodeMapper.insert(node); + + // mock 方法:仅授予查看文件信息权限 + OaFilePermissionDO permission = new OaFilePermissionDO().setNodeId(1L).setSubjectType(1) + .setSubjectId(1L).setLevel(1); + when(filePermissionService.getFilePermissionListByNodeIds(anyCollection())) + .thenReturn(Collections.singletonList(permission)); + + // 调用 + OaFileNodeRespVO result = fileNodeService.getFileNode(1L, 1L); + + // 断言 + assertEquals(node.getName(), result.getName()); + assertEquals(1, result.getLevel()); + assertFalse(result.getFavorite()); + assertNull(result.getUrl()); + verifyNoInteractions(fileApi); + } + + @Test + public void testGetFileNode_downloadPermission() { + // mock 数据 + OaFileNodeDO node = randomFileNodeDO(1L, 0L, 0).setType(1).setUrl("https://example.com/private.txt"); + node.setCreator("2"); + fileNodeMapper.insert(node); + + // mock 方法 + OaFilePermissionDO permission = new OaFilePermissionDO().setNodeId(1L).setSubjectType(1) + .setSubjectId(1L).setLevel(2); + when(filePermissionService.getFilePermissionListByNodeIds(anyCollection())) + .thenReturn(Collections.singletonList(permission)); + when(fileApi.presignGetUrl(node.getUrl(), 300)).thenReturn("https://example.com/download.txt"); + + // 调用 + OaFileNodeRespVO result = fileNodeService.getFileNode(1L, 1L); + + // 断言 + assertEquals(2, result.getLevel()); + assertEquals("https://example.com/download.txt", result.getUrl()); + verify(fileApi).presignGetUrl(node.getUrl(), 300); + } + + @Test + public void testGetFileNode_accessDenied() { + // mock 数据 + OaFileNodeDO node = randomFileNodeDO(1L, 0L, 0).setType(1); + node.setCreator("2"); + fileNodeMapper.insert(node); + + // 调用,并断言异常 + assertServiceException(() -> fileNodeService.getFileNode(1L, 1L), FILE_NODE_ACCESS_DENIED); + verifyNoInteractions(fileApi); + } + + @Test + public void testGetFileNode_expiredPermission() { + // mock 数据 + OaFileNodeDO node = randomFileNodeDO(1L, 0L, 0).setType(1); + node.setCreator("2"); + fileNodeMapper.insert(node); + + // mock 方法 + OaFilePermissionDO permission = new OaFilePermissionDO().setNodeId(1L).setSubjectType(1) + .setSubjectId(1L).setLevel(2).setExpireTime(LocalDateTime.now().minusDays(1)); + when(filePermissionService.getFilePermissionListByNodeIds(anyCollection())) + .thenReturn(Collections.singletonList(permission)); + + // 调用,并断言异常 + assertServiceException(() -> fileNodeService.getFileNode(1L, 1L), FILE_NODE_ACCESS_DENIED); + verifyNoInteractions(fileApi); + } + + @Test + public void testGetFileNode_folder() { + // mock 数据:即使目录残留地址,也不能签发下载地址 + OaFileNodeDO node = randomFileNodeDO(1L, 0L, 0).setUrl("https://example.com/private.txt"); + node.setCreator("1"); + fileNodeMapper.insert(node); + + // 调用 + OaFileNodeRespVO result = fileNodeService.getFileNode(1L, 1L); + + // 断言 + assertEquals(0, result.getType()); + assertEquals(4, result.getLevel()); + assertNull(result.getUrl()); + verifyNoInteractions(fileApi); + } + + @Test + public void testGetFileNode_recycledParent() { + // mock 数据:文件正常,但所属目录已回收 + OaFileNodeDO folder = randomFileNodeDO(1L, 0L, 1); + folder.setCreator("1"); + fileNodeMapper.insert(folder); + OaFileNodeDO node = randomFileNodeDO(2L, 1L, 0).setType(1); + node.setCreator("1"); + fileNodeMapper.insert(node); + + // 调用,并断言异常 + assertServiceException(() -> fileNodeService.getFileNode(2L, 1L), FILE_NODE_NOT_AVAILABLE); + verifyNoInteractions(fileApi); + } + + @Test + public void testGetFileNodePage_noUrl() { + // mock 数据 + OaFileNodeDO node = randomFileNodeDO(1L, 0L, 0).setType(1).setUrl("https://example.com/private.txt"); + node.setCreator("1"); + fileNodeMapper.insert(node); + + // 准备参数:根目录走数据库分页,全局查询走候选范围分页 + OaFileNodePageReqVO reqVO = new OaFileNodePageReqVO().setScope("my").setParentId(0L); + + // 调用 + PageResult rootPage = fileNodeService.getFileNodePage(reqVO, 1L); + PageResult searchPage = fileNodeService.getFileNodePage(reqVO.setParentId(null), 1L); + + // 断言 + assertEquals(1L, rootPage.getTotal()); + assertEquals(1L, searchPage.getTotal()); + assertNull(CollUtil.getFirst(rootPage.getList()).getUrl()); + assertNull(CollUtil.getFirst(searchPage.getList()).getUrl()); + verifyNoInteractions(fileApi); + } + + @Test + public void testGetFileDirectoryList_noUrl() { + // mock 数据 + OaFileNodeDO node = randomFileNodeDO(1L, 0L, 0).setUrl("https://example.com/private.txt"); + node.setCreator("1"); + fileNodeMapper.insert(node); + + // 调用 + List result = fileNodeService.getFileDirectoryList(1L); + + // 断言 + assertEquals(1, result.size()); + assertNull(CollUtil.getFirst(result).getUrl()); + verifyNoInteractions(fileApi); + } + + @Test + public void testGetFileStorage() { + // mock 数据:本人正常文件与回收站文件计入容量,目录及他人文件不计入 + OaFileNodeDO normal = randomFileNodeDO(1L, 0L, 0).setType(1).setSize(100L); + normal.setCreator("1"); + fileNodeMapper.insert(normal); + OaFileNodeDO recycled = randomFileNodeDO(2L, 0L, 1).setType(1).setSize(200L); + recycled.setCreator("1"); + fileNodeMapper.insert(recycled); + OaFileNodeDO folder = randomFileNodeDO(3L, 0L, 0).setType(0).setSize(300L); + folder.setCreator("1"); + fileNodeMapper.insert(folder); + OaFileNodeDO shared = randomFileNodeDO(4L, 0L, 0).setType(1).setSize(400L); + shared.setCreator("2"); + fileNodeMapper.insert(shared); + + // 调用 + OaFileStorageRespVO result = fileNodeService.getFileStorage(1L); + + // 断言 + assertEquals(300L, result.getUsedSize()); + assertEquals(5L * 1024 * 1024 * 1024, result.getTotalSize()); + assertEquals(1L, result.getFileCount()); + assertEquals(0L, result.getSharedCount()); + assertEquals(0L, result.getReceivedCount()); + } + + @Test + public void testGetFileStorage_sharedCounts() { + // mock 数据:本人目录多个接收人只计一次,共享给我的子文件不重复计数 + OaFileNodeDO own = randomFileNodeDO(1L, 0L, 0); + own.setCreator("1"); + fileNodeMapper.insert(own); + OaFileNodeDO received = randomFileNodeDO(2L, 0L, 0); + received.setCreator("2"); + fileNodeMapper.insert(received); + OaFileNodeDO child = randomFileNodeDO(3L, 2L, 0).setType(1); + child.setCreator("2"); + fileNodeMapper.insert(child); + OaFileNodeDO expired = randomFileNodeDO(4L, 0L, 0); + expired.setCreator("1"); + fileNodeMapper.insert(expired); + + // mock 方法 + when(filePermissionService.getFilePermissionListByNodeIds(anyCollection())).thenReturn(Arrays.asList( + new OaFilePermissionDO().setNodeId(1L).setSubjectType(1).setSubjectId(2L).setLevel(2).setInherit(true), + new OaFilePermissionDO().setNodeId(1L).setSubjectType(1).setSubjectId(3L).setLevel(2).setInherit(true), + new OaFilePermissionDO().setNodeId(2L).setSubjectType(1).setSubjectId(1L).setLevel(2).setInherit(true), + new OaFilePermissionDO().setNodeId(4L).setSubjectType(1).setSubjectId(2L).setLevel(2) + .setExpireTime(LocalDateTime.now().minusDays(1)))); + + when(filePermissionService.getValidFilePermissionList(1L, null)).thenReturn(Collections.singletonList( + new OaFilePermissionDO().setNodeId(2L).setSubjectType(1).setSubjectId(1L).setLevel(2).setInherit(true))); + + // 调用 + OaFileStorageRespVO result = fileNodeService.getFileStorage(1L); + + // 断言 + assertEquals(1L, result.getSharedCount()); + assertEquals(1L, result.getReceivedCount()); + } + + @Test + public void testGetFileNodePage_createTime() { + // mock 数据 + LocalDateTime time = LocalDateTime.of(2026, 9, 10, 12, 0); + OaFileNodeDO node = randomFileNodeDO(1L, 0L, 0).setType(1); + node.setCreator("1"); + node.setCreateTime(time); + fileNodeMapper.insert(node); + + // 准备参数:起止边界均包含 + OaFileNodePageReqVO reqVO = new OaFileNodePageReqVO().setScope("my") + .setCreateTime(new LocalDateTime[]{time, time}); + + // 调用,并断言 + assertEquals(1L, fileNodeService.getFileNodePage(reqVO, 1L).getTotal()); + reqVO.setCreateTime(new LocalDateTime[]{time.plusSeconds(1), time.plusDays(1)}); + assertEquals(0L, fileNodeService.getFileNodePage(reqVO, 1L).getTotal()); + } + + @Test + public void testValidateFileNodeAvailable_root() { + // mock 数据 + fileNodeMapper.insert(randomFileNodeDO(1L, 0L, 0)); + + // 调用,并断言 + assertEquals(1L, fileNodeService.validateFileNodeAvailable(1L).getId()); + } + + @Test + public void testValidateFileNodeAvailable_ancestorsNormal() { + // mock 数据 + fileNodeMapper.insert(randomFileNodeDO(1L, 2L, 0)); + fileNodeMapper.insert(randomFileNodeDO(2L, 3L, 0)); + fileNodeMapper.insert(randomFileNodeDO(3L, 0L, 0)); + + // 调用,并断言 + assertEquals(1L, fileNodeService.validateFileNodeAvailable(1L).getId()); + } + + @Test + public void testValidateFileNodeAvailable_notExists() { + + // 调用,并断言异常 + assertServiceException(() -> fileNodeService.validateFileNodeAvailable(1L), FILE_NODE_NOT_EXISTS); + } + + @Test + public void testValidateFileNodeAvailable_selfRecycled() { + // mock 数据 + fileNodeMapper.insert(randomFileNodeDO(1L, 0L, 1)); + + // 调用,并断言异常 + assertServiceException(() -> fileNodeService.validateFileNodeAvailable(1L), FILE_NODE_NOT_AVAILABLE); + } + + @Test + public void testValidateFileNodeAvailable_ancestorRecycled() { + // mock 数据 + fileNodeMapper.insert(randomFileNodeDO(1L, 2L, 0)); + fileNodeMapper.insert(randomFileNodeDO(2L, 3L, 0)); + fileNodeMapper.insert(randomFileNodeDO(3L, 0L, 1)); + + // 调用,并断言异常 + assertServiceException(() -> fileNodeService.validateFileNodeAvailable(1L), FILE_NODE_NOT_AVAILABLE); + } + + @Test + public void testValidateFileNodeAvailable_parentDeleted() { + // mock 数据 + fileNodeMapper.insert(randomFileNodeDO(1L, 2L, 0)); + fileNodeMapper.insert(randomFileNodeDO(2L, 0L, 0)); + fileNodeMapper.deleteById(2L); + + // 调用,并断言异常 + assertServiceException(() -> fileNodeService.validateFileNodeAvailable(1L), FILE_NODE_PATH_INVALID); + } + + @Test + public void testValidateFileNodeAvailable_parentMissing() { + // mock 数据 + fileNodeMapper.insert(randomFileNodeDO(1L, 2L, 0)); + + // 调用,并断言异常 + assertServiceException(() -> fileNodeService.validateFileNodeAvailable(1L), FILE_NODE_PATH_INVALID); + } + + @Test + public void testValidateFileNodeAvailable_cycle() { + // mock 数据 + fileNodeMapper.insert(randomFileNodeDO(1L, 2L, 0)); + fileNodeMapper.insert(randomFileNodeDO(2L, 1L, 0)); + + // 调用,并断言异常 + assertServiceException(() -> fileNodeService.validateFileNodeAvailable(1L), FILE_NODE_PATH_INVALID); + } + + @Test + public void testValidateFileNodeAvailable_parentNotFolder() { + // mock 数据 + fileNodeMapper.insert(randomFileNodeDO(1L, 2L, 0)); + fileNodeMapper.insert(randomFileNodeDO(2L, 0L, 0).setType(1)); + + // 调用,并断言异常 + assertServiceException(() -> fileNodeService.validateFileNodeAvailable(1L), FILE_NODE_PATH_INVALID); + } + + @Test + public void testValidateFileNodeAvailable_restoredParentKeepsChildRecycled() { + // mock 数据 + fileNodeMapper.insert(randomFileNodeDO(1L, 2L, 1)); + fileNodeMapper.insert(randomFileNodeDO(2L, 0L, 1)); + fileNodeMapper.updateById(new OaFileNodeDO().setId(2L).setStatus(0)); + + // 调用,并断言异常 + assertServiceException(() -> fileNodeService.validateFileNodeAvailable(1L), FILE_NODE_NOT_AVAILABLE); + } + + @Test + public void testGetFileNodePage_hideRecycledDescendants() { + // mock 数据 + OaFileNodeDO parent = randomFileNodeDO(1L, 0L, 1); + parent.setCreator("10"); + fileNodeMapper.insert(parent); + OaFileNodeDO child = randomFileNodeDO(2L, 1L, 0); + child.setCreator("10"); + fileNodeMapper.insert(child); + // mock 方法 + when(filePermissionService.getFilePermissionListByNodeIds(anyCollection())).thenReturn(Collections.emptyList()); + when(fileFavoriteService.getFileFavoriteList(10L)).thenReturn( + Arrays.asList(new OaFileFavoriteDO().setNodeId(2L).setUserId(10L))); + + // 调用,并断言:全局搜索和收藏均不能绕过回收目录 + assertEquals(0L, fileNodeService.getFileNodePage(new OaFileNodePageReqVO().setScope("my"), 10L).getTotal()); + assertEquals(0L, fileNodeService.getFileNodePage(new OaFileNodePageReqVO().setScope("favorite"), 10L).getTotal()); + assertEquals(1L, fileNodeService.getFileNodePage(new OaFileNodePageReqVO().setScope("recycle"), 10L).getTotal()); + } + + @Test + public void testValidateFileNodePermission_inherit() { + // mock 数据 + fileNodeMapper.insert(randomFileNodeDO(1L, 0L, 0)); + fileNodeMapper.insert(randomFileNodeDO(2L, 1L, 0)); + // mock 方法 + OaFilePermissionDO permission = new OaFilePermissionDO().setNodeId(1L).setSubjectType(1) + .setSubjectId(10L).setLevel(2).setInherit(true); + when(filePermissionService.getFilePermissionListByNodeIds(anyCollection())).thenReturn(Arrays.asList(permission)); + + // 调用,并断言 + assertEquals(2L, fileNodeService.validateFileNodePermission(2L, 10L, 2).getId()); + permission.setInherit(false); + assertServiceException(() -> fileNodeService.validateFileNodePermission(2L, 10L, 1), FILE_NODE_ACCESS_DENIED); + permission.setInherit(true).setExpireTime(LocalDateTime.now().minusDays(1)); + assertServiceException(() -> fileNodeService.validateFileNodePermission(2L, 10L, 1), FILE_NODE_ACCESS_DENIED); + } + + @Test + public void testRecycleFileNode_preserveChildStatus() { + // mock 数据 + OaFileNodeDO parent = randomFileNodeDO(1L, 0L, 0); + parent.setCreator("10"); + fileNodeMapper.insert(parent); + fileNodeMapper.insert(randomFileNodeDO(2L, 1L, 0)); + + // 调用 + fileNodeService.recycleFileNode(1L, 10L); + // 断言 + assertEquals(1, fileNodeMapper.selectById(1L).getStatus()); + assertEquals(0, fileNodeMapper.selectById(2L).getStatus()); + } + + @Test + public void testDeleteFileNode_preserveChildren() { + // mock 数据 + OaFileNodeDO parent = randomFileNodeDO(1L, 0L, 1); + parent.setCreator("10"); + fileNodeMapper.insert(parent); + fileNodeMapper.insert(randomFileNodeDO(2L, 1L, 0)); + fileNodeMapper.insert(randomFileNodeDO(3L, 2L, 1)); + + // 调用 + fileNodeService.deleteFileNode(1L, 10L); + // 断言 + assertNull(fileNodeMapper.selectById(1L)); + assertNotNull(fileNodeMapper.selectById(2L)); + assertNotNull(fileNodeMapper.selectById(3L)); + verify(filePermissionService).deleteFilePermissionsByNodeIds(Collections.singleton(1L)); + verify(fileFavoriteService).deleteFileFavoritesByNodeIds(Collections.singleton(1L)); + verifyNoInteractions(fileApi); + } + + @Test + public void testRecycleFileNode_notOwner() { + // mock 数据 + OaFileNodeDO node = randomFileNodeDO(1L, 0L, 0); + node.setCreator("20"); + fileNodeMapper.insert(node); + + // 调用,并断言异常 + assertServiceException(() -> fileNodeService.recycleFileNode(1L, 10L), FILE_NODE_ACCESS_DENIED); + } + + @Test + public void testUpdateFileNodeParent_descendant() { + // mock 数据 + OaFileNodeDO parent = randomFileNodeDO(1L, 0L, 0); + parent.setCreator("10"); + fileNodeMapper.insert(parent); + OaFileNodeDO child = randomFileNodeDO(2L, 1L, 0); + child.setCreator("10"); + fileNodeMapper.insert(child); + + // 调用,并断言异常 + assertServiceException(() -> fileNodeService.updateFileNodeParent( + new OaFileNodeMoveReqVO().setId(1L).setParentId(2L), 10L), FILE_NODE_MOVE_INVALID); + } + + @Test + public void testRestoreFileNode_parentRecycled() { + // mock 数据 + fileNodeMapper.insert(randomFileNodeDO(1L, 0L, 1)); + OaFileNodeDO child = randomFileNodeDO(2L, 1L, 1); + child.setCreator("10"); + fileNodeMapper.insert(child); + + // 调用 + fileNodeService.restoreFileNode(2L, 10L); + // 断言:原父目录仍在回收站,文件恢复到本人的根目录 + assertEquals(1, fileNodeMapper.selectById(1L).getStatus()); + assertEquals(0L, fileNodeMapper.selectById(2L).getParentId()); + assertEquals(0, fileNodeMapper.selectById(2L).getStatus()); + } + + @Test + public void testCreateFileNode_frontendMetadata() { + // 准备参数 + OaFileNodeSaveReqVO reqVO = new OaFileNodeSaveReqVO().setParentId(0L).setType(1) + .setName("说明.pdf").setUrl("https://files.example.com/guide.pdf").setSize(1024L); + + // 调用 + Long id = fileNodeService.createFileNode(reqVO, 10L); + // 断言:直接保存前端上传结果,不调用 Infra 查询接口 + OaFileNodeDO node = fileNodeMapper.selectById(id); + assertEquals(reqVO.getUrl(), node.getUrl()); + assertEquals(1024L, node.getSize()); + assertEquals(2, node.getCategory()); + assertEquals(0, node.getStatus()); + verifyNoInteractions(fileApi); + } + + @Test + public void testCreateFileNode_storageFull() { + // mock 数据:回收站文件仍占满本人容量 + OaFileNodeDO file = randomFileNodeDO(1L, 0L, 1).setType(1).setSize(properties.getFile().getStorageSize()); + file.setCreator("10"); + fileNodeMapper.insert(file); + // 准备参数 + OaFileNodeSaveReqVO reqVO = new OaFileNodeSaveReqVO().setParentId(0L).setType(1) + .setName("超额.pdf").setUrl("/files/extra.pdf").setSize(1L); + + // 调用,并断言异常 + assertServiceException(() -> fileNodeService.createFileNode(reqVO, 10L), FILE_STORAGE_NOT_ENOUGH); + assertEquals(1L, fileNodeMapper.selectCount()); + } + + @Test + public void testCreateFileNode_storageExactLimit() { + // mock 数据:本人剩余 1 字节;其他用户容量不计入 + OaFileNodeDO file = randomFileNodeDO(1L, 0L, 0).setType(1).setSize(properties.getFile().getStorageSize() - 1); + file.setCreator("10"); + fileNodeMapper.insert(file); + OaFileNodeDO other = randomFileNodeDO(2L, 0L, 0).setType(1).setSize(properties.getFile().getStorageSize()); + other.setCreator("20"); + fileNodeMapper.insert(other); + // 准备参数 + OaFileNodeSaveReqVO reqVO = new OaFileNodeSaveReqVO().setParentId(0L).setType(1) + .setName("刚好.pdf").setUrl("/files/exact.pdf").setSize(1L); + + // 调用 + Long id = fileNodeService.createFileNode(reqVO, 10L); + // 断言 + assertEquals(1L, fileNodeMapper.selectById(id).getSize()); + } + + @Test + public void testCreateFileNode_folderWhenStorageFull() { + // mock 数据 + OaFileNodeDO file = randomFileNodeDO(1L, 0L, 0).setType(1).setSize(properties.getFile().getStorageSize()); + file.setCreator("10"); + fileNodeMapper.insert(file); + // 准备参数 + OaFileNodeSaveReqVO reqVO = new OaFileNodeSaveReqVO().setParentId(0L).setType(0).setName("新目录"); + + // 调用 + Long id = fileNodeService.createFileNode(reqVO, 10L); + // 断言:目录不占文件容量 + assertEquals(0L, fileNodeMapper.selectById(id).getSize()); + } + + @Test + public void testCreateFileNode_relativeUrl() { + // 准备参数 + OaFileNodeSaveReqVO reqVO = new OaFileNodeSaveReqVO().setParentId(0L).setType(1) + .setName("说明.pdf").setUrl("/files/guide.pdf").setSize(1024L); + + // 调用 + Long id = fileNodeService.createFileNode(reqVO, 10L); + // 断言 + assertEquals(reqVO.getUrl(), fileNodeMapper.selectById(id).getUrl()); + } + + @Test + public void testGetFileNodePage_searchPreservesAncestorPermission() { + // mock 数据:祖先不匹配搜索条件,但授权需要继承到匹配的文件 + OaFileNodeDO parent = randomFileNodeDO(1L, 0L, 0).setName("共享目录"); + parent.setCreator("20"); + fileNodeMapper.insert(parent); + OaFileNodeDO child = randomFileNodeDO(2L, 1L, 0).setType(1).setCategory(2).setName("产品方案.pdf"); + child.setCreator("20"); + fileNodeMapper.insert(child); + OaFileNodeDO unrelated = randomFileNodeDO(3L, 0L, 0).setName("其他目录"); + unrelated.setCreator("30"); + fileNodeMapper.insert(unrelated); + // 准备参数 + OaFileNodePageReqVO reqVO = new OaFileNodePageReqVO().setScope("shared").setName("方案").setCategory(2); + // mock 方法 + when(filePermissionService.getFilePermissionListByNodeIds(anyCollection())).thenReturn(Collections.singletonList( + new OaFilePermissionDO().setNodeId(1L).setSubjectType(1).setSubjectId(10L).setLevel(2).setInherit(true))); + when(filePermissionService.getValidFilePermissionList(10L, null)).thenReturn(Collections.singletonList( + new OaFilePermissionDO().setNodeId(1L).setSubjectType(1).setSubjectId(10L).setLevel(2).setInherit(true))); + + // 调用 + PageResult result = + fileNodeService.getFileNodePage(reqVO, 10L); + // 断言 + assertEquals(1L, result.getTotal()); + assertEquals(2L, CollUtil.getFirst(result.getList()).getId()); + assertEquals(2, CollUtil.getFirst(result.getList()).getLevel()); + verify(filePermissionService).getFilePermissionListByNodeIds( + argThat(ids -> ids.size() == 2 && ids.containsAll(Arrays.asList(1L, 2L)))); + } + + @Test + public void testGetFileNodePage_sharedRootDoesNotDuplicateChild() { + // mock 数据 + OaFileNodeDO parent = randomFileNodeDO(1L, 0L, 0); + parent.setCreator("20"); + fileNodeMapper.insert(parent); + OaFileNodeDO child = randomFileNodeDO(2L, 1L, 0); + child.setCreator("20"); + fileNodeMapper.insert(child); + // mock 方法 + when(filePermissionService.getFilePermissionListByNodeIds(anyCollection())).thenReturn(Collections.singletonList( + new OaFilePermissionDO().setNodeId(1L).setSubjectType(1).setSubjectId(10L).setLevel(2).setInherit(true))); + when(filePermissionService.getValidFilePermissionList(10L, null)).thenReturn(Collections.singletonList( + new OaFilePermissionDO().setNodeId(1L).setSubjectType(1).setSubjectId(10L).setLevel(2).setInherit(true))); + + // 调用 + PageResult result = fileNodeService.getFileNodePage( + new OaFileNodePageReqVO().setScope("shared").setParentId(0L), 10L); + // 断言 + assertEquals(1L, result.getTotal()); + assertEquals(1L, CollUtil.getFirst(result.getList()).getId()); + } + + @Test + public void testGetFileNodePage_secondPageAfterFiltering() { + // mock 数据:无权限项不能占用当前页名额 + OaFileNodeDO first = randomFileNodeDO(1L, 0L, 0).setName("方案甲"); + first.setCreator("10"); + first.setCreateTime(LocalDateTime.of(2026, 9, 10, 12, 0)); + fileNodeMapper.insert(first); + OaFileNodeDO second = randomFileNodeDO(2L, 0L, 0).setName("方案乙"); + second.setCreator("10"); + second.setCreateTime(first.getCreateTime()); + fileNodeMapper.insert(second); + OaFileNodeDO hidden = randomFileNodeDO(3L, 0L, 0).setName("方案丙"); + hidden.setCreator("20"); + fileNodeMapper.insert(hidden); + // 准备参数 + OaFileNodePageReqVO reqVO = new OaFileNodePageReqVO().setScope("my").setName("方案"); + reqVO.setPageNo(2); + reqVO.setPageSize(1); + // mock 方法:仅为当前页补充收藏状态 + when(fileFavoriteService.getFileFavoriteListByUserIdAndNodeIds(10L, Collections.singleton(1L))) + .thenReturn(Collections.singletonList(new OaFileFavoriteDO().setNodeId(1L).setUserId(10L))); + + // 调用 + PageResult result = + fileNodeService.getFileNodePage(reqVO, 10L); + // 断言 + assertEquals(2L, result.getTotal()); + assertEquals(1, result.getList().size()); + assertEquals("10", fileNodeMapper.selectById(CollUtil.getFirst(result.getList()).getId()).getCreator()); + assertTrue(CollUtil.getFirst(result.getList()).getFavorite()); + verify(fileFavoriteService, never()).getFileFavoriteList(anyLong()); + verify(fileFavoriteService).getFileFavoriteListByUserIdAndNodeIds(eq(10L), + argThat(ids -> ids.size() == 1 && ids.contains(CollUtil.getFirst(result.getList()).getId()))); + } + + @Test + public void testGetFileNodePage_myRootDatabasePage() { + // mock 数据:目录优先,同一创建时间按编号倒序;其他人、其他目录和回收项不进入分页 + LocalDateTime createTime = LocalDateTime.of(2026, 9, 10, 12, 0); + for (long id = 1; id <= 6; id++) { + OaFileNodeDO node = randomFileNodeDO(id, id == 5 ? 1L : 0L, id == 6 ? 1 : 0) + .setType(id == 3 ? 1 : 0); + node.setCreator(id == 4 ? "20" : "10"); + node.setCreateTime(createTime); + fileNodeMapper.insert(node); + } + // 准备参数 + OaFileNodePageReqVO reqVO = new OaFileNodePageReqVO().setScope("my").setParentId(0L); + reqVO.setPageNo(2); + reqVO.setPageSize(1); + // mock 方法 + when(fileFavoriteService.getFileFavoriteListByUserIdAndNodeIds(10L, Collections.singleton(1L))) + .thenReturn(Collections.singletonList(new OaFileFavoriteDO().setNodeId(1L).setUserId(10L))); + + // 调用 + PageResult result = fileNodeService.getFileNodePage(reqVO, 10L); + // 断言 + assertEquals(3L, result.getTotal()); + assertEquals(1, result.getList().size()); + assertEquals(1L, CollUtil.getFirst(result.getList()).getId()); + assertEquals(4, CollUtil.getFirst(result.getList()).getLevel()); + assertTrue(CollUtil.getFirst(result.getList()).getFavorite()); + verify(fileFavoriteService, never()).getFileFavoriteList(anyLong()); + verifyNoInteractions(filePermissionService); + } + + @Test + public void testGetFileNodePage_myRootSearch() { + // mock 数据:数据库分页保留名称、分类和时间的组合筛选 + LocalDateTime createTime = LocalDateTime.of(2026, 9, 10, 12, 0); + OaFileNodeDO matched = randomFileNodeDO(1L, 0L, 0).setType(1).setName("方案.pdf").setCategory(1); + matched.setCreator("10"); + matched.setCreateTime(createTime); + fileNodeMapper.insert(matched); + OaFileNodeDO other = randomFileNodeDO(2L, 0L, 0).setType(1).setName("其他.pdf").setCategory(1); + other.setCreator("10"); + other.setCreateTime(createTime); + fileNodeMapper.insert(other); + // 准备参数 + OaFileNodePageReqVO reqVO = new OaFileNodePageReqVO().setScope("my").setParentId(0L) + .setName("方案").setCategory(1).setCreateTime(new LocalDateTime[]{createTime, createTime}); + + // 调用,并断言 + assertEquals(1L, fileNodeService.getFileNodePage(reqVO, 10L).getTotal()); + reqVO.setCategory(2); + assertEquals(0L, fileNodeService.getFileNodePage(reqVO, 10L).getTotal()); + reqVO.setCategory(1).setCreateTime(new LocalDateTime[]{createTime.plusSeconds(1), createTime.plusDays(1)}); + assertEquals(0L, fileNodeService.getFileNodePage(reqVO, 10L).getTotal()); + } + + @Test + public void testGetFileNodePage_myRootOutOfRange() { + // mock 数据 + OaFileNodeDO node = randomFileNodeDO(1L, 0L, 0); + node.setCreator("10"); + fileNodeMapper.insert(node); + // 准备参数 + OaFileNodePageReqVO reqVO = new OaFileNodePageReqVO().setScope("my").setParentId(0L); + reqVO.setPageNo(2); + reqVO.setPageSize(1); + + // 调用 + PageResult result = fileNodeService.getFileNodePage(reqVO, 10L); + // 断言:越界页保留总数,不重复加载首页,也不查询收藏 + assertEquals(1L, result.getTotal()); + assertTrue(CollUtil.isEmpty(result.getList())); + verifyNoInteractions(fileFavoriteService, filePermissionService); + } + + @Test + public void testGetFileNodePage_ownDirectoryDatabasePage() { + // mock 数据:本人目录中的他人文件仍继承管理权限,不能按子节点创建人过滤 + OaFileNodeDO folder = randomFileNodeDO(1L, 0L, 0); + folder.setCreator("10"); + fileNodeMapper.insert(folder); + for (long id = 2; id <= 4; id++) { + OaFileNodeDO node = randomFileNodeDO(id, 1L, id == 4 ? 1 : 0).setType(1); + node.setCreator("20"); + node.setCreateTime(LocalDateTime.of(2026, 9, 10, 12, 0)); + fileNodeMapper.insert(node); + } + // 准备参数 + OaFileNodePageReqVO reqVO = new OaFileNodePageReqVO().setScope("my").setParentId(1L); + reqVO.setPageNo(2); + reqVO.setPageSize(1); + + // 调用 + PageResult result = fileNodeService.getFileNodePage(reqVO, 10L); + // 断言 + assertEquals(2L, result.getTotal()); + assertEquals(1, result.getList().size()); + assertEquals(2L, CollUtil.getFirst(result.getList()).getId()); + assertEquals(4, CollUtil.getFirst(result.getList()).getLevel()); + verify(filePermissionService).getFilePermissionListByNodeIds(Collections.singleton(1L)); + verify(fileFavoriteService).getFileFavoriteListByUserIdAndNodeIds(10L, Collections.singleton(2L)); + verify(fileFavoriteService, never()).getFileFavoriteList(anyLong()); + } + + @Test + public void testGetFileNodePage_ownDirectoryRecycledAncestor() { + // mock 数据:本人目录的祖先已回收时,不得直接数据库分页暴露子文件 + OaFileNodeDO ancestor = randomFileNodeDO(1L, 0L, 1); + ancestor.setCreator("10"); + fileNodeMapper.insert(ancestor); + OaFileNodeDO folder = randomFileNodeDO(2L, 1L, 0); + folder.setCreator("10"); + fileNodeMapper.insert(folder); + OaFileNodeDO file = randomFileNodeDO(3L, 2L, 0).setType(1); + file.setCreator("10"); + fileNodeMapper.insert(file); + + // 调用,并断言异常 + assertServiceException(() -> fileNodeService.getFileNodePage( + new OaFileNodePageReqVO().setScope("my").setParentId(2L), 10L), FILE_NODE_NOT_AVAILABLE); + verifyNoInteractions(fileFavoriteService); + } + + @Test + public void testGetFileNodePage_sharedDirectoryFiltersBeforePaging() { + // mock 数据:共享目录不继承时,仅对子文件的单独授权生效 + OaFileNodeDO folder = randomFileNodeDO(1L, 0L, 0); + folder.setCreator("20"); + fileNodeMapper.insert(folder); + for (long id = 2; id <= 4; id++) { + OaFileNodeDO file = randomFileNodeDO(id, 1L, 0).setType(1); + file.setCreator("20"); + file.setCreateTime(LocalDateTime.of(2026, 9, 10, 12, 0)); + fileNodeMapper.insert(file); + } + // mock 方法:编号最大的文件没有权限,不应占据第一页 + when(filePermissionService.getFilePermissionListByNodeIds(anyCollection())).thenReturn(Arrays.asList( + new OaFilePermissionDO().setNodeId(1L).setSubjectType(1).setSubjectId(10L).setLevel(1).setInherit(false), + new OaFilePermissionDO().setNodeId(2L).setSubjectType(1).setSubjectId(10L).setLevel(2).setInherit(false), + new OaFilePermissionDO().setNodeId(3L).setSubjectType(1).setSubjectId(10L).setLevel(1).setInherit(false))); + // 准备参数 + OaFileNodePageReqVO reqVO = new OaFileNodePageReqVO().setScope("shared").setParentId(1L); + reqVO.setPageNo(2); + reqVO.setPageSize(1); + + // 调用 + PageResult result = fileNodeService.getFileNodePage(reqVO, 10L); + // 断言 + assertEquals(2L, result.getTotal()); + assertEquals(1, result.getList().size()); + assertEquals(2L, CollUtil.getFirst(result.getList()).getId()); + assertEquals(2, CollUtil.getFirst(result.getList()).getLevel()); + verify(fileFavoriteService).getFileFavoriteListByUserIdAndNodeIds(10L, Collections.singleton(2L)); + } + + @Test + public void testGetFileNodePage_favoritePage() { + // mock 数据:收藏入口仍用完整收藏编号筛选,只为当前页拼接响应 + for (long id = 1; id <= 3; id++) { + OaFileNodeDO file = randomFileNodeDO(id, 0L, 0).setType(1); + file.setCreator("10"); + file.setCreateTime(LocalDateTime.of(2026, 9, 10, 12, 0)); + fileNodeMapper.insert(file); + } + // mock 方法 + when(fileFavoriteService.getFileFavoriteList(10L)).thenReturn(Arrays.asList( + new OaFileFavoriteDO().setNodeId(1L).setUserId(10L), + new OaFileFavoriteDO().setNodeId(2L).setUserId(10L))); + when(fileFavoriteService.getFileFavoriteListByUserIdAndNodeIds(10L, Collections.singleton(1L))) + .thenReturn(Collections.singletonList(new OaFileFavoriteDO().setNodeId(1L).setUserId(10L))); + // 准备参数 + OaFileNodePageReqVO reqVO = new OaFileNodePageReqVO().setScope("favorite"); + reqVO.setPageNo(2); + reqVO.setPageSize(1); + + // 调用 + PageResult result = fileNodeService.getFileNodePage(reqVO, 10L); + // 断言 + assertEquals(2L, result.getTotal()); + assertEquals(1, result.getList().size()); + assertEquals(1L, CollUtil.getFirst(result.getList()).getId()); + assertTrue(CollUtil.getFirst(result.getList()).getFavorite()); + verify(fileFavoriteService).getFileFavoriteList(10L); + verify(fileFavoriteService).getFileFavoriteListByUserIdAndNodeIds(10L, Collections.singleton(1L)); + } + + @Test + public void testGetFileStorage_recycledAndDeletedFiles() { + // mock 数据:正常子文件被回收目录遮蔽,仍占容量;逻辑删除的文件不占容量 + OaFileNodeDO folder = randomFileNodeDO(1L, 0L, 1); + folder.setCreator("10"); + fileNodeMapper.insert(folder); + OaFileNodeDO hidden = randomFileNodeDO(2L, 1L, 0).setType(1).setSize(100L); + hidden.setCreator("10"); + fileNodeMapper.insert(hidden); + OaFileNodeDO recycled = randomFileNodeDO(3L, 0L, 1).setType(1).setSize(200L); + recycled.setCreator("10"); + fileNodeMapper.insert(recycled); + OaFileNodeDO deleted = randomFileNodeDO(4L, 0L, 0).setType(1).setSize(400L); + deleted.setCreator("10"); + fileNodeMapper.insert(deleted); + fileNodeMapper.deleteById(deleted.getId()); + + // 调用 + OaFileStorageRespVO result = fileNodeService.getFileStorage(10L); + // 断言 + assertEquals(300L, result.getUsedSize()); + assertEquals(0L, result.getFileCount()); + verify(filePermissionService).getFilePermissionListByNodeIds( + argThat(ids -> ids.size() == 2 && ids.containsAll(Arrays.asList(1L, 2L)))); + } + + @Test + public void testGetFileStorage_onlyRelatedNodes() { + // mock 数据:统计不能加载其他用户未共享的文件 + OaFileNodeDO own = randomFileNodeDO(1L, 0L, 0).setType(1).setSize(100L); + own.setCreator("10"); + fileNodeMapper.insert(own); + OaFileNodeDO unrelated = randomFileNodeDO(2L, 0L, 0).setType(1).setSize(200L); + unrelated.setCreator("20"); + fileNodeMapper.insert(unrelated); + + // 调用 + OaFileStorageRespVO result = fileNodeService.getFileStorage(10L); + // 断言 + assertEquals(100L, result.getUsedSize()); + assertEquals(1L, result.getFileCount()); + verify(filePermissionService).getFilePermissionListByNodeIds( + argThat(ids -> ids.size() == 1 && ids.contains(1L))); + } + + @Test + public void testGetFileNodePage_directSharingWithoutInheritance() { + // mock 数据:直接共享目录不继承时,不加载其子文件 + OaFileNodeDO folder = randomFileNodeDO(1L, 0L, 0); + folder.setCreator("20"); + fileNodeMapper.insert(folder); + OaFileNodeDO child = randomFileNodeDO(2L, 1L, 0).setType(1); + child.setCreator("20"); + fileNodeMapper.insert(child); + OaFilePermissionDO permission = new OaFilePermissionDO().setNodeId(1L).setSubjectType(1) + .setSubjectId(10L).setLevel(2).setInherit(false); + // mock 方法 + when(filePermissionService.getValidFilePermissionList(10L, null)).thenReturn(Collections.singletonList(permission)); + when(filePermissionService.getFilePermissionListByNodeIds(anyCollection())).thenReturn(Collections.singletonList(permission)); + + // 调用 + PageResult result = fileNodeService.getFileNodePage( + new OaFileNodePageReqVO().setScope("shared"), 10L); + // 断言 + assertEquals(1L, result.getTotal()); + assertEquals(1L, CollUtil.getFirst(result.getList()).getId()); + verify(filePermissionService).getFilePermissionListByNodeIds( + argThat(ids -> ids.size() == 1 && ids.contains(1L))); + } + + @Test + public void testGetFileNodePage_ownerPermissionInherited() { + // mock 数据:本人目录中由其他人创建的子目录、文件,仍保留管理权限 + OaFileNodeDO folder = randomFileNodeDO(1L, 0L, 0); + folder.setCreator("10"); + fileNodeMapper.insert(folder); + OaFileNodeDO subfolder = randomFileNodeDO(2L, 1L, 0); + subfolder.setCreator("20"); + fileNodeMapper.insert(subfolder); + OaFileNodeDO child = randomFileNodeDO(3L, 2L, 0).setType(1).setName("方案.pdf"); + child.setCreator("20"); + fileNodeMapper.insert(child); + OaFileNodeDO unrelated = randomFileNodeDO(4L, 0L, 0).setType(1).setName("其他方案.pdf"); + unrelated.setCreator("30"); + fileNodeMapper.insert(unrelated); + + // 调用 + PageResult result = fileNodeService.getFileNodePage( + new OaFileNodePageReqVO().setScope("shared").setName("方案"), 10L); + // 断言 + assertEquals(1L, result.getTotal()); + assertEquals(3L, CollUtil.getFirst(result.getList()).getId()); + assertEquals(4, CollUtil.getFirst(result.getList()).getLevel()); + verify(filePermissionService).getFilePermissionListByNodeIds( + argThat(ids -> ids.size() == 3 && !ids.contains(4L))); + } + + @Test + public void testGetFileDirectoryList_onlyAvailableFolders() { + // mock 数据:只返回本人有效目录,文件、其他用户目录及回收子目录不加入结果 + OaFileNodeDO folder = randomFileNodeDO(1L, 0L, 0); + folder.setCreator("10"); + fileNodeMapper.insert(folder); + OaFileNodeDO file = randomFileNodeDO(2L, 0L, 0).setType(1); + file.setCreator("10"); + fileNodeMapper.insert(file); + OaFileNodeDO recycled = randomFileNodeDO(3L, 0L, 1); + recycled.setCreator("10"); + fileNodeMapper.insert(recycled); + OaFileNodeDO hidden = randomFileNodeDO(4L, 3L, 0); + hidden.setCreator("10"); + fileNodeMapper.insert(hidden); + + // 调用 + List result = fileNodeService.getFileDirectoryList(10L); + // 断言 + assertEquals(1, result.size()); + assertEquals(1L, CollUtil.getFirst(result).getId()); + } + + // ========== 随机对象 ========== + + /** + * 构造大小为零的目录节点。 + * + * @param id 节点编号 + * @param parentId 父节点编号 + * @param status 业务状态 + * @return 未入库的测试对象 + */ + private static OaFileNodeDO randomFileNodeDO(Long id, Long parentId, Integer status) { + return randomPojo(OaFileNodeDO.class, node -> { + node.setId(id); + node.setParentId(parentId); + node.setType(0); + node.setCategory(null); + node.setStatus(status); + node.setSize(0L); + }); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/file/OaFilePermissionServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/file/OaFilePermissionServiceImplTest.java new file mode 100644 index 0000000000..a1d4802212 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/file/OaFilePermissionServiceImplTest.java @@ -0,0 +1,201 @@ +package cn.iocoder.yudao.module.oa.service.file; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.controller.admin.file.vo.permission.OaFilePermissionSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.file.OaFilePermissionDO; +import cn.iocoder.yudao.module.oa.dal.mysql.file.OaFilePermissionMapper; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaFilePermissionServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaFilePermissionServiceImpl.class) +public class OaFilePermissionServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaFilePermissionService filePermissionService; + + @Resource + private OaFilePermissionMapper filePermissionMapper; + + @MockitoBean + private OaFileNodeService fileNodeService; + @MockitoBean + private AdminUserApi adminUserApi; + @MockitoBean + private DeptApi deptApi; + + @Test + public void testSaveFilePermission_createUser() { + // 准备参数 + OaFilePermissionSaveReqVO reqVO = new OaFilePermissionSaveReqVO().setNodeId(10L) + .setSubjectType(1).setSubjectId(20L).setLevel(1).setInherit(true); + + // 调用 + Long id = filePermissionService.saveFilePermission(reqVO, 1L); + // 断言 + OaFilePermissionDO permission = filePermissionMapper.selectById(id); + assertEquals(10L, permission.getNodeId()); + assertEquals(20L, permission.getSubjectId()); + assertEquals(1, permission.getLevel()); + verify(adminUserApi).validateUser(20L); + verify(fileNodeService).validateFileNodePermission(10L, 1L, 4); + verifyNoInteractions(deptApi); + } + + @Test + public void testSaveFilePermission_updateAndClearExpireTime() { + // mock 数据 + OaFilePermissionDO permission = randomFilePermissionDO(10L, 1, 20L); + permission.setExpireTime(LocalDateTime.now().plusDays(1)); + filePermissionMapper.insert(permission); + // 准备参数 + OaFilePermissionSaveReqVO reqVO = new OaFilePermissionSaveReqVO().setNodeId(10L) + .setSubjectType(1).setSubjectId(20L).setLevel(3).setInherit(false); + + // 调用 + Long id = filePermissionService.saveFilePermission(reqVO, 1L); + // 断言 + assertEquals(permission.getId(), id); + assertEquals(1, filePermissionMapper.selectList().size()); + OaFilePermissionDO result = filePermissionMapper.selectById(id); + assertEquals(3, result.getLevel()); + assertFalse(result.getInherit()); + assertNull(result.getExpireTime()); + } + + @Test + public void testSaveFilePermission_departmentUsesIndependentSubject() { + // mock 数据:同编号用户授权不能被部门授权覆盖 + OaFilePermissionDO userPermission = randomFilePermissionDO(10L, 1, 20L); + filePermissionMapper.insert(userPermission); + // 准备参数 + OaFilePermissionSaveReqVO reqVO = new OaFilePermissionSaveReqVO().setNodeId(10L) + .setSubjectType(2).setSubjectId(20L).setLevel(2).setInherit(true); + + // 调用 + Long id = filePermissionService.saveFilePermission(reqVO, 1L); + // 断言 + assertNotEquals(userPermission.getId(), id); + assertEquals(2, filePermissionMapper.selectList().size()); + verify(deptApi).validateDeptList(Collections.singleton(20L)); + verifyNoInteractions(adminUserApi); + } + + @Test + public void testSaveFilePermission_invalidSubjectType() { + // 准备参数 + OaFilePermissionSaveReqVO reqVO = new OaFilePermissionSaveReqVO().setNodeId(10L) + .setSubjectType(99).setSubjectId(20L).setLevel(1).setInherit(true); + + // 调用,并断言 + assertThrows(IllegalArgumentException.class, () -> filePermissionService.saveFilePermission(reqVO, 1L)); + assertTrue(filePermissionMapper.selectList().isEmpty()); + verifyNoInteractions(adminUserApi, deptApi); + } + + @Test + public void testGetFilePermissionListByNodeIds() { + // mock 数据 + OaFilePermissionDO permission = randomFilePermissionDO(10L, 1, 20L); + filePermissionMapper.insert(permission); + filePermissionMapper.insert(randomFilePermissionDO(30L, 1, 20L)); + + // 调用,并断言 + assertEquals(permission.getId(), CollUtil.getFirst(filePermissionService.getFilePermissionListByNodeIds(Collections.singleton(10L))).getId()); + assertTrue(filePermissionService.getFilePermissionListByNodeIds(Collections.emptyList()).isEmpty()); + } + + @Test + public void testDeleteFilePermissionsByNodeIds() { + // mock 数据 + OaFilePermissionDO first = randomFilePermissionDO(10L, 1, 20L); + OaFilePermissionDO second = randomFilePermissionDO(11L, 1, 20L); + OaFilePermissionDO retained = randomFilePermissionDO(12L, 1, 20L); + filePermissionMapper.insert(first); + filePermissionMapper.insert(second); + filePermissionMapper.insert(retained); + + // 调用 + filePermissionService.deleteFilePermissionsByNodeIds(Collections.emptyList()); + filePermissionService.deleteFilePermissionsByNodeIds(Arrays.asList(10L, 11L)); + // 断言 + assertNull(filePermissionMapper.selectById(first.getId())); + assertNull(filePermissionMapper.selectById(second.getId())); + assertNotNull(filePermissionMapper.selectById(retained.getId())); + } + + @Test + public void testGetValidFilePermissionList_userAndDepartment() { + // mock 数据:本人及所属部门有效授权保留,过期、其他人和已删除授权排除 + OaFilePermissionDO user = randomFilePermissionDO(1L, 1, 10L).setExpireTime(null); + filePermissionMapper.insert(user); + OaFilePermissionDO dept = randomFilePermissionDO(2L, 2, 100L).setExpireTime(LocalDateTime.now().plusDays(1)); + filePermissionMapper.insert(dept); + filePermissionMapper.insert(randomFilePermissionDO(3L, 1, 10L).setExpireTime(LocalDateTime.now().minusDays(1))); + filePermissionMapper.insert(randomFilePermissionDO(4L, 1, 20L).setExpireTime(null)); + OaFilePermissionDO deleted = randomFilePermissionDO(5L, 1, 10L).setExpireTime(null); + filePermissionMapper.insert(deleted); + filePermissionMapper.deleteById(deleted.getId()); + + // 调用 + List result = filePermissionService.getValidFilePermissionList(10L, 100L); + // 断言 + assertEquals(2, result.size()); + assertTrue(result.stream().anyMatch(permission -> permission.getId().equals(user.getId()))); + assertTrue(result.stream().anyMatch(permission -> permission.getId().equals(dept.getId()))); + } + + @Test + public void testGetValidFilePermissionList_withoutDepartment() { + // mock 数据:没有部门时仅匹配本人授权 + OaFilePermissionDO user = randomFilePermissionDO(1L, 1, 10L).setExpireTime(null); + filePermissionMapper.insert(user); + filePermissionMapper.insert(randomFilePermissionDO(2L, 2, 10L).setExpireTime(null)); + + // 调用 + List result = filePermissionService.getValidFilePermissionList(10L, null); + // 断言 + assertEquals(1, result.size()); + assertEquals(user.getId(), CollUtil.getFirst(result).getId()); + } + + // ========== 随机对象 ========== + + /** + * 构造允许继承且永不过期的查看权限。 + * + * @param nodeId 文件节点编号 + * @param subjectType 授权对象类型 + * @param subjectId 授权对象编号 + * @return 未入库的测试对象 + */ + private static OaFilePermissionDO randomFilePermissionDO(Long nodeId, Integer subjectType, Long subjectId) { + return randomPojo(OaFilePermissionDO.class, permission -> { + permission.setNodeId(nodeId); + permission.setSubjectType(subjectType); + permission.setSubjectId(subjectId); + permission.setLevel(1); + permission.setInherit(true); + permission.setExpireTime(null); + }); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/leave/OaLeaveApplyServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/leave/OaLeaveApplyServiceImplTest.java new file mode 100644 index 0000000000..6f955d8b67 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/leave/OaLeaveApplyServiceImplTest.java @@ -0,0 +1,413 @@ +package cn.iocoder.yudao.module.oa.service.leave; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi; +import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO; +import cn.iocoder.yudao.module.oa.controller.admin.leave.vo.OaLeaveApplyPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.leave.vo.OaLeaveApplySaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.leave.vo.OaLeaveApplySubmitReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.leave.OaLeaveApplyDO; +import cn.iocoder.yudao.module.oa.dal.mysql.leave.OaLeaveApplyMapper; +import cn.iocoder.yudao.module.oa.enums.attendance.OaAttendanceTypeEnum; +import cn.iocoder.yudao.module.oa.enums.leave.OaLeaveTypeEnum; +import cn.iocoder.yudao.module.oa.service.attendance.OaAttendanceService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.getMonthDateTimeRange; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaLeaveApplyServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaLeaveApplyServiceImpl.class) +public class OaLeaveApplyServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaLeaveApplyService leaveApplyService; + + @Resource + private OaLeaveApplyMapper leaveApplyMapper; + + @MockitoBean + private OaAttendanceService attendanceService; + @MockitoBean + private BpmProcessInstanceApi processInstanceApi; + + @BeforeEach + public void before() { + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(10L).setUserType(2), new MockHttpServletRequest()); + } + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void testUpdateLeaveApplyStatus_createAttendance() { + // mock 数据 + OaLeaveApplyDO apply = randomPojo(OaLeaveApplyDO.class, row -> row.setId(null).setType(1).setUrgency(1)) + .setStatus(1).setDays(2).setStartTime(LocalDateTime.of(2026, 9, 1, 8, 0)); + apply.setCreator("10"); + leaveApplyMapper.insert(apply); + + // 调用:审批通过后生成考勤明细 + leaveApplyService.updateLeaveApplyStatus(apply.getId(), 2); + // 断言 + assertEquals(2, leaveApplyMapper.selectById(apply.getId()).getStatus()); + verify(attendanceService).createApplyAttendance(10L, + OaAttendanceTypeEnum.LEAVE, + apply.getStartTime()); + } + + @Test + public void testUpdateLeaveApplyStatus_rejectWithoutAttendance() { + // mock 数据 + OaLeaveApplyDO apply = randomPojo(OaLeaveApplyDO.class, row -> row.setId(null).setType(1).setUrgency(1)).setStatus(1); + leaveApplyMapper.insert(apply); + + // 调用 + leaveApplyService.updateLeaveApplyStatus(apply.getId(), 3); + // 断言 + verifyNoInteractions(attendanceService); + } + + @Test + public void testDraft_updateAndSubmitPermissions() { + // mock 数据 + Long id = leaveApplyService.createLeaveApply(randomLeaveApplySaveReqVO()); + OaLeaveApplySaveReqVO reqVO = randomLeaveApplySaveReqVO().setId(id).setTitle("修改后的草稿"); + + // 调用,并断言:他人不能修改、提交 + assertServiceException(() -> leaveApplyService.updateLeaveApply(reqVO, 99L), APPLY_ACCESS_DENIED); + assertServiceException(() -> leaveApplyService.submitLeaveApply(new OaLeaveApplySubmitReqVO() + .setId(id), 99L), APPLY_ACCESS_DENIED); + leaveApplyService.updateLeaveApply(reqVO, 10L); + assertEquals("修改后的草稿", leaveApplyMapper.selectById(id).getTitle()); + assertEquals(-1, leaveApplyMapper.selectById(id).getStatus()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testSubmit_duplicateAndUpdateRunning() { + // mock 数据 + Long id = leaveApplyService.createLeaveApply(randomLeaveApplySaveReqVO()); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("process-1"); + + // 调用 + leaveApplyService.submitLeaveApply(new OaLeaveApplySubmitReqVO().setId(id), 10L); + // 断言 + assertServiceException(() -> leaveApplyService.submitLeaveApply(new OaLeaveApplySubmitReqVO() + .setId(id), 10L), APPLY_STATUS_INVALID); + assertServiceException(() -> leaveApplyService.updateLeaveApply(randomLeaveApplySaveReqVO() + .setId(id), 10L), APPLY_STATUS_INVALID); + verify(processInstanceApi, times(1)).createProcessInstance(eq(10L), any()); + } + + @Test + public void testCreateLeaveApply_success() { + // 准备参数 + OaLeaveApplySaveReqVO reqVO = randomLeaveApplySaveReqVO(); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("process-1"); + + // 调用 + Long id = leaveApplyService.createLeaveApply(reqVO); + assertEquals(-1, leaveApplyMapper.selectById(id).getStatus()); + assertNull(leaveApplyMapper.selectById(id).getProcessInstanceId()); + verifyNoInteractions(processInstanceApi); + leaveApplyService.submitLeaveApply(new OaLeaveApplySubmitReqVO().setId(id), 10L); + + // 断言 + OaLeaveApplyDO application = leaveApplyMapper.selectById(id); + assertEquals("10", application.getCreator()); + assertEquals(1, application.getStatus()); + assertEquals("process-1", application.getProcessInstanceId()); + assertEquals(1, application.getDays()); + verify(processInstanceApi).createProcessInstance(eq(10L), argThat(dto -> + "oa_leave_apply".equals(dto.getProcessDefinitionKey()) && id.toString().equals(dto.getBusinessKey()) + && !dto.getVariables().containsKey("title") && Integer.valueOf(1).equals(dto.getVariables().get("days")))); + } + + @Test + public void testCreateLeaveApply_processFailureRollback() { + // 准备参数 + OaLeaveApplySaveReqVO reqVO = randomLeaveApplySaveReqVO(); + // mock 方法 + when(processInstanceApi.createProcessInstance(anyLong(), any())).thenThrow(new IllegalStateException("流程尚未配置")); + + // 调用 + Long id = leaveApplyService.createLeaveApply(reqVO); + // 断言 + assertThrows(IllegalStateException.class, () -> leaveApplyService.submitLeaveApply( + new OaLeaveApplySubmitReqVO().setId(id), 10L)); + assertEquals(1L, leaveApplyMapper.selectCount()); + assertEquals(-1, leaveApplyMapper.selectById(id).getStatus()); + assertNull(leaveApplyMapper.selectById(id).getProcessInstanceId()); + } + + @Test + public void testGetLeaveApply_success() { + // mock 数据 + OaLeaveApplyDO application = randomLeaveApplyDO(); + leaveApplyMapper.insert(application); + + // 调用 + OaLeaveApplyDO result = leaveApplyService.getLeaveApply(application.getId()); + + // 断言 + assertEquals(application.getId(), result.getId()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testGetLeaveApply_notExists() { + + // 调用 + OaLeaveApplyDO result = leaveApplyService.getLeaveApply(1024L); + + // 断言 + assertNull(result); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testSubmitLeaveApply_synchronousApproval() { + // mock 数据 + Long id = leaveApplyService.createLeaveApply(randomLeaveApplySaveReqVO()); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenAnswer(invocation -> { + BpmProcessInstanceCreateReqDTO reqDTO = invocation.getArgument(1); + leaveApplyService.updateLeaveApplyStatus(Long.valueOf(reqDTO.getBusinessKey()), 2); + return "process-1"; + }); + + // 调用 + leaveApplyService.submitLeaveApply(new OaLeaveApplySubmitReqVO().setId(id), 10L); + + // 断言 + OaLeaveApplyDO leaveApply = leaveApplyMapper.selectById(id); + assertEquals(2, leaveApply.getStatus()); + assertEquals("process-1", leaveApply.getProcessInstanceId()); + } + + @Test + public void testGetLeaveApplyPage_scopeAndFilter() { + // mock 数据 + OaLeaveApplyDO application = randomLeaveApplyDO().setTitle("匹配申请"); + leaveApplyMapper.insert(application); + OaLeaveApplyDO otherApplication = randomLeaveApplyDO().setTitle("匹配申请"); + otherApplication.setCreator("99"); + leaveApplyMapper.insert(otherApplication); + leaveApplyMapper.insert(randomLeaveApplyDO().setId(null).setStatus(2).setTitle("匹配申请")); + // 准备参数 + OaLeaveApplyPageReqVO reqVO = new OaLeaveApplyPageReqVO().setTitle("匹配").setStatus(1); + + // 调用 + PageResult page = leaveApplyService.getLeaveApplyPage(10L, reqVO); + + // 断言 + assertEquals(1L, page.getTotal()); + assertEquals(application.getId(), CollUtil.getFirst(page.getList()).getId()); + } + + @Test + public void testUpdateLeaveApplyStatus_successAndRepeated() { + // mock 数据 + OaLeaveApplyDO application = randomLeaveApplyDO(); + leaveApplyMapper.insert(application); + + // 调用 + leaveApplyService.updateLeaveApplyStatus(application.getId(), 2); + leaveApplyService.updateLeaveApplyStatus(application.getId(), 2); + + // 断言 + assertEquals(2, leaveApplyMapper.selectById(application.getId()).getStatus()); + } + + @Test + public void testUpdateLeaveApplyStatus_notExists() { + + // 调用,并断言异常 + assertServiceException(() -> leaveApplyService.updateLeaveApplyStatus(1024L, 2), APPLY_NOT_EXISTS); + } + + @Test + public void testCreateLeaveApply_crossMidnight() { + // 准备参数 + OaLeaveApplySaveReqVO reqVO = randomLeaveApplySaveReqVO() + .setStartTime(LocalDateTime.of(2026, 9, 13, 23, 0)) + .setEndTime(LocalDateTime.of(2026, 9, 14, 1, 0)); + + // 调用 + Long id = leaveApplyService.createLeaveApply(reqVO); + + // 断言:跨午夜的 2 小时仍按 1 天计算,不按首尾日期计为 2 天 + assertEquals(1, leaveApplyMapper.selectById(id).getDays()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testUpdateLeaveApply_recalculateDays() { + // mock 数据 + Long id = leaveApplyService.createLeaveApply(randomLeaveApplySaveReqVO()); + // 准备参数 + OaLeaveApplySaveReqVO reqVO = randomLeaveApplySaveReqVO().setId(id) + .setStartTime(LocalDateTime.of(2026, 9, 13, 8, 0)) + .setEndTime(LocalDateTime.of(2026, 9, 14, 20, 0)); + + // 调用 + leaveApplyService.updateLeaveApply(reqVO, 10L); + + // 断言 + OaLeaveApplyDO leaveApply = leaveApplyMapper.selectById(id); + assertEquals(2, leaveApply.getDays()); + assertEquals(-1, leaveApply.getStatus()); + verifyNoInteractions(processInstanceApi); + } + + @ParameterizedTest + @CsvSource({"2,4", "4,10", "6,10"}) + public void testCreateLeaveApply_daysLimit(Integer type, Integer maxDays) { + // 准备参数:恰好达到上限 + LocalDateTime startTime = LocalDateTime.of(2026, 9, 1, 9, 0); + OaLeaveApplySaveReqVO reqVO = randomLeaveApplySaveReqVO().setType(type) + .setStartTime(startTime).setEndTime(startTime.plusDays(maxDays)); + + // 调用 + Long id = leaveApplyService.createLeaveApply(reqVO); + + // 断言:合法申请正常保存;多一分钟向上取整后超限 + assertEquals(maxDays, leaveApplyMapper.selectById(id).getDays()); + reqVO.setEndTime(reqVO.getEndTime().plusMinutes(1)); + assertServiceException(() -> leaveApplyService.createLeaveApply(reqVO), LEAVE_APPLY_DAYS_EXCEEDED, + OaLeaveTypeEnum.valueOf(type).getName(), maxDays); + assertEquals(1L, leaveApplyMapper.selectCount()); + } + + @ParameterizedTest + @CsvSource({"1", "3", "5", "7"}) + public void testCreateLeaveApply_otherTypesNotLimited(Integer type) { + // 准备参数:其他类型不套用单次天数上限 + LocalDateTime startTime = LocalDateTime.of(2026, 9, 1, 9, 0); + OaLeaveApplySaveReqVO reqVO = randomLeaveApplySaveReqVO().setType(type) + .setStartTime(startTime).setEndTime(startTime.plusDays(20)); + + // 调用 + Long id = leaveApplyService.createLeaveApply(reqVO); + + // 断言 + assertEquals(20, leaveApplyMapper.selectById(id).getDays()); + } + + @Test + public void testUpdateLeaveApply_daysLimit() { + // mock 数据 + Long id = leaveApplyService.createLeaveApply(randomLeaveApplySaveReqVO()); + // 准备参数 + OaLeaveApplySaveReqVO reqVO = randomLeaveApplySaveReqVO().setId(id).setType(2); + reqVO.setEndTime(reqVO.getStartTime().plusDays(5)); + + // 调用,并断言:修改类型或时间不能绕过上限 + assertServiceException(() -> leaveApplyService.updateLeaveApply(reqVO, 10L), + LEAVE_APPLY_DAYS_EXCEEDED, "事假", 4); + assertEquals(1, leaveApplyMapper.selectById(id).getType()); + assertEquals(1, leaveApplyMapper.selectById(id).getDays()); + } + + @Test + public void testSubmitLeaveApply_legacyDraftDaysLimit() { + // mock 数据:旧草稿超出限制 + OaLeaveApplyDO application = randomLeaveApplyDO().setType(4).setDays(11).setStatus(-1); + leaveApplyMapper.insert(application); + // 准备参数 + OaLeaveApplySubmitReqVO reqVO = new OaLeaveApplySubmitReqVO().setId(application.getId()); + + // 调用,并断言异常 + assertServiceException(() -> leaveApplyService.submitLeaveApply(reqVO, 10L), + LEAVE_APPLY_DAYS_EXCEEDED, "婚假", 10); + assertEquals(-1, leaveApplyMapper.selectById(application.getId()).getStatus()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testGetApprovedLeaveDaysMap() { + // mock 数据:包含月初、月末开始且跨月的申请 + LocalDateTime startTime = LocalDateTime.of(2026, 9, 1, 0, 0); + leaveApplyMapper.insert(randomLeaveApplyDO().setStatus(2).setStartTime(startTime).setDays(2)); + leaveApplyMapper.insert(randomLeaveApplyDO().setStatus(2).setStartTime(startTime.plusMonths(1).minusSeconds(1)) + .setEndTime(startTime.plusMonths(1).plusDays(2)).setDays(3)); + // 排除非通过、前后月份、其他用户和已删除的申请 + for (Integer status : Arrays.asList(-1, 1, 3, 4)) { + leaveApplyMapper.insert(randomLeaveApplyDO().setStatus(status).setStartTime(startTime).setDays(10)); + } + leaveApplyMapper.insert(randomLeaveApplyDO().setStatus(2).setStartTime(startTime.minusSeconds(1)).setDays(10)); + leaveApplyMapper.insert(randomLeaveApplyDO().setStatus(2).setStartTime(startTime.plusMonths(1)).setDays(10)); + OaLeaveApplyDO otherUserApply = randomLeaveApplyDO().setStatus(2).setStartTime(startTime).setDays(10); + otherUserApply.setCreator("99"); + leaveApplyMapper.insert(otherUserApply); + OaLeaveApplyDO deletedApply = randomLeaveApplyDO().setStatus(2).setStartTime(startTime).setDays(10); + deletedApply.setDeleted(true); + leaveApplyMapper.insert(deletedApply); + // 准备参数 + LocalDateTime[] monthTime = getMonthDateTimeRange(2026, 9); + + // 调用 + Map result = leaveApplyService.getApprovedLeaveDaysMap(Arrays.asList(10L, 20L), monthTime); + + // 断言:整笔归入开始月份,无申请用户不返回 + assertEquals(Collections.singletonMap(10L, 5), result); + assertTrue(leaveApplyService.getApprovedLeaveDaysMap(Collections.emptyList(), monthTime).isEmpty()); + } + + // ========== 随机对象 ========== + + /** + * 构造申请参数,固定业务字段的合法取值。 + * + * @return 请求参数 + */ + private static OaLeaveApplySaveReqVO randomLeaveApplySaveReqVO() { + return randomPojo(OaLeaveApplySaveReqVO.class).setTitle("业务申请").setUrgency(1).setId(null).setType(1) + .setStartTime(LocalDateTime.of(2026, 9, 13, 8, 0)).setEndTime(LocalDateTime.of(2026, 9, 13, 20, 0)); + } + + /** + * 构造测试数据,固定业务字段的合法取值。 + * + * @return 未入库的测试对象 + */ + private static OaLeaveApplyDO randomLeaveApplyDO() { + return randomPojo(OaLeaveApplyDO.class, application -> { + application.setId(null).setTitle("申请").setUrgency(1).setStatus(1).setProcessInstanceId("process-1"); + application.setCreator("10").setDeleted(false); + }); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/mail/OaMailAccountServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/mail/OaMailAccountServiceImplTest.java new file mode 100644 index 0000000000..82e255d7e0 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/mail/OaMailAccountServiceImplTest.java @@ -0,0 +1,253 @@ +package cn.iocoder.yudao.module.oa.service.mail; + +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.controller.admin.mail.vo.account.OaMailAccountSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.mail.OaMailAccountDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.mail.OaMailProviderDO; +import cn.iocoder.yudao.module.oa.dal.mysql.mail.OaMailAccountMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaMailAccountServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaMailAccountServiceImpl.class) +public class OaMailAccountServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaMailAccountServiceImpl mailAccountService; + + @Resource(name = "oaMailAccountMapper") // 区分 System 模块的同名 Mapper + private OaMailAccountMapper mailAccountMapper; + + @MockitoBean + private OaMailProviderService mailProviderService; + @MockitoBean + private OaMailMessageClient mailMessageClient; + + @AfterEach + public void clearLogin() { + SecurityContextHolder.clearContext(); + } + + @Test + public void testUpdateMailAccount_identityImmutable() { + // mock 数据 + OaMailAccountDO account = randomMailAccountDO("1"); + mailAccountMapper.insert(account); + // 准备参数:逐一验证三项账号身份均不可修改 + OaMailAccountSaveReqVO reqVO = new OaMailAccountSaveReqVO().setId(account.getId()) + .setProviderId(account.getProviderId()).setMail(account.getMail()).setUsername(account.getUsername()) + .setPassword("").setDefaultStatus(false).setStatus(0); + + // 调用,并断言异常:邮箱地址、登录账号、服务商均不可变更 + reqVO.setMail("changed@example.com"); + assertServiceException(() -> mailAccountService.updateMailAccount(reqVO, 1L), MAIL_ACCOUNT_IDENTITY_IMMUTABLE); + reqVO.setMail(account.getMail()).setUsername("changed-login"); + assertServiceException(() -> mailAccountService.updateMailAccount(reqVO, 1L), MAIL_ACCOUNT_IDENTITY_IMMUTABLE); + reqVO.setUsername(account.getUsername()).setProviderId(account.getProviderId() + 1); + assertServiceException(() -> mailAccountService.updateMailAccount(reqVO, 1L), MAIL_ACCOUNT_IDENTITY_IMMUTABLE); + assertEquals(account.getMail(), mailAccountMapper.selectById(account.getId()).getMail()); + } + + @Test + public void testUpdateMailAccount_passwordAndStatus() { + // mock 数据 + OaMailAccountDO account = randomMailAccountDO("1"); + mailAccountMapper.insert(account); + // 准备参数 + OaMailAccountSaveReqVO reqVO = new OaMailAccountSaveReqVO().setId(account.getId()) + .setProviderId(account.getProviderId()).setMail(account.getMail()).setUsername(account.getUsername()) + .setPassword("new-test-password").setDefaultStatus(false).setStatus(1); + when(mailProviderService.validateMailProviderExists(account.getProviderId())) + .thenReturn(new OaMailProviderDO().setStatus(0)); + + // 调用 + mailAccountService.updateMailAccount(reqVO, 1L); + // 断言 + OaMailAccountDO result = mailAccountMapper.selectById(account.getId()); + assertEquals("new-test-password", result.getPassword()); + assertEquals(1, result.getStatus()); + assertEquals(account.getUsername(), result.getUsername()); + } + + @Test + public void testGetMailAccountListByStatus() { + // mock 数据:包含其他员工的邮箱、重复地址及停用账号 + mailAccountMapper.insert(randomMailAccountDO("1").setMail("a@example.com").setStatus(0)); + mailAccountMapper.insert(randomMailAccountDO("2").setMail("b@example.com").setStatus(0)); + mailAccountMapper.insert(randomMailAccountDO("3").setMail("a@example.com").setStatus(0)); + mailAccountMapper.insert(randomMailAccountDO("4").setMail("disabled@example.com").setStatus(1)); + + // 调用 + List accounts = mailAccountService.getMailAccountListByStatus(0); + + // 断言 + assertEquals(Arrays.asList("a@example.com", "a@example.com", "b@example.com"), + convertList(accounts, OaMailAccountDO::getMail)); + assertTrue(convertList(accounts, OaMailAccountDO::getCreator).containsAll(Arrays.asList("1", "2", "3"))); + accounts.forEach(account -> { + assertNull(account.getPassword()); + assertNull(account.getUsername()); + assertNull(account.getProviderId()); + }); + } + + @Test + public void testGetMailAccountListByStatus_empty() { + + // 调用,并断言 + assertTrue(mailAccountService.getMailAccountListByStatus(0).isEmpty()); + } + + @Test + public void testGetMailAccountListByUserIdAndStatus() { + // mock 数据 + OaMailAccountDO own = randomMailAccountDO("1").setStatus(0); + mailAccountMapper.insert(own); + mailAccountMapper.insert(randomMailAccountDO("2")); + mailAccountMapper.insert(randomMailAccountDO("1").setStatus(1)); + + // 调用 + List accounts = mailAccountService.getMailAccountListByUserIdAndStatus(1L, 0); + // 断言 + assertEquals(1, accounts.size()); + assertEquals(Arrays.asList(own.getId()), convertList(accounts, OaMailAccountDO::getId)); + } + + @Test + public void testGetMailAccountListByUserIdAndStatus_allStatuses() { + // mock 数据 + mailAccountMapper.insert(randomMailAccountDO("1").setStatus(0)); + mailAccountMapper.insert(randomMailAccountDO("1").setStatus(1)); + mailAccountMapper.insert(randomMailAccountDO("2").setStatus(0)); + + // 调用 + List accounts = mailAccountService.getMailAccountListByUserIdAndStatus(1L, null); + // 断言:管理列表保留本人停用账号,不包含他人账号 + assertEquals(2, accounts.size()); + assertTrue(accounts.stream().allMatch(account -> "1".equals(account.getCreator()))); + } + + @Test + public void testGetMailAccount_notOwner() { + // mock 数据 + OaMailAccountDO account = randomMailAccountDO("1"); + mailAccountMapper.insert(account); + + // 调用,并断言异常 + assertServiceException(() -> mailAccountService.validateMailAccount(account.getId(), 2L), MAIL_ACCOUNT_NOT_EXISTS); + } + + @Test + public void testUpdateMailAccount_emptyPassword() { + // mock 数据 + OaMailAccountDO account = randomMailAccountDO("1"); + mailAccountMapper.insert(account); + // 准备参数 + OaMailAccountSaveReqVO reqVO = new OaMailAccountSaveReqVO().setId(account.getId()) + .setProviderId(account.getProviderId()).setMail(account.getMail()).setUsername(account.getUsername()) + .setPassword("").setDefaultStatus(false).setStatus(0); + // mock 方法 + when(mailProviderService.validateMailProviderExists(1L)).thenReturn(new OaMailProviderDO().setStatus(0)); + + // 调用 + mailAccountService.updateMailAccount(reqVO, 1L); + // 断言 + assertEquals(account.getPassword(), mailAccountMapper.selectById(account.getId()).getPassword()); + assertEquals("1", mailAccountMapper.selectById(account.getId()).getCreator()); + } + + @Test + public void testUpdateMailAccountDefault_onlyOwner() { + // mock 数据 + OaMailAccountDO first = randomMailAccountDO("1").setDefaultStatus(true); + OaMailAccountDO second = randomMailAccountDO("1"); + OaMailAccountDO other = randomMailAccountDO("2").setDefaultStatus(true); + mailAccountMapper.insert(first); + mailAccountMapper.insert(second); + mailAccountMapper.insert(other); + // mock 方法 + when(mailProviderService.validateMailProviderExists(second.getProviderId())) + .thenReturn(new OaMailProviderDO().setStatus(0)); + + // 调用 + mailAccountService.updateMailAccountDefault(second.getId(), 1L); + // 断言 + assertFalse(mailAccountMapper.selectById(first.getId()).getDefaultStatus()); + assertTrue(mailAccountMapper.selectById(second.getId()).getDefaultStatus()); + assertTrue(mailAccountMapper.selectById(other.getId()).getDefaultStatus()); + } + + @Test + public void testCreateMailAccount_passwordAndAudit() { + // 准备参数 + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(1L), new MockHttpServletRequest()); + OaMailAccountSaveReqVO reqVO = new OaMailAccountSaveReqVO().setProviderId(1L) + .setMail("test@example.com").setUsername("test@example.com").setPassword("test-input") + .setDefaultStatus(true).setStatus(0); + // mock 方法 + when(mailProviderService.validateMailProviderExists(1L)).thenReturn(new OaMailProviderDO().setStatus(0)); + + // 调用 + Long id = mailAccountService.createMailAccount(reqVO, 1L); + // 断言 + OaMailAccountDO account = mailAccountMapper.selectById(id); + assertEquals("1", account.getCreator()); + assertEquals("test-input", account.getPassword()); + } + + @Test + public void testDeleteMailAccount_notOwner() { + // mock 数据 + OaMailAccountDO account = randomMailAccountDO("1"); + mailAccountMapper.insert(account); + + // 调用,并断言异常 + assertServiceException(() -> mailAccountService.deleteMailAccount(account.getId(), 2L), MAIL_ACCOUNT_NOT_EXISTS); + assertNotNull(mailAccountMapper.selectById(account.getId())); + } + + @Test + public void testConnection_disabledAccount() { + // mock 数据 + OaMailAccountDO account = randomMailAccountDO("1").setStatus(1); + mailAccountMapper.insert(account); + + // 调用,并断言异常 + assertServiceException(() -> mailAccountService.testMailAccountConnection(account.getId(), 1L), MAIL_ACCOUNT_DISABLED); + verifyNoInteractions(mailMessageClient); + } + + // ========== 随机对象 ========== + /** + * 构造已启用且非默认的测试邮箱账号。 + * + * @param creator 创建人编号字符串 + * @return 未入库的测试对象 + */ + private static OaMailAccountDO randomMailAccountDO(String creator) { + return randomPojo(OaMailAccountDO.class, account -> account.setId(null) + .setMail("test@example.com").setUsername("test@example.com").setPassword("test-password") + .setProviderId(1L).setStatus(0).setDefaultStatus(false).setCreator(creator)); + } +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/mail/OaMailMessageServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/mail/OaMailMessageServiceImplTest.java new file mode 100644 index 0000000000..1c21f4b414 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/mail/OaMailMessageServiceImplTest.java @@ -0,0 +1,528 @@ +package cn.iocoder.yudao.module.oa.service.mail; + +import cn.hutool.core.lang.Pair; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.controller.admin.mail.vo.message.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.mail.*; +import cn.iocoder.yudao.module.oa.dal.mysql.mail.*; +import jakarta.annotation.Resource; +import jakarta.mail.*; +import jakarta.mail.internet.MimeMessage; +import org.eclipse.angus.mail.imap.AppendUID; +import org.eclipse.angus.mail.imap.IMAPFolder; +import org.eclipse.angus.mail.imap.IMAPStore; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.MAIL_ACCOUNT_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaMailMessageServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import({OaMailMessageServiceImpl.class, OaMailFolderServiceImpl.class}) +public class OaMailMessageServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaMailMessageServiceImpl mailMessageService; + + @Resource + private OaMailMessageMapper mailMessageMapper; + @Resource + private OaMailFolderMapper mailFolderMapper; + + @MockitoBean + private OaMailAccountService mailAccountService; + @MockitoBean + private OaMailMessageClient mailMessageClient; + + @Test + public void testRestoreMailMessage_failureKeepsIndex() throws Exception { + // mock 数据 + OaMailFolderDO trash = randomMailFolderDO().setType("TRASH").setName("Trash"); + mailFolderMapper.insert(trash); + OaMailMessageDO index = randomMailMessageDO(trash.getId(), 1L); + mailMessageMapper.insert(index); + OaMailAccountDO account = new OaMailAccountDO().setId(1L); + Store store = mock(Store.class); + Folder folder = mock(Folder.class); + Message message = mock(Message.class); + // mock 方法 + when(mailAccountService.lockMailAccount(1L, 1L)).thenReturn(account); + when(mailMessageClient.openStore(account)).thenReturn(store); + when(mailMessageClient.getFolders(store)).thenReturn(Collections.singletonMap("INBOX", "INBOX")); + when(store.getFolder("Trash")).thenReturn(folder); + when(folder.isOpen()).thenReturn(true); + when(mailMessageClient.getMessage(eq(folder), any())).thenReturn(message); + doThrow(new MessagingException("连接中断")).when(mailMessageClient).moveMessage(store, folder, message, "INBOX"); + + // 调用,并断言:失败保留本地索引,供同步核实 + assertThrows(ServiceException.class, + () -> mailMessageService.restoreMailMessage(index.getId(), 1L)); + assertNotNull(mailMessageMapper.selectById(index.getId())); + verify(folder).close(false); + verify(folder, never()).close(true); + } + + @Test + public void testRestoreMailMessage_movesToInbox() throws Exception { + // mock 数据 + OaMailFolderDO trash = randomMailFolderDO().setType("TRASH").setName("Trash"); + mailFolderMapper.insert(trash); + OaMailMessageDO index = randomMailMessageDO(trash.getId(), 1L); + mailMessageMapper.insert(index); + OaMailAccountDO account = new OaMailAccountDO().setId(1L); + Store store = mock(Store.class); + Folder folder = mock(Folder.class); + Message message = mock(Message.class); + // mock 方法 + when(mailAccountService.lockMailAccount(1L, 1L)).thenReturn(account); + when(mailMessageClient.openStore(account)).thenReturn(store); + when(mailMessageClient.getFolders(store)).thenReturn(Collections.singletonMap("INBOX", "INBOX")); + when(store.getFolder("Trash")).thenReturn(folder); + when(folder.isOpen()).thenReturn(true); + when(mailMessageClient.getMessage(eq(folder), any())).thenReturn(message); + + // 调用 + mailMessageService.restoreMailMessage(index.getId(), 1L); + // 断言 + verify(mailMessageClient).moveMessage(store, folder, message, "INBOX"); + assertNull(mailMessageMapper.selectById(index.getId())); + verify(folder).close(false); + verify(folder, never()).close(true); + } + + @Test + public void testGetMailMessageAttachment_ownership() { + // mock 数据 + OaMailFolderDO folder = randomMailFolderDO(); + mailFolderMapper.insert(folder); + OaMailMessageDO message = randomMailMessageDO(folder.getId(), 1L); + mailMessageMapper.insert(message); + // mock 方法:账号服务执行本人归属校验 + when(mailAccountService.validateMailAccount(1L, 2L)).thenThrow( + exception( + MAIL_ACCOUNT_NOT_EXISTS)); + + // 调用,并断言:他人不能连接该邮箱下载附件 + assertServiceException( + () -> mailMessageService.getMailMessageAttachment(message.getId(), "1", 2L), + MAIL_ACCOUNT_NOT_EXISTS); + verifyNoInteractions(mailMessageClient); + } + + @Test + public void testGetMailMessageAttachment_readOnlyAndClosed() throws Exception { + // mock 数据 + OaMailFolderDO localFolder = randomMailFolderDO(); + mailFolderMapper.insert(localFolder); + OaMailMessageDO message = randomMailMessageDO(localFolder.getId(), 1L); + mailMessageMapper.insert(message); + OaMailAccountDO account = new OaMailAccountDO().setId(1L); + Store store = mock(Store.class); + Folder remoteFolder = mock(Folder.class); + Message remoteMessage = mock(Message.class); + // mock 方法 + when(mailAccountService.validateMailAccount(1L, 1L)).thenReturn(account); + when(mailMessageClient.openStore(account)).thenReturn(store); + when(store.getFolder("INBOX")).thenReturn(remoteFolder); + when(mailMessageClient.getMessage(eq(remoteFolder), any())).thenReturn(remoteMessage); + when(mailMessageClient.getAttachment(remoteMessage, "1")) + .thenReturn(Pair.of("合同.txt", new byte[]{1, 2})); + + // 调用 + Pair result = mailMessageService.getMailMessageAttachment(message.getId(), "1", 1L); + // 断言 + assertArrayEquals(new byte[]{1, 2}, result.getValue()); + verify(remoteFolder).open(Folder.READ_ONLY); + verify(remoteFolder).close(); + verify(store).close(); + } + + @Test + public void testSyncMailMessages_updateAndRemove() throws Exception { + // mock 数据 + OaMailFolderDO localFolder = randomMailFolderDO(); + mailFolderMapper.insert(localFolder); + OaMailMessageDO first = randomMailMessageDO(localFolder.getId(), 10L); + OaMailMessageDO removed = randomMailMessageDO(localFolder.getId(), 11L); + mailMessageMapper.insert(first); + mailMessageMapper.insert(removed); + // mock 方法 + OaMailAccountDO account = new OaMailAccountDO().setId(1L); + Store store = mock(Store.class); + Folder folder = mock(Folder.class, withSettings().extraInterfaces(UIDFolder.class)); + when(mailAccountService.lockMailAccount(1L, 1L)).thenReturn(account); + when(mailMessageClient.openStore(account)).thenReturn(store); + when(mailMessageClient.getFolders(store)).thenReturn(Collections.singletonMap("INBOX", "INBOX")); + when(store.getFolder("INBOX")).thenReturn(folder); + when(((UIDFolder) folder).getUIDValidity()).thenReturn(100L); + when(folder.isOpen()).thenReturn(true); + when(mailMessageClient.getMessageList(folder)).thenReturn(Arrays.asList( + randomMailMessageDO(null, 10L).setId(null).setReadStatus(true), randomMailMessageDO(null, 12L).setId(null))); + + // 调用 + assertEquals(2, mailMessageService.syncMailMessageList(1L, 1L)); + // 断言 + assertTrue(mailMessageMapper.selectById(first.getId()).getReadStatus()); + assertNull(mailMessageMapper.selectById(removed.getId())); + assertEquals(2, mailMessageMapper.selectListByFolderId(localFolder.getId()).size()); + assertNotNull(mailFolderMapper.selectById(localFolder.getId()).getSyncTime()); + verify(folder).close(false); + verify(store).close(); + } + + @Test + public void testSyncMailMessages_uidValidityReset() throws Exception { + // mock 数据 + OaMailFolderDO localFolder = randomMailFolderDO(); + mailFolderMapper.insert(localFolder); + OaMailMessageDO old = randomMailMessageDO(localFolder.getId(), 1L); + mailMessageMapper.insert(old); + // mock 方法 + OaMailAccountDO account = new OaMailAccountDO().setId(1L); + Store store = mock(Store.class); + Folder folder = mock(Folder.class, withSettings().extraInterfaces(UIDFolder.class)); + when(mailAccountService.lockMailAccount(1L, 1L)).thenReturn(account); + when(mailMessageClient.openStore(account)).thenReturn(store); + when(mailMessageClient.getFolders(store)).thenReturn(Collections.singletonMap("INBOX", "INBOX")); + when(store.getFolder("INBOX")).thenReturn(folder); + when(((UIDFolder) folder).getUIDValidity()).thenReturn(200L); + when(mailMessageClient.getMessageList(folder)).thenReturn(Collections.singletonList(randomMailMessageDO(null, 1L) + .setId(null).setUidValidity(200L))); + + // 调用 + mailMessageService.syncMailMessageList(1L, 1L); + // 断言:UID 相同但有效期改变,不能复用旧详情编号 + assertNull(mailMessageMapper.selectById(old.getId())); + assertEquals(200L, mailFolderMapper.selectById(localFolder.getId()).getUidValidity()); + assertEquals(1, mailMessageMapper.selectListByFolderId(localFolder.getId()).size()); + } + + @Test + public void testGetMailMessagePage_filterAndScope() { + // mock 数据 + OaMailFolderDO folder = randomMailFolderDO(); + mailFolderMapper.insert(folder); + mailMessageMapper.insert(randomMailMessageDO(folder.getId(), 1L).setSubject("验收邮件").setReadStatus(false)); + mailMessageMapper.insert(randomMailMessageDO(folder.getId(), 2L).setSubject("验收邮件").setReadStatus(true)); + mailMessageMapper.insert(randomMailMessageDO(folder.getId(), 3L).setAccountId(2L).setSubject("验收邮件")); + // 准备参数 + OaMailMessagePageReqVO reqVO = new OaMailMessagePageReqVO().setAccountId(1L).setFolderKey("UNREAD").setKeyword("验收"); + + // 调用 + PageResult page = mailMessageService.getMailMessagePage(reqVO, 1L); + // 断言 + assertEquals(1L, page.getTotal()); + assertFalse(page.getList().get(0).getReadStatus()); + verify(mailAccountService).validateMailAccount(1L, 1L); + assertEquals(2L, mailMessageService.getMailMessagePage(reqVO.setFolderKey("INBOX"), 1L).getTotal()); + } + + @Test + public void testGetMailFolderList_dynamicAndCounts() { + // mock 数据 + OaMailFolderDO inbox = randomMailFolderDO(); + mailFolderMapper.insert(inbox); + OaMailFolderDO custom = randomMailFolderDO().setName("客户/合同").setType("CUSTOM"); + mailFolderMapper.insert(custom); + mailFolderMapper.insert(randomMailFolderDO().setName("已移除").setType("CUSTOM").setAvailable(false)); + mailFolderMapper.insert(randomMailFolderDO().setName("他人目录").setType("CUSTOM").setAccountId(2L)); + mailMessageMapper.insert(randomMailMessageDO(inbox.getId(), 1L)); + mailMessageMapper.insert(randomMailMessageDO(custom.getId(), 2L)); + mailMessageMapper.insert(randomMailMessageDO(custom.getId(), 3L).setReadStatus(true)); + + // 调用 + List folders = mailMessageService.getMailFolderList(1L, 1L); + // 断言 + assertEquals(3, folders.size()); + assertEquals("INBOX", folders.get(0).getKey()); + assertEquals("UNREAD", folders.get(1).getKey()); + assertEquals("客户/合同", folders.get(2).getName()); + assertEquals(1L, folders.get(2).getUnreadCount()); + OaMailMessagePageReqVO request = new OaMailMessagePageReqVO() + .setAccountId(1L).setFolderKey(folders.get(2).getKey()); + assertEquals(2L, mailMessageService.getMailMessagePage(request, 1L).getTotal()); + assertEquals(0L, mailMessageService.getMailMessagePage(request.setAccountId(2L), 2L).getTotal()); + } + + @Test + public void testGetMailMessage_cacheAndUpdate() throws Exception { + // mock 数据 + OaMailFolderDO folder = randomMailFolderDO(); + mailFolderMapper.insert(folder); + OaMailMessageDO index = randomMailMessageDO(folder.getId(), 1L); + mailMessageMapper.insert(index); + OaMailAccountDO account = new OaMailAccountDO().setId(1L); + Store store = mock(Store.class); + Folder remote = mock(Folder.class); + OaMailMessageDO detail = new OaMailMessageDO(); + detail.setContent("

正文

"); + detail.setReplyTos(Arrays.asList("reply@example.com")); + detail.setAttachments(Collections.singletonList(new OaMailMessageDO.Attachment() + .setPart("1").setName("合同.pdf").setSize(123))); + // mock 方法 + when(mailAccountService.validateMailAccount(1L, 1L)).thenReturn(account); + when(mailAccountService.lockMailAccount(1L, 1L)).thenReturn(account); + when(mailMessageClient.openStore(account)).thenReturn(store); + when(store.getFolder("INBOX")).thenReturn(remote); + when(mailMessageClient.getDetail(any(), any())).thenReturn(detail); + when(mailMessageClient.sanitizeHtml(anyString())).thenAnswer(invocation -> new OaMailMessageClientImpl().sanitizeHtml(invocation.getArgument(0))); + + // 调用:首次远端读取,第二次从数据库读取 + mailMessageService.getMailMessage(index.getId(), 1L); + OaMailMessageDO cached = mailMessageService.getMailMessage(index.getId(), 1L); + // 断言 + verify(mailMessageClient, times(1)).openStore(account); + assertTrue(cached.getContent().contains("正文")); + assertEquals(Collections.singletonList("reply@example.com"), cached.getReplyTos()); + assertEquals("合同.pdf", cached.getAttachments().get(0).getName()); + assertTrue(mailMessageMapper.selectById(index.getId()).getContent().contains("正文")); + assertTrue(mailMessageService.getMailMessagePage(new OaMailMessagePageReqVO() + .setAccountId(1L).setFolderKey("INBOX"), 1L).getList().get(0).getContent().contains("正文")); + mailMessageMapper.updateById(new OaMailMessageDO().setId(index.getId()).setSubject("同步更新")); + assertTrue(mailMessageMapper.selectById(index.getId()).getContent().contains("正文")); + } + + @Test + public void testGetMailMessage_emptyCacheAndOwnership() { + // mock 数据 + OaMailFolderDO folder = randomMailFolderDO(); + mailFolderMapper.insert(folder); + OaMailMessageDO index = randomMailMessageDO(folder.getId(), 1L).setContent(""); + mailMessageMapper.insert(index); + + // 调用,并断言 + assertEquals("", mailMessageService.getMailMessage(index.getId(), 1L).getContent()); + verifyNoInteractions(mailMessageClient); + when(mailAccountService.validateMailAccount(1L, 2L)).thenThrow(new IllegalArgumentException("无权访问")); + assertThrows(IllegalArgumentException.class, () -> mailMessageService.getMailMessage(index.getId(), 2L)); + } + + @Test + public void testGetMailMessage_notOwner() { + // mock 数据 + OaMailMessageDO index = randomMailMessageDO(1L, 1L); + mailMessageMapper.insert(index); + // mock 方法 + when(mailAccountService.validateMailAccount(1L, 2L)).thenThrow(new IllegalArgumentException("无权访问")); + + // 调用,并断言 + assertThrows(IllegalArgumentException.class, () -> mailMessageService.getMailMessage(index.getId(), 2L)); + verifyNoInteractions(mailMessageClient); + } + + @Test + public void testUpdateMailMessageRead_noExpunge() throws Exception { + // mock 数据 + OaMailFolderDO localFolder = randomMailFolderDO(); + mailFolderMapper.insert(localFolder); + OaMailMessageDO index = randomMailMessageDO(localFolder.getId(), 1L); + mailMessageMapper.insert(index); + // mock 方法 + OaMailAccountDO account = new OaMailAccountDO().setId(1L); + Store store = mock(Store.class); + Folder folder = mock(Folder.class); + Message remote = mock(Message.class); + when(mailAccountService.lockMailAccount(1L, 1L)).thenReturn(account); + when(mailMessageClient.openStore(account)).thenReturn(store); + when(store.getFolder("INBOX")).thenReturn(folder); + when(folder.isOpen()).thenReturn(true); + when(mailMessageClient.getMessage(eq(folder), any())).thenReturn(remote); + + // 调用 + mailMessageService.updateMailMessageRead(index.getId(), true, 1L); + // 断言 + verify(remote).setFlag(Flags.Flag.SEEN, true); + assertTrue(mailMessageMapper.selectById(index.getId()).getReadStatus()); + verify(folder).close(false); + verify(folder, never()).close(true); + } + + @Test + public void testSendMailMessage_sentCopyFailureNotResend() throws Exception { + // 准备参数 + OaMailMessageSaveReqVO reqVO = new OaMailMessageSaveReqVO().setAccountId(1L).setMode("new"); + OaMailAccountDO account = new OaMailAccountDO().setId(1L); + MimeMessage mail = new MimeMessage(Session.getInstance(new Properties())); + // mock 方法 + when(mailAccountService.lockMailAccount(1L, 1L)).thenReturn(account); + when(mailMessageClient.createMessage(account, reqVO, true)).thenReturn(mail); + when(mailMessageClient.openStore(account)).thenThrow(new MessagingException("IMAP unavailable")); + + // 调用 + String result = mailMessageService.sendMailMessage(reqVO, 1L); + // 断言 + assertTrue(result.contains("服务器已接收")); + assertTrue(result.contains("勿重发")); + verify(mailMessageClient, times(1)).sendMessage(account, mail); + } + + @ParameterizedTest + @CsvSource({"主题, Re: 主题", "Re: 主题, Re: 主题", "re: 主题, re: 主题", "RE: 主题, RE: 主题"}) + public void testGetMailMessageCompose_replyAll(String subject, String expectedSubject) throws Exception { + // mock 数据 + OaMailFolderDO localFolder = randomMailFolderDO(); + mailFolderMapper.insert(localFolder); + OaMailMessageDO index = randomMailMessageDO(localFolder.getId(), 1L); + mailMessageMapper.insert(index); + OaMailAccountDO account = new OaMailAccountDO().setId(1L).setMail("self@example.com"); + Store store = mock(Store.class); + Folder folder = mock(Folder.class); + when(mailAccountService.validateMailAccount(1L, 1L)).thenReturn(account); + when(mailAccountService.lockMailAccount(1L, 1L)).thenReturn(account); + when(mailMessageClient.openStore(account)).thenReturn(store); + when(store.getFolder("INBOX")).thenReturn(folder); + OaMailMessageDO detail = new OaMailMessageDO(); + detail.setAccountId(1L).setFolderId(localFolder.getId()).setSender("sender@example.com") + .setRecipients(Arrays.asList("self@example.com", "other@example.com")) + .setCcs(Arrays.asList("other@example.com", "cc@example.com")).setSubject(subject); + detail.setReplyTos(Arrays.asList("reply@example.com")); + detail.setContent("

正文

"); + when(mailMessageClient.getDetail(eq(folder), any())).thenReturn(detail); + when(mailMessageClient.sanitizeHtml(anyString())).thenAnswer(invocation -> new OaMailMessageClientImpl().sanitizeHtml(invocation.getArgument(0))); + when(mailMessageClient.parseAddresses(anyList())).thenAnswer(invocation -> new OaMailMessageClientImpl().parseAddresses(invocation.getArgument(0))); + + // 调用 + OaMailMessageSaveReqVO result = mailMessageService.getMailMessageCompose(index.getId(), "replyAll", 1L); + // 断言 + assertEquals(expectedSubject, result.getSubject()); + assertEquals(Arrays.asList("reply@example.com", "other@example.com"), result.getRecipients()); + assertEquals(Collections.singletonList("cc@example.com"), result.getCcs()); + assertFalse(result.getContent().contains("style")); + assertFalse(result.getContent().contains("tracking.invalid")); + } + + @Test + public void testGetMailMessageCompose_draftKeepCollapseImageAndLink() throws Exception { + // mock 数据 + OaMailFolderDO localFolder = randomMailFolderDO().setType("DRAFTS"); + mailFolderMapper.insert(localFolder); + OaMailMessageDO index = randomMailMessageDO(localFolder.getId(), 1L); + mailMessageMapper.insert(index); + OaMailAccountDO account = new OaMailAccountDO().setId(1L).setMail("self@example.com"); + Store store = mock(Store.class); + Folder folder = mock(Folder.class); + OaMailMessageDO detail = new OaMailMessageDO().setAccountId(1L).setFolderId(localFolder.getId()).setSubject("草稿") + .setContent("
详细说明" + + "

折叠正文

" + + "原文危险链接"); + // mock 方法 + when(mailAccountService.validateMailAccount(1L, 1L)).thenReturn(account); + when(mailAccountService.lockMailAccount(1L, 1L)).thenReturn(account); + when(mailMessageClient.openStore(account)).thenReturn(store); + when(store.getFolder("INBOX")).thenReturn(folder); + when(mailMessageClient.getDetail(eq(folder), any())).thenReturn(detail); + when(mailMessageClient.sanitizeHtml(anyString())).thenAnswer( + invocation -> new OaMailMessageClientImpl().sanitizeHtml(invocation.getArgument(0))); + + // 调用 + OaMailMessageSaveReqVO result = mailMessageService.getMailMessageCompose(index.getId(), "draft", 1L); + // 断言:编辑草稿不把折叠结构降级成普通文本 + assertTrue(result.getContent().contains("
")); + assertTrue(result.getContent().contains("详细说明")); + assertTrue(result.getContent().contains("折叠正文")); + assertFalse(result.getContent().contains("ontoggle")); + assertTrue(result.getContent().contains("https://example.com/image.png")); + assertTrue(result.getContent().contains("href=\"https://example.com/article\"")); + assertFalse(result.getContent().contains("onerror")); + assertFalse(result.getContent().contains("javascript:")); + } + + @Test + public void testSaveMailMessageDraft_appendAndSync() throws Exception { + // mock 数据 + OaMailAccountDO account = new OaMailAccountDO().setId(1L); + OaMailMessageSaveReqVO reqVO = new OaMailMessageSaveReqVO().setAccountId(1L).setMode("new"); + IMAPStore store = mock(IMAPStore.class); + IMAPFolder folder = mock(IMAPFolder.class); + MimeMessage message = new MimeMessage(Session.getInstance(new Properties())); + // mock 方法 + when(mailAccountService.lockMailAccount(1L, 1L)).thenReturn(account); + when(mailMessageClient.openStore(account)).thenReturn(store); + when(mailMessageClient.getFolders(store)).thenReturn(Collections.singletonMap("DRAFTS", "Drafts")); + when(store.hasCapability("UIDPLUS")).thenReturn(true); + when(store.getFolder("Drafts")).thenReturn(folder); + when(mailMessageClient.createMessage(account, reqVO, false)).thenReturn(message); + when(folder.appendUIDMessages(any())).thenReturn(new AppendUID[]{ + new AppendUID(100L, 20L)}); + when(folder.getUIDValidity()).thenReturn(100L); + when(folder.isOpen()).thenReturn(true); + when(mailMessageClient.getMessageList(folder)).thenReturn(Collections.singletonList(randomMailMessageDO(null, 20L) + .setId(null))); + + // 调用 + Long id = mailMessageService.saveMailMessageDraft(reqVO, 1L); + // 断言 + assertEquals(20L, mailMessageMapper.selectById(id).getUid()); + assertTrue(message.isSet(Flags.Flag.DRAFT)); + verify(folder, never()).close(true); + verify(mailMessageClient, never()).sendMessage(any(), any()); + } + + @Test + public void testSyncMailMessages_failureRetainsSnapshot() throws Exception { + // mock 数据 + OaMailFolderDO localFolder = randomMailFolderDO(); + mailFolderMapper.insert(localFolder); + OaMailMessageDO index = randomMailMessageDO(localFolder.getId(), 1L); + mailMessageMapper.insert(index); + OaMailAccountDO account = new OaMailAccountDO().setId(1L); + Store store = mock(Store.class); + Folder folder = mock(Folder.class); + // mock 方法 + when(mailAccountService.lockMailAccount(1L, 1L)).thenReturn(account); + when(mailMessageClient.openStore(account)).thenReturn(store); + when(mailMessageClient.getFolders(store)).thenReturn(Collections.singletonMap("INBOX", "INBOX")); + when(store.getFolder("INBOX")).thenReturn(folder); + when(mailMessageClient.getMessageList(folder)).thenThrow(new MessagingException("连接中断")); + + // 调用,并断言 + assertThrows(ServiceException.class, () -> mailMessageService.syncMailMessageList(1L, 1L)); + assertNotNull(mailMessageMapper.selectById(index.getId())); + assertNull(mailFolderMapper.selectById(localFolder.getId()).getSyncTime()); + } + + // ========== 随机对象 ========== + + /** + * 构造可用且尚未同步的收件箱。 + * + * @return 未入库的测试对象 + */ + private static OaMailFolderDO randomMailFolderDO() { + return randomPojo(OaMailFolderDO.class, folder -> { + folder.setAccountId(1L).setName("INBOX").setType("INBOX").setUidValidity(100L).setAvailable(true); + folder.setSyncTime(null); + }); + } + + /** + * 构造未读且尚未缓存正文的邮件索引。 + * + * @param folderId 邮箱目录编号 + * @param uid 服务端邮件 UID + * @return 未入库的测试对象 + */ + private static OaMailMessageDO randomMailMessageDO(Long folderId, Long uid) { + return randomPojo(OaMailMessageDO.class, message -> { + message.setContent(null).setReplyTos(null).setAttachments(null); + message.setAccountId(1L).setFolderId(folderId).setUid(uid).setUidValidity(100L) + .setReadStatus(false).setHasAttach(false).setSize(100).setReceiveTime(LocalDateTime.of(2026, 9, 8, 10, 0)); + }); + } +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomBookingServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomBookingServiceImplTest.java new file mode 100644 index 0000000000..da4d08ad7b --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomBookingServiceImplTest.java @@ -0,0 +1,634 @@ +package cn.iocoder.yudao.module.oa.service.meetingroom; + +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; +import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi; +import cn.iocoder.yudao.module.oa.controller.admin.meetingroom.vo.booking.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.meetingroom.*; +import cn.iocoder.yudao.module.oa.dal.mysql.meetingroom.OaMeetingRoomBookingMapper; +import cn.iocoder.yudao.module.oa.dal.redis.no.OaNoRedisDAO; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import cn.iocoder.yudao.module.oa.service.meetingroom.listener.OaMeetingRoomBookingStatusListener; +import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi; +import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaMeetingRoomBookingServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import({OaMeetingRoomBookingServiceImpl.class, OaMeetingRoomBookingStatusListener.class}) +public class OaMeetingRoomBookingServiceImplTest extends BaseDbUnitTest { + + @Resource + private ApplicationEventPublisher eventPublisher; + + @Resource + private OaMeetingRoomBookingServiceImpl meetingRoomBookingService; + + @Resource + private OaMeetingRoomBookingMapper meetingRoomBookingMapper; + + @MockitoBean + private OaMeetingRoomService meetingRoomService; + @MockitoBean + private AdminUserApi adminUserApi; + @MockitoBean + private OaNoRedisDAO noRedisDAO; + @MockitoBean + private BpmProcessInstanceApi processInstanceApi; + @MockitoBean + private NotifyMessageSendApi notifyMessageSendApi; + + @Test + public void testStartMeetingRoomBooking_roomUnavailable() { + // mock 数据 + OaMeetingRoomBookingDO booking = randomMeetingRoomBookingDO().setStatus(2) + .setStartTime(LocalDateTime.now().minusMinutes(5)).setEndTime(LocalDateTime.now().plusHours(1)); + meetingRoomBookingMapper.insert(booking); + // mock 方法:审批后会议室进入维修 + when(meetingRoomService.validateMeetingRoomExists(10L)).thenReturn(randomMeetingRoomDO().setStatus(1)); + + // 调用,并断言异常 + assertServiceException(() -> meetingRoomBookingService.startMeetingRoomBooking(booking.getId(), 1L), + MEETING_ROOM_NOT_AVAILABLE); + assertEquals(0, meetingRoomBookingMapper.selectById(booking.getId()).getUseStatus()); + } + + @Test + public void testStartMeetingRoomBooking_bookingDisabled() { + // mock 数据 + OaMeetingRoomBookingDO booking = randomMeetingRoomBookingDO().setStatus(2) + .setStartTime(LocalDateTime.now().minusMinutes(5)).setEndTime(LocalDateTime.now().plusHours(1)); + meetingRoomBookingMapper.insert(booking); + // mock 方法 + when(meetingRoomService.validateMeetingRoomExists(10L)).thenReturn(randomMeetingRoomDO().setAllowBooking(false)); + + // 调用,并断言异常 + assertServiceException(() -> meetingRoomBookingService.startMeetingRoomBooking(booking.getId(), 1L), + MEETING_ROOM_NOT_AVAILABLE); + } + + @Test + public void testCreateMeetingRoomBooking_noDuplicate() { + // mock 数据 + OaMeetingRoomBookingDO booking = randomMeetingRoomBookingDO().setNo("HY20260916000001"); + meetingRoomBookingMapper.insert(booking); + // 准备参数 + OaMeetingRoomBookingSaveReqVO reqVO = BeanUtils.toBean(booking, OaMeetingRoomBookingSaveReqVO.class).setId(null); + // mock 方法 + when(adminUserApi.validateUser(1L)).thenReturn( + new AdminUserRespDTO().setId(1L).setDeptId(10L)); + when(noRedisDAO.generate(OaNoRedisDAO.MEETING_ROOM_BOOKING_NO_PREFIX)).thenReturn(booking.getNo()); + + // 调用,并断言异常 + assertServiceException(() -> meetingRoomBookingService.createMeetingRoomBooking(reqVO, 1L), + MEETING_ROOM_BOOKING_NO_DUPLICATE); + assertEquals(1L, meetingRoomBookingMapper.selectCount()); + } + + @Test + public void testGetMeetingRoomBooking_notExists() { + + // 调用,并断言 + assertNull(meetingRoomBookingService.getMeetingRoomBooking(-1L)); + } + + @Test + public void testSubmitMeetingRoomBooking_withoutApproval() { + // mock 数据 + OaMeetingRoomBookingDO booking = randomMeetingRoomBookingDO(); + meetingRoomBookingMapper.insert(booking); + // mock 方法 + when(meetingRoomService.validateMeetingRoomExists(booking.getRoomId())).thenReturn(randomMeetingRoomDO() + .setNeedApproval(false)); + + // 调用 + String processInstanceId = meetingRoomBookingService.submitMeetingRoomBooking(booking.getId(), 1L); + + // 断言 + OaMeetingRoomBookingDO actual = meetingRoomBookingMapper.selectById(booking.getId()); + assertNull(processInstanceId); + assertEquals(2, actual.getStatus()); + assertEquals(0, actual.getUseStatus()); + assertFalse(actual.getNeedApproval()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testSubmitMeetingRoomBooking_withApproval() { + // mock 数据 + OaMeetingRoomBookingDO booking = randomMeetingRoomBookingDO(); + meetingRoomBookingMapper.insert(booking); + // mock 方法 + when(meetingRoomService.validateMeetingRoomExists(booking.getRoomId())).thenReturn(randomMeetingRoomDO() + .setNeedApproval(true)); + when(processInstanceApi.createProcessInstance(eq(1L), any())).thenReturn("process-1"); + + // 调用 + String processInstanceId = meetingRoomBookingService.submitMeetingRoomBooking(booking.getId(), 1L); + + // 断言 + assertEquals("process-1", processInstanceId); + OaMeetingRoomBookingDO actual = meetingRoomBookingMapper.selectById(booking.getId()); + assertEquals(1, actual.getStatus()); + assertEquals("process-1", actual.getProcessInstanceId()); + assertTrue(actual.getNeedApproval()); + } + + @Test + public void testSubmitMeetingRoomBooking_conflict() { + // mock 数据 + OaMeetingRoomBookingDO booking = randomMeetingRoomBookingDO(); + meetingRoomBookingMapper.insert(booking); + OaMeetingRoomBookingDO existing = randomMeetingRoomBookingDO().setStartTime(booking.getStartTime()) + .setEndTime(booking.getEndTime()).setStatus(1); + meetingRoomBookingMapper.insert(existing); + // mock 方法 + when(meetingRoomService.validateMeetingRoomExists(booking.getRoomId())).thenReturn(randomMeetingRoomDO()); + + // 调用,并断言异常 + assertServiceException(() -> meetingRoomBookingService.submitMeetingRoomBooking(booking.getId(), 1L), + MEETING_ROOM_BOOKING_TIME_CONFLICT); + assertEquals(-1, meetingRoomBookingMapper.selectById(booking.getId()).getStatus()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testSubmitMeetingRoomBooking_adjacent() { + // mock 数据 + OaMeetingRoomBookingDO booking = randomMeetingRoomBookingDO(); + meetingRoomBookingMapper.insert(booking); + OaMeetingRoomBookingDO existing = randomMeetingRoomBookingDO().setStartTime(booking.getEndTime()) + .setEndTime(booking.getEndTime().plusHours(1)).setStatus(2); + meetingRoomBookingMapper.insert(existing); + // mock 方法 + when(meetingRoomService.validateMeetingRoomExists(booking.getRoomId())).thenReturn(randomMeetingRoomDO()); + + // 调用 + meetingRoomBookingService.submitMeetingRoomBooking(booking.getId(), 1L); + + // 断言 + assertEquals(2, meetingRoomBookingMapper.selectById(booking.getId()).getStatus()); + } + + @Test + public void testSubmitMeetingRoomBooking_notOwner() { + // mock 数据 + OaMeetingRoomBookingDO booking = randomMeetingRoomBookingDO(); + meetingRoomBookingMapper.insert(booking); + // mock 方法 + when(meetingRoomService.validateMeetingRoomExists(booking.getRoomId())).thenReturn(randomMeetingRoomDO()); + + // 调用,并断言异常 + assertServiceException(() -> meetingRoomBookingService.submitMeetingRoomBooking(booking.getId(), 2L), + MEETING_ROOM_BOOKING_NOT_OWNER); + } + + @Test + public void testSubmitMeetingRoomBooking_specifiedMembers() { + // mock 数据 + OaMeetingRoomBookingDO booking = randomMeetingRoomBookingDO(); + meetingRoomBookingMapper.insert(booking); + // mock 方法 + when(meetingRoomService.validateMeetingRoomExists(booking.getRoomId())) + .thenReturn(randomMeetingRoomDO().setBookingScope(1).setBookingUserIds(Collections.singletonList(2L))); + + // 调用,并断言异常 + assertServiceException(() -> meetingRoomBookingService.submitMeetingRoomBooking(booking.getId(), 1L), + MEETING_ROOM_NOT_AVAILABLE); + } + + @Test + public void testSubmitMeetingRoomBooking_processFailureRollback() { + // mock 数据 + OaMeetingRoomBookingDO booking = randomMeetingRoomBookingDO(); + meetingRoomBookingMapper.insert(booking); + // mock 方法 + when(meetingRoomService.validateMeetingRoomExists(booking.getRoomId())).thenReturn(randomMeetingRoomDO() + .setNeedApproval(true)); + when(processInstanceApi.createProcessInstance(eq(1L), any())).thenThrow(new IllegalStateException("流程未部署")); + + // 调用,并断言 + assertThrows(IllegalStateException.class, () -> meetingRoomBookingService.submitMeetingRoomBooking(booking.getId(), 1L)); + assertEquals(-1, meetingRoomBookingMapper.selectById(booking.getId()).getStatus()); + } + + @Test + public void testCancelMeetingRoomBooking_approved() { + // mock 数据 + OaMeetingRoomBookingDO booking = randomMeetingRoomBookingDO().setStatus(2).setNeedApproval(false); + meetingRoomBookingMapper.insert(booking); + + // 调用 + meetingRoomBookingService.cancelMeetingRoomBooking(booking.getId(), 1L); + + // 断言 + OaMeetingRoomBookingDO actual = meetingRoomBookingMapper.selectById(booking.getId()); + assertEquals(2, actual.getStatus()); + assertEquals(3, actual.getUseStatus()); + assertFalse(meetingRoomBookingService.hasActiveMeetingRoomBooking(booking.getRoomId())); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testUpdateMeetingRoomBookingStatus_reject() { + // mock 数据 + OaMeetingRoomBookingDO booking = randomMeetingRoomBookingDO().setStatus(1).setNeedApproval(true) + .setProcessInstanceId("process-1"); + meetingRoomBookingMapper.insert(booking); + + // 调用 + meetingRoomBookingService.updateMeetingRoomBookingStatus(booking.getId(), 3); + + // 断言 + assertEquals(3, meetingRoomBookingMapper.selectById(booking.getId()).getUseStatus()); + assertFalse(meetingRoomBookingService.hasActiveMeetingRoomBooking(booking.getRoomId())); + } + + @Test + public void testUpdateMeetingRoomBookingStatus_completedNotReset() { + // mock 数据 + OaMeetingRoomBookingDO booking = randomMeetingRoomBookingDO().setStatus(2).setUseStatus(2) + .setNeedApproval(true).setProcessInstanceId("process-1"); + meetingRoomBookingMapper.insert(booking); + + // 调用 + meetingRoomBookingService.updateMeetingRoomBookingStatus(booking.getId(), 2); + meetingRoomBookingService.updateMeetingRoomBookingStatus(booking.getId(), 3); + + // 断言 + OaMeetingRoomBookingDO actual = meetingRoomBookingMapper.selectById(booking.getId()); + assertEquals(2, actual.getStatus()); + assertEquals(2, actual.getUseStatus()); + assertEquals("process-1", actual.getProcessInstanceId()); + } + + @Test + public void testSubmitMeetingRoomBooking_synchronousApproval() { + // mock 数据 + OaMeetingRoomBookingDO booking = randomMeetingRoomBookingDO(); + meetingRoomBookingMapper.insert(booking); + // mock 方法 + when(meetingRoomService.validateMeetingRoomExists(booking.getRoomId())) + .thenReturn(randomMeetingRoomDO().setNeedApproval(true)); + when(processInstanceApi.createProcessInstance(eq(1L), any())).thenAnswer(invocation -> { + eventPublisher.publishEvent(new BpmProcessInstanceStatusEvent(this).setId("auto-process") + .setProcessDefinitionKey(BpmModelConstants.MEETING_ROOM_BOOKING) + .setBusinessKey(booking.getId().toString()).setStatus(2)); + return "auto-process"; + }); + + // 调用 + meetingRoomBookingService.submitMeetingRoomBooking(booking.getId(), 1L); + + // 断言 + OaMeetingRoomBookingDO actual = meetingRoomBookingMapper.selectById(booking.getId()); + assertEquals(2, actual.getStatus()); + assertEquals(0, actual.getUseStatus()); + assertEquals("auto-process", actual.getProcessInstanceId()); + } + + @Test + public void testStartAndFinishMeetingRoomBooking() { + // mock 数据 + OaMeetingRoomBookingDO booking = randomMeetingRoomBookingDO().setStatus(2) + .setStartTime(LocalDateTime.now().minusMinutes(5)).setEndTime(LocalDateTime.now().plusHours(1)); + meetingRoomBookingMapper.insert(booking); + + // mock 方法 + when(meetingRoomService.validateMeetingRoomExists(booking.getRoomId())).thenReturn(randomMeetingRoomDO()); + + // 调用 + meetingRoomBookingService.startMeetingRoomBooking(booking.getId(), 1L); + // 断言 + assertEquals(1, meetingRoomBookingMapper.selectById(booking.getId()).getUseStatus()); + + // 调用 + meetingRoomBookingService.finishMeetingRoomBooking(booking.getId(), 1L); + // 断言 + assertEquals(2, meetingRoomBookingMapper.selectById(booking.getId()).getUseStatus()); + assertFalse(meetingRoomBookingService.hasActiveMeetingRoomBooking(booking.getRoomId())); + } + + @Test + public void testStartMeetingRoomBooking_beforeStart() { + // mock 数据 + OaMeetingRoomBookingDO booking = randomMeetingRoomBookingDO().setStatus(2); + meetingRoomBookingMapper.insert(booking); + + // 调用,并断言异常 + assertServiceException(() -> meetingRoomBookingService.startMeetingRoomBooking(booking.getId(), 1L), + MEETING_ROOM_BOOKING_STATUS_INVALID); + } + + @Test + public void testGetMeetingRoomBookingPage_emptyRelatedFilter() { + // mock 数据 + meetingRoomBookingMapper.insert(randomMeetingRoomBookingDO()); + // 准备参数 + OaMeetingRoomBookingPageReqVO reqVO = new OaMeetingRoomBookingPageReqVO().setRoomName("不存在的房间"); + // mock 方法 + when(meetingRoomService.getMeetingRoomListByName(reqVO.getRoomName())).thenReturn(Collections.emptyList()); + + // 调用,并断言 + assertEquals(0, meetingRoomBookingService.getMeetingRoomBookingPage(1L, reqVO).getTotal()); + } + + @Test + public void testGetMeetingRoomBookingPage_ownerScope() { + // mock 数据 + meetingRoomBookingMapper.insert(randomMeetingRoomBookingDO()); + OaMeetingRoomBookingDO otherBooking = randomMeetingRoomBookingDO(); + otherBooking.setCreator("2"); + meetingRoomBookingMapper.insert(otherBooking); + // 准备参数 + OaMeetingRoomBookingPageReqVO reqVO = new OaMeetingRoomBookingPageReqVO(); + + // 调用,并断言 + assertEquals(1, meetingRoomBookingService.getMeetingRoomBookingPage(1L, reqVO).getTotal()); + } + + @Test + public void testGetMeetingRoomBookingPage_endTimeAndCreator() { + // mock 数据 + LocalDateTime endTime = LocalDateTime.of(2026, 9, 18, 12, 0); + OaMeetingRoomBookingDO matched = randomMeetingRoomBookingDO().setEndTime(endTime); + meetingRoomBookingMapper.insert(matched); + meetingRoomBookingMapper.insert(randomMeetingRoomBookingDO().setEndTime(endTime.plusDays(1))); + OaMeetingRoomBookingDO other = randomMeetingRoomBookingDO().setEndTime(endTime); + other.setCreator("2"); + meetingRoomBookingMapper.insert(other); + // 准备参数 + OaMeetingRoomBookingPageReqVO reqVO = new OaMeetingRoomBookingPageReqVO().setCreator("1") + .setEndTime(new LocalDateTime[]{endTime, endTime.plusHours(1)}); + + // 调用,并断言 + assertEquals(1L, meetingRoomBookingService.getMeetingRoomBookingPage(1L, reqVO).getTotal()); + // 创建人筛选不扩大本人预定范围 + reqVO.setCreator("2"); + assertEquals(0L, meetingRoomBookingService.getMeetingRoomBookingPage(1L, reqVO).getTotal()); + } + + @Test + public void testGetMeetingRoomBookingSchedule_excludesInvalidBookings() { + // mock 数据 + OaMeetingRoomBookingDO booking = randomMeetingRoomBookingDO(); + meetingRoomBookingMapper.insert(booking); + meetingRoomBookingMapper.insert(randomMeetingRoomBookingDO().setStartTime(booking.getStartTime()) + .setEndTime(booking.getEndTime()).setStatus(3)); + meetingRoomBookingMapper.insert(randomMeetingRoomBookingDO().setStartTime(booking.getStartTime()) + .setEndTime(booking.getEndTime()).setStatus(2)); + // 准备参数 + OaMeetingRoomBookingScheduleReqVO reqVO = new OaMeetingRoomBookingScheduleReqVO().setRoomId(10L) + .setStartTime(booking.getStartTime()).setEndTime(booking.getEndTime()); + + // 调用,并断言 + assertEquals(1, meetingRoomBookingService.getMeetingRoomBookingListBySchedule(reqVO).size()); + } + + @Test + public void testSendMeetingRoomBookingReminder_recipientsAndRepeat() { + // mock 数据:申请人、主持人、参会人存在重复 + LocalDateTime remindTime = LocalDateTime.of(2026, 9, 16, 10, 0); + OaMeetingRoomBookingDO booking = randomMeetingRoomBookingDO().setStatus(2).setReminderType(2) + .setStartTime(remindTime.plusMinutes(5)).setModeratorUserId(2L) + .setAttendeeUserIds(Arrays.asList(1L, 2L, 3L, 3L)); + meetingRoomBookingMapper.insert(booking); + // mock 方法 + when(meetingRoomService.validateMeetingRoomExists(10L)) + .thenReturn(randomMeetingRoomDO().setName("第一会议室").setLocation("三楼")); + + // 调用,并断言 + assertTrue(meetingRoomBookingService.sendMeetingRoomBookingReminder(booking.getId(), remindTime)); + assertFalse(meetingRoomBookingService.sendMeetingRoomBookingReminder(booking.getId(), remindTime)); + assertTrue(meetingRoomBookingMapper.selectById(booking.getId()).getReminded()); + ArgumentCaptor captor = ArgumentCaptor.forClass(NotifySendSingleToUserReqDTO.class); + verify(notifyMessageSendApi, times(3)).sendSingleMessageToAdmin(captor.capture()); + assertEquals(Arrays.asList(1L, 2L, 3L), convertList(captor.getAllValues(), NotifySendSingleToUserReqDTO::getUserId)); + NotifySendSingleToUserReqDTO message = captor.getValue(); + assertEquals("oa_meeting_room_booking_reminder", message.getTemplateCode()); + assertEquals(booking.getTitle(), message.getTemplateParams().get("title")); + assertEquals("第一会议室", message.getTemplateParams().get("roomName")); + assertEquals("三楼", message.getTemplateParams().get("location")); + assertEquals("2026-09-16 10:05", message.getTemplateParams().get("startTime")); + } + + @ParameterizedTest + @CsvSource({"2,5", "3,10", "4,15", "5,30"}) + public void testSendMeetingRoomBookingReminder_timeBoundary(int type, int minutes) { + // mock 数据 + LocalDateTime remindTime = LocalDateTime.of(2026, 9, 16, 10, 0); + OaMeetingRoomBookingDO booking = randomMeetingRoomBookingDO().setStatus(2).setReminderType(type) + .setStartTime(remindTime.plusMinutes(minutes)); + meetingRoomBookingMapper.insert(booking); + // mock 方法 + when(meetingRoomService.validateMeetingRoomExists(10L)).thenReturn(randomMeetingRoomDO()); + + // 调用,并断言:边界前一秒不提醒,刚好进入提醒窗口时提醒 + assertFalse(meetingRoomBookingService.sendMeetingRoomBookingReminder(booking.getId(), remindTime.minusSeconds(1))); + assertTrue(meetingRoomBookingService.sendMeetingRoomBookingReminder(booking.getId(), remindTime)); + verify(notifyMessageSendApi, times(1)).sendSingleMessageToAdmin(any()); + } + + @ParameterizedTest + @CsvSource({"-1,0,2,false,5", "1,0,2,false,5", "3,0,2,false,5", "4,0,2,false,5", + "2,1,2,false,5", "2,2,2,false,5", "2,3,2,false,5", "2,0,1,false,5", + "2,0,2,true,5", "2,0,2,false,0", "2,0,2,false,-1"}) + public void testSendMeetingRoomBookingReminder_skipInvalid(int status, int useStatus, int reminderType, + boolean reminded, int minutes) { + // mock 数据:草稿、审批未通过、已使用/取消、不提醒、已提醒、已开始均不发送 + LocalDateTime remindTime = LocalDateTime.of(2026, 9, 16, 10, 0); + OaMeetingRoomBookingDO booking = randomMeetingRoomBookingDO().setStatus(status).setUseStatus(useStatus) + .setReminderType(reminderType).setReminded(reminded).setStartTime(remindTime.plusMinutes(minutes)); + meetingRoomBookingMapper.insert(booking); + + // 调用,并断言 + assertFalse(meetingRoomBookingService.sendMeetingRoomBookingReminder(booking.getId(), remindTime)); + verifyNoInteractions(notifyMessageSendApi, meetingRoomService); + } + + @Test + public void testSendMeetingRoomBookingReminders_filterAndFailureRetry() { + // mock 数据:包含未到时间、已开始、已取消、已提醒及不开提醒的记录 + LocalDateTime remindTime = LocalDateTime.of(2026, 9, 16, 10, 0); + OaMeetingRoomBookingDO failed = randomMeetingRoomBookingDO().setStatus(2).setReminderType(2) + .setStartTime(remindTime.plusMinutes(1)).setTitle("发送失败的会议"); + meetingRoomBookingMapper.insert(failed); + OaMeetingRoomBookingDO due = randomMeetingRoomBookingDO().setStatus(2).setReminderType(5) + .setStartTime(remindTime.plusMinutes(30)); + meetingRoomBookingMapper.insert(due); + meetingRoomBookingMapper.insert(randomMeetingRoomBookingDO().setStatus(2).setReminderType(2) + .setStartTime(remindTime.plusMinutes(6))); + meetingRoomBookingMapper.insert(randomMeetingRoomBookingDO().setStatus(2).setReminderType(5) + .setStartTime(remindTime.plusMinutes(31))); + meetingRoomBookingMapper.insert(randomMeetingRoomBookingDO().setStatus(2).setReminderType(2).setStartTime(remindTime)); + meetingRoomBookingMapper.insert(randomMeetingRoomBookingDO().setStatus(2).setUseStatus(3).setReminderType(2) + .setStartTime(remindTime.plusMinutes(1))); + meetingRoomBookingMapper.insert(randomMeetingRoomBookingDO().setStatus(1).setReminderType(2) + .setStartTime(remindTime.plusMinutes(1))); + meetingRoomBookingMapper.insert(randomMeetingRoomBookingDO().setStatus(2).setReminded(true).setReminderType(2) + .setStartTime(remindTime.plusMinutes(1))); + meetingRoomBookingMapper.insert(randomMeetingRoomBookingDO().setStatus(2).setReminderType(1) + .setStartTime(remindTime.plusMinutes(1))); + // mock 方法:先扫描的会议失败,仍继续处理后一条 + when(meetingRoomService.validateMeetingRoomExists(10L)).thenReturn(randomMeetingRoomDO()); + when(notifyMessageSendApi.sendSingleMessageToAdmin(any())) + .thenThrow(new IllegalStateException("站内信发送失败")).thenReturn(10L); + + // 调用 + try (MockedStatic timeMock = mockStatic(LocalDateTime.class, CALLS_REAL_METHODS)) { + timeMock.when(LocalDateTime::now).thenReturn(remindTime); + // 断言 + assertEquals(1, meetingRoomBookingService.sendMeetingRoomBookingReminders()); + assertFalse(meetingRoomBookingMapper.selectById(failed.getId()).getReminded()); + assertTrue(meetingRoomBookingMapper.selectById(due.getId()).getReminded()); + assertEquals(1, meetingRoomBookingService.sendMeetingRoomBookingReminders()); + assertEquals(0, meetingRoomBookingService.sendMeetingRoomBookingReminders()); + } + verify(notifyMessageSendApi, times(3)).sendSingleMessageToAdmin(any()); + } + + @Test + public void testSendMeetingRoomBookingReminder_partialFailureRollback() { + // mock 数据 + LocalDateTime remindTime = LocalDateTime.of(2026, 9, 16, 10, 0); + OaMeetingRoomBookingDO booking = randomMeetingRoomBookingDO().setStatus(2).setReminderType(2) + .setStartTime(remindTime.plusMinutes(5)).setAttendeeUserIds(Collections.singletonList(2L)); + meetingRoomBookingMapper.insert(booking); + // mock 方法:第二个接收人发送失败 + when(meetingRoomService.validateMeetingRoomExists(10L)).thenReturn(randomMeetingRoomDO()); + when(notifyMessageSendApi.sendSingleMessageToAdmin(any())) + .thenReturn(10L).thenThrow(new IllegalStateException("发送失败")).thenReturn(11L); + + // 调用,并断言:回滚提醒标记,重试完整会议 + assertThrows(IllegalStateException.class, + () -> meetingRoomBookingService.sendMeetingRoomBookingReminder(booking.getId(), remindTime)); + assertFalse(meetingRoomBookingMapper.selectById(booking.getId()).getReminded()); + assertTrue(meetingRoomBookingService.sendMeetingRoomBookingReminder(booking.getId(), remindTime)); + verify(notifyMessageSendApi, times(4)).sendSingleMessageToAdmin(any()); + } + + @Test + public void testSendMeetingRoomBookingReminders_deletedAndEmpty() { + // mock 数据 + LocalDateTime remindTime = LocalDateTime.now(); + OaMeetingRoomBookingDO booking = randomMeetingRoomBookingDO().setStatus(2).setReminderType(5) + .setStartTime(remindTime.plusMinutes(10)); + meetingRoomBookingMapper.insert(booking); + meetingRoomBookingMapper.deleteById(booking.getId()); + + // 调用,并断言:已删除会议在单条和扫描入口都不提醒 + assertFalse(meetingRoomBookingService.sendMeetingRoomBookingReminder(booking.getId(), remindTime)); + assertEquals(0, meetingRoomBookingService.sendMeetingRoomBookingReminders()); + verifyNoInteractions(notifyMessageSendApi, meetingRoomService); + } + + @Test + public void testCancelExpiredMeetingRoomBookings() { + // mock 数据:覆盖到期边界、未来会议、使用中、已完成、已取消、审批中及已删除会议 + LocalDateTime endTime = LocalDateTime.of(2026, 9, 18, 12, 0); + OaMeetingRoomBookingDO expired = randomMeetingRoomBookingDO().setStatus(2).setEndTime(endTime.minusSeconds(1)); + meetingRoomBookingMapper.insert(expired); + OaMeetingRoomBookingDO boundary = randomMeetingRoomBookingDO().setStatus(2).setEndTime(endTime); + meetingRoomBookingMapper.insert(boundary); + OaMeetingRoomBookingDO future = randomMeetingRoomBookingDO().setStatus(2).setEndTime(endTime.plusSeconds(1)); + meetingRoomBookingMapper.insert(future); + OaMeetingRoomBookingDO inUse = randomMeetingRoomBookingDO().setStatus(2).setUseStatus(1).setEndTime(endTime); + meetingRoomBookingMapper.insert(inUse); + OaMeetingRoomBookingDO completed = randomMeetingRoomBookingDO().setStatus(2).setUseStatus(2).setEndTime(endTime); + meetingRoomBookingMapper.insert(completed); + OaMeetingRoomBookingDO cancelled = randomMeetingRoomBookingDO().setStatus(2).setUseStatus(3).setEndTime(endTime); + meetingRoomBookingMapper.insert(cancelled); + OaMeetingRoomBookingDO running = randomMeetingRoomBookingDO().setStatus(1).setEndTime(endTime); + meetingRoomBookingMapper.insert(running); + OaMeetingRoomBookingDO deleted = randomMeetingRoomBookingDO().setStatus(2).setEndTime(endTime); + meetingRoomBookingMapper.insert(deleted); + meetingRoomBookingMapper.deleteById(deleted.getId()); + + // 调用 + try (MockedStatic time = mockStatic(LocalDateTime.class, CALLS_REAL_METHODS)) { + time.when(LocalDateTime::now).thenReturn(endTime); + assertEquals(2, meetingRoomBookingService.cancelExpiredMeetingRoomBookings()); + assertEquals(0, meetingRoomBookingService.cancelExpiredMeetingRoomBookings()); + } + // 断言:只取消已通过且尚未使用的过期预定,保留审批结果 + assertEquals(3, meetingRoomBookingMapper.selectById(expired.getId()).getUseStatus()); + assertEquals(2, meetingRoomBookingMapper.selectById(expired.getId()).getStatus()); + assertEquals(3, meetingRoomBookingMapper.selectById(boundary.getId()).getUseStatus()); + assertEquals(0, meetingRoomBookingMapper.selectById(future.getId()).getUseStatus()); + assertEquals(1, meetingRoomBookingMapper.selectById(inUse.getId()).getUseStatus()); + assertEquals(2, meetingRoomBookingMapper.selectById(completed.getId()).getUseStatus()); + assertEquals(3, meetingRoomBookingMapper.selectById(cancelled.getId()).getUseStatus()); + assertEquals(0, meetingRoomBookingMapper.selectById(running.getId()).getUseStatus()); + assertNull(meetingRoomBookingMapper.selectById(deleted.getId())); + } + + @Test + public void testUpdateMeetingRoomBookingStatus_approvedAfterEndTime() { + // mock 数据 + OaMeetingRoomBookingDO booking = randomMeetingRoomBookingDO().setStatus(1) + .setStartTime(LocalDateTime.now().minusHours(2)).setEndTime(LocalDateTime.now().minusHours(1)); + meetingRoomBookingMapper.insert(booking); + + // 调用 + meetingRoomBookingService.updateMeetingRoomBookingStatus(booking.getId(), 2); + // 断言:审批结果保留通过,但不再进入待使用 + OaMeetingRoomBookingDO result = meetingRoomBookingMapper.selectById(booking.getId()); + assertEquals(2, result.getStatus()); + assertEquals(3, result.getUseStatus()); + } + + // ========== 随机对象 ========== + + /** + * 构造测试数据,固定业务字段的合法取值。 + * + * @return 未入库的测试对象 + */ + private static OaMeetingRoomBookingDO randomMeetingRoomBookingDO() { + return randomPojo(OaMeetingRoomBookingDO.class, booking -> { + booking.setCreator("1"); + booking.setId(null).setRoomId(10L) + .setTitle("会议室预定测试").setStatus(-1).setUseStatus(0) + .setStartTime(LocalDateTime.now().plusDays(1)).setEndTime(LocalDateTime.now().plusDays(1).plusHours(1)) + .setModeratorUserId(1L).setAttendeeUserIds(Collections.emptyList()).setReminderType(1).setReminded(false) + .setNeedApproval(null).setProcessInstanceId(null).setFileUrls(Collections.emptyList()); + }); + } + + /** + * 构造测试数据,固定业务字段的合法取值。 + * + * @return 未入库的测试对象 + */ + private static OaMeetingRoomDO randomMeetingRoomDO() { + return randomPojo(OaMeetingRoomDO.class, room -> room.setId(10L).setAllowBooking(true) + .setStatus(0).setNeedApproval(false).setBookingScope(0).setBookingUserIds(Collections.emptyList())); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomServiceImplTest.java new file mode 100644 index 0000000000..a63139ea14 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomServiceImplTest.java @@ -0,0 +1,209 @@ +package cn.iocoder.yudao.module.oa.service.meetingroom; + +import cn.hutool.json.JSONUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.controller.admin.meetingroom.vo.room.OaMeetingRoomPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.meetingroom.vo.room.OaMeetingRoomSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.meetingroom.OaMeetingRoomDO; +import cn.iocoder.yudao.module.oa.dal.mysql.meetingroom.OaMeetingRoomMapper; +import cn.iocoder.yudao.module.system.api.dict.DictDataApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.Collections; +import javax.sql.DataSource; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.MEETING_ROOM_HAS_BOOKING; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaMeetingRoomServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaMeetingRoomServiceImpl.class) +public class OaMeetingRoomServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaMeetingRoomService meetingRoomService; + + @Resource + private OaMeetingRoomMapper meetingRoomMapper; + @Resource + private DataSource dataSource; + + @MockitoBean + private OaMeetingRoomBookingService meetingRoomBookingService; + @MockitoBean + private AdminUserApi adminUserApi; + @MockitoBean + private DictDataApi dictDataApi; + + @BeforeEach + public void before() { + // H2 不支持 JSON_CONTAINS,补充数字数组成员匹配函数用于查询测试 + new JdbcTemplate(dataSource).execute("CREATE ALIAS IF NOT EXISTS JSON_CONTAINS FOR " + + "\"cn.iocoder.yudao.module.oa.service.meetingroom.OaMeetingRoomServiceImplTest.jsonContains\""); + } + + /** + * 为 H2 查询提供 MySQL JSON_CONTAINS 的数字数组匹配能力。 + * + * @param json JSON 数组 + * @param candidate 待匹配的编号 + * @return 数组是否包含编号;空数组字段返回 null + */ + public static Boolean jsonContains(String json, String candidate) { + return json == null ? null : JSONUtil.toList(json, Long.class).contains(Long.valueOf(candidate)); + } + + @Test + public void testUpdateMeetingRoom_success() { + // mock 数据 + OaMeetingRoomDO room = randomMeetingRoomDO(); + meetingRoomMapper.insert(room); + // 准备参数 + OaMeetingRoomSaveReqVO reqVO = BeanUtils.toBean(room, OaMeetingRoomSaveReqVO.class); + reqVO.setName("修改后的会议室"); + + // 调用 + meetingRoomService.updateMeetingRoom(reqVO); + + // 断言 + assertEquals(reqVO.getName(), meetingRoomMapper.selectById(room.getId()).getName()); + verify(adminUserApi).validateUser(room.getManagerUserId()); + } + + @Test + public void testDeleteMeetingRoom_success() { + // mock 数据 + OaMeetingRoomDO room = randomMeetingRoomDO(); + meetingRoomMapper.insert(room); + + // 调用 + meetingRoomService.deleteMeetingRoom(room.getId()); + + // 断言 + assertNull(meetingRoomMapper.selectById(room.getId())); + verify(meetingRoomBookingService).hasActiveMeetingRoomBooking(room.getId()); + } + + @Test + public void testDeleteMeetingRoom_hasBooking() { + // mock 数据 + OaMeetingRoomDO room = randomMeetingRoomDO(); + meetingRoomMapper.insert(room); + // mock 方法 + when(meetingRoomBookingService.hasActiveMeetingRoomBooking(room.getId())).thenReturn(true); + + // 调用,并断言异常 + assertServiceException(() -> meetingRoomService.deleteMeetingRoom(room.getId()), MEETING_ROOM_HAS_BOOKING); + assertNotNull(meetingRoomMapper.selectById(room.getId())); + } + + @Test + public void testGetMeetingRoomPageByBookingUserId_sortAndPage() { + // mock 数据 + OaMeetingRoomDO firstRoom = randomMeetingRoomDO().setId(1024L).setSort(1); + meetingRoomMapper.insert(firstRoom); + OaMeetingRoomDO secondRoom = randomMeetingRoomDO().setId(1025L).setSort(1); + meetingRoomMapper.insert(secondRoom); + OaMeetingRoomDO thirdRoom = randomMeetingRoomDO().setId(1026L).setSort(2); + meetingRoomMapper.insert(thirdRoom); + + // 准备参数 + OaMeetingRoomPageReqVO reqVO = new OaMeetingRoomPageReqVO(); + reqVO.setPageSize(2); + + // 调用 + PageResult firstPage = meetingRoomService.getMeetingRoomPageByBookingUserId(reqVO, 1L); + reqVO.setPageNo(2); + PageResult secondPage = meetingRoomService.getMeetingRoomPageByBookingUserId(reqVO, 1L); + + // 断言 + assertEquals(3L, firstPage.getTotal()); + assertEquals(Arrays.asList(1025L, 1024L), convertList(firstPage.getList(), OaMeetingRoomDO::getId)); + assertEquals(3L, secondPage.getTotal()); + assertEquals(Collections.singletonList(1026L), convertList(secondPage.getList(), OaMeetingRoomDO::getId)); + } + + @Test + public void testGetMeetingRoomPageByBookingUserId_scope() { + // mock 数据 + OaMeetingRoomDO allRoom = randomMeetingRoomDO().setId(1024L); + meetingRoomMapper.insert(allRoom); + OaMeetingRoomDO specifiedRoom = randomMeetingRoomDO().setId(1025L).setBookingScope(1) + .setBookingUserIds(Arrays.asList(1L, 2L)); + meetingRoomMapper.insert(specifiedRoom); + meetingRoomMapper.insert(randomMeetingRoomDO().setBookingScope(1).setBookingUserIds(Collections.singletonList(11L))); + meetingRoomMapper.insert(randomMeetingRoomDO().setBookingScope(1)); + meetingRoomMapper.insert(randomMeetingRoomDO().setStatus(2)); + meetingRoomMapper.insert(randomMeetingRoomDO().setAllowBooking(false)); + // 准备参数 + OaMeetingRoomPageReqVO reqVO = new OaMeetingRoomPageReqVO(); + reqVO.setPageSize(1); + + // 调用 + PageResult firstPage = meetingRoomService.getMeetingRoomPageByBookingUserId(reqVO, 1L); + reqVO.setPageNo(2); + PageResult secondPage = meetingRoomService.getMeetingRoomPageByBookingUserId(reqVO, 1L); + reqVO.setPageNo(3); + PageResult emptyPage = meetingRoomService.getMeetingRoomPageByBookingUserId(reqVO, 1L); + + // 断言 + assertEquals(2L, firstPage.getTotal()); + assertEquals(Collections.singletonList(1025L), convertList(firstPage.getList(), OaMeetingRoomDO::getId)); + assertEquals(2L, secondPage.getTotal()); + assertEquals(Collections.singletonList(1024L), convertList(secondPage.getList(), OaMeetingRoomDO::getId)); + assertTrue(emptyPage.getList().isEmpty()); + assertEquals(2L, emptyPage.getTotal()); + assertEquals(6L, meetingRoomService.getMeetingRoomPage(new OaMeetingRoomPageReqVO()).getTotal()); + } + + @Test + public void testGetMeetingRoomPageByBookingUserId_filter() { + // mock 数据 + OaMeetingRoomDO room = randomMeetingRoomDO().setName("项目会议室").setLocation("三楼"); + meetingRoomMapper.insert(room); + meetingRoomMapper.insert(randomMeetingRoomDO().setName("培训会议室").setLocation("三楼")); + meetingRoomMapper.insert(randomMeetingRoomDO().setName("项目会议室").setLocation("二楼")); + meetingRoomMapper.insert(randomMeetingRoomDO().setName("项目会议室").setLocation("三楼").setType(2)); + // 准备参数 + OaMeetingRoomPageReqVO reqVO = new OaMeetingRoomPageReqVO(); + reqVO.setName("项目").setLocation("三楼").setType(1).setStatus(0); + + // 调用 + PageResult page = meetingRoomService.getMeetingRoomPageByBookingUserId(reqVO, 1L); + + // 断言 + assertEquals(1L, page.getTotal()); + assertEquals(Collections.singletonList(room.getId()), convertList(page.getList(), OaMeetingRoomDO::getId)); + } + + // ========== 随机对象 ========== + + /** + * 构造测试数据,固定业务字段的合法取值。 + * + * @return 未入库的测试对象 + */ + private static OaMeetingRoomDO randomMeetingRoomDO() { + return randomPojo(OaMeetingRoomDO.class, room -> room.setType(1).setStatus(0) + .setSeatCount(10).setSort(0).setAllowBooking(true).setNeedApproval(false).setBookingScope(0) + .setEquipments(Collections.emptyList()).setBookingUserIds(Collections.emptyList()) + .setFileUrls(Collections.emptyList())); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/note/OaNoteCategoryServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/note/OaNoteCategoryServiceImplTest.java new file mode 100644 index 0000000000..d7f9ce2124 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/note/OaNoteCategoryServiceImplTest.java @@ -0,0 +1,108 @@ +package cn.iocoder.yudao.module.oa.service.note; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.controller.admin.note.vo.category.OaNoteCategorySaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.note.OaNoteCategoryDO; +import cn.iocoder.yudao.module.oa.dal.mysql.note.OaNoteCategoryMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.NOTE_CATEGORY_NAME_DUPLICATE; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.NOTE_CATEGORY_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * {@link OaNoteCategoryServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaNoteCategoryServiceImpl.class) +public class OaNoteCategoryServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaNoteCategoryServiceImpl noteCategoryService; + + @Resource + private OaNoteCategoryMapper noteCategoryMapper; + + @MockitoBean + private OaNoteService noteService; + + @Test + public void testCreateNoteCategory_duplicateName() { + // mock 数据 + OaNoteCategoryDO category = randomNoteCategoryDO(); + noteCategoryMapper.insert(category); + // 准备参数 + OaNoteCategorySaveReqVO reqVO = new OaNoteCategorySaveReqVO().setName(category.getName()).setSort(0); + + // 调用,并断言异常 + assertServiceException(() -> noteCategoryService.createNoteCategory(reqVO, category.getUserId()), + NOTE_CATEGORY_NAME_DUPLICATE); + } + + @Test + public void testUpdateNoteCategory_keepOwner() { + // mock 数据 + OaNoteCategoryDO category = randomNoteCategoryDO(); + noteCategoryMapper.insert(category); + // 准备参数 + OaNoteCategorySaveReqVO reqVO = new OaNoteCategorySaveReqVO().setId(category.getId()) + .setName("更新目录").setSort(2); + + // 调用 + noteCategoryService.updateNoteCategory(reqVO, category.getUserId()); + + // 断言 + OaNoteCategoryDO updatedCategory = noteCategoryMapper.selectById(category.getId()); + assertEquals(category.getUserId(), updatedCategory.getUserId()); + assertEquals(reqVO.getName(), updatedCategory.getName()); + assertEquals(reqVO.getSort(), updatedCategory.getSort()); + } + + @Test + public void testDeleteNoteCategory_success() { + // mock 数据 + OaNoteCategoryDO category = randomNoteCategoryDO(); + noteCategoryMapper.insert(category); + + // 调用 + noteCategoryService.deleteNoteCategory(category.getId(), category.getUserId()); + + // 断言 + assertNull(noteCategoryMapper.selectById(category.getId())); + verify(noteService).deleteNotesByCategoryId(category.getId(), category.getUserId()); + } + + @Test + public void testDeleteNoteCategory_notOwner() { + // mock 数据 + OaNoteCategoryDO category = randomNoteCategoryDO(); + noteCategoryMapper.insert(category); + + // 调用,并断言异常 + assertServiceException(() -> noteCategoryService.deleteNoteCategory(category.getId(), randomLongId()), + NOTE_CATEGORY_NOT_EXISTS); + verifyNoInteractions(noteService); + } + + // ========== 随机对象 ========== + + /** + * 构造排序值为零的笔记分类。 + * + * @return 未入库的测试对象 + */ + private static OaNoteCategoryDO randomNoteCategoryDO() { + return randomPojo(OaNoteCategoryDO.class, o -> o.setId(null).setSort(0)); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/note/OaNoteServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/note/OaNoteServiceImplTest.java new file mode 100644 index 0000000000..c8732aee39 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/note/OaNoteServiceImplTest.java @@ -0,0 +1,547 @@ +package cn.iocoder.yudao.module.oa.service.note; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.controller.admin.note.vo.OaNotePageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.note.vo.OaNoteSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.note.OaNoteDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.note.OaNoteReceiverDO; +import cn.iocoder.yudao.module.oa.dal.mysql.note.OaNoteMapper; +import cn.iocoder.yudao.module.oa.dal.mysql.note.OaNoteReceiverMapper; +import cn.iocoder.yudao.module.oa.enums.note.OaNoteTypeEnum; +import cn.iocoder.yudao.module.oa.enums.schedule.OaPriorityEnum; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import javax.sql.DataSource; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.NOTE_ACCESS_DENIED; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link OaNoteServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaNoteServiceImpl.class) +public class OaNoteServiceImplTest extends BaseDbUnitTest { + + @Resource + private DataSource dataSource; + + @Resource + private OaNoteServiceImpl noteService; + + @Resource + private OaNoteMapper noteMapper; + @Resource + private OaNoteReceiverMapper noteReceiverMapper; + + @MockitoBean + private OaNoteCategoryService noteCategoryService; + @MockitoBean + private AdminUserApi adminUserApi; + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void testCreateNote_withoutCategory() { + // 准备参数:首页快捷笔记不传目录 + Long userId = randomLongId(); + OaNoteSaveReqVO reqVO = randomNoteSaveReqVO().setCategoryId(null); + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(userId), new MockHttpServletRequest()); + + // 调用 + Long id = noteService.createNote(reqVO, userId); + // 断言 + assertNull(noteMapper.selectById(id).getCategoryId()); + assertEquals(reqVO.getContent(), noteService.getNote(id, userId).getContent()); + } + + @Test + public void testCreateNote_ownerHoldsNote() { + // 准备参数 + Long userId = randomLongId(); + OaNoteSaveReqVO reqVO = randomNoteSaveReqVO(); + + // 调用 + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(userId), new MockHttpServletRequest()); + Long noteId = noteService.createNote(reqVO, userId); + + // 断言 + OaNoteDO note = noteMapper.selectById(noteId); + assertEquals(userId.toString(), note.getCreator()); + assertEquals(OaNoteTypeEnum.SHARED.getType(), note.getType()); + assertEquals(reqVO.getCategoryId(), note.getCategoryId()); + assertEquals(reqVO.getContent(), note.getContent()); + assertEquals(1, noteReceiverMapper.selectListByNoteId(noteId).size()); + assertEquals(userId, CollUtil.getFirst(noteReceiverMapper.selectListByNoteId(noteId)).getUserId()); + assertEquals(noteId, noteService.getNote(noteId, userId).getId()); + assertEquals(1L, noteService.getMyNotePage(new OaNotePageReqVO(), userId).getTotal()); + assertEquals(0L, noteService.getReceivedNotePage(new OaNotePageReqVO(), userId).getTotal()); + } + + @Test + public void testGetNote_sharedReceiver() { + // mock 数据 + Long receiverUserId = randomLongId(); + OaNoteDO note = randomNoteDO(randomLongId()); + noteMapper.insert(note); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()) + .setUserId(Long.valueOf(note.getCreator()))); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()).setUserId(receiverUserId)); + + // 调用 + OaNoteDO result = noteService.getNote(note.getId(), receiverUserId); + + // 断言 + assertEquals(note.getId(), result.getId()); + } + + @Test + public void testUpdateNote_clearFileUrls() { + // mock 数据 + Long userId = randomLongId(); + OaNoteDO note = randomNoteDO(userId).setCategoryId(randomLongId()).setContent("旧内容") + .setFileUrls(Collections.singletonList("https://example.com/a.txt")); + noteMapper.insert(note); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()) + .setUserId(Long.valueOf(note.getCreator()))); + // 准备参数 + OaNoteSaveReqVO reqVO = randomNoteSaveReqVO().setId(note.getId()) + .setCategoryId(randomLongId()).setContent("更新后的内容").setFileUrls(null); + + // 调用 + noteService.updateNote(reqVO, userId); + + // 断言 + OaNoteDO updatedNote = noteMapper.selectById(note.getId()); + assertEquals(reqVO.getCategoryId(), updatedNote.getCategoryId()); + assertEquals(reqVO.getContent(), updatedNote.getContent()); + assertNull(updatedNote.getFileUrls()); + } + + @Test + public void testUpdateNoteShare_repeatedReceiver() { + // mock 数据 + Long creatorUserId = randomLongId(); + Long receiverUserId = randomLongId(); + OaNoteDO note = randomNoteDO(creatorUserId); + noteMapper.insert(note); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()) + .setUserId(Long.valueOf(note.getCreator()))); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()).setUserId(receiverUserId)); + // 准备参数 + + // 调用 + noteService.updateNoteShare(note.getId(), Collections.singletonList(receiverUserId), creatorUserId); + noteService.updateNoteShare(note.getId(), Collections.singletonList(receiverUserId), creatorUserId); + + // 断言 + assertEquals(2, noteReceiverMapper.selectListByNoteIds(Collections.singleton(note.getId())).size()); + } + + @Test + public void testUpdateNoteShare_preserveAndRemoveReceivers() { + // mock 数据 + Long userId = randomLongId(); + OaNoteDO note = randomNoteDO(userId); + noteMapper.insert(note); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()) + .setUserId(Long.valueOf(note.getCreator()))); + OaNoteReceiverDO receiver = new OaNoteReceiverDO().setNoteId(note.getId()).setUserId(2L); + noteReceiverMapper.insert(receiver); + + // 调用:新增接收人,不重建已有关系 + noteService.updateNoteShare(note.getId(), Arrays.asList(2L, 3L, 3L), userId); + + // 断言 + assertEquals(3, noteReceiverMapper.selectListByNoteId(note.getId()).size()); + assertEquals(receiver.getId(), noteReceiverMapper.selectByNoteIdAndUserId(note.getId(), 2L).getId()); + + // 调用:反复取消、添加同一接收人 + for (int i = 0; i < 3; i++) { + noteService.updateNoteShare(note.getId(), Collections.emptyList(), userId); + noteService.updateNoteShare(note.getId(), Collections.singletonList(2L), userId); + } + + // 断言:历史关系仍保留,默认 Map 只返回未删除关系 + assertTrue(new JdbcTemplate(dataSource).queryForObject("SELECT COUNT(*) FROM oa_note_receiver WHERE deleted = TRUE", Long.class) > 0); + assertEquals(new HashSet<>(Arrays.asList(userId, 2L)), new HashSet<>(noteService.getNoteReceiverUserIdListMap( + Collections.singleton(note.getId())).get(note.getId()))); + assertTrue(CollUtil.isEmpty(noteService.getNoteReceiverUserIdListMap(Collections.emptyList()))); + } + + @Test + public void testDeleteReceivedNote_onlyCurrentReceiver() { + // mock 数据 + Long userId = randomLongId(); + Long otherUserId = randomLongId(); + OaNoteDO note = randomNoteDO(randomLongId()); + noteMapper.insert(note); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()) + .setUserId(Long.valueOf(note.getCreator()))); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()).setUserId(userId)); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()).setUserId(otherUserId)); + + // 调用 + noteService.deleteReceivedNote(note.getId(), userId); + + // 断言:仅移除本人关系,正文和其他接收人的访问保留 + assertNull(noteReceiverMapper.selectByNoteIdAndUserId(note.getId(), userId)); + assertEquals(note.getId(), noteService.getNote(note.getId(), otherUserId).getId()); + assertServiceException(() -> noteService.getNote(note.getId(), userId), NOTE_ACCESS_DENIED); + assertServiceException(() -> noteService.deleteReceivedNote(note.getId(), userId), NOTE_ACCESS_DENIED); + } + + @Test + public void testDeleteNote_sharedOwnerExits() { + // mock 数据 + Long ownerId = randomLongId(); + Long receiverId = randomLongId(); + OaNoteDO note = randomNoteDO(ownerId).setCategoryId(randomLongId()); + noteMapper.insert(note); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()) + .setUserId(Long.valueOf(note.getCreator()))); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()).setUserId(receiverId)); + + // 调用 + noteService.deleteNote(note.getId(), ownerId); + + // 断言:作者退出所有入口,其他持有人及正文不受影响 + assertNull(noteReceiverMapper.selectByNoteIdAndUserId(note.getId(), ownerId)); + assertEquals(ownerId.toString(), noteMapper.selectById(note.getId()).getCreator()); + assertServiceException(() -> noteService.updateNote(randomNoteSaveReqVO() + .setId(note.getId()), ownerId), NOTE_ACCESS_DENIED); + assertEquals(0L, noteService.getMyNotePage(new OaNotePageReqVO(), ownerId).getTotal()); + assertServiceException(() -> noteService.getNote(note.getId(), ownerId), NOTE_ACCESS_DENIED); + assertServiceException(() -> noteService.updateNoteFavorite(note.getId(), true, ownerId), NOTE_ACCESS_DENIED); + assertServiceException(() -> noteService.updateNoteShare(note.getId(), Collections.emptyList(), ownerId), + NOTE_ACCESS_DENIED); + assertServiceException(() -> noteService.deleteNote(note.getId(), ownerId), NOTE_ACCESS_DENIED); + assertEquals(note.getId(), noteService.getNote(note.getId(), receiverId).getId()); + noteService.deleteNotesByCategoryId(note.getCategoryId(), ownerId); + assertEquals(note.getId(), noteService.getNote(note.getId(), receiverId).getId()); + } + + @Test + public void testUpdateNoteFavorite_receiversShareState() { + // mock 数据 + Long ownerId = randomLongId(); + Long receiverId = randomLongId(); + Long otherReceiverId = randomLongId(); + OaNoteDO note = randomNoteDO(ownerId); + noteMapper.insert(note); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()) + .setUserId(Long.valueOf(note.getCreator()))); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()).setUserId(receiverId)); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()).setUserId(otherReceiverId)); + + // 调用 + noteService.updateNoteFavorite(note.getId(), true, receiverId); + + // 断言:源产品的主表收藏状态对所有持有人一致,外人不能修改 + assertTrue(noteService.getNote(note.getId(), ownerId).getFavorite()); + assertTrue(noteService.getNote(note.getId(), otherReceiverId).getFavorite()); + assertEquals(1L, noteService.getReceivedNotePage(new OaNotePageReqVO().setFavorite(true), receiverId).getTotal()); + assertServiceException(() -> noteService.updateNoteFavorite(note.getId(), false, randomLongId()), NOTE_ACCESS_DENIED); + noteService.deleteReceivedNote(note.getId(), receiverId); + assertServiceException(() -> noteService.updateNoteFavorite(note.getId(), false, receiverId), NOTE_ACCESS_DENIED); + } + + @Test + public void testDeleteNote_privateDeletesBody() { + // mock 数据 + Long ownerId = randomLongId(); + OaNoteDO note = randomNoteDO(ownerId); + noteMapper.insert(note); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()) + .setUserId(Long.valueOf(note.getCreator()))); + + // 调用 + noteService.deleteNote(note.getId(), ownerId); + + // 断言 + assertNull(noteMapper.selectById(note.getId())); + } + + @Test + public void testDeleteNote_notOwner() { + // mock 数据 + OaNoteDO note = randomNoteDO(randomLongId()); + noteMapper.insert(note); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()) + .setUserId(Long.valueOf(note.getCreator()))); + + // 调用,并断言异常 + assertServiceException(() -> noteService.deleteNote(note.getId(), randomLongId()), NOTE_ACCESS_DENIED); + } + + @Test + public void testUpdateNoteFavorite_keepOtherFields() { + // mock 数据 + Long userId = randomLongId(); + OaNoteDO note = randomNoteDO(userId).setCategoryId(randomLongId()).setContent("原内容") + .setFileUrls(Collections.singletonList("https://example.com/a.txt")); + noteMapper.insert(note); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()) + .setUserId(Long.valueOf(note.getCreator()))); + + // 调用 + noteService.updateNoteFavorite(note.getId(), true, userId); + + // 断言 + OaNoteDO updatedNote = noteMapper.selectById(note.getId()); + assertTrue(updatedNote.getFavorite()); + assertEquals(note.getCategoryId(), updatedNote.getCategoryId()); + assertEquals(note.getContent(), updatedNote.getContent()); + assertEquals(note.getFileUrls(), updatedNote.getFileUrls()); + } + + @Test + public void testUpdateNoteShare_duplicateReceivers() { + // 准备参数 + Long userId = randomLongId(); + Long receiverUserId = randomLongId(); + OaNoteDO note = randomNoteDO(userId); + noteMapper.insert(note); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()) + .setUserId(Long.valueOf(note.getCreator()))); + + // 调用 + noteService.updateNoteShare(note.getId(), Arrays.asList(userId, receiverUserId, userId, receiverUserId), userId); + + // 断言 + assertEquals(2, noteReceiverMapper.selectListByNoteIds(Collections.singleton(note.getId())).size()); + } + + @Test + public void testGetNote_notReceiver() { + // mock 数据 + OaNoteDO note = randomNoteDO(randomLongId()); + noteMapper.insert(note); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()) + .setUserId(Long.valueOf(note.getCreator()))); + + // 调用,并断言异常 + assertServiceException(() -> noteService.getNote(note.getId(), randomLongId()), NOTE_ACCESS_DENIED); + } + + @Test + public void testGetNotePage_mine() { + // mock 数据 + Long userId = randomLongId(); + OaNoteDO mine = randomNoteDO(userId); + noteMapper.insert(mine); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(mine.getId()).setUserId(userId)); + OaNoteDO shared = randomNoteDO(randomLongId()); + noteMapper.insert(shared); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(shared.getId()).setUserId(userId)); + // 准备参数 + OaNotePageReqVO reqVO = new OaNotePageReqVO(); + + // 调用 + PageResult page = noteService.getMyNotePage(reqVO, userId); + + // 断言 + assertEquals(1L, page.getTotal()); + assertEquals(mine.getId(), CollUtil.getFirst(page.getList()).getId()); + } + + @Test + public void testGetReceivedNotePage_onlyReceiverAndFilters() { + // mock 数据 + Long userId = randomLongId(); + OaNoteDO received = randomNoteDO(randomLongId()).setTitle("共享会议"); + noteMapper.insert(received); + noteMapper.insert(randomNoteDO(randomLongId())); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(received.getId()).setUserId(userId)); + // 准备参数 + OaNotePageReqVO reqVO = new OaNotePageReqVO(); + + // 调用,并断言 + assertEquals(1L, noteService.getReceivedNotePage(reqVO, userId).getTotal()); + assertEquals(received.getId(), CollUtil.getFirst(noteService.getReceivedNotePage(reqVO, userId).getList()).getId()); + reqVO.setTitle("不存在"); + assertEquals(0L, noteService.getReceivedNotePage(reqVO, userId).getTotal()); + } + + @Test + public void testGetNotePage_sharedEmpty() { + // mock 数据 + Long userId = randomLongId(); + OaNoteDO mine = randomNoteDO(userId); + noteMapper.insert(mine); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(mine.getId()).setUserId(userId)); + // 准备参数 + OaNotePageReqVO reqVO = new OaNotePageReqVO(); + + // 调用 + PageResult page = noteService.getReceivedNotePage(reqVO, userId); + + // 断言 + assertEquals(0L, page.getTotal()); + assertTrue(CollUtil.isEmpty(page.getList())); + } + + @Test + public void testUpdateNoteShare_clearAndKeepContent() { + // mock 数据 + Long userId = randomLongId(); + OaNoteDO note = randomNoteDO(userId).setCategoryId(randomLongId()) + .setFileUrls(Collections.singletonList("https://example.com/note.txt")); + noteMapper.insert(note); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()) + .setUserId(Long.valueOf(note.getCreator()))); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()).setUserId(randomLongId())); + + // 调用 + noteService.updateNoteShare(note.getId(), Collections.emptyList(), userId); + + // 断言 + assertEquals(1, noteReceiverMapper.selectListByNoteId(note.getId()).size()); + assertEquals(userId, CollUtil.getFirst(noteReceiverMapper.selectListByNoteId(note.getId())).getUserId()); + assertEquals(note.getId(), noteService.getNote(note.getId(), userId).getId()); + OaNoteDO updated = noteMapper.selectById(note.getId()); + assertEquals(note.getContent(), updated.getContent()); + assertEquals(note.getCategoryId(), updated.getCategoryId()); + assertEquals(note.getFileUrls(), updated.getFileUrls()); + } + + @Test + public void testUpdateNoteShare_notOwner() { + // mock 数据 + OaNoteDO note = randomNoteDO(randomLongId()); + noteMapper.insert(note); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()) + .setUserId(Long.valueOf(note.getCreator()))); + + // 调用,并断言异常 + assertServiceException(() -> noteService.updateNoteShare(note.getId(), Collections.emptyList(), randomLongId()), + NOTE_ACCESS_DENIED); + } + + @Test + public void testUpdateNote_keepReceivers() { + // mock 数据 + Long userId = randomLongId(); + Long receiverId = randomLongId(); + OaNoteDO note = randomNoteDO(userId); + noteMapper.insert(note); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()) + .setUserId(Long.valueOf(note.getCreator()))); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()).setUserId(receiverId)); + // 准备参数,内容编辑不应覆盖共享关系 + OaNoteSaveReqVO reqVO = randomNoteSaveReqVO().setId(note.getId()); + + // 调用 + noteService.updateNote(reqVO, userId); + + // 断言 + assertEquals(2, noteReceiverMapper.selectListByNoteId(note.getId()).size()); + assertEquals(receiverId, noteReceiverMapper.selectByNoteIdAndUserId(note.getId(), receiverId).getUserId()); + } + + @Test + public void testDeleteNote_sharedWithoutOtherHolders() { + // mock 数据 + Long userId = randomLongId(); + OaNoteDO note = randomNoteDO(userId).setType(OaNoteTypeEnum.SHARED.getType()); + noteMapper.insert(note); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()).setUserId(userId)); + + // 调用 + noteService.deleteNote(note.getId(), userId); + + // 断言:共享类型只退出关系,即使没有其他持有人也保留正文 + assertEquals(note.getId(), noteMapper.selectById(note.getId()).getId()); + assertTrue(CollUtil.isEmpty(noteReceiverMapper.selectListByNoteId(note.getId()))); + assertServiceException(() -> noteService.getNote(note.getId(), userId), NOTE_ACCESS_DENIED); + } + + @Test + public void testDeleteReceivedNote_ownerMustUseDeleteEntry() { + // mock 数据 + Long userId = randomLongId(); + OaNoteDO note = randomNoteDO(userId); + noteMapper.insert(note); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(note.getId()).setUserId(userId)); + + // 调用,并断言:不能通过接收人入口绕过创建人的删除入口 + assertServiceException(() -> noteService.deleteReceivedNote(note.getId(), userId), NOTE_ACCESS_DENIED); + assertEquals(note.getId(), noteService.getNote(note.getId(), userId).getId()); + } + + @Test + public void testDeleteNotesByCategoryId_onlyHeldNotes() { + // mock 数据 + Long userId = randomLongId(); + Long categoryId = randomLongId(); + OaNoteDO held = randomNoteDO(userId).setCategoryId(categoryId); + noteMapper.insert(held); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(held.getId()).setUserId(userId)); + OaNoteDO exited = randomNoteDO(userId).setCategoryId(categoryId); + noteMapper.insert(exited); + Long receiverId = randomLongId(); + noteReceiverMapper.insert(new OaNoteReceiverDO().setNoteId(exited.getId()).setUserId(receiverId)); + + // 调用 + noteService.deleteNotesByCategoryId(categoryId, userId); + + // 断言:仅清理仍持有的目录笔记,已经退出的正文及其他人的关系保留 + assertNull(noteMapper.selectById(held.getId())); + assertTrue(CollUtil.isEmpty(noteReceiverMapper.selectListByNoteId(held.getId()))); + assertEquals(exited.getId(), noteService.getNote(exited.getId(), receiverId).getId()); + assertServiceException(() -> noteService.getNote(exited.getId(), userId), NOTE_ACCESS_DENIED); + } + + // ========== 随机对象 ========== + + /** + * 构造普通优先级的共享笔记保存参数。 + * + * @return 保存参数 + */ + private static OaNoteSaveReqVO randomNoteSaveReqVO() { + return randomPojo(OaNoteSaveReqVO.class, o -> o.setId(null).setCategoryId(randomLongId()) + .setType(OaNoteTypeEnum.SHARED.getType()).setPriority(OaPriorityEnum.NORMAL.getPriority()) + .setTitle("会议纪要").setContent("会议结论").setFileUrls(null)); + } + + /** + * 构造未收藏的私人笔记。 + * + * @param creatorUserId 创建人编号 + * @return 未入库的测试对象 + */ + private static OaNoteDO randomNoteDO(Long creatorUserId) { + OaNoteDO note = randomPojo(OaNoteDO.class, o -> o.setId(null) + .setCategoryId(null).setType(OaNoteTypeEnum.PRIVATE.getType()) + .setPriority(OaPriorityEnum.NORMAL.getPriority()).setTitle("个人笔记") + .setContent("笔记内容").setFileUrls(null).setFavorite(false)); + note.setCreator(creatorUserId.toString()); + return note; + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocReceiveServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocReceiveServiceImplTest.java new file mode 100644 index 0000000000..c445449adb --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocReceiveServiceImplTest.java @@ -0,0 +1,739 @@ +package cn.iocoder.yudao.module.oa.service.officialdoc; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi; +import cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.receive.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.officialdoc.*; +import cn.iocoder.yudao.module.oa.dal.mysql.officialdoc.*; +import cn.iocoder.yudao.module.oa.dal.redis.no.OaNoRedisDAO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; +import java.util.*; + +import static cn.iocoder.yudao.framework.common.util.object.BeanUtils.toBean; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaOfficialDocReceiveServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaOfficialDocReceiveServiceImpl.class) +public class OaOfficialDocReceiveServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaOfficialDocReceiveService officialDocReceiveService; + + @Resource + private OaOfficialDocReceiveMapper officialDocReceiveMapper; + + @MockitoBean + private AdminUserApi adminUserApi; + @MockitoBean + private BpmProcessInstanceApi processInstanceApi; + @MockitoBean + private OaNoRedisDAO noRedisDAO; + + @ParameterizedTest + @ValueSource(ints = {-1, 3, 4}) + public void testDeleteOfficialDocReceive_allowedStatuses(int status) { + // mock 数据 + OaOfficialDocReceiveDO receive = randomOfficialDocReceiveDO().setStatus(status); + officialDocReceiveMapper.insert(receive); + + // 调用,并断言:其他人不能删除 + assertServiceException(() -> officialDocReceiveService.deleteOfficialDocReceive(receive.getId(), 2L), + OFFICIAL_DOC_ACCESS_DENIED); + officialDocReceiveService.deleteOfficialDocReceive(receive.getId(), 1L); + assertNull(officialDocReceiveMapper.selectById(receive.getId())); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2}) + public void testDeleteOfficialDocReceive_activeOrApproved(int status) { + // mock 数据 + OaOfficialDocReceiveDO receive = randomOfficialDocReceiveDO().setStatus(status); + officialDocReceiveMapper.insert(receive); + + // 调用,并断言异常 + assertServiceException(() -> officialDocReceiveService.deleteOfficialDocReceive(receive.getId(), 1L), + OFFICIAL_DOC_STATUS_INVALID); + assertNotNull(officialDocReceiveMapper.selectById(receive.getId())); + } + + @Test + public void testCreateOfficialDocReceiveListByOfficialDocSend() { + // 准备参数 + OaOfficialDocSendDO send = randomPojo(OaOfficialDocSendDO.class, o -> o.setId(100L) + .setMainDeptIds(Arrays.asList(10L, 10L, 11L)).setCopyDeptIds(Arrays.asList(10L, 12L, 12L)) + .setSecrecyLevel(0).setUrgencyLevel(0).setFileUrls(Collections.emptyList())); + // mock 方法 + when(noRedisDAO.generate("SW")).thenReturn("SW1", "SW2", "SW3", "SW4"); + + // 调用 + officialDocReceiveService.createOfficialDocReceiveListByOfficialDocSend(send); + // 断言 + List receives = officialDocReceiveMapper.selectListBySendId(100L); + assertEquals(4, receives.size()); + assertNotNull(CollUtil.findOne(receives, o -> o.getReceiveDeptId().equals(10L) && o.getReceiveType() == 0)); + assertNotNull(CollUtil.findOne(receives, o -> o.getReceiveDeptId().equals(10L) && o.getReceiveType() == 1)); + assertNotNull(CollUtil.findOne(receives, o -> o.getReceiveDeptId().equals(11L) && o.getReceiveType() == 0)); + assertNotNull(CollUtil.findOne(receives, o -> o.getReceiveDeptId().equals(12L) && o.getReceiveType() == 1)); + assertTrue(receives.stream().allMatch(o -> o.getHandleStatus() == 0 && o.getStatus() == -1)); + assertTrue(receives.stream().allMatch(o -> o.getHandlerUserId() == null)); + assertTrue(receives.stream().allMatch(o -> "".equals(o.getCreator()))); + verify(noRedisDAO, times(4)).generate("SW"); + } + + @Test + public void testCreateOfficialDocReceiveListByOfficialDocSend_repeatedCall() { + // 准备参数 + OaOfficialDocSendDO send = randomPojo(OaOfficialDocSendDO.class, o -> o.setId(100L) + .setMainDeptIds(Collections.singletonList(10L)).setCopyDeptIds(Collections.singletonList(10L)) + .setSecrecyLevel(0).setUrgencyLevel(0).setFileUrls(Collections.emptyList())); + // mock 方法 + when(noRedisDAO.generate("SW")).thenReturn("SW1", "SW2"); + + // 调用 + officialDocReceiveService.createOfficialDocReceiveListByOfficialDocSend(send); + officialDocReceiveService.createOfficialDocReceiveListByOfficialDocSend(send); + // 断言 + List receives = officialDocReceiveMapper.selectListBySendId(100L); + assertEquals(2, receives.size()); + assertNotNull(CollUtil.findOne(receives, o -> o.getReceiveType() == 0)); + assertNotNull(CollUtil.findOne(receives, o -> o.getReceiveType() == 1)); + verify(noRedisDAO, times(2)).generate("SW"); + } + + @Test + public void testCreateOfficialDocReceiveListByOfficialDocSend_existingMain() { + // mock 数据 + OaOfficialDocReceiveDO mainReceive = randomOfficialDocReceiveDO().setSendId(100L).setReceiveType(0); + officialDocReceiveMapper.insert(mainReceive); + // 准备参数 + OaOfficialDocSendDO send = randomPojo(OaOfficialDocSendDO.class, o -> o.setId(100L) + .setMainDeptIds(Collections.singletonList(10L)).setCopyDeptIds(Collections.singletonList(10L)) + .setSecrecyLevel(0).setUrgencyLevel(0).setFileUrls(Collections.emptyList())); + // mock 方法 + when(noRedisDAO.generate("SW")).thenReturn("SW-COPY"); + + // 调用 + officialDocReceiveService.createOfficialDocReceiveListByOfficialDocSend(send); + // 断言 + List receives = officialDocReceiveMapper.selectListBySendId(100L); + assertEquals(2, receives.size()); + assertEquals(mainReceive.getId(), CollUtil.findOne(receives, o -> o.getReceiveType() == 0).getId()); + assertEquals(1, officialDocReceiveMapper.selectByNo("SW-COPY").getReceiveType()); + verify(noRedisDAO).generate("SW"); + } + + @Test + public void testCreateOfficialDocReceiveListByOfficialDocSend_existingCopy() { + // mock 数据 + OaOfficialDocReceiveDO copyReceive = randomOfficialDocReceiveDO().setSendId(100L).setReceiveType(1); + officialDocReceiveMapper.insert(copyReceive); + // 准备参数 + OaOfficialDocSendDO send = randomPojo(OaOfficialDocSendDO.class, o -> o.setId(100L) + .setMainDeptIds(Collections.singletonList(10L)).setCopyDeptIds(Collections.singletonList(10L)) + .setSecrecyLevel(0).setUrgencyLevel(0).setFileUrls(Collections.emptyList())); + // mock 方法 + when(noRedisDAO.generate("SW")).thenReturn("SW-MAIN"); + + // 调用 + officialDocReceiveService.createOfficialDocReceiveListByOfficialDocSend(send); + // 断言 + List receives = officialDocReceiveMapper.selectListBySendId(100L); + assertEquals(2, receives.size()); + assertEquals(copyReceive.getId(), CollUtil.findOne(receives, o -> o.getReceiveType() == 1).getId()); + assertEquals(0, officialDocReceiveMapper.selectByNo("SW-MAIN").getReceiveType()); + verify(noRedisDAO).generate("SW"); + } + + @Test + public void testCreateOfficialDocReceiveListByOfficialDocSend_onlyMain() { + // 准备参数 + OaOfficialDocSendDO send = randomPojo(OaOfficialDocSendDO.class, o -> o.setId(100L) + .setMainDeptIds(Collections.singletonList(10L)).setCopyDeptIds(null) + .setSecrecyLevel(0).setUrgencyLevel(0).setFileUrls(Collections.emptyList())); + // mock 方法 + when(noRedisDAO.generate("SW")).thenReturn("SW-MAIN"); + + // 调用 + officialDocReceiveService.createOfficialDocReceiveListByOfficialDocSend(send); + // 断言 + List receives = officialDocReceiveMapper.selectListBySendId(100L); + assertEquals(1, receives.size()); + assertEquals(0, CollUtil.getFirst(receives).getReceiveType()); + assertEquals(10L, CollUtil.getFirst(receives).getReceiveDeptId()); + verify(noRedisDAO).generate("SW"); + } + + @Test + public void testCreateOfficialDocReceiveListByOfficialDocSend_onlyCopy() { + // 准备参数 + OaOfficialDocSendDO send = randomPojo(OaOfficialDocSendDO.class, o -> o.setId(100L) + .setMainDeptIds(Collections.emptyList()).setCopyDeptIds(Collections.singletonList(10L)) + .setSecrecyLevel(0).setUrgencyLevel(0).setFileUrls(Collections.emptyList())); + // mock 方法 + when(noRedisDAO.generate("SW")).thenReturn("SW-COPY"); + + // 调用 + officialDocReceiveService.createOfficialDocReceiveListByOfficialDocSend(send); + // 断言 + List receives = officialDocReceiveMapper.selectListBySendId(100L); + assertEquals(1, receives.size()); + assertEquals(1, CollUtil.getFirst(receives).getReceiveType()); + assertEquals(10L, CollUtil.getFirst(receives).getReceiveDeptId()); + verify(noRedisDAO).generate("SW"); + } + + @Test + public void testCreateOfficialDocReceiveListByOfficialDocSend_emptyDeptIds() { + // 准备参数 + OaOfficialDocSendDO send = randomPojo(OaOfficialDocSendDO.class, + o -> o.setId(100L).setMainDeptIds(null).setCopyDeptIds(Collections.emptyList())); + + // 调用 + officialDocReceiveService.createOfficialDocReceiveListByOfficialDocSend(send); + // 断言 + assertTrue(CollUtil.isEmpty(officialDocReceiveMapper.selectListBySendId(100L))); + verifyNoInteractions(noRedisDAO); + } + + @Test + public void testCreateOfficialDocReceive() { + // 准备参数 + OaOfficialDocReceiveSaveReqVO reqVO = toBean(randomOfficialDocReceiveDO(), OaOfficialDocReceiveSaveReqVO.class) + .setId(999L).setReceiveType(1).setHandlerUserId(null); + // mock 方法 + when(noRedisDAO.generate("SW")).thenReturn("SW-MANUAL-1"); + + // 调用 + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(1L), new MockHttpServletRequest()); + Long id; + try { + id = officialDocReceiveService.createOfficialDocReceive(reqVO); + } finally { + SecurityContextHolder.clearContext(); + } + // 断言 + OaOfficialDocReceiveDO receive = officialDocReceiveMapper.selectById(id); + assertNotEquals(999L, id); + assertNull(receive.getSendId()); + assertNull(receive.getHandlerUserId()); + assertNull(receive.getProcessInstanceId()); + assertEquals("1", receive.getCreator()); + assertEquals("SW-MANUAL-1", receive.getNo()); + assertEquals(reqVO.getTitle(), receive.getTitle()); + assertEquals(reqVO.getReceiveTime(), receive.getReceiveTime()); + assertEquals(0, receive.getReceiveType()); + assertEquals(-1, receive.getStatus()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testUpdateOfficialDocReceive_manual() { + // mock 数据 + OaOfficialDocReceiveDO receive = randomOfficialDocReceiveDO().setSendId(null).setHandlerUserId(null); + officialDocReceiveMapper.insert(receive); + // 准备参数 + OaOfficialDocReceiveSaveReqVO reqVO = toBean(receive, OaOfficialDocReceiveSaveReqVO.class) + .setTitle("手工收文修改").setDocumentNo("来文〔2026〕1号").setHandlerUserId(2L); + + // 调用 + officialDocReceiveService.updateOfficialDocReceive(reqVO, 1L); + // 断言 + OaOfficialDocReceiveDO result = officialDocReceiveMapper.selectById(receive.getId()); + assertEquals(reqVO.getTitle(), result.getTitle()); + assertEquals(reqVO.getDocumentNo(), result.getDocumentNo()); + assertEquals(2L, result.getHandlerUserId()); + assertNull(result.getSendId()); + assertServiceException(() -> officialDocReceiveService.updateOfficialDocReceive(reqVO, 2L), OFFICIAL_DOC_ACCESS_DENIED); + } + + @Test + public void testSubmitOfficialDocReceive_manual() { + // mock 数据 + OaOfficialDocReceiveDO receive = randomOfficialDocReceiveDO().setSendId(null).setHandlerUserId(null); + officialDocReceiveMapper.insert(receive); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(1L), any())).thenReturn("manual-process"); + + // 调用 + String processInstanceId = officialDocReceiveService.submitOfficialDocReceive(receive.getId(), 1L); + // 断言 + assertEquals("manual-process", processInstanceId); + assertEquals(1, officialDocReceiveMapper.selectById(receive.getId()).getStatus()); + } + + @Test + public void testDeleteOfficialDocReceive_manual() { + // mock 数据 + OaOfficialDocReceiveDO receive = randomOfficialDocReceiveDO().setSendId(null).setHandlerUserId(null); + officialDocReceiveMapper.insert(receive); + + // 调用,并断言异常 + assertServiceException(() -> officialDocReceiveService.deleteOfficialDocReceive(receive.getId(), 2L), + OFFICIAL_DOC_ACCESS_DENIED); + officialDocReceiveService.deleteOfficialDocReceive(receive.getId(), 1L); + assertNull(officialDocReceiveMapper.selectById(receive.getId())); + } + + @Test + public void testClaimOfficialDocReceive() { + // mock 数据 + OaOfficialDocReceiveDO receive = randomOfficialDocReceiveDO().setHandleStatus(0).setHandlerUserId(null) + .setReceiveTime(null); + receive.setCreator(""); + officialDocReceiveMapper.insert(receive); + // mock 方法 + when(adminUserApi.getUser(1L)).thenReturn(new AdminUserRespDTO().setDeptId(10L)); + + // 调用 + officialDocReceiveService.claimOfficialDocReceive(receive.getId(), 1L); + // 断言 + OaOfficialDocReceiveDO result = officialDocReceiveMapper.selectById(receive.getId()); + assertNull(result.getHandlerUserId()); + assertEquals("1", result.getCreator()); + assertEquals(1, result.getHandleStatus()); + assertNull(result.getReceiveTime()); + assertServiceException(() -> officialDocReceiveService.claimOfficialDocReceive(receive.getId(), 1L), + OFFICIAL_DOC_STATUS_INVALID); + } + + @Test + public void testClaimOfficialDocReceive_preserveBusinessFields() { + // mock 数据 + OaOfficialDocReceiveDO receive = randomOfficialDocReceiveDO().setHandleStatus(0).setHandlerUserId(2L) + .setReceiveTime(LocalDateTime.of(2026, 9, 1, 10, 0)); + receive.setCreator(""); + officialDocReceiveMapper.insert(receive); + // mock 方法 + when(adminUserApi.getUser(1L)).thenReturn(new AdminUserRespDTO().setDeptId(10L)); + + // 调用 + officialDocReceiveService.claimOfficialDocReceive(receive.getId(), 1L); + // 断言 + OaOfficialDocReceiveDO result = officialDocReceiveMapper.selectById(receive.getId()); + assertEquals("1", result.getCreator()); + assertEquals(1, result.getHandleStatus()); + assertEquals(receive.getHandlerUserId(), result.getHandlerUserId()); + assertEquals(receive.getReceiveTime(), result.getReceiveTime()); + } + + @Test + public void testClaimOfficialDocReceive_otherDept() { + // mock 数据 + OaOfficialDocReceiveDO receive = randomOfficialDocReceiveDO().setHandleStatus(0).setHandlerUserId(null); + receive.setCreator(""); + officialDocReceiveMapper.insert(receive); + // mock 方法 + when(adminUserApi.getUser(2L)).thenReturn(new AdminUserRespDTO().setDeptId(20L)); + + // 调用,并断言异常 + assertServiceException(() -> officialDocReceiveService.claimOfficialDocReceive(receive.getId(), 2L), + OFFICIAL_DOC_ACCESS_DENIED); + assertNull(officialDocReceiveMapper.selectById(receive.getId()).getHandlerUserId()); + } + + @Test + public void testUpdateOfficialDocReceive_formalFileUrl() { + // mock 数据 + OaOfficialDocReceiveDO receive = randomOfficialDocReceiveDO().setSendId(null); + officialDocReceiveMapper.insert(receive); + // 准备参数 + OaOfficialDocReceiveSaveReqVO reqVO = toBean(receive, OaOfficialDocReceiveSaveReqVO.class); + reqVO.setFormalFileUrl("https://example.com/document.pdf") + .setFileUrls(Collections.singletonList("https://example.com/attachment.pdf")); + + // 调用 + officialDocReceiveService.updateOfficialDocReceive(reqVO, 1L); + // 断言 + assertEquals(reqVO.getFormalFileUrl(), officialDocReceiveMapper.selectById(receive.getId()).getFormalFileUrl()); + assertEquals(reqVO.getFileUrls(), officialDocReceiveMapper.selectById(receive.getId()).getFileUrls()); + } + + @Test + public void testUpdateOfficialDocReceive_generatedFilesReadOnly() { + // mock 数据 + OaOfficialDocReceiveDO receive = randomOfficialDocReceiveDO().setSendId(10L) + .setFileUrls(Collections.singletonList("https://example.com/original.txt")) + .setFormalFileUrl("https://example.com/original.pdf"); + officialDocReceiveMapper.insert(receive); + // 准备参数 + OaOfficialDocReceiveSaveReqVO reqVO = toBean(receive, OaOfficialDocReceiveSaveReqVO.class) + .setTitle("编辑自动收文").setFormalFileUrl("https://example.com/replacement.pdf") + .setFileUrls(Collections.singletonList("https://example.com/replacement.txt")); + + // 调用 + officialDocReceiveService.updateOfficialDocReceive(reqVO, 1L); + // 断言 + OaOfficialDocReceiveDO result = officialDocReceiveMapper.selectById(receive.getId()); + assertEquals(reqVO.getTitle(), result.getTitle()); + assertEquals(receive.getFileUrls(), result.getFileUrls()); + assertEquals(receive.getFormalFileUrl(), result.getFormalFileUrl()); + + // 调用,并断言清空请求也不能删除来源文件 + reqVO.setFileUrls(Collections.emptyList()).setFormalFileUrl(""); + officialDocReceiveService.updateOfficialDocReceive(reqVO, 1L); + result = officialDocReceiveMapper.selectById(receive.getId()); + assertEquals(receive.getFileUrls(), result.getFileUrls()); + assertEquals(receive.getFormalFileUrl(), result.getFormalFileUrl()); + } + + @Test + public void testUpdateOfficialDocReceive_generatedWithoutFiles() { + // mock 数据 + OaOfficialDocReceiveDO receive = randomOfficialDocReceiveDO().setSendId(10L).setFileUrls(null).setFormalFileUrl(null); + officialDocReceiveMapper.insert(receive); + // 准备参数 + OaOfficialDocReceiveSaveReqVO reqVO = toBean(receive, OaOfficialDocReceiveSaveReqVO.class) + .setFileUrls(Collections.singletonList("https://example.com/attachment.pdf")) + .setFormalFileUrl("https://example.com/document.pdf"); + + // 调用 + officialDocReceiveService.updateOfficialDocReceive(reqVO, 1L); + // 断言 + OaOfficialDocReceiveDO result = officialDocReceiveMapper.selectById(receive.getId()); + assertNull(result.getFileUrls()); + assertNull(result.getFormalFileUrl()); + } + + @Test + public void testUpdateOfficialDocReceive_ignoreNullFields() { + // mock 数据 + OaOfficialDocReceiveDO receive = randomOfficialDocReceiveDO().setFormalFileUrl("https://example.com/document.pdf"); + officialDocReceiveMapper.insert(receive); + // 准备参数 + OaOfficialDocReceiveSaveReqVO reqVO = toBean(receive, OaOfficialDocReceiveSaveReqVO.class); + reqVO.setFormalFileUrl(null).setInstruction(null).setResult(null).setDeadlineTime(null) + .setSummary(null).setRemark(null).setFileUrls(null); + + // 调用 + officialDocReceiveService.updateOfficialDocReceive(reqVO, 1L); + // 断言 + OaOfficialDocReceiveDO result = officialDocReceiveMapper.selectById(receive.getId()); + assertEquals(receive.getFormalFileUrl(), result.getFormalFileUrl()); + assertEquals(receive.getInstruction(), result.getInstruction()); + assertEquals(receive.getResult(), result.getResult()); + assertEquals(receive.getDeadlineTime(), result.getDeadlineTime()); + assertEquals(receive.getSummary(), result.getSummary()); + assertEquals(receive.getRemark(), result.getRemark()); + assertEquals(receive.getFileUrls(), result.getFileUrls()); + } + + @Test + public void testUpdateOfficialDocReceive_notCreator() { + // mock 数据 + OaOfficialDocReceiveDO receive = randomOfficialDocReceiveDO(); + officialDocReceiveMapper.insert(receive); + + // 调用,并断言异常 + assertServiceException(() -> officialDocReceiveService.updateOfficialDocReceive(toBean(receive, OaOfficialDocReceiveSaveReqVO.class), 2L), + OFFICIAL_DOC_ACCESS_DENIED); + } + + @Test + public void testSubmitOfficialDocReceive() { + // mock 数据 + OaOfficialDocReceiveDO receive = randomOfficialDocReceiveDO(); + officialDocReceiveMapper.insert(receive); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(1L), any())).thenReturn("receive-process"); + + // 调用 + officialDocReceiveService.submitOfficialDocReceive(receive.getId(), 1L); + // 断言 + OaOfficialDocReceiveDO result = officialDocReceiveMapper.selectById(receive.getId()); + assertEquals(1, result.getStatus()); + assertEquals(2, result.getHandleStatus()); + // 重复提交抛出业务异常,不创建新的流程 + assertServiceException(() -> officialDocReceiveService.submitOfficialDocReceive(receive.getId(), 1L), + OFFICIAL_DOC_STATUS_INVALID); + verify(processInstanceApi, times(1)).createProcessInstance(eq(1L), any()); + } + + @Test + public void testSubmitOfficialDocReceive_immediateApprove() { + // mock 数据 + OaOfficialDocReceiveDO receive = randomOfficialDocReceiveDO(); + officialDocReceiveMapper.insert(receive); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(1L), any())).thenAnswer(invocation -> { + officialDocReceiveService.updateOfficialDocReceiveStatus(receive.getId(), 2); + return "auto-process"; + }); + + // 调用 + officialDocReceiveService.submitOfficialDocReceive(receive.getId(), 1L); + // 断言 + OaOfficialDocReceiveDO result = officialDocReceiveMapper.selectById(receive.getId()); + assertEquals(2, result.getStatus()); + assertEquals(3, result.getHandleStatus()); + assertEquals("auto-process", result.getProcessInstanceId()); + } + + @Test + public void testSubmitOfficialDocReceive_processFailureRollback() { + // mock 数据 + OaOfficialDocReceiveDO receive = randomOfficialDocReceiveDO(); + officialDocReceiveMapper.insert(receive); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(1L), any())).thenThrow(new IllegalStateException("未配置流程")); + + // 调用,并断言 + assertThrows(IllegalStateException.class, () -> officialDocReceiveService.submitOfficialDocReceive(receive.getId(), 1L)); + OaOfficialDocReceiveDO result = officialDocReceiveMapper.selectById(receive.getId()); + assertEquals(-1, result.getStatus()); + assertEquals(1, result.getHandleStatus()); + assertNull(result.getProcessInstanceId()); + } + + @Test + public void testUpdateOfficialDocReceiveStatus_duplicate() { + // mock 数据 + OaOfficialDocReceiveDO receive = randomOfficialDocReceiveDO().setStatus(1).setHandleStatus(2) + .setProcessInstanceId("receive-process"); + officialDocReceiveMapper.insert(receive); + + // 调用 + officialDocReceiveService.updateOfficialDocReceiveStatus(receive.getId(), 2); + officialDocReceiveService.updateOfficialDocReceiveStatus(receive.getId(), 2); + // 断言 + OaOfficialDocReceiveDO result = officialDocReceiveMapper.selectById(receive.getId()); + assertEquals(2, result.getStatus()); + assertEquals(3, result.getHandleStatus()); + } + + @Test + public void testGetOfficialDocReceive() { + // mock 数据 + OaOfficialDocReceiveDO receive = randomOfficialDocReceiveDO(); + officialDocReceiveMapper.insert(receive); + + // 调用 + OaOfficialDocReceiveDO result = officialDocReceiveService.getOfficialDocReceive(receive.getId()); + // 断言 + assertEquals(receive.getId(), result.getId()); + assertEquals(receive.getTitle(), result.getTitle()); + verifyNoInteractions(adminUserApi, processInstanceApi); + } + + @Test + public void testGetOfficialDocReceive_notExists() { + + // 调用,并断言 + assertNull(officialDocReceiveService.getOfficialDocReceive(-1L)); + } + + @Test + public void testUpdateOfficialDocReceive_notExists() { + // 准备参数 + OaOfficialDocReceiveSaveReqVO reqVO = toBean(randomOfficialDocReceiveDO(), OaOfficialDocReceiveSaveReqVO.class) + .setId(-1L); + + // 调用,并断言异常 + assertServiceException(() -> officialDocReceiveService.updateOfficialDocReceive(reqVO, 1L), OFFICIAL_DOC_NOT_EXISTS); + } + + @Test + public void testUpdateOfficialDocReceiveStatus_reject() { + // mock 数据 + OaOfficialDocReceiveDO receive = randomOfficialDocReceiveDO().setStatus(1).setProcessInstanceId("receive-process"); + officialDocReceiveMapper.insert(receive); + + // 调用 + officialDocReceiveService.updateOfficialDocReceiveStatus(receive.getId(), 3); + // 断言 + OaOfficialDocReceiveDO result = officialDocReceiveMapper.selectById(receive.getId()); + assertEquals(3, result.getStatus()); + assertEquals(1, result.getHandleStatus()); + assertEquals("receive-process", result.getProcessInstanceId()); + } + + @Test + public void testUpdateOfficialDocReceiveStatus_notExists() { + + // 调用,并断言异常 + assertServiceException(() -> officialDocReceiveService.updateOfficialDocReceiveStatus(-1L, 2), OFFICIAL_DOC_NOT_EXISTS); + } + + @Test + public void testGetOfficialDocReceivePage_noDept() { + // mock 方法 + when(adminUserApi.getUser(1L)).thenReturn(new AdminUserRespDTO()); + + // 调用 + PageResult result = officialDocReceiveService.getOfficialDocReceivePage(new OaOfficialDocReceivePageReqVO(), 1L); + // 断言 + assertEquals(0L, result.getTotal()); + } + + @Test + public void testGetOfficialDocReceivePage_deptScope() { + // mock 数据 + officialDocReceiveMapper.insert(randomOfficialDocReceiveDO().setTitle("本部门")); + officialDocReceiveMapper.insert(randomOfficialDocReceiveDO().setReceiveDeptId(20L)); + // mock 方法 + when(adminUserApi.getUser(1L)).thenReturn(new AdminUserRespDTO().setDeptId(10L)); + + // 调用 + PageResult result = officialDocReceiveService.getOfficialDocReceivePage(new OaOfficialDocReceivePageReqVO(), 1L); + // 断言 + assertEquals(1L, result.getTotal()); + assertEquals("本部门", CollUtil.getFirst(result.getList()).getTitle()); + } + + + @Test + public void testSubmitOfficialDocReceive_copyOnly() { + // mock 数据 + OaOfficialDocReceiveDO receive = randomOfficialDocReceiveDO().setReceiveType(1); + officialDocReceiveMapper.insert(receive); + + // 调用,并断言异常 + assertServiceException(() -> officialDocReceiveService.submitOfficialDocReceive(receive.getId(), 1L), + OFFICIAL_DOC_STATUS_INVALID); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testUpdateOfficialDocReceive_generatedFields() { + // mock 数据 + OaOfficialDocReceiveDO receive = randomOfficialDocReceiveDO(); + officialDocReceiveMapper.insert(receive); + // 准备参数 + OaOfficialDocReceiveSaveReqVO reqVO = toBean(receive, OaOfficialDocReceiveSaveReqVO.class) + .setTitle("修改后的收文标题").setDocumentNo("收文〔2026〕2号") + .setSecrecyLevel(1).setUrgencyLevel(2).setReceiveDeptId(20L).setHandlerUserId(2L); + + // 调用 + officialDocReceiveService.updateOfficialDocReceive(reqVO, 1L); + // 断言 + OaOfficialDocReceiveDO result = officialDocReceiveMapper.selectById(receive.getId()); + assertEquals(reqVO.getTitle(), result.getTitle()); + assertEquals(reqVO.getDocumentNo(), result.getDocumentNo()); + assertEquals(reqVO.getSecrecyLevel(), result.getSecrecyLevel()); + assertEquals(reqVO.getUrgencyLevel(), result.getUrgencyLevel()); + assertEquals(20L, result.getReceiveDeptId()); + assertEquals(2L, result.getHandlerUserId()); + assertEquals("1", result.getCreator()); + assertEquals(receive.getSendId(), result.getSendId()); + assertEquals(receive.getStatus(), result.getStatus()); + assertServiceException(() -> officialDocReceiveService.updateOfficialDocReceive(reqVO, 2L), OFFICIAL_DOC_ACCESS_DENIED); + officialDocReceiveService.updateOfficialDocReceive(reqVO, 1L); + } + + @Test + public void testUpdateOfficialDocReceive_preserveSource() { + // mock 数据 + OaOfficialDocReceiveDO receive = randomOfficialDocReceiveDO().setSendId(10L); + officialDocReceiveMapper.insert(receive); + // 准备参数 + OaOfficialDocReceiveSaveReqVO reqVO = toBean(receive, OaOfficialDocReceiveSaveReqVO.class).setTitle("编辑自动收文"); + + // 调用 + officialDocReceiveService.updateOfficialDocReceive(reqVO, 1L); + // 断言 + assertEquals(reqVO.getTitle(), officialDocReceiveMapper.selectById(receive.getId()).getTitle()); + assertEquals(10L, officialDocReceiveMapper.selectById(receive.getId()).getSendId()); + } + + @Test + public void testSubmitOfficialDocReceive_afterHandlerChanged() { + // mock 数据 + OaOfficialDocReceiveDO receive = randomOfficialDocReceiveDO().setHandlerUserId(2L); + officialDocReceiveMapper.insert(receive); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(1L), any())).thenReturn("creator-process"); + + // 调用,并断言异常 + assertServiceException(() -> officialDocReceiveService.submitOfficialDocReceive(receive.getId(), 2L), + OFFICIAL_DOC_ACCESS_DENIED); + assertEquals("creator-process", officialDocReceiveService.submitOfficialDocReceive(receive.getId(), 1L)); + } + + @Test + public void testCreateOfficialDocReceive_noDuplicate() { + // mock 数据 + officialDocReceiveMapper.insert(randomOfficialDocReceiveDO().setNo("SW-DUPLICATE")); + // 准备参数 + OaOfficialDocReceiveSaveReqVO reqVO = toBean(randomOfficialDocReceiveDO(), OaOfficialDocReceiveSaveReqVO.class); + // mock 方法 + when(noRedisDAO.generate(OaNoRedisDAO.OFFICIAL_DOC_RECEIVE_NO_PREFIX)).thenReturn("SW-DUPLICATE"); + + // 调用,并断言异常 + assertServiceException(() -> officialDocReceiveService.createOfficialDocReceive(reqVO), + OFFICIAL_DOC_RECEIVE_NO_DUPLICATE); + assertEquals(1L, officialDocReceiveMapper.selectCount()); + } + + @Test + public void testCreateOfficialDocReceiveListByOfficialDocSend_noDuplicate() { + // mock 数据 + officialDocReceiveMapper.insert(randomOfficialDocReceiveDO().setNo("SW-DUPLICATE").setSendId(99L)); + // 准备参数 + OaOfficialDocSendDO send = randomPojo(OaOfficialDocSendDO.class, + o -> o.setId(100L).setMainDeptIds(Arrays.asList(10L)).setCopyDeptIds(Collections.emptyList())); + // mock 方法 + when(noRedisDAO.generate(OaNoRedisDAO.OFFICIAL_DOC_RECEIVE_NO_PREFIX)).thenReturn("SW-DUPLICATE"); + + // 调用,并断言异常 + assertServiceException(() -> officialDocReceiveService.createOfficialDocReceiveListByOfficialDocSend(send), + OFFICIAL_DOC_RECEIVE_NO_DUPLICATE); + assertTrue(CollUtil.isEmpty(officialDocReceiveMapper.selectListBySendId(100L))); + } + + @Test + public void testCreateOfficialDocReceiveListByOfficialDocSend_notClaimedByOperator() { + // 准备参数 + OaOfficialDocSendDO send = randomPojo(OaOfficialDocSendDO.class, + o -> o.setId(100L).setMainDeptIds(Arrays.asList(10L)).setCopyDeptIds(Collections.emptyList()) + .setSecrecyLevel(0).setUrgencyLevel(0).setFileUrls(Collections.emptyList())); + // mock 方法 + when(noRedisDAO.generate(OaNoRedisDAO.OFFICIAL_DOC_RECEIVE_NO_PREFIX)).thenReturn("SW-UNCLAIMED"); + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(1L), new MockHttpServletRequest()); + + // 调用 + try { + officialDocReceiveService.createOfficialDocReceiveListByOfficialDocSend(send); + } finally { + SecurityContextHolder.clearContext(); + } + // 断言 + assertEquals("", officialDocReceiveMapper.selectByNo("SW-UNCLAIMED").getCreator()); + } + + // ========== 随机对象 ========== + + /** + * 构造测试数据,固定业务字段的合法取值。 + * + * @return 未入库的测试对象 + */ + private static OaOfficialDocReceiveDO randomOfficialDocReceiveDO() { + return randomPojo(OaOfficialDocReceiveDO.class, o -> o.setId(null) + .setReceiveDeptId(10L).setHandlerUserId(1L).setReceiveType(0).setHandleStatus(1) + .setSecrecyLevel(0).setUrgencyLevel(0).setStatus(-1).setProcessInstanceId(null) + .setFileUrls(Collections.emptyList()).setCreator("1")); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocSendServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocSendServiceImplTest.java new file mode 100644 index 0000000000..c897b8dd18 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocSendServiceImplTest.java @@ -0,0 +1,443 @@ +package cn.iocoder.yudao.module.oa.service.officialdoc; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi; +import cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.send.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.officialdoc.*; +import cn.iocoder.yudao.module.oa.dal.mysql.officialdoc.*; +import cn.iocoder.yudao.module.oa.dal.redis.no.OaNoRedisDAO; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.object.BeanUtils.toBean; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaOfficialDocSendServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaOfficialDocSendServiceImpl.class) +public class OaOfficialDocSendServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaOfficialDocSendService officialDocSendService; + + @Resource + private OaOfficialDocSendMapper officialDocSendMapper; + + @MockitoBean + private BpmProcessInstanceApi processInstanceApi; + @MockitoBean + private OaNoRedisDAO noRedisDAO; + @MockitoBean + private DeptApi deptApi; + @MockitoBean + private OaOfficialDocTemplateService officialDocTemplateService; + @MockitoBean + private OaOfficialDocReceiveService officialDocReceiveService; + + + @ParameterizedTest + @ValueSource(ints = {-1, 3, 4}) + public void testDeleteOfficialDocSend_allowedStatuses(int status) { + // mock 数据 + OaOfficialDocSendDO send = randomOfficialDocSendDO().setStatus(status); + officialDocSendMapper.insert(send); + + // 调用,并断言:其他人不能删除 + assertServiceException(() -> officialDocSendService.deleteOfficialDocSend(send.getId(), 2L), OFFICIAL_DOC_ACCESS_DENIED); + officialDocSendService.deleteOfficialDocSend(send.getId(), 1L); + assertNull(officialDocSendMapper.selectById(send.getId())); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2}) + public void testDeleteOfficialDocSend_activeOrApproved(int status) { + // mock 数据 + OaOfficialDocSendDO send = randomOfficialDocSendDO().setStatus(status); + officialDocSendMapper.insert(send); + + // 调用,并断言异常 + assertServiceException(() -> officialDocSendService.deleteOfficialDocSend(send.getId(), 1L), OFFICIAL_DOC_STATUS_INVALID); + assertNotNull(officialDocSendMapper.selectById(send.getId())); + } + + @Test + public void testCreateOfficialDocSend_noDuplicate() { + // mock 数据 + OaOfficialDocSendDO send = randomOfficialDocSendDO().setNo("FW20260916000001"); + officialDocSendMapper.insert(send); + // 准备参数 + OaOfficialDocSendSaveReqVO reqVO = toBean(send, OaOfficialDocSendSaveReqVO.class).setId(null); + // mock 方法 + when(noRedisDAO.generate(OaNoRedisDAO.OFFICIAL_DOC_SEND_NO_PREFIX)).thenReturn(send.getNo()); + + // 调用,并断言异常 + assertServiceException(() -> officialDocSendService.createOfficialDocSend(reqVO, 1L), + OFFICIAL_DOC_SEND_NO_DUPLICATE); + assertEquals(1L, officialDocSendMapper.selectCount()); + } + + @Test + public void testGetOfficialDocSendMap() { + // mock 数据 + OaOfficialDocSendDO send = randomOfficialDocSendDO(); + officialDocSendMapper.insert(send); + + // 调用 + Map result = officialDocSendService.getOfficialDocSendMap(Collections.singleton(send.getId())); + // 断言 + assertEquals(1, result.size()); + assertEquals(send.getTitle(), result.get(send.getId()).getTitle()); + } + + @Test + public void testGetOfficialDocSendMap_empty() { + // mock 方法 + OaOfficialDocSendService service = mock(OaOfficialDocSendService.class, CALLS_REAL_METHODS); + + // 调用 + Map result = service.getOfficialDocSendMap(Collections.emptyList()); + // 断言 + assertTrue(result.isEmpty()); + verify(service, never()).getOfficialDocSendList(any()); + } + + @Test + public void testGetOfficialDocSendList() { + // mock 数据 + OaOfficialDocSendDO send = randomOfficialDocSendDO(); + officialDocSendMapper.insert(send); + OaOfficialDocSendDO otherSend = randomOfficialDocSendDO(); + officialDocSendMapper.insert(otherSend); + + // 调用 + List result = officialDocSendService.getOfficialDocSendList(Collections.singleton(send.getId())); + // 断言 + assertEquals(1, result.size()); + assertEquals(send.getId(), CollUtil.getFirst(result).getId()); + assertEquals(send.getTitle(), CollUtil.getFirst(result).getTitle()); + } + + @Test + public void testGetOfficialDocSendList_empty() { + // mock 数据 + officialDocSendMapper.insert(randomOfficialDocSendDO()); + + // 调用 + List result = officialDocSendService.getOfficialDocSendList(Collections.emptyList()); + // 断言 + assertTrue(CollUtil.isEmpty(result)); + } + + @Test + public void testCreateOfficialDocSend() { + // 准备参数 + OaOfficialDocSendSaveReqVO reqVO = toBean(randomOfficialDocSendDO(), OaOfficialDocSendSaveReqVO.class).setId(null); + // mock 方法 + when(noRedisDAO.generate("FW")).thenReturn("FW20260913000001"); + + // 调用 + Long id = officialDocSendService.createOfficialDocSend(reqVO, 1L); + // 断言 + OaOfficialDocSendDO send = officialDocSendMapper.selectById(id); + assertEquals(-1, send.getStatus()); + assertEquals("FW20260913000001", send.getNo()); + assertEquals("测试〔2026〕1号", send.getDocumentNo()); + assertNull(send.getProcessInstanceId()); + assertEquals(Arrays.asList(10L, 11L), send.getMainDeptIds()); + verify(officialDocTemplateService).validateOfficialDocTemplate(reqVO.getTemplateId()); + } + + @Test + public void testCreateOfficialDocSend_disabledTemplate() { + // 准备参数 + OaOfficialDocSendSaveReqVO reqVO = toBean(randomOfficialDocSendDO(), OaOfficialDocSendSaveReqVO.class); + // mock 方法 + when(officialDocTemplateService.validateOfficialDocTemplate(reqVO.getTemplateId())) + .thenThrow(exception(OFFICIAL_DOC_TEMPLATE_DISABLED)); + + // 调用,并断言异常 + assertServiceException(() -> officialDocSendService.createOfficialDocSend(reqVO, 1L), OFFICIAL_DOC_TEMPLATE_DISABLED); + verifyNoInteractions(noRedisDAO); + } + + @Test + public void testUpdateOfficialDocSend_notOwner() { + // mock 数据 + OaOfficialDocSendDO send = randomOfficialDocSendDO(); + officialDocSendMapper.insert(send); + + // 调用,并断言异常 + assertServiceException(() -> officialDocSendService.updateOfficialDocSend(toBean(send, OaOfficialDocSendSaveReqVO.class), 2L), + OFFICIAL_DOC_ACCESS_DENIED); + } + + @Test + public void testDeleteOfficialDocSend_submitted() { + // mock 数据 + OaOfficialDocSendDO send = randomOfficialDocSendDO().setStatus(1); + officialDocSendMapper.insert(send); + + // 调用,并断言异常 + assertServiceException(() -> officialDocSendService.deleteOfficialDocSend(send.getId(), 1L), OFFICIAL_DOC_STATUS_INVALID); + assertNotNull(officialDocSendMapper.selectById(send.getId())); + } + + @Test + public void testSubmitOfficialDocSend() { + // mock 数据 + OaOfficialDocSendDO send = randomOfficialDocSendDO(); + officialDocSendMapper.insert(send); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(1L), any())).thenReturn("process-1"); + + // 调用 + String processId = officialDocSendService.submitOfficialDocSend(send.getId(), 1L); + // 断言 + assertEquals("process-1", processId); + assertServiceException(() -> officialDocSendService.submitOfficialDocSend(send.getId(), 1L), OFFICIAL_DOC_STATUS_INVALID); + assertEquals(1, officialDocSendMapper.selectById(send.getId()).getStatus()); + verify(processInstanceApi, times(1)).createProcessInstance(eq(1L), any()); + } + + @Test + public void testSubmitOfficialDocSend_immediateApprove() { + // mock 数据 + OaOfficialDocSendDO send = randomOfficialDocSendDO(); + officialDocSendMapper.insert(send); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(1L), any())).thenAnswer(invocation -> { + officialDocSendService.updateOfficialDocSendStatus(send.getId(), "auto-process", 2); + return "auto-process"; + }); + + // 调用 + officialDocSendService.submitOfficialDocSend(send.getId(), 1L); + // 断言 + assertEquals(2, officialDocSendMapper.selectById(send.getId()).getStatus()); + assertEquals("auto-process", officialDocSendMapper.selectById(send.getId()).getProcessInstanceId()); + verify(officialDocReceiveService).createOfficialDocReceiveListByOfficialDocSend(any()); + } + + @Test + public void testSubmitOfficialDocSend_processFailureRollback() { + // mock 数据 + OaOfficialDocSendDO send = randomOfficialDocSendDO(); + officialDocSendMapper.insert(send); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(1L), any())).thenThrow(new IllegalStateException("未配置流程")); + + // 调用,并断言 + assertThrows(IllegalStateException.class, () -> officialDocSendService.submitOfficialDocSend(send.getId(), 1L)); + assertEquals(-1, officialDocSendMapper.selectById(send.getId()).getStatus()); + } + + @Test + public void testUpdateOfficialDocSendStatus_duplicate() { + // mock 数据 + OaOfficialDocSendDO send = randomOfficialDocSendDO().setStatus(1).setProcessInstanceId("process-1"); + officialDocSendMapper.insert(send); + + // 调用 + officialDocSendService.updateOfficialDocSendStatus(send.getId(), "process-1", 2); + officialDocSendService.updateOfficialDocSendStatus(send.getId(), "process-1", 2); + officialDocSendService.updateOfficialDocSendStatus(send.getId(), "process-1", 3); + // 断言 + assertEquals(2, officialDocSendMapper.selectById(send.getId()).getStatus()); + verify(officialDocReceiveService, times(1)).createOfficialDocReceiveListByOfficialDocSend(any()); + } + + @Test + public void testUpdateOfficialDocSendStatus_wrongProcess() { + // mock 数据 + OaOfficialDocSendDO send = randomOfficialDocSendDO().setStatus(1).setProcessInstanceId("process-1"); + officialDocSendMapper.insert(send); + + // 调用,并断言异常 + assertServiceException(() -> officialDocSendService.updateOfficialDocSendStatus(send.getId(), "process-other", 2), + OFFICIAL_DOC_PROCESS_MISMATCH); + verifyNoInteractions(officialDocReceiveService); + } + + @Test + public void testGetOfficialDocSend() { + // mock 数据 + OaOfficialDocSendDO send = randomOfficialDocSendDO(); + officialDocSendMapper.insert(send); + + // 调用 + OaOfficialDocSendDO result = officialDocSendService.getOfficialDocSend(send.getId()); + // 断言 + assertEquals(send.getId(), result.getId()); + assertEquals(send.getTitle(), result.getTitle()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testGetOfficialDocSend_notExists() { + + // 调用,并断言 + assertNull(officialDocSendService.getOfficialDocSend(999L)); + } + + @Test + public void testGetOfficialDocSendPage() { + // mock 数据 + officialDocSendMapper.insert(randomOfficialDocSendDO().setTitle("当前用户")); + officialDocSendMapper.insert((OaOfficialDocSendDO) randomOfficialDocSendDO().setTitle("其他用户").setCreator("2")); + + // 调用 + PageResult result = officialDocSendService.getOfficialDocSendPage(new OaOfficialDocSendPageReqVO(), 1L); + // 断言 + assertEquals(1L, result.getTotal()); + assertEquals("当前用户", CollUtil.getFirst(result.getList()).getTitle()); + } + + + @Test + public void testUpdateOfficialDocSend_ignoreNullFields() { + // mock 数据 + OaOfficialDocSendDO send = randomOfficialDocSendDO(); + officialDocSendMapper.insert(send); + // 准备参数 + OaOfficialDocSendSaveReqVO reqVO = toBean(send, OaOfficialDocSendSaveReqVO.class) + .setTitle("修改后的标题").setContent(null).setRemark(null).setCopyDeptIds(null); + + // 调用 + officialDocSendService.updateOfficialDocSend(reqVO, 1L); + // 断言 + OaOfficialDocSendDO result = officialDocSendMapper.selectById(send.getId()); + assertEquals("修改后的标题", result.getTitle()); + assertEquals(send.getContent(), result.getContent()); + assertEquals(send.getRemark(), result.getRemark()); + assertEquals(send.getCopyDeptIds(), result.getCopyDeptIds()); + assertEquals(-1, result.getStatus()); + assertEquals(send.getCreator(), result.getCreator()); + assertEquals(send.getProcessInstanceId(), result.getProcessInstanceId()); + } + + @Test + public void testCreateOfficialDocSend_documentNoDuplicate() { + // mock 数据 + OaOfficialDocSendDO existing = randomOfficialDocSendDO().setDocumentNo("测试〔2026〕1号"); + officialDocSendMapper.insert(existing); + // 准备参数:新增请求携带已有编号也不能排除已有发文 + OaOfficialDocSendSaveReqVO reqVO = toBean(existing, OaOfficialDocSendSaveReqVO.class); + + // 调用,并断言异常 + assertServiceException(() -> officialDocSendService.createOfficialDocSend(reqVO, 1L), + OFFICIAL_DOC_SEND_DOCUMENT_NO_DUPLICATE); + assertEquals(1L, officialDocSendMapper.selectCount()); + verifyNoInteractions(noRedisDAO, processInstanceApi); + } + + @Test + public void testUpdateOfficialDocSend_documentNoDuplicate() { + // mock 数据 + officialDocSendMapper.insert(randomOfficialDocSendDO().setDocumentNo("测试〔2026〕1号")); + OaOfficialDocSendDO send = randomOfficialDocSendDO().setSequence(2).setDocumentNo("测试〔2026〕2号"); + officialDocSendMapper.insert(send); + // 准备参数 + OaOfficialDocSendSaveReqVO reqVO = toBean(send, OaOfficialDocSendSaveReqVO.class).setSequence(1); + + // 调用,并断言异常 + assertServiceException(() -> officialDocSendService.updateOfficialDocSend(reqVO, 1L), + OFFICIAL_DOC_SEND_DOCUMENT_NO_DUPLICATE); + assertEquals("测试〔2026〕2号", officialDocSendMapper.selectById(send.getId()).getDocumentNo()); + } + + @Test + public void testUpdateOfficialDocSend_keepDocumentNo() { + // mock 数据 + OaOfficialDocSendDO send = randomOfficialDocSendDO().setDocumentNo("测试〔2026〕1号"); + officialDocSendMapper.insert(send); + // 准备参数 + OaOfficialDocSendSaveReqVO reqVO = toBean(send, OaOfficialDocSendSaveReqVO.class).setTitle("修改标题"); + + // 调用 + officialDocSendService.updateOfficialDocSend(reqVO, 1L); + // 断言 + assertEquals("修改标题", officialDocSendMapper.selectById(send.getId()).getTitle()); + assertEquals(send.getDocumentNo(), officialDocSendMapper.selectById(send.getId()).getDocumentNo()); + } + + @Test + public void testSubmitOfficialDocSend_documentNoDuplicate() { + // mock 数据:历史重复草稿不能继续提交 + officialDocSendMapper.insert(randomOfficialDocSendDO().setDocumentNo("测试〔2026〕1号")); + OaOfficialDocSendDO send = randomOfficialDocSendDO().setDocumentNo("测试〔2026〕1号"); + officialDocSendMapper.insert(send); + + // 调用,并断言异常 + assertServiceException(() -> officialDocSendService.submitOfficialDocSend(send.getId(), 1L), + OFFICIAL_DOC_SEND_DOCUMENT_NO_DUPLICATE); + assertEquals(-1, officialDocSendMapper.selectById(send.getId()).getStatus()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testCreateOfficialDocSend_withoutSequence() { + // mock 数据 + officialDocSendMapper.insert(randomOfficialDocSendDO().setSequence(null).setDocumentNo("测试〔2026〕")); + // 准备参数 + OaOfficialDocSendSaveReqVO reqVO = toBean(randomOfficialDocSendDO(), OaOfficialDocSendSaveReqVO.class).setSequence(null); + // mock 方法 + when(noRedisDAO.generate("FW")).thenReturn("FW20260918000001"); + + // 调用 + Long id = officialDocSendService.createOfficialDocSend(reqVO, 1L); + // 断言 + assertEquals("测试〔2026〕", officialDocSendMapper.selectById(id).getDocumentNo()); + assertEquals(2L, officialDocSendMapper.selectCount()); + } + + @Test + public void testCreateOfficialDocSend_deletedDocumentNo() { + // mock 数据 + OaOfficialDocSendDO deleted = randomOfficialDocSendDO().setDocumentNo("测试〔2026〕1号"); + officialDocSendMapper.insert(deleted); + officialDocSendMapper.deleteById(deleted.getId()); + // 准备参数 + OaOfficialDocSendSaveReqVO reqVO = toBean(randomOfficialDocSendDO(), OaOfficialDocSendSaveReqVO.class); + // mock 方法 + when(noRedisDAO.generate("FW")).thenReturn("FW20260918000001"); + + // 调用 + Long id = officialDocSendService.createOfficialDocSend(reqVO, 1L); + // 断言 + assertEquals("测试〔2026〕1号", officialDocSendMapper.selectById(id).getDocumentNo()); + assertEquals(1L, officialDocSendMapper.selectCount()); + } + + // ========== 随机对象 ========== + + /** + * 构造测试数据,固定业务字段的合法取值。 + * + * @return 未入库的测试对象 + */ + private static OaOfficialDocSendDO randomOfficialDocSendDO() { + return randomPojo(OaOfficialDocSendDO.class, o -> o.setId(null) + .setNoPrefix("测试").setYear(2026).setSequence(1).setSecrecyLevel(0).setUrgencyLevel(0) + .setDisclosureType(0).setStatus(-1).setProcessInstanceId(null).setSignerUserId(null) + .setMainDeptIds(Arrays.asList(10L, 11L)).setCopyDeptIds(Collections.singletonList(12L)) + .setFileUrls(Collections.singletonList("https://example.com/attachment.pdf")).setCreator("1")); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocTemplateServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocTemplateServiceImplTest.java new file mode 100644 index 0000000000..5aa1247c27 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocTemplateServiceImplTest.java @@ -0,0 +1,178 @@ +package cn.iocoder.yudao.module.oa.service.officialdoc; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.controller.admin.officialdoc.vo.template.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.officialdoc.*; +import cn.iocoder.yudao.module.oa.dal.mysql.officialdoc.*; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import static cn.iocoder.yudao.framework.common.util.object.BeanUtils.toBean; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaOfficialDocTemplateServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaOfficialDocTemplateServiceImpl.class) +public class OaOfficialDocTemplateServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaOfficialDocTemplateService officialDocTemplateService; + + @Resource + private OaOfficialDocTemplateMapper officialDocTemplateMapper; + + @MockitoBean + private OaOfficialDocSendService officialDocSendService; + + @Test + public void testDeleteOfficialDocTemplate_inUse() { + // mock 数据 + OaOfficialDocTemplateDO record = randomOfficialDocTemplateDO(); + officialDocTemplateMapper.insert(record); + // mock 方法 + when(officialDocSendService.getOfficialDocSendCountByTemplateId(record.getId())).thenReturn(1L); + + // 调用,并断言异常 + assertServiceException(() -> officialDocTemplateService.deleteOfficialDocTemplate(record.getId()), + OFFICIAL_DOC_TEMPLATE_IN_USE); + assertNotNull(officialDocTemplateMapper.selectById(record.getId())); + } + + @Test + public void testCreateOfficialDocTemplate() { + // 准备参数 + OaOfficialDocTemplateSaveReqVO reqVO = toBean(randomOfficialDocTemplateDO(), OaOfficialDocTemplateSaveReqVO.class) + .setId(null); + + // 调用 + Long id = officialDocTemplateService.createOfficialDocTemplate(reqVO); + // 断言 + assertEquals(reqVO.getName(), officialDocTemplateMapper.selectById(id).getName()); + } + + @Test + public void testUpdateOfficialDocTemplate() { + // mock 数据 + OaOfficialDocTemplateDO template = randomOfficialDocTemplateDO(); + officialDocTemplateMapper.insert(template); + // 准备参数 + OaOfficialDocTemplateSaveReqVO reqVO = toBean(template, OaOfficialDocTemplateSaveReqVO.class) + .setName("修改后的模板").setNoPrefix(null).setRemark(null).setSealPicUrl(null); + + // 调用 + officialDocTemplateService.updateOfficialDocTemplate(reqVO); + // 断言 + assertEquals("修改后的模板", officialDocTemplateMapper.selectById(template.getId()).getName()); + assertEquals(template.getNoPrefix(), officialDocTemplateMapper.selectById(template.getId()).getNoPrefix()); + assertEquals(template.getRemark(), officialDocTemplateMapper.selectById(template.getId()).getRemark()); + assertEquals(template.getSealPicUrl(), officialDocTemplateMapper.selectById(template.getId()).getSealPicUrl()); + } + + @Test + public void testDeleteOfficialDocTemplate() { + // mock 数据 + OaOfficialDocTemplateDO template = randomOfficialDocTemplateDO(); + officialDocTemplateMapper.insert(template); + + // 调用 + officialDocTemplateService.deleteOfficialDocTemplate(template.getId()); + // 断言 + assertNull(officialDocTemplateMapper.selectById(template.getId())); + } + + @Test + public void testGetOfficialDocTemplate_notExists() { + // 调用,并断言 + assertNull(officialDocTemplateService.getOfficialDocTemplate(999L)); + } + + @Test + public void testUpdateOfficialDocTemplate_notExists() { + // 准备参数 + OaOfficialDocTemplateSaveReqVO reqVO = toBean(randomOfficialDocTemplateDO(), OaOfficialDocTemplateSaveReqVO.class) + .setId(999L); + + // 调用,并断言异常 + assertServiceException(() -> officialDocTemplateService.updateOfficialDocTemplate(reqVO), + OFFICIAL_DOC_TEMPLATE_NOT_EXISTS); + } + + @Test + public void testDeleteOfficialDocTemplate_notExists() { + + // 调用,并断言异常 + assertServiceException(() -> officialDocTemplateService.deleteOfficialDocTemplate(999L), + OFFICIAL_DOC_TEMPLATE_NOT_EXISTS); + } + + @Test + public void testValidateOfficialDocTemplate() { + // mock 数据 + OaOfficialDocTemplateDO template = randomOfficialDocTemplateDO().setStatus(0); + officialDocTemplateMapper.insert(template); + + // 调用 + OaOfficialDocTemplateDO result = officialDocTemplateService.validateOfficialDocTemplate(template.getId()); + // 断言 + assertEquals(template.getId(), result.getId()); + } + + @Test + public void testValidateOfficialDocTemplate_notExists() { + + // 调用,并断言异常 + assertServiceException(() -> officialDocTemplateService.validateOfficialDocTemplate(999L), + OFFICIAL_DOC_TEMPLATE_NOT_EXISTS); + } + + @Test + public void testValidateOfficialDocTemplate_disabled() { + // mock 数据 + OaOfficialDocTemplateDO template = randomOfficialDocTemplateDO().setStatus(1); + officialDocTemplateMapper.insert(template); + + // 调用,并断言异常 + assertServiceException(() -> officialDocTemplateService.validateOfficialDocTemplate(template.getId()), + OFFICIAL_DOC_TEMPLATE_DISABLED); + } + + @Test + public void testGetOfficialDocTemplatePage() { + // mock 数据 + officialDocTemplateMapper.insert(randomOfficialDocTemplateDO().setName("示例模板").setStatus(0)); + officialDocTemplateMapper.insert(randomOfficialDocTemplateDO().setName("其他模板").setStatus(1)); + // 准备参数 + OaOfficialDocTemplatePageReqVO reqVO = new OaOfficialDocTemplatePageReqVO().setName("示例").setStatus(0); + + // 调用 + PageResult result = officialDocTemplateService.getOfficialDocTemplatePage(reqVO); + // 断言 + assertEquals(1L, result.getTotal()); + assertEquals("示例模板", CollUtil.getFirst(result.getList()).getName()); + } + + + // ========== 随机对象 ========== + + /** + * 构造测试数据,固定业务字段的合法取值。 + * + * @return 未入库的测试对象 + */ + private static OaOfficialDocTemplateDO randomOfficialDocTemplateDO() { + return randomPojo(OaOfficialDocTemplateDO.class, o -> o.setId(null).setFontSize(32) + .setSeparatorType(0).setStatus(0).setSort(0)); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/overtime/OaOvertimeApplyServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/overtime/OaOvertimeApplyServiceImplTest.java new file mode 100644 index 0000000000..3b5e3ce396 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/overtime/OaOvertimeApplyServiceImplTest.java @@ -0,0 +1,280 @@ +package cn.iocoder.yudao.module.oa.service.overtime; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi; +import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO; +import cn.iocoder.yudao.module.oa.controller.admin.overtime.vo.OaOvertimeApplyPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.overtime.vo.OaOvertimeApplySaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.overtime.vo.OaOvertimeApplySubmitReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.overtime.OaOvertimeApplyDO; +import cn.iocoder.yudao.module.oa.dal.mysql.overtime.OaOvertimeApplyMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaOvertimeApplyServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaOvertimeApplyServiceImpl.class) +public class OaOvertimeApplyServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaOvertimeApplyService overtimeApplyService; + + @Resource + private OaOvertimeApplyMapper overtimeApplyMapper; + + @MockitoBean + private BpmProcessInstanceApi processInstanceApi; + + @BeforeEach + public void before() { + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(10L).setUserType(2), new MockHttpServletRequest()); + } + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void testDraft_updateAndSubmitPermissions() { + // mock 数据 + Long id = overtimeApplyService.createOvertimeApply(randomOvertimeApplySaveReqVO()); + OaOvertimeApplySaveReqVO reqVO = randomOvertimeApplySaveReqVO().setId(id).setTitle("修改后的草稿"); + + // 调用,并断言:他人不能修改、提交 + assertServiceException(() -> overtimeApplyService.updateOvertimeApply(reqVO, 99L), APPLY_ACCESS_DENIED); + assertServiceException(() -> overtimeApplyService.submitOvertimeApply(new OaOvertimeApplySubmitReqVO() + .setId(id), 99L), APPLY_ACCESS_DENIED); + overtimeApplyService.updateOvertimeApply(reqVO, 10L); + assertEquals("修改后的草稿", overtimeApplyMapper.selectById(id).getTitle()); + assertEquals(-1, overtimeApplyMapper.selectById(id).getStatus()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testSubmit_duplicateAndUpdateRunning() { + // mock 数据 + Long id = overtimeApplyService.createOvertimeApply(randomOvertimeApplySaveReqVO()); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("process-1"); + + // 调用 + overtimeApplyService.submitOvertimeApply(new OaOvertimeApplySubmitReqVO().setId(id), 10L); + // 断言 + assertServiceException(() -> overtimeApplyService.submitOvertimeApply(new OaOvertimeApplySubmitReqVO() + .setId(id), 10L), APPLY_STATUS_INVALID); + assertServiceException(() -> overtimeApplyService.updateOvertimeApply(randomOvertimeApplySaveReqVO() + .setId(id), 10L), APPLY_STATUS_INVALID); + verify(processInstanceApi, times(1)).createProcessInstance(eq(10L), any()); + } + + @Test + public void testCreateOvertimeApply_success() { + // 准备参数 + OaOvertimeApplySaveReqVO reqVO = randomOvertimeApplySaveReqVO(); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("process-1"); + + // 调用 + Long id = overtimeApplyService.createOvertimeApply(reqVO); + assertEquals(-1, overtimeApplyMapper.selectById(id).getStatus()); + assertNull(overtimeApplyMapper.selectById(id).getProcessInstanceId()); + verifyNoInteractions(processInstanceApi); + overtimeApplyService.submitOvertimeApply(new OaOvertimeApplySubmitReqVO().setId(id), 10L); + + // 断言 + OaOvertimeApplyDO application = overtimeApplyMapper.selectById(id); + assertEquals("10", application.getCreator()); + assertEquals(1, application.getStatus()); + assertEquals("process-1", application.getProcessInstanceId()); + assertEquals(new BigDecimal("0.5"), application.getDays()); + verify(processInstanceApi).createProcessInstance(eq(10L), argThat(dto -> + "oa_overtime_apply".equals(dto.getProcessDefinitionKey()) && id.toString().equals(dto.getBusinessKey()) + && !dto.getVariables().containsKey("title"))); + } + + @Test + public void testCreateOvertimeApply_processFailureRollback() { + // 准备参数 + OaOvertimeApplySaveReqVO reqVO = randomOvertimeApplySaveReqVO(); + // mock 方法 + when(processInstanceApi.createProcessInstance(anyLong(), any())).thenThrow(new IllegalStateException("流程尚未配置")); + + // 调用 + Long id = overtimeApplyService.createOvertimeApply(reqVO); + // 断言 + assertThrows(IllegalStateException.class, () -> overtimeApplyService.submitOvertimeApply( + new OaOvertimeApplySubmitReqVO().setId(id), 10L)); + assertEquals(1L, overtimeApplyMapper.selectCount()); + assertEquals(-1, overtimeApplyMapper.selectById(id).getStatus()); + assertNull(overtimeApplyMapper.selectById(id).getProcessInstanceId()); + } + + @Test + public void testGetOvertimeApply_success() { + // mock 数据 + OaOvertimeApplyDO application = randomOvertimeApplyDO(); + overtimeApplyMapper.insert(application); + + // 调用 + OaOvertimeApplyDO result = overtimeApplyService.getOvertimeApply(application.getId()); + + // 断言 + assertEquals(application.getId(), result.getId()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testGetOvertimeApply_notExists() { + + // 调用 + OaOvertimeApplyDO result = overtimeApplyService.getOvertimeApply(1024L); + + // 断言 + assertNull(result); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testSubmitOvertimeApply_synchronousApproval() { + // mock 数据 + Long id = overtimeApplyService.createOvertimeApply(randomOvertimeApplySaveReqVO()); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenAnswer(invocation -> { + BpmProcessInstanceCreateReqDTO reqDTO = invocation.getArgument(1); + overtimeApplyService.updateOvertimeApplyStatus(Long.valueOf(reqDTO.getBusinessKey()), 2); + return "process-1"; + }); + + // 调用 + overtimeApplyService.submitOvertimeApply(new OaOvertimeApplySubmitReqVO().setId(id), 10L); + + // 断言 + OaOvertimeApplyDO overtimeApply = overtimeApplyMapper.selectById(id); + assertEquals(2, overtimeApply.getStatus()); + assertEquals("process-1", overtimeApply.getProcessInstanceId()); + } + + @Test + public void testGetOvertimeApplyPage_scopeAndFilter() { + // mock 数据 + OaOvertimeApplyDO application = randomOvertimeApplyDO().setTitle("匹配申请"); + overtimeApplyMapper.insert(application); + OaOvertimeApplyDO otherApplication = randomOvertimeApplyDO().setTitle("匹配申请"); + otherApplication.setCreator("99"); + overtimeApplyMapper.insert(otherApplication); + overtimeApplyMapper.insert(randomOvertimeApplyDO().setId(null).setStatus(2).setTitle("匹配申请")); + // 准备参数 + OaOvertimeApplyPageReqVO reqVO = new OaOvertimeApplyPageReqVO().setTitle("匹配").setStatus(1); + + // 调用 + PageResult page = overtimeApplyService.getOvertimeApplyPage(10L, reqVO); + + // 断言 + assertEquals(1L, page.getTotal()); + assertEquals(application.getId(), CollUtil.getFirst(page.getList()).getId()); + } + + @Test + public void testUpdateOvertimeApplyStatus_successAndRepeated() { + // mock 数据 + OaOvertimeApplyDO application = randomOvertimeApplyDO(); + overtimeApplyMapper.insert(application); + + // 调用 + overtimeApplyService.updateOvertimeApplyStatus(application.getId(), 2); + overtimeApplyService.updateOvertimeApplyStatus(application.getId(), 2); + + // 断言 + assertEquals(2, overtimeApplyMapper.selectById(application.getId()).getStatus()); + } + + @Test + public void testUpdateOvertimeApplyStatus_notExists() { + + // 调用,并断言异常 + assertServiceException(() -> overtimeApplyService.updateOvertimeApplyStatus(1024L, 2), APPLY_NOT_EXISTS); + } + + @Test + public void testCreateOvertimeApply_roundHalfUp() { + // 准备参数 + OaOvertimeApplySaveReqVO reqVO = randomOvertimeApplySaveReqVO() + .setStartTime(LocalDateTime.of(2026, 9, 13, 23, 0)) + .setEndTime(LocalDateTime.of(2026, 9, 14, 2, 36)); + + // 调用 + Long id = overtimeApplyService.createOvertimeApply(reqVO); + + // 断言:3.6 小时为 0.15 天,保留一位小数四舍五入为 0.2 天 + assertEquals(new BigDecimal("0.2"), overtimeApplyMapper.selectById(id).getDays()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testUpdateOvertimeApply_recalculateDays() { + // mock 数据 + Long id = overtimeApplyService.createOvertimeApply(randomOvertimeApplySaveReqVO()); + // 准备参数 + OaOvertimeApplySaveReqVO reqVO = randomOvertimeApplySaveReqVO().setId(id) + .setStartTime(LocalDateTime.of(2026, 9, 13, 8, 0)) + .setEndTime(LocalDateTime.of(2026, 9, 14, 20, 0)); + + // 调用 + overtimeApplyService.updateOvertimeApply(reqVO, 10L); + + // 断言 + OaOvertimeApplyDO overtimeApply = overtimeApplyMapper.selectById(id); + assertEquals(new BigDecimal("1.5"), overtimeApply.getDays()); + assertEquals(-1, overtimeApply.getStatus()); + verifyNoInteractions(processInstanceApi); + } + + // ========== 随机对象 ========== + + /** + * 构造申请参数,固定业务字段的合法取值。 + * + * @return 请求参数 + */ + private static OaOvertimeApplySaveReqVO randomOvertimeApplySaveReqVO() { + return randomPojo(OaOvertimeApplySaveReqVO.class).setTitle("业务申请").setUrgency(1).setId(null).setType(1) + .setStartTime(LocalDateTime.of(2026, 9, 13, 8, 0)).setEndTime(LocalDateTime.of(2026, 9, 13, 20, 0)); + } + + /** + * 构造测试数据,固定业务字段的合法取值。 + * + * @return 未入库的测试对象 + */ + private static OaOvertimeApplyDO randomOvertimeApplyDO() { + return randomPojo(OaOvertimeApplyDO.class, application -> { + application.setId(null).setTitle("申请").setUrgency(1).setStatus(1).setProcessInstanceId("process-1"); + application.setCreator("10").setDeleted(false); + }); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/plan/OaPlanServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/plan/OaPlanServiceImplTest.java new file mode 100644 index 0000000000..ae81ff836d --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/plan/OaPlanServiceImplTest.java @@ -0,0 +1,342 @@ +package cn.iocoder.yudao.module.oa.service.plan; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.controller.admin.plan.vo.OaPlanPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.plan.vo.OaPlanSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.plan.OaPlanDO; +import cn.iocoder.yudao.module.oa.dal.mysql.plan.OaPlanMapper; +import cn.iocoder.yudao.module.oa.enums.plan.OaPlanStatusEnum; +import cn.iocoder.yudao.module.oa.enums.plan.OaPlanTypeEnum; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import jakarta.validation.ConstraintViolationException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.PLAN_ACCESS_DENIED; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +/** + * {@link OaPlanServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaPlanServiceImpl.class) +public class OaPlanServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaPlanServiceImpl planService; + + @Resource + private OaPlanMapper planMapper; + + @MockitoBean + private AdminUserApi adminUserApi; + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void testCreatePlan_success() { + // 准备参数 + Long userId = randomLongId(); + OaPlanSaveReqVO reqVO = buildPlanSaveReqVO(); + + // 调用 + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(userId), new MockHttpServletRequest()); + Long planId = planService.createPlan(reqVO); + + // 断言 + OaPlanDO plan = planMapper.selectById(planId); + assertEquals(userId.toString(), plan.getCreator()); + assertEquals(reqVO.getTitle(), plan.getTitle()); + } + + @Test + public void testUpdatePlan_ignoreNullFields() { + // mock 数据 + Long userId = randomLongId(); + OaPlanDO plan = buildPlanDO(userId).setLabel("重点").setSummary("已完成") + .setFileUrls(Collections.singletonList("https://example.com/a.txt")); + planMapper.insert(plan); + // 准备参数 + OaPlanSaveReqVO reqVO = buildPlanSaveReqVO().setId(plan.getId()) + .setLabel(null).setSummary(null).setFileUrls(null); + + // 调用 + planService.updatePlan(reqVO, userId); + + // 断言 + OaPlanDO updatedPlan = planMapper.selectById(plan.getId()); + assertEquals(plan.getLabel(), updatedPlan.getLabel()); + assertEquals(plan.getSummary(), updatedPlan.getSummary()); + assertEquals(plan.getFileUrls(), updatedPlan.getFileUrls()); + } + + @Test + public void testUpdatePlan_preserveCreator() { + // mock 数据 + Long userId = randomLongId(); + OaPlanDO plan = buildPlanDO(userId); + planMapper.insert(plan); + // 准备参数 + OaPlanSaveReqVO reqVO = buildPlanSaveReqVO().setId(plan.getId()); + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(userId), new MockHttpServletRequest()); + + // 调用 + planService.updatePlan(reqVO, userId); + + // 断言 + OaPlanDO updatedPlan = planMapper.selectById(plan.getId()); + assertEquals(userId.toString(), updatedPlan.getCreator()); + assertEquals(reqVO.getTitle(), updatedPlan.getTitle()); + assertEquals(plan.getId(), planService.getPlan(plan.getId(), userId).getId()); + } + + @Test + public void testUpdatePlan_notOwner() { + // mock 数据 + OaPlanDO plan = buildPlanDO(randomLongId()); + planMapper.insert(plan); + // 准备参数 + OaPlanSaveReqVO reqVO = buildPlanSaveReqVO().setId(plan.getId()); + + // 调用,并断言异常 + assertServiceException(() -> planService.updatePlan(reqVO, randomLongId()), PLAN_ACCESS_DENIED); + } + + @Test + public void testGetLatestPlanMap_emptyUsers() { + + // 调用 + Map result = planService.getLatestPlanMap(Collections.emptyList(), + OaPlanTypeEnum.WEEK.getType(), new LocalDateTime[]{LocalDateTime.now(), LocalDateTime.now()}); + + // 断言 + assertEquals(Collections.emptyMap(), result); + } + + @Test + public void testDeletePlan_notOwner() { + // mock 数据 + OaPlanDO plan = buildPlanDO(randomLongId()); + planMapper.insert(plan); + + // 调用,并断言异常 + assertServiceException(() -> planService.deletePlan(plan.getId(), randomLongId()), PLAN_ACCESS_DENIED); + } + + @Test + public void testGetPlan_notOwner() { + // mock 数据 + OaPlanDO plan = buildPlanDO(randomLongId()); + planMapper.insert(plan); + + // 调用,并断言异常 + assertServiceException(() -> planService.getPlan(plan.getId(), randomLongId()), PLAN_ACCESS_DENIED); + } + + @Test + public void testAddPlanComment_success() { + // mock 数据 + Long managerUserId = randomLongId(); + Long subordinateUserId = randomLongId(); + OaPlanDO plan = buildPlanDO(subordinateUserId).setLabel("重点").setSummary("原总结") + .setComment("第一次点评") + .setFileUrls(Collections.singletonList("https://example.com/a.txt")); + planMapper.insert(plan); + when(adminUserApi.getUserListBySubordinate(managerUserId)).thenReturn( + Collections.singletonList(new AdminUserRespDTO().setId(subordinateUserId))); + + // 调用 + planService.addPlanComment(plan.getId(), "验收通过", managerUserId); + + // 断言 + OaPlanDO updatedPlan = planMapper.selectById(plan.getId()); + assertEquals("第一次点评" + System.lineSeparator() + "验收通过", updatedPlan.getComment()); + assertEquals(plan.getLabel(), updatedPlan.getLabel()); + assertEquals(plan.getSummary(), updatedPlan.getSummary()); + assertEquals(plan.getFileUrls(), updatedPlan.getFileUrls()); + } + + @Test + public void testAddPlanComment_atMaxLength() { + // mock 数据:已有点评与本次追加内容按字符数计算 + Long ownerId = randomLongId(); + Long managerId = randomLongId(); + OaPlanDO plan = buildPlanDO(ownerId).setComment(StrUtil.repeat("a", 65533 - System.lineSeparator().length())); + planMapper.insert(plan); + // mock 方法 + when(adminUserApi.getUserListBySubordinate(managerId)).thenReturn( + Collections.singletonList(new AdminUserRespDTO().setId(ownerId))); + + // 调用:包含换行后恰好达到 65535 个字符 + planService.addPlanComment(plan.getId(), "ab", managerId); + + // 断言 + assertEquals(plan.getComment() + System.lineSeparator() + "ab", + planMapper.selectById(plan.getId()).getComment()); + } + + @Test + public void testAddPlanComment_exceedsMaxLength() { + // mock 数据:已有点评与本次追加内容按字符数计算 + Long ownerId = randomLongId(); + Long managerId = randomLongId(); + OaPlanDO plan = buildPlanDO(ownerId).setComment(StrUtil.repeat("a", 65533 - System.lineSeparator().length())); + planMapper.insert(plan); + // mock 方法 + when(adminUserApi.getUserListBySubordinate(managerId)).thenReturn( + Collections.singletonList(new AdminUserRespDTO().setId(ownerId))); + + // 调用,并断言:累计字符数超限时保留原点评 + ConstraintViolationException exception = assertThrows(ConstraintViolationException.class, + () -> planService.addPlanComment(plan.getId(), "abc", managerId)); + assertTrue(exception.getConstraintViolations().stream().anyMatch(violation -> + "comment".equals(violation.getPropertyPath().toString()) + && "点评内容长度不能超过 65535 个字符".equals(violation.getMessage()))); + assertEquals(plan.getComment(), planMapper.selectById(plan.getId()).getComment()); + } + + @Test + public void testAddPlanComment_notSubordinate() { + // mock 数据 + Long managerUserId = randomLongId(); + OaPlanDO plan = buildPlanDO(randomLongId()); + planMapper.insert(plan); + when(adminUserApi.getUserListBySubordinate(managerUserId)).thenReturn(Collections.emptyList()); + + // 调用,并断言异常 + assertServiceException(() -> planService.addPlanComment(plan.getId(), "验收通过", managerUserId), + PLAN_ACCESS_DENIED); + } + + @Test + public void testGetLatestPlanMap() { + // mock 数据 + Long firstUserId = randomLongId(); + Long secondUserId = randomLongId(); + LocalDateTime beginTime = LocalDateTime.now().minusDays(1); + LocalDateTime endTime = LocalDateTime.now().plusDays(1); + OaPlanDO oldPlan = buildPlanDO(firstUserId); + oldPlan.setCreateTime(beginTime.plusHours(1)); + planMapper.insert(oldPlan); + OaPlanDO latestPlan = buildPlanDO(firstUserId).setTitle("最新计划"); + latestPlan.setCreateTime(beginTime.plusHours(2)); + planMapper.insert(latestPlan); + OaPlanDO secondUserPlan = buildPlanDO(secondUserId); + secondUserPlan.setCreateTime(beginTime.plusHours(1)); + planMapper.insert(secondUserPlan); + + // 调用 + Map result = planService.getLatestPlanMap(Arrays.asList(firstUserId, secondUserId), + OaPlanTypeEnum.WEEK.getType(), new LocalDateTime[]{beginTime, endTime}); + + // 断言 + assertEquals(2, result.size()); + assertEquals(latestPlan.getId(), result.get(firstUserId).getId()); + assertEquals(secondUserPlan.getId(), result.get(secondUserId).getId()); + } + + @Test + public void testGetPlanPage_onlyMineAndSortByCreateTime() { + // mock 数据 + Long userId = randomLongId(); + OaPlanDO dayPlan = buildPlanDO(userId).setType(OaPlanTypeEnum.DAY.getType()); + dayPlan.setCreateTime(LocalDateTime.now().minusDays(1)); + planMapper.insert(dayPlan); + OaPlanDO monthPlan = buildPlanDO(userId).setType(OaPlanTypeEnum.MONTH.getType()); + monthPlan.setCreateTime(LocalDateTime.now()); + planMapper.insert(monthPlan); + planMapper.insert(buildPlanDO(randomLongId()).setType(OaPlanTypeEnum.MONTH.getType())); + // 准备参数 + OaPlanPageReqVO pageReqVO = new OaPlanPageReqVO(); + + // 调用 + PageResult result = planService.getPlanPage(pageReqVO, userId); + + // 断言 + assertEquals(2, result.getTotal()); + assertEquals(Arrays.asList(monthPlan.getId(), dayPlan.getId()), + convertList(result.getList(), OaPlanDO::getId)); + } + + @Test + public void testGetLatestPlanMap_sameCreateTimeAndRangeBoundary() { + // mock 数据 + Long userId = randomLongId(); + LocalDateTime beginTime = LocalDateTime.of(2026, 9, 1, 0, 0); + LocalDateTime endTime = LocalDateTime.of(2026, 9, 30, 23, 59, 59); + OaPlanDO firstPlan = buildPlanDO(userId).setId(101L); + firstPlan.setCreateTime(endTime); + planMapper.insert(firstPlan); + OaPlanDO latestPlan = buildPlanDO(userId).setId(102L); + latestPlan.setCreateTime(endTime); + planMapper.insert(latestPlan); + OaPlanDO nextMonthPlan = buildPlanDO(userId).setId(103L); + nextMonthPlan.setCreateTime(endTime.plusSeconds(1)); + planMapper.insert(nextMonthPlan); + + // 调用 + Map result = planService.getLatestPlanMap(Collections.singletonList(userId), + OaPlanTypeEnum.WEEK.getType(), new LocalDateTime[]{beginTime, endTime}); + + // 断言:包含结束边界,相同创建时间取编号较大的记录,不包含下一个周期 + assertEquals(1, result.size()); + assertEquals(latestPlan.getId(), result.get(userId).getId()); + } + + // ========== 随机对象 ========== + + /** + * 构造开始时间晚于当前时间的未完成周计划参数。 + * + * @return 保存参数 + */ + private static OaPlanSaveReqVO buildPlanSaveReqVO() { + LocalDateTime startTime = LocalDateTime.now().plusDays(1); + return new OaPlanSaveReqVO().setType(OaPlanTypeEnum.WEEK.getType()) + .setStatus(OaPlanStatusEnum.UNFINISHED.getStatus()).setTitle("本周工作计划") + .setContent("完成 OA 工作计划管理功能迁移与自动化测试") + .setStartTime(startTime).setEndTime(startTime.plusDays(7)); + } + + /** + * 构造开始时间晚于当前时间的未完成周计划。 + * + * @param userId 用户编号 + * @return 未入库的测试对象 + */ + private static OaPlanDO buildPlanDO(Long userId) { + LocalDateTime startTime = LocalDateTime.now().plusDays(1); + OaPlanDO plan = new OaPlanDO().setType(OaPlanTypeEnum.WEEK.getType()) + .setStatus(OaPlanStatusEnum.UNFINISHED.getStatus()).setTitle("旧计划") + .setContent("完成 OA 工作计划管理功能迁移与自动化测试") + .setStartTime(startTime).setEndTime(startTime.plusDays(7)); + plan.setCreator(userId.toString()); + return plan; + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/regular/OaRegularApplyServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/regular/OaRegularApplyServiceImplTest.java new file mode 100644 index 0000000000..543fb64063 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/regular/OaRegularApplyServiceImplTest.java @@ -0,0 +1,279 @@ +package cn.iocoder.yudao.module.oa.service.regular; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi; +import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO; +import cn.iocoder.yudao.module.oa.controller.admin.regular.vo.OaRegularApplyPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.regular.vo.OaRegularApplySaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.regular.vo.OaRegularApplySubmitReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.regular.OaRegularApplyDO; +import cn.iocoder.yudao.module.oa.dal.mysql.regular.OaRegularApplyMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaRegularApplyServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaRegularApplyServiceImpl.class) +public class OaRegularApplyServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaRegularApplyService regularApplyService; + + @Resource + private OaRegularApplyMapper regularApplyMapper; + + @MockitoBean + private BpmProcessInstanceApi processInstanceApi; + + @BeforeEach + public void before() { + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(10L).setUserType(2), new MockHttpServletRequest()); + } + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void testDraft_updateAndSubmitPermissions() { + // mock 数据 + Long id = regularApplyService.createRegularApply(randomRegularApplySaveReqVO()); + OaRegularApplySaveReqVO reqVO = randomRegularApplySaveReqVO().setId(id).setTitle("修改后的草稿"); + + // 调用,并断言:他人不能修改、提交 + assertServiceException(() -> regularApplyService.updateRegularApply(reqVO, 99L), APPLY_ACCESS_DENIED); + assertServiceException(() -> regularApplyService.submitRegularApply(new OaRegularApplySubmitReqVO() + .setId(id), 99L), APPLY_ACCESS_DENIED); + regularApplyService.updateRegularApply(reqVO, 10L); + assertEquals("修改后的草稿", regularApplyMapper.selectById(id).getTitle()); + assertEquals(-1, regularApplyMapper.selectById(id).getStatus()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testSubmit_duplicateAndUpdateRunning() { + // mock 数据 + Long id = regularApplyService.createRegularApply(randomRegularApplySaveReqVO()); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("process-1"); + + // 调用 + regularApplyService.submitRegularApply(new OaRegularApplySubmitReqVO().setId(id), 10L); + // 断言 + assertServiceException(() -> regularApplyService.submitRegularApply(new OaRegularApplySubmitReqVO() + .setId(id), 10L), APPLY_STATUS_INVALID); + assertServiceException(() -> regularApplyService.updateRegularApply(randomRegularApplySaveReqVO() + .setId(id), 10L), APPLY_STATUS_INVALID); + verify(processInstanceApi, times(1)).createProcessInstance(eq(10L), any()); + } + + @Test + public void testCreateRegularApply_success() { + // 准备参数 + OaRegularApplySaveReqVO reqVO = randomRegularApplySaveReqVO(); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("process-1"); + + // 调用 + Long id = regularApplyService.createRegularApply(reqVO); + assertEquals(-1, regularApplyMapper.selectById(id).getStatus()); + assertNull(regularApplyMapper.selectById(id).getProcessInstanceId()); + verifyNoInteractions(processInstanceApi); + regularApplyService.submitRegularApply(new OaRegularApplySubmitReqVO().setId(id), 10L); + + // 断言 + OaRegularApplyDO application = regularApplyMapper.selectById(id); + assertEquals("10", application.getCreator()); + assertEquals(1, application.getStatus()); + assertEquals("process-1", application.getProcessInstanceId()); + assertEquals(1, application.getDays()); + verify(processInstanceApi).createProcessInstance(eq(10L), argThat(dto -> + "oa_regular_apply".equals(dto.getProcessDefinitionKey()) && id.toString().equals(dto.getBusinessKey()) + && !dto.getVariables().containsKey("title"))); + } + + @Test + public void testCreateRegularApply_processFailureRollback() { + // 准备参数 + OaRegularApplySaveReqVO reqVO = randomRegularApplySaveReqVO(); + // mock 方法 + when(processInstanceApi.createProcessInstance(anyLong(), any())).thenThrow(new IllegalStateException("流程尚未配置")); + + // 调用 + Long id = regularApplyService.createRegularApply(reqVO); + // 断言 + assertThrows(IllegalStateException.class, () -> regularApplyService.submitRegularApply( + new OaRegularApplySubmitReqVO().setId(id), 10L)); + assertEquals(1L, regularApplyMapper.selectCount()); + assertEquals(-1, regularApplyMapper.selectById(id).getStatus()); + assertNull(regularApplyMapper.selectById(id).getProcessInstanceId()); + } + + @Test + public void testGetRegularApply_success() { + // mock 数据 + OaRegularApplyDO application = randomRegularApplyDO(); + regularApplyMapper.insert(application); + + // 调用 + OaRegularApplyDO result = regularApplyService.getRegularApply(application.getId()); + + // 断言 + assertEquals(application.getId(), result.getId()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testGetRegularApply_notExists() { + + // 调用 + OaRegularApplyDO result = regularApplyService.getRegularApply(1024L); + + // 断言 + assertNull(result); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testSubmitRegularApply_synchronousApproval() { + // mock 数据 + Long id = regularApplyService.createRegularApply(randomRegularApplySaveReqVO()); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenAnswer(invocation -> { + BpmProcessInstanceCreateReqDTO reqDTO = invocation.getArgument(1); + regularApplyService.updateRegularApplyStatus(Long.valueOf(reqDTO.getBusinessKey()), 2); + return "process-1"; + }); + + // 调用 + regularApplyService.submitRegularApply(new OaRegularApplySubmitReqVO().setId(id), 10L); + + // 断言 + OaRegularApplyDO regularApply = regularApplyMapper.selectById(id); + assertEquals(2, regularApply.getStatus()); + assertEquals("process-1", regularApply.getProcessInstanceId()); + } + + @Test + public void testGetRegularApplyPage_scopeAndFilter() { + // mock 数据 + OaRegularApplyDO application = randomRegularApplyDO().setTitle("匹配申请"); + regularApplyMapper.insert(application); + OaRegularApplyDO otherApplication = randomRegularApplyDO().setTitle("匹配申请"); + otherApplication.setCreator("99"); + regularApplyMapper.insert(otherApplication); + regularApplyMapper.insert(randomRegularApplyDO().setId(null).setStatus(2).setTitle("匹配申请")); + // 准备参数 + OaRegularApplyPageReqVO reqVO = new OaRegularApplyPageReqVO().setTitle("匹配").setStatus(1); + + // 调用 + PageResult page = regularApplyService.getRegularApplyPage(10L, reqVO); + + // 断言 + assertEquals(1L, page.getTotal()); + assertEquals(application.getId(), CollUtil.getFirst(page.getList()).getId()); + } + + @Test + public void testUpdateRegularApplyStatus_successAndRepeated() { + // mock 数据 + OaRegularApplyDO application = randomRegularApplyDO(); + regularApplyMapper.insert(application); + + // 调用 + regularApplyService.updateRegularApplyStatus(application.getId(), 2); + regularApplyService.updateRegularApplyStatus(application.getId(), 2); + + // 断言 + assertEquals(2, regularApplyMapper.selectById(application.getId()).getStatus()); + } + + @Test + public void testUpdateRegularApplyStatus_notExists() { + + // 调用,并断言异常 + assertServiceException(() -> regularApplyService.updateRegularApplyStatus(1024L, 2), APPLY_NOT_EXISTS); + } + + @Test + public void testCreateRegularApply_crossMidnight() { + // 准备参数 + OaRegularApplySaveReqVO reqVO = randomRegularApplySaveReqVO() + .setStartTime(LocalDateTime.of(2026, 9, 13, 23, 0)) + .setEndTime(LocalDateTime.of(2026, 9, 14, 1, 0)); + + // 调用 + Long id = regularApplyService.createRegularApply(reqVO); + + // 断言:跨午夜的 2 小时按时长向上取整为 1 天 + assertEquals(1, regularApplyMapper.selectById(id).getDays()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testUpdateRegularApply_recalculateDays() { + // mock 数据 + Long id = regularApplyService.createRegularApply(randomRegularApplySaveReqVO()); + // 准备参数 + OaRegularApplySaveReqVO reqVO = randomRegularApplySaveReqVO().setId(id) + .setStartTime(LocalDateTime.of(2026, 9, 13, 8, 0)) + .setEndTime(LocalDateTime.of(2026, 9, 14, 20, 0)); + + // 调用 + regularApplyService.updateRegularApply(reqVO, 10L); + + // 断言 + OaRegularApplyDO regularApply = regularApplyMapper.selectById(id); + assertEquals(2, regularApply.getDays()); + assertEquals(-1, regularApply.getStatus()); + verifyNoInteractions(processInstanceApi); + } + + // ========== 随机对象 ========== + + /** + * 构造申请参数,固定业务字段的合法取值。 + * + * @return 请求参数 + */ + private static OaRegularApplySaveReqVO randomRegularApplySaveReqVO() { + return randomPojo(OaRegularApplySaveReqVO.class).setTitle("业务申请").setUrgency(1).setId(null) + .setStartTime(LocalDateTime.of(2026, 9, 13, 8, 0)).setEndTime(LocalDateTime.of(2026, 9, 13, 20, 0)); + } + + /** + * 构造测试数据,固定业务字段的合法取值。 + * + * @return 未入库的测试对象 + */ + private static OaRegularApplyDO randomRegularApplyDO() { + return randomPojo(OaRegularApplyDO.class, application -> { + application.setId(null).setTitle("申请").setUrgency(1).setStatus(1).setProcessInstanceId("process-1"); + application.setCreator("10").setDeleted(false); + }); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/reimbursement/OaReimbursementServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/reimbursement/OaReimbursementServiceImplTest.java new file mode 100644 index 0000000000..06c337bbdc --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/reimbursement/OaReimbursementServiceImplTest.java @@ -0,0 +1,307 @@ +package cn.iocoder.yudao.module.oa.service.reimbursement; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.jackson.config.YudaoJacksonAutoConfiguration; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi; +import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO; +import cn.iocoder.yudao.module.oa.controller.admin.reimbursement.vo.OaReimbursementPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.reimbursement.vo.OaReimbursementSaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.reimbursement.vo.OaReimbursementSubmitReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.reimbursement.OaReimbursementDO; +import cn.iocoder.yudao.module.oa.dal.mysql.reimbursement.OaReimbursementMapper; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaReimbursementServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import({OaReimbursementServiceImpl.class, JacksonAutoConfiguration.class, YudaoJacksonAutoConfiguration.class}) +public class OaReimbursementServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaReimbursementService reimbursementService; + + @Resource + private OaReimbursementMapper reimbursementMapper; + @Resource + private ObjectMapper objectMapper; + + @MockitoBean + private AdminUserApi adminUserApi; + @MockitoBean + private BpmProcessInstanceApi processInstanceApi; + + @BeforeEach + public void before() { + // JSON 处理器为静态共享配置,每个用例恢复当前上下文的生产日期序列化配置 + JacksonTypeHandler.setObjectMapper(objectMapper); + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(10L).setUserType(2), new MockHttpServletRequest()); + } + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void testDraft_updateAndSubmitPermissions() { + // mock 数据 + Long id = reimbursementService.createReimbursement(randomReimbursementSaveReqVO()); + OaReimbursementSaveReqVO reqVO = randomReimbursementSaveReqVO().setId(id).setTitle("修改后的草稿"); + + // 调用,并断言:他人不能修改、提交 + assertServiceException(() -> reimbursementService.updateReimbursement(reqVO, 99L), APPLY_ACCESS_DENIED); + assertServiceException(() -> reimbursementService.submitReimbursement(new OaReimbursementSubmitReqVO() + .setId(id), 99L), APPLY_ACCESS_DENIED); + reimbursementService.updateReimbursement(reqVO, 10L); + assertEquals("修改后的草稿", reimbursementMapper.selectById(id).getTitle()); + assertEquals(-1, reimbursementMapper.selectById(id).getStatus()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testSubmit_duplicateAndUpdateRunning() { + // mock 数据 + Long id = reimbursementService.createReimbursement(randomReimbursementSaveReqVO()); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("process-1"); + + // 调用 + reimbursementService.submitReimbursement(new OaReimbursementSubmitReqVO().setId(id), 10L); + // 断言 + assertServiceException(() -> reimbursementService.submitReimbursement(new OaReimbursementSubmitReqVO() + .setId(id), 10L), APPLY_STATUS_INVALID); + assertServiceException(() -> reimbursementService.updateReimbursement(randomReimbursementSaveReqVO() + .setId(id), 10L), APPLY_STATUS_INVALID); + verify(processInstanceApi, times(1)).createProcessInstance(eq(10L), any()); + } + + @Test + public void testCreateReimbursement_success() { + // 准备参数 + OaReimbursementSaveReqVO reqVO = randomReimbursementSaveReqVO(); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("process-1"); + + // 调用 + Long id = reimbursementService.createReimbursement(reqVO); + assertEquals(-1, reimbursementMapper.selectById(id).getStatus()); + assertNull(reimbursementMapper.selectById(id).getProcessInstanceId()); + verifyNoInteractions(processInstanceApi); + reimbursementService.submitReimbursement(new OaReimbursementSubmitReqVO().setId(id), 10L); + + // 断言 + OaReimbursementDO application = reimbursementMapper.selectById(id); + assertEquals("10", application.getCreator()); + assertEquals(1, application.getStatus()); + assertEquals("process-1", application.getProcessInstanceId()); + assertEquals(new BigDecimal("12.34"), application.getTotalPrice()); + assertEquals(2, application.getInvoiceCount()); + assertEquals(1, CollUtil.getFirst(application.getItems()).getExpenseType()); + verify(processInstanceApi).createProcessInstance(eq(10L), argThat(dto -> + "oa_reimbursement".equals(dto.getProcessDefinitionKey()) && id.toString().equals(dto.getBusinessKey()) + && !dto.getVariables().containsKey("title"))); + } + + @Test + public void testCreateReimbursement_processFailureRollback() { + // 准备参数 + OaReimbursementSaveReqVO reqVO = randomReimbursementSaveReqVO(); + // mock 方法 + when(processInstanceApi.createProcessInstance(anyLong(), any())).thenThrow(new IllegalStateException("流程尚未配置")); + + // 调用 + Long id = reimbursementService.createReimbursement(reqVO); + // 断言 + assertThrows(IllegalStateException.class, () -> reimbursementService.submitReimbursement( + new OaReimbursementSubmitReqVO().setId(id), 10L)); + assertEquals(1L, reimbursementMapper.selectCount()); + assertEquals(-1, reimbursementMapper.selectById(id).getStatus()); + assertNull(reimbursementMapper.selectById(id).getProcessInstanceId()); + } + + @Test + public void testGetReimbursement_success() { + // mock 数据 + OaReimbursementDO application = randomReimbursementDO(); + reimbursementMapper.insert(application); + + // 调用 + OaReimbursementDO result = reimbursementService.getReimbursement(application.getId()); + + // 断言 + assertEquals(application.getId(), result.getId()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testGetReimbursement_notExists() { + + // 调用 + OaReimbursementDO result = reimbursementService.getReimbursement(1024L); + + // 断言 + assertNull(result); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testSubmitReimbursement_synchronousApproval() { + // mock 数据 + Long id = reimbursementService.createReimbursement(randomReimbursementSaveReqVO()); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenAnswer(invocation -> { + BpmProcessInstanceCreateReqDTO reqDTO = invocation.getArgument(1); + reimbursementService.updateReimbursementStatus(Long.valueOf(reqDTO.getBusinessKey()), 2); + return "process-1"; + }); + + // 调用 + reimbursementService.submitReimbursement(new OaReimbursementSubmitReqVO().setId(id), 10L); + + // 断言 + OaReimbursementDO reimbursement = reimbursementMapper.selectById(id); + assertEquals(2, reimbursement.getStatus()); + assertEquals("process-1", reimbursement.getProcessInstanceId()); + } + + @Test + public void testGetReimbursementPage_scopeAndFilter() { + // mock 数据 + OaReimbursementDO application = randomReimbursementDO().setTitle("匹配申请"); + reimbursementMapper.insert(application); + OaReimbursementDO otherApplication = randomReimbursementDO().setTitle("匹配申请"); + otherApplication.setCreator("99"); + reimbursementMapper.insert(otherApplication); + reimbursementMapper.insert(randomReimbursementDO().setId(null).setStatus(2).setTitle("匹配申请")); + // 准备参数 + OaReimbursementPageReqVO reqVO = new OaReimbursementPageReqVO().setTitle("匹配").setStatus(1); + + // 调用 + PageResult page = reimbursementService.getReimbursementPage(10L, reqVO); + + // 断言 + assertEquals(1L, page.getTotal()); + assertEquals(application.getId(), CollUtil.getFirst(page.getList()).getId()); + } + + @Test + public void testUpdateReimbursementStatus_successAndRepeated() { + // mock 数据 + OaReimbursementDO application = randomReimbursementDO(); + reimbursementMapper.insert(application); + + // 调用 + reimbursementService.updateReimbursementStatus(application.getId(), 2); + reimbursementService.updateReimbursementStatus(application.getId(), 2); + + // 断言 + assertEquals(2, reimbursementMapper.selectById(application.getId()).getStatus()); + } + + @Test + public void testUpdateReimbursementStatus_notExists() { + + // 调用,并断言异常 + assertServiceException(() -> reimbursementService.updateReimbursementStatus(1024L, 2), APPLY_NOT_EXISTS); + } + + @Test + public void testUpdateReimbursement_recalculateTotals() { + // mock 数据 + Long id = reimbursementService.createReimbursement(randomReimbursementSaveReqVO()); + // 准备参数 + OaReimbursementSaveReqVO reqVO = randomReimbursementSaveReqVO().setId(id); + CollUtil.getFirst(reqVO.getItems()).setPrice(new BigDecimal("25.50")).setInvoiceCount(3); + + // 调用 + reimbursementService.updateReimbursement(reqVO, 10L); + + // 断言 + OaReimbursementDO reimbursement = reimbursementMapper.selectById(id); + assertEquals(new BigDecimal("25.50"), reimbursement.getTotalPrice()); + assertEquals(3, reimbursement.getInvoiceCount()); + assertEquals(new BigDecimal("25.50"), CollUtil.getFirst(reimbursement.getItems()).getPrice()); + assertEquals(-1, reimbursement.getStatus()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testCreateReimbursement_multipleItems() { + // 准备参数 + OaReimbursementSaveReqVO reqVO = randomReimbursementSaveReqVO(); + OaReimbursementSaveReqVO.Item firstItem = CollUtil.getFirst(reqVO.getItems()); + OaReimbursementSaveReqVO.Item secondItem = new OaReimbursementSaveReqVO.Item() + .setExpenseTime(firstItem.getExpenseTime()).setExpenseType(2) + .setPrice(new BigDecimal("7.66")).setInvoiceCount(3); + reqVO.setItems(Arrays.asList(firstItem, secondItem)); + + // 调用 + Long id = reimbursementService.createReimbursement(reqVO); + + // 断言 + OaReimbursementDO reimbursement = reimbursementMapper.selectById(id); + assertEquals(new BigDecimal("20.00"), reimbursement.getTotalPrice()); + assertEquals(5, reimbursement.getInvoiceCount()); + assertEquals(2, reimbursement.getItems().size()); + verify(adminUserApi).validateUser(reqVO.getWitnessUserId()); + verifyNoInteractions(processInstanceApi); + } + + // ========== 随机对象 ========== + + /** + * 构造申请参数,固定业务字段的合法取值。 + * + * @return 请求参数 + */ + private static OaReimbursementSaveReqVO randomReimbursementSaveReqVO() { + return randomPojo(OaReimbursementSaveReqVO.class).setTitle("业务申请").setUrgency(1).setId(null) + .setWitnessUserId(20L).setPaymentMethod(1) + .setItems(Collections.singletonList(new OaReimbursementSaveReqVO.Item() + .setExpenseTime(LocalDateTime.of(2026, 9, 13, 8, 0)).setExpenseType(1) + .setPrice(new BigDecimal("12.34")).setInvoiceCount(2))); + } + + /** + * 构造测试数据,固定业务字段的合法取值。 + * + * @return 未入库的测试对象 + */ + private static OaReimbursementDO randomReimbursementDO() { + return randomPojo(OaReimbursementDO.class, application -> { + application.setId(null).setTitle("申请").setUrgency(1).setStatus(1).setProcessInstanceId("process-1"); + application.setCreator("10").setDeleted(false); + application.setTotalPrice(new BigDecimal("12.34")).setItems(Collections.emptyList()).setInvoiceCount(2); + }); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/resign/OaResignApplyServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/resign/OaResignApplyServiceImplTest.java new file mode 100644 index 0000000000..b367ca3815 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/resign/OaResignApplyServiceImplTest.java @@ -0,0 +1,246 @@ +package cn.iocoder.yudao.module.oa.service.resign; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi; +import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO; +import cn.iocoder.yudao.module.oa.controller.admin.resign.vo.OaResignApplyPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.resign.vo.OaResignApplySaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.resign.vo.OaResignApplySubmitReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.resign.OaResignApplyDO; +import cn.iocoder.yudao.module.oa.dal.mysql.resign.OaResignApplyMapper; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaResignApplyServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaResignApplyServiceImpl.class) +public class OaResignApplyServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaResignApplyService resignApplyService; + + @Resource + private OaResignApplyMapper resignApplyMapper; + + @MockitoBean + private AdminUserApi adminUserApi; + @MockitoBean + private BpmProcessInstanceApi processInstanceApi; + + @BeforeEach + public void before() { + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(10L).setUserType(2), new MockHttpServletRequest()); + } + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void testDraft_updateAndSubmitPermissions() { + // mock 数据 + Long id = resignApplyService.createResignApply(randomResignApplySaveReqVO()); + OaResignApplySaveReqVO reqVO = randomResignApplySaveReqVO().setId(id).setTitle("修改后的草稿"); + + // 调用,并断言:他人不能修改、提交 + assertServiceException(() -> resignApplyService.updateResignApply(reqVO, 99L), APPLY_ACCESS_DENIED); + assertServiceException(() -> resignApplyService.submitResignApply(new OaResignApplySubmitReqVO() + .setId(id), 99L), APPLY_ACCESS_DENIED); + resignApplyService.updateResignApply(reqVO, 10L); + assertEquals("修改后的草稿", resignApplyMapper.selectById(id).getTitle()); + assertEquals(-1, resignApplyMapper.selectById(id).getStatus()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testSubmit_duplicateAndUpdateRunning() { + // mock 数据 + Long id = resignApplyService.createResignApply(randomResignApplySaveReqVO()); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("process-1"); + + // 调用 + resignApplyService.submitResignApply(new OaResignApplySubmitReqVO().setId(id), 10L); + // 断言 + assertServiceException(() -> resignApplyService.submitResignApply(new OaResignApplySubmitReqVO() + .setId(id), 10L), APPLY_STATUS_INVALID); + assertServiceException(() -> resignApplyService.updateResignApply(randomResignApplySaveReqVO() + .setId(id), 10L), APPLY_STATUS_INVALID); + verify(processInstanceApi, times(1)).createProcessInstance(eq(10L), any()); + } + + @Test + public void testCreateResignApply_success() { + // 准备参数 + OaResignApplySaveReqVO reqVO = randomResignApplySaveReqVO(); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("process-1"); + + // 调用 + Long id = resignApplyService.createResignApply(reqVO); + assertEquals(-1, resignApplyMapper.selectById(id).getStatus()); + assertNull(resignApplyMapper.selectById(id).getProcessInstanceId()); + verifyNoInteractions(processInstanceApi); + resignApplyService.submitResignApply(new OaResignApplySubmitReqVO().setId(id), 10L); + + // 断言 + OaResignApplyDO application = resignApplyMapper.selectById(id); + assertEquals("10", application.getCreator()); + assertEquals(1, application.getStatus()); + assertEquals("process-1", application.getProcessInstanceId()); + verify(processInstanceApi).createProcessInstance(eq(10L), argThat(dto -> + "oa_resign_apply".equals(dto.getProcessDefinitionKey()) && id.toString().equals(dto.getBusinessKey()))); + } + + @Test + public void testCreateResignApply_processFailureRollback() { + // 准备参数 + OaResignApplySaveReqVO reqVO = randomResignApplySaveReqVO(); + // mock 方法 + when(processInstanceApi.createProcessInstance(anyLong(), any())).thenThrow(new IllegalStateException("流程尚未配置")); + + // 调用 + Long id = resignApplyService.createResignApply(reqVO); + // 断言 + assertThrows(IllegalStateException.class, () -> resignApplyService.submitResignApply( + new OaResignApplySubmitReqVO().setId(id), 10L)); + assertEquals(1L, resignApplyMapper.selectCount()); + assertEquals(-1, resignApplyMapper.selectById(id).getStatus()); + assertNull(resignApplyMapper.selectById(id).getProcessInstanceId()); + } + + @Test + public void testGetResignApply_owner() { + // mock 数据 + OaResignApplyDO application = randomResignApplyDO(); + resignApplyMapper.insert(application); + + // 调用 + OaResignApplyDO result = resignApplyService.getResignApply(application.getId()); + + // 断言 + assertEquals(application.getId(), result.getId()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testGetResignApply_notExists() { + + // 调用 + OaResignApplyDO result = resignApplyService.getResignApply(1024L); + + // 断言 + assertNull(result); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testGetResignApplyPage_scopeAndFilter() { + // mock 数据 + OaResignApplyDO application = randomResignApplyDO().setTitle("匹配申请"); + resignApplyMapper.insert(application); + OaResignApplyDO otherApplication = randomResignApplyDO().setTitle("匹配申请"); + otherApplication.setCreator("99"); + resignApplyMapper.insert(otherApplication); + resignApplyMapper.insert(randomResignApplyDO().setId(null).setStatus(2).setTitle("匹配申请")); + // 准备参数 + OaResignApplyPageReqVO reqVO = new OaResignApplyPageReqVO().setTitle("匹配").setStatus(1); + + // 调用 + PageResult page = resignApplyService.getResignApplyPage(10L, reqVO); + + // 断言 + assertEquals(1L, page.getTotal()); + assertEquals(application.getId(), CollUtil.getFirst(page.getList()).getId()); + } + + @ParameterizedTest + @ValueSource(ints = {2, 3, 4}) + public void testUpdateResignApplyStatus_successAndRepeated(Integer status) { + // mock 数据 + OaResignApplyDO application = randomResignApplyDO(); + resignApplyMapper.insert(application); + + // 调用 + resignApplyService.updateResignApplyStatus(application.getId(), status); + resignApplyService.updateResignApplyStatus(application.getId(), status); + + // 断言 + assertEquals(status, resignApplyMapper.selectById(application.getId()).getStatus()); + } + @Test + public void testUpdateResignApplyStatus_notExists() { + + // 调用,并断言异常 + assertServiceException(() -> resignApplyService.updateResignApplyStatus(1024L, 2), APPLY_NOT_EXISTS); + } + + @Test + public void testSubmitResignApply_synchronousApproval() { + // mock 数据 + Long id = resignApplyService.createResignApply(randomResignApplySaveReqVO()); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenAnswer(invocation -> { + BpmProcessInstanceCreateReqDTO reqDTO = invocation.getArgument(1); + resignApplyService.updateResignApplyStatus(Long.valueOf(reqDTO.getBusinessKey()), 2); + return "process-1"; + }); + + // 调用 + resignApplyService.submitResignApply(new OaResignApplySubmitReqVO().setId(id), 10L); + + // 断言 + OaResignApplyDO resignApply = resignApplyMapper.selectById(id); + assertEquals(2, resignApply.getStatus()); + assertEquals("process-1", resignApply.getProcessInstanceId()); + } + + // ========== 随机对象 ========== + + /** + * 构造申请参数,固定业务字段的合法取值。 + * + * @return 请求参数 + */ + private static OaResignApplySaveReqVO randomResignApplySaveReqVO() { + return randomPojo(OaResignApplySaveReqVO.class).setTitle("业务申请").setUrgency(1).setId(null).setHandoverUserId(20L) + .setHasPendingReimbursement(false); + } + + /** + * 构造测试数据,固定业务字段的合法取值。 + * + * @return 未入库的测试对象 + */ + private static OaResignApplyDO randomResignApplyDO() { + return randomPojo(OaResignApplyDO.class, application -> { + application.setId(null).setTitle("申请").setUrgency(1).setStatus(1).setProcessInstanceId("process-1"); + application.setCreator("10").setDeleted(false); + }); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/schedule/OaScheduleServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/schedule/OaScheduleServiceImplTest.java new file mode 100644 index 0000000000..a1c89e4fda --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/schedule/OaScheduleServiceImplTest.java @@ -0,0 +1,693 @@ +package cn.iocoder.yudao.module.oa.service.schedule; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.controller.admin.schedule.vo.OaSchedulePageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.schedule.vo.OaScheduleSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.schedule.OaScheduleDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.schedule.OaScheduleParticipantDO; +import cn.iocoder.yudao.module.oa.dal.mysql.schedule.OaScheduleMapper; +import cn.iocoder.yudao.module.oa.dal.mysql.schedule.OaScheduleParticipantMapper; +import cn.iocoder.yudao.module.oa.enums.schedule.OaPriorityEnum; +import cn.iocoder.yudao.module.oa.enums.schedule.OaScheduleTypeEnum; +import cn.iocoder.yudao.module.oa.framework.config.OaProperties; +import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi; +import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import javax.sql.DataSource; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.SCHEDULE_ACCESS_DENIED; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.SCHEDULE_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * {@link OaScheduleServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import({OaScheduleServiceImpl.class, OaProperties.class}) +public class OaScheduleServiceImplTest extends BaseDbUnitTest { + + @Resource + private DataSource dataSource; + + @Resource + private OaScheduleServiceImpl scheduleService; + + @Resource + private OaScheduleMapper scheduleMapper; + @Resource + private OaScheduleParticipantMapper scheduleParticipantMapper; + + @MockitoBean + private AdminUserApi adminUserApi; + @MockitoBean + private NotifyMessageSendApi notifyMessageSendApi; + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void testGetSchedulePage_overlapIncludesPreviousMonth() { + // mock 数据 + Long userId = randomLongId(); + OaScheduleDO crossing = buildScheduleDO(userId).setStartTime(LocalDateTime.of(2026, 8, 30, 9, 0)) + .setEndTime(LocalDateTime.of(2026, 9, 2, 18, 0)); + scheduleMapper.insert(crossing); + scheduleMapper.insert(buildScheduleDO(userId).setStartTime(LocalDateTime.of(2026, 8, 29, 9, 0)) + .setEndTime(LocalDateTime.of(2026, 8, 31, 23, 59))); + // 准备参数 + OaSchedulePageReqVO reqVO = new OaSchedulePageReqVO(); + reqVO.setIncludeMine(true); + reqVO.setIncludeReceived(false); + reqVO.setOverlapTime(new LocalDateTime[]{LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 30, 23, 59)}); + + // 调用 + PageResult result = scheduleService.getSchedulePage(reqVO, userId); + // 断言 + assertEquals(1L, result.getTotal()); + assertEquals(crossing.getId(), CollUtil.getFirst(result.getList()).getId()); + } + + @Test + public void testSchedulePages_separateOwnerAndParticipant() { + // mock 数据 + Long userId = randomLongId(); + OaScheduleDO mine = buildScheduleDO(userId).setTitle("我的日程"); + scheduleMapper.insert(mine); + OaScheduleDO received = buildScheduleDO(randomLongId()).setTitle("收到日程"); + scheduleMapper.insert(received); + OaScheduleDO other = buildScheduleDO(randomLongId()); + scheduleMapper.insert(other); + scheduleParticipantMapper.insert(new OaScheduleParticipantDO().setScheduleId(received.getId()).setUserId(userId)); + // 准备参数 + OaSchedulePageReqVO reqVO = new OaSchedulePageReqVO(); + + // 调用,并断言 + assertEquals(mine.getId(), CollUtil.getFirst(scheduleService.getMySchedulePage(reqVO, userId).getList()).getId()); + assertEquals(1L, scheduleService.getMySchedulePage(reqVO, userId).getTotal()); + assertEquals(received.getId(), CollUtil.getFirst(scheduleService.getReceivedSchedulePage(reqVO, userId).getList()).getId()); + reqVO.setTitle("我的日程"); + assertEquals(0L, scheduleService.getReceivedSchedulePage(reqVO, userId).getTotal()); + assertEquals(0L, scheduleService.getReceivedSchedulePage(new OaSchedulePageReqVO(), randomLongId()).getTotal()); + } + + @Test + public void testGetSchedulePage_bothScopesDeduplicatedAndPaged() { + // mock 数据:本人的日程同时存在参与关系,其他人的非共享日程不应返回 + Long userId = randomLongId(); + OaScheduleDO mine = buildScheduleDO(userId).setStartTime(LocalDateTime.of(2026, 9, 9, 9, 0)); + scheduleMapper.insert(mine); + OaScheduleDO received = buildScheduleDO(randomLongId()).setStartTime(LocalDateTime.of(2026, 9, 10, 9, 0)); + scheduleMapper.insert(received); + scheduleMapper.insert(buildScheduleDO(randomLongId())); + scheduleParticipantMapper.insert(new OaScheduleParticipantDO().setScheduleId(mine.getId()).setUserId(userId)); + scheduleParticipantMapper.insert(new OaScheduleParticipantDO().setScheduleId(received.getId()).setUserId(userId)); + + // 准备参数 + OaSchedulePageReqVO reqVO = new OaSchedulePageReqVO(); + reqVO.setPageSize(1); + reqVO.setIncludeMine(true); + reqVO.setIncludeReceived(true); + + // 调用,并断言:按同一排序分页,重叠记录不重复计数 + assertEquals(2L, scheduleService.getSchedulePage(reqVO, userId).getTotal()); + assertEquals(received.getId(), CollUtil.getFirst(scheduleService.getSchedulePage(reqVO, userId).getList()).getId()); + reqVO.setPageNo(2); + assertEquals(mine.getId(), CollUtil.getFirst(scheduleService.getSchedulePage(reqVO, userId).getList()).getId()); + + // 断言:搜索条件同时限制本人和共享日程 + reqVO.setTitle("不存在的日程"); + assertEquals(0L, scheduleService.getSchedulePage(reqVO, userId).getTotal()); + } + + @Test + public void testGetSchedulePage_singleScope() { + // mock 数据 + Long userId = randomLongId(); + OaScheduleDO mine = buildScheduleDO(userId); + scheduleMapper.insert(mine); + OaScheduleDO received = buildScheduleDO(randomLongId()); + scheduleMapper.insert(received); + scheduleParticipantMapper.insert(new OaScheduleParticipantDO().setScheduleId(received.getId()).setUserId(userId)); + + // 准备参数 + OaSchedulePageReqVO reqVO = new OaSchedulePageReqVO(); + reqVO.setIncludeMine(true); + reqVO.setIncludeReceived(false); + + // 调用,并断言:仅本人 + assertEquals(1L, scheduleService.getSchedulePage(reqVO, userId).getTotal()); + assertEquals(mine.getId(), CollUtil.getFirst(scheduleService.getSchedulePage(reqVO, userId).getList()).getId()); + + // 调用,并断言:仅共享 + reqVO.setIncludeMine(false); + reqVO.setIncludeReceived(true); + assertEquals(1L, scheduleService.getSchedulePage(reqVO, userId).getTotal()); + assertEquals(received.getId(), CollUtil.getFirst(scheduleService.getSchedulePage(reqVO, userId).getList()).getId()); + } + + @Test + public void testGetSchedulePage_noScope() { + // mock 数据 + Long userId = randomLongId(); + scheduleMapper.insert(buildScheduleDO(userId)); + + // 准备参数 + OaSchedulePageReqVO reqVO = new OaSchedulePageReqVO(); + reqVO.setIncludeMine(false); + reqVO.setIncludeReceived(false); + + // 调用,并断言 + assertEquals(0L, scheduleService.getSchedulePage(reqVO, userId).getTotal()); + assertTrue(CollUtil.isEmpty(scheduleService.getSchedulePage(reqVO, userId).getList())); + } + + @Test + public void testGetSchedulePage_withoutParticipation() { + // mock 数据 + Long userId = randomLongId(); + OaScheduleDO mine = buildScheduleDO(userId); + scheduleMapper.insert(mine); + scheduleMapper.insert(buildScheduleDO(randomLongId())); + + // 调用 + OaSchedulePageReqVO reqVO = new OaSchedulePageReqVO(); + reqVO.setIncludeMine(true); + reqVO.setIncludeReceived(true); + // 断言 + assertEquals(1L, scheduleService.getSchedulePage(reqVO, userId).getTotal()); + assertEquals(mine.getId(), CollUtil.getFirst(scheduleService.getSchedulePage(reqVO, userId).getList()).getId()); + reqVO.setIncludeMine(false); + assertEquals(0L, scheduleService.getSchedulePage(reqVO, userId).getTotal()); + } + + @Test + public void testCreateSchedule_success() { + // 准备参数 + Long userId = randomLongId(); + Long participantUserId = randomLongId(); + OaScheduleSaveReqVO reqVO = buildScheduleSaveReqVO() + .setParticipantUserIds(Collections.singletonList(participantUserId)); + + // 调用 + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(userId), new MockHttpServletRequest()); + Long scheduleId = scheduleService.createSchedule(reqVO, userId); + + // 断言 + OaScheduleDO schedule = scheduleMapper.selectById(scheduleId); + assertNotNull(schedule); + assertEquals(userId.toString(), schedule.getCreator()); + assertEquals(reqVO.getTitle(), schedule.getTitle()); + assertEquals(participantUserId, CollUtil.getFirst(scheduleParticipantMapper.selectListByScheduleIds( + Collections.singleton(scheduleId))).getUserId()); + } + + @Test + public void testGetSchedule_sharedParticipant() { + // mock 数据 + Long ownerUserId = randomLongId(); + Long participantUserId = randomLongId(); + OaScheduleDO schedule = buildScheduleDO(ownerUserId); + scheduleMapper.insert(schedule); + scheduleParticipantMapper.insert(new OaScheduleParticipantDO().setScheduleId(schedule.getId()) + .setUserId(participantUserId)); + + // 调用 + OaScheduleDO result = scheduleService.getSchedule(schedule.getId(), participantUserId); + + // 断言 + assertEquals(schedule.getId(), result.getId()); + } + + @Test + public void testUpdateSchedule_notOwner() { + // mock 数据 + OaScheduleDO schedule = buildScheduleDO(randomLongId()); + scheduleMapper.insert(schedule); + // 准备参数 + OaScheduleSaveReqVO reqVO = buildScheduleSaveReqVO().setId(schedule.getId()) + .setParticipantUserIds(Arrays.asList(randomLongId(), randomLongId())); + + // 调用,并断言异常 + assertServiceException(() -> scheduleService.updateSchedule(reqVO, randomLongId()), SCHEDULE_ACCESS_DENIED); + } + + @Test + public void testUpdateSchedule_repeatedParticipant() { + // mock 数据 + Long ownerUserId = randomLongId(); + Long participantUserId = randomLongId(); + OaScheduleDO schedule = buildScheduleDO(ownerUserId); + scheduleMapper.insert(schedule); + scheduleParticipantMapper.insert(new OaScheduleParticipantDO().setScheduleId(schedule.getId()) + .setUserId(participantUserId)); + // 准备参数 + OaScheduleSaveReqVO reqVO = buildScheduleSaveReqVO().setId(schedule.getId()) + .setParticipantUserIds(Collections.singletonList(participantUserId)); + + // 调用 + scheduleService.updateSchedule(reqVO, ownerUserId); + scheduleService.updateSchedule(reqVO, ownerUserId); + + // 断言 + assertEquals(1, scheduleParticipantMapper.selectListByScheduleIds( + Collections.singleton(schedule.getId())).size()); + } + + @Test + public void testUpdateSchedule_preserveReadStatusAndResetReminder() { + // mock 数据 + Long userId = randomLongId(); + OaScheduleDO schedule = buildScheduleDO(userId).setReminded(true); + scheduleMapper.insert(schedule); + LocalDateTime readTime = LocalDateTime.of(2026, 1, 1, 9, 0); + OaScheduleParticipantDO participant = new OaScheduleParticipantDO().setScheduleId(schedule.getId()).setUserId(2L) + .setReadStatus(true).setReadTime(readTime); + scheduleParticipantMapper.insert(participant); + // 准备参数 + OaScheduleSaveReqVO reqVO = buildScheduleSaveReqVO().setId(schedule.getId()) + .setParticipantUserIds(Arrays.asList(2L, 3L, 3L)); + + // 调用 + scheduleService.updateSchedule(reqVO, userId); + + // 断言:保留已有关系编号,新增去重,重置提醒但保留归属和阅读回执 + assertEquals(2, scheduleParticipantMapper.selectListByScheduleId(schedule.getId()).size()); + assertNotNull(scheduleParticipantMapper.selectById(participant.getId())); + assertFalse(scheduleMapper.selectById(schedule.getId()).getReminded()); + assertEquals(userId.toString(), scheduleMapper.selectById(schedule.getId()).getCreator()); + assertEquals(readTime, scheduleParticipantMapper.selectById(participant.getId()).getReadTime()); + assertTrue(scheduleParticipantMapper.selectById(participant.getId()).getReadStatus()); + OaScheduleParticipantDO added = CollUtil.findOne( + scheduleParticipantMapper.selectListByScheduleId(schedule.getId()), item -> item.getUserId().equals(3L)); + assertFalse(added.getReadStatus()); + assertNull(added.getReadTime()); + } + + @Test + public void testUpdateSchedule_removeAndReAddParticipants() { + // mock 数据 + Long userId = randomLongId(); + OaScheduleDO schedule = buildScheduleDO(userId); + scheduleMapper.insert(schedule); + OaScheduleSaveReqVO reqVO = buildScheduleSaveReqVO().setId(schedule.getId()); + + // 调用:反复取消并添加同一参与人,不能违反唯一索引 + for (int i = 0; i < 3; i++) { + scheduleService.updateSchedule(reqVO.setParticipantUserIds(Collections.singletonList(2L)), userId); + scheduleService.updateSchedule(reqVO.setParticipantUserIds(Collections.emptyList()), userId); + } + + // 断言:历史关系仍保留,但业务查询不再返回 + assertEquals(3L, new JdbcTemplate(dataSource).queryForObject("SELECT COUNT(*) FROM oa_schedule_participant WHERE deleted = TRUE", Long.class)); + assertTrue(CollUtil.isEmpty(scheduleParticipantMapper.selectListByScheduleId(schedule.getId()))); + } + + @Test + public void testSendScheduleReminder_allUsersAndRepeat() { + // mock 数据:创建人同时也是参与人 + LocalDateTime remindTime = LocalDateTime.now(); + Long ownerId = 1L; + Long participantId = 2L; + OaScheduleDO schedule = buildScheduleDO(ownerId).setRemind(true).setReminded(false) + .setStartTime(remindTime.plusHours(1)); + scheduleMapper.insert(schedule); + scheduleParticipantMapper.insert(new OaScheduleParticipantDO().setScheduleId(schedule.getId()).setUserId(ownerId)); + scheduleParticipantMapper.insert(new OaScheduleParticipantDO().setScheduleId(schedule.getId()).setUserId(participantId)); + // mock 方法 + when(notifyMessageSendApi.sendSingleMessageToAdmin(any())).thenReturn(10L); + + // 调用,并断言:创建人和参与人各发送一次,重复执行不再发送 + assertTrue(scheduleService.sendScheduleReminder(schedule.getId(), remindTime)); + assertTrue(scheduleMapper.selectById(schedule.getId()).getReminded()); + assertFalse(scheduleService.sendScheduleReminder(schedule.getId(), remindTime)); + verify(notifyMessageSendApi, times(1)).sendSingleMessageToAdmin(argThat(req -> ownerId.equals(req.getUserId()))); + verify(notifyMessageSendApi, times(1)).sendSingleMessageToAdmin(argThat(req -> participantId.equals(req.getUserId()))); + } + + @Test + public void testSendScheduleReminders_timeAndStatus() { + // mock 数据:仅第一条位于严格的 24 小时窗口内,且开启提醒、尚未提醒 + LocalDateTime remindTime = LocalDateTime.of(2026, 9, 9, 10, 0); + OaScheduleDO due = buildScheduleDO(1L).setRemind(true).setReminded(false).setStartTime(remindTime.plusHours(1)); + scheduleMapper.insert(due); + scheduleMapper.insert(buildScheduleDO(1L).setRemind(true).setReminded(false).setStartTime(remindTime)); + scheduleMapper.insert(buildScheduleDO(1L).setRemind(true).setReminded(false).setStartTime(remindTime.plusHours(24))); + scheduleMapper.insert(buildScheduleDO(1L).setRemind(true).setReminded(false).setStartTime(remindTime.minusMinutes(1))); + scheduleMapper.insert(buildScheduleDO(1L).setRemind(false).setReminded(false).setStartTime(remindTime.plusHours(1))); + scheduleMapper.insert(buildScheduleDO(1L).setRemind(true).setReminded(true).setStartTime(remindTime.plusHours(1))); + + // mock 方法 + when(notifyMessageSendApi.sendSingleMessageToAdmin(any())).thenReturn(10L); + try (MockedStatic timeMock = mockStatic(LocalDateTime.class, CALLS_REAL_METHODS)) { + timeMock.when(LocalDateTime::now).thenReturn(remindTime); + + // 调用,并断言:通过批量入口验证查询范围,只有符合条件的日程实际发送 + assertEquals(1, scheduleService.sendScheduleReminders()); + assertTrue(scheduleMapper.selectById(due.getId()).getReminded()); + verify(notifyMessageSendApi, times(1)).sendSingleMessageToAdmin(any()); + } + } + + @Test + public void testSendScheduleReminder_failureDoesNotMarkAndCanRetry() { + // mock 数据 + LocalDateTime remindTime = LocalDateTime.now(); + OaScheduleDO schedule = buildScheduleDO(1L).setRemind(true).setReminded(false) + .setStartTime(remindTime.plusHours(1)); + scheduleMapper.insert(schedule); + scheduleParticipantMapper.insert(new OaScheduleParticipantDO().setScheduleId(schedule.getId()).setUserId(2L)); + // mock 方法:参与人成功、创建人失败 + when(notifyMessageSendApi.sendSingleMessageToAdmin(any())) + .thenReturn(10L).thenThrow(new IllegalStateException("发送失败")).thenReturn(11L); + + // 调用,并断言:失败不能标记完成,重试整条日程 + assertThrows(IllegalStateException.class, + () -> scheduleService.sendScheduleReminder(schedule.getId(), remindTime)); + assertFalse(scheduleMapper.selectById(schedule.getId()).getReminded()); + assertTrue(scheduleService.sendScheduleReminder(schedule.getId(), remindTime)); + assertTrue(scheduleMapper.selectById(schedule.getId()).getReminded()); + verify(notifyMessageSendApi, times(4)).sendSingleMessageToAdmin(any()); + } + + @Test + public void testSendScheduleReminder_disabledDoesNotSend() { + // mock 数据 + LocalDateTime remindTime = LocalDateTime.now(); + OaScheduleDO schedule = buildScheduleDO(1L).setRemind(false).setReminded(false) + .setStartTime(remindTime.plusHours(1)); + scheduleMapper.insert(schedule); + + // 调用,并断言 + assertFalse(scheduleService.sendScheduleReminder(schedule.getId(), remindTime)); + verifyNoInteractions(notifyMessageSendApi); + } + + @Test + public void testSendScheduleReminder_doesNotCheckMessageId() { + // mock 数据 + LocalDateTime remindTime = LocalDateTime.now(); + OaScheduleDO schedule = buildScheduleDO(1L).setRemind(true).setReminded(false) + .setStartTime(remindTime.plusHours(1)); + scheduleMapper.insert(schedule); + // mock 方法 + when(notifyMessageSendApi.sendSingleMessageToAdmin(any())).thenReturn(null); + + // 调用,并断言 + assertTrue(scheduleService.sendScheduleReminder(schedule.getId(), remindTime)); + assertTrue(scheduleMapper.selectById(schedule.getId()).getReminded()); + } + + @Test + public void testSendScheduleReminder_creatorOnly() { + // mock 数据 + LocalDateTime remindTime = LocalDateTime.now(); + OaScheduleDO schedule = buildScheduleDO(1L).setRemind(true).setReminded(false) + .setStartTime(remindTime.plusHours(1)); + scheduleMapper.insert(schedule); + // mock 方法 + when(notifyMessageSendApi.sendSingleMessageToAdmin(any())).thenReturn(10L); + + // 调用,并断言:没有参与人也提醒创建人 + assertTrue(scheduleService.sendScheduleReminder(schedule.getId(), remindTime)); + verify(notifyMessageSendApi).sendSingleMessageToAdmin(argThat(req -> Long.valueOf(1L).equals(req.getUserId()))); + } + + @Test + public void testSendScheduleReminders_failureDoesNotBlockOthers() { + // mock 数据:较早日程失败,后续日程仍需发送 + LocalDateTime remindTime = LocalDateTime.now(); + OaScheduleDO failed = buildScheduleDO(1L).setRemind(true).setReminded(false) + .setStartTime(remindTime.plusHours(1)); + OaScheduleDO success = buildScheduleDO(2L).setRemind(true).setReminded(false) + .setStartTime(remindTime.plusHours(2)); + scheduleMapper.insert(failed); + scheduleMapper.insert(success); + // mock 方法:验证批量内部调用也在事务内 + when(notifyMessageSendApi.sendSingleMessageToAdmin(any())).thenAnswer(invocation -> { + assertTrue(TransactionSynchronizationManager.isActualTransactionActive()); + NotifySendSingleToUserReqDTO req = invocation.getArgument(0); + if (Long.valueOf(1L).equals(req.getUserId())) { + throw new IllegalStateException("发送失败"); + } + return 10L; + }); + + // 调用 + int count = scheduleService.sendScheduleReminders(); + + // 断言:统计和异常隔离均由 Service 负责 + assertEquals(1, count); + assertFalse(scheduleMapper.selectById(failed.getId()).getReminded()); + assertTrue(scheduleMapper.selectById(success.getId()).getReminded()); + verify(notifyMessageSendApi, times(2)).sendSingleMessageToAdmin(any()); + } + + @Test + public void testSendScheduleReminders_empty() { + + // 调用,并断言 + assertEquals(0, scheduleService.sendScheduleReminders()); + verifyNoInteractions(notifyMessageSendApi); + } + + @Test + public void testUpdateScheduleReadStatus_success() { + // mock 数据 + OaScheduleDO schedule = buildScheduleDO(randomLongId()); + scheduleMapper.insert(schedule); + OaScheduleParticipantDO participant = new OaScheduleParticipantDO().setScheduleId(schedule.getId()) + .setUserId(2L).setReadStatus(false); + scheduleParticipantMapper.insert(participant); + OaScheduleParticipantDO other = new OaScheduleParticipantDO().setScheduleId(schedule.getId()) + .setUserId(3L).setReadStatus(false); + scheduleParticipantMapper.insert(other); + + // 调用 + scheduleService.updateScheduleReadStatus(schedule.getId(), 2L); + + // 断言:只更新本人参与关系 + OaScheduleParticipantDO result = scheduleParticipantMapper.selectById(participant.getId()); + assertTrue(result.getReadStatus()); + assertNotNull(result.getReadTime()); + assertFalse(scheduleParticipantMapper.selectById(other.getId()).getReadStatus()); + assertNull(scheduleParticipantMapper.selectById(other.getId()).getReadTime()); + } + + @Test + public void testUpdateScheduleReadStatus_preserveFirstReadTime() { + // mock 数据 + OaScheduleDO schedule = buildScheduleDO(randomLongId()); + scheduleMapper.insert(schedule); + LocalDateTime readTime = LocalDateTime.of(2026, 1, 1, 9, 0); + OaScheduleParticipantDO participant = new OaScheduleParticipantDO().setScheduleId(schedule.getId()) + .setUserId(2L).setReadStatus(true).setReadTime(readTime); + scheduleParticipantMapper.insert(participant); + + // 调用 + scheduleService.updateScheduleReadStatus(schedule.getId(), 2L); + scheduleService.updateScheduleReadStatus(schedule.getId(), 2L); + + // 断言:不覆盖首次时间 + assertEquals(readTime, scheduleParticipantMapper.selectById(participant.getId()).getReadTime()); + } + + @Test + public void testGetScheduleAndPages_doNotMarkRead() { + // mock 数据 + OaScheduleDO schedule = buildScheduleDO(randomLongId()); + scheduleMapper.insert(schedule); + OaScheduleParticipantDO participant = new OaScheduleParticipantDO().setScheduleId(schedule.getId()) + .setUserId(2L).setReadStatus(false); + scheduleParticipantMapper.insert(participant); + + // 调用 + scheduleService.getSchedule(schedule.getId(), 2L); + scheduleService.getScheduleParticipantList(schedule.getId()); + scheduleService.getReceivedSchedulePage(new OaSchedulePageReqVO(), 2L); + scheduleService.getMySchedulePage(new OaSchedulePageReqVO(), 2L); + + // 断言 + OaScheduleParticipantDO result = scheduleParticipantMapper.selectById(participant.getId()); + assertFalse(result.getReadStatus()); + assertNull(result.getReadTime()); + } + + @Test + public void testUpdateScheduleReadStatus_notParticipant() { + // mock 数据 + OaScheduleDO schedule = buildScheduleDO(randomLongId()); + scheduleMapper.insert(schedule); + + // 调用,并断言异常 + assertServiceException(() -> scheduleService.updateScheduleReadStatus(schedule.getId(), 2L), + SCHEDULE_ACCESS_DENIED); + assertServiceException(() -> scheduleService.getSchedule(schedule.getId(), 2L), + SCHEDULE_ACCESS_DENIED); + } + + @Test + public void testUpdateScheduleReadStatus_ownerWithoutParticipation() { + // mock 数据 + Long userId = randomLongId(); + OaScheduleDO schedule = buildScheduleDO(userId); + scheduleMapper.insert(schedule); + + // 调用 + scheduleService.updateScheduleReadStatus(schedule.getId(), userId); + + // 断言:不为创建人自动新增参与关系 + assertTrue(CollUtil.isEmpty(scheduleParticipantMapper.selectListByScheduleId(schedule.getId()))); + } + + @Test + public void testUpdateScheduleReadStatus_removedParticipant() { + // mock 数据 + OaScheduleDO schedule = buildScheduleDO(randomLongId()); + scheduleMapper.insert(schedule); + OaScheduleParticipantDO participant = new OaScheduleParticipantDO().setScheduleId(schedule.getId()) + .setUserId(2L).setReadStatus(false); + scheduleParticipantMapper.insert(participant); + scheduleParticipantMapper.deleteById(participant.getId()); + + // 调用,并断言异常 + assertServiceException(() -> scheduleService.updateScheduleReadStatus(schedule.getId(), 2L), + SCHEDULE_ACCESS_DENIED); + } + + @Test + public void testUpdateScheduleReadStatus_notExists() { + // 准备参数 + Long id = randomLongId(); + + // 调用,并断言异常 + assertServiceException(() -> scheduleService.updateScheduleReadStatus(id, 2L), SCHEDULE_NOT_EXISTS); + } + + @Test + public void testUpdateScheduleReadStatus_deletedSchedule() { + // mock 数据 + OaScheduleDO schedule = buildScheduleDO(randomLongId()); + scheduleMapper.insert(schedule); + OaScheduleParticipantDO participant = new OaScheduleParticipantDO().setScheduleId(schedule.getId()) + .setUserId(2L).setReadStatus(false); + scheduleParticipantMapper.insert(participant); + scheduleMapper.deleteById(schedule.getId()); + + // 调用,并断言异常 + assertServiceException(() -> scheduleService.updateScheduleReadStatus(schedule.getId(), 2L), SCHEDULE_NOT_EXISTS); + assertFalse(scheduleParticipantMapper.selectById(participant.getId()).getReadStatus()); + } + + @Test + public void testUpdateSchedule_reAddedParticipantUnread() { + // mock 数据 + Long userId = randomLongId(); + OaScheduleDO schedule = buildScheduleDO(userId); + scheduleMapper.insert(schedule); + OaScheduleParticipantDO participant = new OaScheduleParticipantDO().setScheduleId(schedule.getId()) + .setUserId(2L).setReadStatus(true).setReadTime(LocalDateTime.of(2026, 1, 1, 9, 0)); + scheduleParticipantMapper.insert(participant); + // 准备参数 + OaScheduleSaveReqVO reqVO = buildScheduleSaveReqVO().setId(schedule.getId()); + + // 调用:先移除,再重新邀请同一个用户 + scheduleService.updateSchedule(reqVO.setParticipantUserIds(Collections.emptyList()), userId); + scheduleService.updateSchedule(reqVO.setParticipantUserIds(Collections.singletonList(2L)), userId); + + // 断言:历史阅读不沿用到新参与关系 + OaScheduleParticipantDO result = CollUtil.getFirst(scheduleParticipantMapper.selectListByScheduleId(schedule.getId())); + assertFalse(result.getReadStatus()); + assertNull(result.getReadTime()); + } + + @Test + public void testGetScheduleParticipantUserIdListMap_success() { + // mock 数据 + scheduleParticipantMapper.insert(new OaScheduleParticipantDO().setScheduleId(10L).setUserId(1L).setReadStatus(false)); + scheduleParticipantMapper.insert(new OaScheduleParticipantDO().setScheduleId(10L).setUserId(2L).setReadStatus(false)); + scheduleParticipantMapper.insert(new OaScheduleParticipantDO().setScheduleId(20L).setUserId(3L).setReadStatus(false)); + + // 调用 + Map> participants = scheduleService.getScheduleParticipantUserIdListMap(Collections.singletonList(10L)); + + // 断言 + assertEquals(1, participants.size()); + assertEquals(2, participants.get(10L).size()); + assertTrue(participants.get(10L).containsAll(Arrays.asList(1L, 2L))); + } + + @Test + public void testGetScheduleParticipantUserIdListMap_empty() { + + // 调用,并断言 + assertTrue(scheduleService.getScheduleParticipantUserIdListMap(Collections.emptyList()).isEmpty()); + } + + // ========== 随机对象 ========== + + /** + * 构造开始时间晚于当前时间且开启提醒的日程参数。 + * + * @return 保存参数 + */ + private static OaScheduleSaveReqVO buildScheduleSaveReqVO() { + LocalDateTime startTime = LocalDateTime.now().plusHours(1); + return new OaScheduleSaveReqVO().setType(OaScheduleTypeEnum.REMINDER.getType()) + .setPriority(OaPriorityEnum.NORMAL.getPriority()).setTitle("产品周会").setDescription("同步项目进度") + .setStartTime(startTime).setEndTime(startTime.plusHours(1)).setRemind(true); + } + + /** + * 构造开始时间晚于当前时间且关闭提醒的日程。 + * + * @param ownerUserId 创建人编号 + * @return 未入库的测试对象 + */ + private static OaScheduleDO buildScheduleDO(Long ownerUserId) { + LocalDateTime startTime = LocalDateTime.now().plusHours(1); + OaScheduleDO schedule = new OaScheduleDO().setType(OaScheduleTypeEnum.REMINDER.getType()) + .setPriority(OaPriorityEnum.NORMAL.getPriority()).setTitle("共享日程") + .setStartTime(startTime).setEndTime(startTime.plusHours(1)).setRemind(false); + schedule.setCreator(ownerUserId.toString()); + return schedule; + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/seal/OaSealApplyServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/seal/OaSealApplyServiceImplTest.java new file mode 100644 index 0000000000..926930e652 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/seal/OaSealApplyServiceImplTest.java @@ -0,0 +1,638 @@ +package cn.iocoder.yudao.module.oa.service.seal; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; +import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi; +import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO; +import cn.iocoder.yudao.module.oa.controller.admin.seal.vo.apply.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.seal.*; +import cn.iocoder.yudao.module.oa.dal.mysql.seal.OaSealApplyMapper; +import cn.iocoder.yudao.module.oa.dal.redis.no.OaNoRedisDAO; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import cn.iocoder.yudao.module.oa.service.seal.listener.OaSealApplyStatusListener; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; + +import static cn.iocoder.yudao.framework.common.util.object.BeanUtils.toBean; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaSealApplyServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import({OaSealApplyServiceImpl.class, OaSealApplyStatusListener.class}) +public class OaSealApplyServiceImplTest extends BaseDbUnitTest { + + @Resource + private ApplicationEventPublisher eventPublisher; + + @Resource + private OaSealApplyService sealApplyService; + + @Resource + private OaSealApplyMapper sealApplyMapper; + + @MockitoBean + private OaSealService sealService; + @MockitoBean + private AdminUserApi adminUserApi; + @MockitoBean + private BpmProcessInstanceApi processInstanceApi; + @MockitoBean + private OaNoRedisDAO noRedisDAO; + + @Test + public void testCreateSealApply() { + // 准备参数 + OaSealApplySaveReqVO reqVO = toBean(randomSealApplyDO(), OaSealApplySaveReqVO.class) + .setId(999L).setActualReturnTime(LocalDateTime.now().minusHours(1).withNano(0)) + .setFileUrls(Arrays.asList("https://example.com/a.pdf", "https://example.com/b.pdf")); + // mock 方法 + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + when(adminUserApi.getUser(10L)).thenReturn(new AdminUserRespDTO().setId(10L).setDeptId(20L)); + when(noRedisDAO.generate(OaNoRedisDAO.SEAL_APPLY_NO_PREFIX)).thenReturn("YY20260912000001"); + + // 调用 + Long id = sealApplyService.createSealApply(reqVO, 10L); + // 断言 + OaSealApplyDO apply = sealApplyMapper.selectById(id); + assertNotEquals(999L, id); + assertEquals("YY20260912000001", apply.getNo()); + verify(noRedisDAO).generate(OaNoRedisDAO.SEAL_APPLY_NO_PREFIX); + assertEquals(10L, apply.getUserId()); + assertEquals(20L, apply.getDeptId()); + assertEquals(40L, apply.getKeeperUserId()); + assertEquals(1, apply.getSealType()); + assertEquals(20L, apply.getKeeperDeptId()); + assertEquals(-1, apply.getStatus()); + assertEquals(0, apply.getUseStatus()); + assertNull(apply.getProcessInstanceId()); + assertNull(apply.getActualUseTime()); + assertEquals(reqVO.getActualReturnTime(), apply.getActualReturnTime()); + assertEquals(reqVO.getFileUrls(), apply.getFileUrls()); + } + + @Test + public void testUpdateSealApply_clearFiles() { + // mock 数据 + OaSealApplyDO apply = randomSealApplyDO().setFileUrls(Arrays.asList("https://example.com/a.pdf")); + sealApplyMapper.insert(apply); + // 准备参数 + OaSealApplySaveReqVO reqVO = toBean(apply, OaSealApplySaveReqVO.class).setFileUrls(Collections.emptyList()) + .setActualReturnTime(LocalDateTime.now().minusMinutes(10).withNano(0)); + // mock 方法 + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + + // 调用 + sealApplyService.updateSealApply(reqVO, 10L); + // 断言 + assertEquals(Collections.emptyList(), sealApplyMapper.selectById(apply.getId()).getFileUrls()); + assertEquals(reqVO.getActualReturnTime(), sealApplyMapper.selectById(apply.getId()).getActualReturnTime()); + assertEquals(0, sealApplyMapper.selectById(apply.getId()).getUseStatus()); + } + + @Test + public void testCreateSealApply_contractOptional() { + // 准备参数 + OaSealApplySaveReqVO reqVO = toBean(randomSealApplyDO(), OaSealApplySaveReqVO.class) + .setContractPrice(null).setContractParty(null).setDocumentTitle(null); + // mock 方法 + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + when(adminUserApi.getUser(10L)).thenReturn(new AdminUserRespDTO().setId(10L).setDeptId(20L)); + when(noRedisDAO.generate(OaNoRedisDAO.SEAL_APPLY_NO_PREFIX)).thenReturn("YY20260912000001"); + + // 调用 + Long id = sealApplyService.createSealApply(reqVO, 10L); + // 断言 + assertNull(sealApplyMapper.selectById(id).getContractPrice()); + assertNull(sealApplyMapper.selectById(id).getContractParty()); + } + + @Test + public void testCreateSealApply_borrowReturnOptional() { + // 准备参数 + OaSealApplySaveReqVO reqVO = toBean(randomSealApplyDO(), OaSealApplySaveReqVO.class).setExpectedReturnTime(null); + // mock 方法 + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + when(adminUserApi.getUser(10L)).thenReturn(new AdminUserRespDTO().setId(10L).setDeptId(20L)); + when(noRedisDAO.generate(OaNoRedisDAO.SEAL_APPLY_NO_PREFIX)).thenReturn("YY20260912000001"); + + // 调用 + Long id = sealApplyService.createSealApply(reqVO, 10L); + // 断言 + assertNull(sealApplyMapper.selectById(id).getExpectedReturnTime()); + } + + @Test + public void testCreateSealApply_disabled() { + // 准备参数 + OaSealApplySaveReqVO reqVO = toBean(randomSealApplyDO(), OaSealApplySaveReqVO.class); + // mock 方法 + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO().setStatus(1)); + when(adminUserApi.getUser(10L)).thenReturn(new AdminUserRespDTO().setId(10L).setDeptId(20L)); + when(noRedisDAO.generate(OaNoRedisDAO.SEAL_APPLY_NO_PREFIX)).thenReturn("YY20260912000001"); + + // 调用,并断言:台账状态不额外限制申请 + assertNotNull(sealApplyService.createSealApply(reqVO, 10L)); + } + + @Test + public void testSubmitSealApply_autoApprove() { + // mock 数据 + OaSealApplyDO apply = randomSealApplyDO().setProcessInstanceId(null); + sealApplyMapper.insert(apply); + // mock 方法 + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenAnswer(invocation -> { + // 模拟流程创建返回前同步发布审批通过事件 + eventPublisher.publishEvent(new BpmProcessInstanceStatusEvent(this).setId("seal-auto-process") + .setProcessDefinitionKey(BpmModelConstants.SEAL_APPLY) + .setBusinessKey(apply.getId().toString()).setStatus(2)); + return "seal-auto-process"; + }); + + // 调用 + String processInstanceId = sealApplyService.submitSealApply(apply.getId(), 10L); + // 断言 + OaSealApplyDO updated = sealApplyMapper.selectById(apply.getId()); + assertEquals("seal-auto-process", processInstanceId); + assertEquals(processInstanceId, updated.getProcessInstanceId()); + assertEquals(2, updated.getStatus()); + } + + @Test + public void testSubmitSealApply_repeated() { + // mock 数据 + OaSealApplyDO apply = randomSealApplyDO(); + sealApplyMapper.insert(apply); + // mock 方法 + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenAnswer(invocation -> { + BpmProcessInstanceCreateReqDTO reqDTO = invocation.getArgument(1); + // BPM 会追加流程内置变量,调用方必须提供可写的变量集合 + reqDTO.getVariables().put("PROCESS_START_USER_ID", 10L); + return "seal-process"; + }); + + // 调用 + assertEquals("seal-process", sealApplyService.submitSealApply(apply.getId(), 10L)); + assertServiceException(() -> sealApplyService.submitSealApply(apply.getId(), 10L), SEAL_APPLY_STATUS_INVALID); + // 断言 + verify(processInstanceApi, times(1)).createProcessInstance(eq(10L), any()); + assertEquals(1, sealApplyMapper.selectById(apply.getId()).getStatus()); + } + + @Test + public void testSubmitSealApply_approvedPendingConflict() { + // mock 数据:已有申请审批通过,但不会自动流转用印状态 + OaSealApplyDO other = randomSealApplyDO().setStatus(1); + OaSealApplyDO apply = randomSealApplyDO(); + sealApplyMapper.insert(other); + sealApplyMapper.insert(apply); + sealApplyService.updateSealApplyStatus(other.getId(), 2); + // mock 方法 + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + + // 调用,并断言:审批通过后仍占用预计外借时段 + assertServiceException(() -> sealApplyService.submitSealApply(apply.getId(), 10L), SEAL_APPLY_TIME_CONFLICT); + assertEquals(0, sealApplyMapper.selectById(other.getId()).getUseStatus()); + assertEquals(-1, sealApplyMapper.selectById(apply.getId()).getStatus()); + verifyNoInteractions(processInstanceApi); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, 2, 3, 4}) + public void testSubmitSealApply_approvedConflictIndependentOfUseStatus(int useStatus) { + // mock 数据:审批通过后的预计时段不依赖历史用印状态 + sealApplyMapper.insert(randomSealApplyDO().setStatus(2).setUseStatus(useStatus)); + OaSealApplyDO apply = randomSealApplyDO(); + sealApplyMapper.insert(apply); + // mock 方法 + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + + // 调用,并断言:即使历史记录为已归还,也按预计结束时间释放预约 + assertServiceException(() -> sealApplyService.submitSealApply(apply.getId(), 10L), SEAL_APPLY_TIME_CONFLICT); + verifyNoInteractions(processInstanceApi); + } + + @ParameterizedTest + @ValueSource(ints = {-1, 3, 4}) + public void testSubmitSealApply_inactiveApplicationDoesNotOccupy(int status) { + // mock 数据:草稿、驳回及撤销的外借申请不占用预约 + sealApplyMapper.insert(randomSealApplyDO().setStatus(status).setUseStatus(2)); + OaSealApplyDO apply = randomSealApplyDO(); + sealApplyMapper.insert(apply); + // mock 方法 + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("no-conflict"); + + // 调用,并断言 + assertEquals("no-conflict", sealApplyService.submitSealApply(apply.getId(), 10L)); + } + + @Test + public void testSubmitSealApply_conflict() { + // mock 数据 + OaSealApplyDO other = randomSealApplyDO().setStatus(1); + OaSealApplyDO apply = randomSealApplyDO(); + sealApplyMapper.insert(other); + sealApplyMapper.insert(apply); + // mock 方法 + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + + // 调用及断言 + assertServiceException(() -> sealApplyService.submitSealApply(apply.getId(), 10L), SEAL_APPLY_TIME_CONFLICT); + verifyNoInteractions(processInstanceApi); + assertEquals(-1, sealApplyMapper.selectById(apply.getId()).getStatus()); + } + + @Test + public void testSubmitSealApply_approvedPendingBoundaryConflict() { + // mock 数据:预计归还时刻与新申请用印时刻相同,闭区间仍重叠 + OaSealApplyDO other = randomSealApplyDO().setStatus(2); + OaSealApplyDO apply = randomSealApplyDO().setExpectedUseTime(other.getExpectedReturnTime()) + .setExpectedReturnTime(other.getExpectedReturnTime().plusHours(1)); + sealApplyMapper.insert(other); + sealApplyMapper.insert(apply); + // mock 方法 + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + + // 调用,并断言异常 + assertServiceException(() -> sealApplyService.submitSealApply(apply.getId(), 10L), SEAL_APPLY_TIME_CONFLICT); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testSubmitSealApply_bpmFailureRollsBack() { + // mock 数据 + OaSealApplyDO apply = randomSealApplyDO(); + sealApplyMapper.insert(apply); + // mock 方法 + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenThrow(new IllegalStateException("test")); + + // 调用及断言 + assertThrows(IllegalStateException.class, () -> sealApplyService.submitSealApply(apply.getId(), 10L)); + assertEquals(-1, sealApplyMapper.selectById(apply.getId()).getStatus()); + assertNull(sealApplyMapper.selectById(apply.getId()).getProcessInstanceId()); + } + + @Test + public void testUpdateSealApplyStatus_approveNotUsed() { + // mock 数据 + OaSealApplyDO apply = randomSealApplyDO().setStatus(1).setProcessInstanceId("p1"); + sealApplyMapper.insert(apply); + // mock 方法 + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + + // 调用 + sealApplyService.updateSealApplyStatus(apply.getId(), 2); + sealApplyService.updateSealApplyStatus(apply.getId(), 2); + // 断言 + OaSealApplyDO result = sealApplyMapper.selectById(apply.getId()); + assertEquals(2, result.getStatus()); + assertEquals(0, result.getUseStatus()); + assertNull(result.getActualUseTime()); + } + + @Test + public void testUpdateSealApplyStatus_notExists() { + + // 调用,并断言异常 + assertServiceException(() -> sealApplyService.updateSealApplyStatus(-1L, 2), SEAL_APPLY_NOT_EXISTS); + } + + @Test + public void testGetSealApply() { + // mock 数据 + OaSealApplyDO apply = randomSealApplyDO().setStatus(1).setProcessInstanceId("p1"); + sealApplyMapper.insert(apply); + + // 调用及断言 + assertEquals(apply.getId(), sealApplyService.getSealApply(apply.getId()).getId()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testGetSealApply_notExists() { + + // 调用及断言:与 OA leave 一致,不存在时返回空 + assertNull(sealApplyService.getSealApply(999L)); + } + + @Test + public void testDeleteSealApply_ownerOnly() { + // mock 数据 + OaSealApplyDO apply = randomSealApplyDO(); + sealApplyMapper.insert(apply); + + // 调用及断言 + assertServiceException(() -> sealApplyService.deleteSealApply(apply.getId(), 40L), SEAL_APPLY_NOT_OWNER); + sealApplyService.deleteSealApply(apply.getId(), 10L); + assertNull(sealApplyMapper.selectById(apply.getId())); + } + + @Test + public void testGetSealApplyPage_userScope() { + // mock 数据 + OaSealApplyDO mine = randomSealApplyDO(); + OaSealApplyDO other = randomSealApplyDO().setUserId(99L).setKeeperUserId(10L); + sealApplyMapper.insert(mine); + sealApplyMapper.insert(other); + // 准备参数 + OaSealApplyPageReqVO reqVO = new OaSealApplyPageReqVO(); + + // 调用 + PageResult pageResult = sealApplyService.getSealApplyPage(10L, reqVO); + // 断言:仅返回本人申请,即使其他申请由本人保管也不包含在列表中 + assertEquals(1L, pageResult.getTotal()); + assertEquals(mine.getId(), CollUtil.getFirst(pageResult.getList()).getId()); + } + + @Test + public void testUpdateSealApply_keepNullFields() { + // mock 数据 + OaSealApplyDO apply = randomSealApplyDO().setType(1).setMode(2) + .setExpectedReturnTime(LocalDateTime.now().plusDays(3).withNano(0)); + sealApplyMapper.insert(apply); + // 准备参数 + OaSealApplySaveReqVO reqVO = toBean(apply, OaSealApplySaveReqVO.class).setType(2).setMode(1); + // mock 方法 + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + + // 调用 + sealApplyService.updateSealApply(reqVO, 10L); + // 断言 + OaSealApplyDO updated = sealApplyMapper.selectById(apply.getId()); + assertEquals(0, apply.getContractPrice().compareTo(updated.getContractPrice())); + assertEquals(apply.getContractParty(), updated.getContractParty()); + assertEquals(apply.getExpectedReturnTime(), updated.getExpectedReturnTime()); + } + + @Test + public void testGetSealApplyPage_overdueUseStatus() { + // mock 数据:已标逾期、外借超时、尚未超时各一条 + sealApplyMapper.insert(randomSealApplyDO().setStatus(2).setUseStatus(4)); + sealApplyMapper.insert(randomSealApplyDO().setStatus(2).setUseStatus(2) + .setExpectedReturnTime(LocalDateTime.now().minusHours(1))); + sealApplyMapper.insert(randomSealApplyDO().setStatus(2).setUseStatus(2)); + + // 调用,并断言:按已保存状态筛选,不自动推导逾期 + assertEquals(1L, sealApplyService.getSealApplyPage(10L, + new OaSealApplyPageReqVO().setUseStatus(4)).getTotal()); + assertEquals(2L, sealApplyService.getSealApplyPage(10L, + new OaSealApplyPageReqVO().setUseStatus(2)).getTotal()); + } + + @Test + public void testGetSealApplyPage_borrowedUseStatus() { + // mock 数据 + sealApplyMapper.insert(randomSealApplyDO().setUseStatus(4)); + sealApplyMapper.insert(randomSealApplyDO().setUseStatus(2)); + // 准备参数 + OaSealApplyPageReqVO reqVO = new OaSealApplyPageReqVO().setUseStatus(2); + + // 调用及断言:只返回本人的外借状态申请,不包含逾期状态 + assertEquals(1L, sealApplyService.getSealApplyPage(10L, reqVO).getTotal()); + } + + @Test + public void testGetSealApplyPage_useStatusNotSelected() { + // mock 数据 + sealApplyMapper.insert(randomSealApplyDO().setUseStatus(4)); + sealApplyMapper.insert(randomSealApplyDO().setUseStatus(2)); + // 准备参数 + OaSealApplyPageReqVO reqVO = new OaSealApplyPageReqVO(); + + // 调用及断言:未选择或清空用印状态时不限制状态 + assertEquals(2L, sealApplyService.getSealApplyPage(10L, reqVO).getTotal()); + } + + @Test + public void testSubmitSealApply_overdueDoesNotExtendExpectedPeriod() { + // mock 数据:历史逾期状态不把已经结束的预计时段无限延长 + sealApplyMapper.insert(randomSealApplyDO().setStatus(2).setUseStatus(4) + .setExpectedUseTime(LocalDateTime.now().minusDays(2)) + .setExpectedReturnTime(LocalDateTime.now().minusDays(1))); + OaSealApplyDO apply = randomSealApplyDO(); + sealApplyMapper.insert(apply); + // mock 方法 + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("p1"); + + // 调用,并断言:不增加逾期状态占用规则 + assertEquals("p1", sealApplyService.submitSealApply(apply.getId(), 10L)); + } + + @Test + public void testSubmitSealApply_onsiteDoesNotOccupy() { + // mock 数据 + OaSealApplyDO other = randomSealApplyDO().setStatus(1); + OaSealApplyDO apply = randomSealApplyDO().setMode(1); + sealApplyMapper.insert(other); + sealApplyMapper.insert(apply); + // mock 方法 + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("onsite"); + + // 调用,并断言 + assertEquals("onsite", sealApplyService.submitSealApply(apply.getId(), 10L)); + } + + @Test + public void testSubmitSealApply_pastTime() { + // mock 数据 + OaSealApplyDO apply = randomSealApplyDO().setExpectedUseTime(LocalDateTime.now().minusDays(2)) + .setExpectedReturnTime(LocalDateTime.now().minusDays(1)); + sealApplyMapper.insert(apply); + // mock 方法 + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("past"); + + // 调用,并断言 + assertEquals("past", sealApplyService.submitSealApply(apply.getId(), 10L)); + } + + @Test + public void testSubmitSealApply_borrowedBoundaryConflict() { + // mock 数据:已借出的归还时刻与新申请用印时刻相同,闭区间仍重叠 + OaSealApplyDO other = randomSealApplyDO().setStatus(2).setUseStatus(2); + OaSealApplyDO apply = randomSealApplyDO().setExpectedUseTime(other.getExpectedReturnTime()) + .setExpectedReturnTime(other.getExpectedReturnTime().plusHours(1)); + sealApplyMapper.insert(other); + sealApplyMapper.insert(apply); + // mock 方法 + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + + // 调用,并断言异常 + assertServiceException(() -> sealApplyService.submitSealApply(apply.getId(), 10L), SEAL_APPLY_TIME_CONFLICT); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testSubmitSealApply_approvedPendingOutsidePeriod() { + // mock 数据:审批通过只占用预计时段,结束之后的新预约正常提交 + OaSealApplyDO other = randomSealApplyDO().setStatus(2); + OaSealApplyDO apply = randomSealApplyDO().setExpectedUseTime(other.getExpectedReturnTime().plusHours(1)) + .setExpectedReturnTime(other.getExpectedReturnTime().plusHours(2)); + sealApplyMapper.insert(other); + sealApplyMapper.insert(apply); + // mock 方法 + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("outside"); + + // 调用,并断言 + assertEquals("outside", sealApplyService.submitSealApply(apply.getId(), 10L)); + } + + @Test + public void testUpdateSealApplyStatus_noConflictRecheckOrSealLookup() { + // mock 数据:审批回调仅更新原申请,不重新检查台账及预约 + OaSealApplyDO apply = randomSealApplyDO().setStatus(1).setProcessInstanceId("p1"); + sealApplyMapper.insert(apply); + sealApplyMapper.insert(randomSealApplyDO().setStatus(1)); + + // 调用 + sealApplyService.updateSealApplyStatus(apply.getId(), 2); + // 断言 + assertEquals(2, sealApplyMapper.selectById(apply.getId()).getStatus()); + verifyNoInteractions(sealService); + } + + @Test + public void testCanceledSealApply_stillReadOnly() { + // mock 数据:撤回后不恢复草稿,保持 OA 请假的流程风格 + OaSealApplyDO apply = randomSealApplyDO().setStatus(4); + sealApplyMapper.insert(apply); + // mock 方法 + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + + // 调用,并断言异常 + assertServiceException(() -> sealApplyService.updateSealApply(toBean(apply, OaSealApplySaveReqVO.class), 10L), + SEAL_APPLY_STATUS_INVALID); + assertServiceException(() -> sealApplyService.submitSealApply(apply.getId(), 10L), SEAL_APPLY_STATUS_INVALID); + assertServiceException(() -> sealApplyService.deleteSealApply(apply.getId(), 10L), SEAL_APPLY_STATUS_INVALID); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2, 3, 4, 5}) + public void testCreateSealApply_fiveTypes(int type) { + // 准备参数 + OaSealApplySaveReqVO reqVO = toBean(randomSealApplyDO(), OaSealApplySaveReqVO.class).setType(type); + // mock 方法 + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO()); + when(adminUserApi.getUser(10L)).thenReturn(new AdminUserRespDTO().setId(10L).setDeptId(20L)); + when(noRedisDAO.generate(OaNoRedisDAO.SEAL_APPLY_NO_PREFIX)).thenReturn("YY20260912000001"); + + // 调用 + Long id = sealApplyService.createSealApply(reqVO, 10L); + // 断言 + assertEquals(type, sealApplyMapper.selectById(id).getType()); + } + + @Test + public void testCreateSealApply_noDuplicate() { + // mock 数据 + OaSealApplyDO apply = randomSealApplyDO().setNo("YY20260912000001"); + sealApplyMapper.insert(apply); + // 准备参数 + OaSealApplySaveReqVO reqVO = toBean(apply, OaSealApplySaveReqVO.class); + // mock 方法 + when(sealService.validateSealExists(apply.getSealId())).thenReturn(randomSealDO()); + when(adminUserApi.getUser(10L)).thenReturn(new AdminUserRespDTO().setId(10L).setDeptId(20L)); + when(noRedisDAO.generate(OaNoRedisDAO.SEAL_APPLY_NO_PREFIX)).thenReturn(apply.getNo()); + + // 调用,并断言异常 + assertServiceException(() -> sealApplyService.createSealApply(reqVO, 10L), SEAL_APPLY_NO_DUPLICATE); + } + + @Test + public void testSubmitSealApply_refreshSealSnapshot() { + // mock 数据 + OaSealApplyDO apply = randomSealApplyDO().setSealType(1).setKeeperDeptId(20L); + sealApplyMapper.insert(apply); + // mock 方法:草稿保存后印章保管部门发生变化,提交时和原有快照一起刷新 + when(sealService.validateSealExists(30L)).thenReturn(randomSealDO().setType(2).setKeeperDeptId(21L)); + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("seal-snapshot-process"); + + // 调用 + sealApplyService.submitSealApply(apply.getId(), 10L); + // 断言 + OaSealApplyDO result = sealApplyMapper.selectById(apply.getId()); + assertEquals(2, result.getSealType()); + assertEquals(21L, result.getKeeperDeptId()); + } + + @Test + public void testGetSealApplyPage_deptAndCreateTime() { + // mock 数据 + LocalDateTime createTime = LocalDateTime.of(2026, 9, 18, 10, 0); + OaSealApplyDO matched = randomSealApplyDO(); + matched.setCreateTime(createTime); + sealApplyMapper.insert(matched); + OaSealApplyDO otherDept = randomSealApplyDO().setDeptId(21L); + otherDept.setCreateTime(createTime); + sealApplyMapper.insert(otherDept); + OaSealApplyDO outsideTime = randomSealApplyDO(); + outsideTime.setCreateTime(createTime.minusDays(1)); + sealApplyMapper.insert(outsideTime); + // 准备参数 + OaSealApplyPageReqVO reqVO = new OaSealApplyPageReqVO().setDeptId(20L) + .setCreateTime(new LocalDateTime[]{createTime, createTime.plusHours(1)}); + + // 调用 + PageResult result = sealApplyService.getSealApplyPage(10L, reqVO); + // 断言:包含起点,排除其他部门及范围外记录 + assertEquals(1L, result.getTotal()); + assertEquals(matched.getId(), CollUtil.getFirst(result.getList()).getId()); + } + + // ========== 随机对象 ========== + + /** + * 构造尚未提交、未使用的用印申请 + * + * @return 未入库的测试对象 + */ + private static OaSealApplyDO randomSealApplyDO() { + return randomPojo(OaSealApplyDO.class, o -> o.setId(null).setUserId(10L).setDeptId(20L).setSealId(30L) + .setKeeperUserId(40L).setSealType(1).setKeeperDeptId(20L).setType(1).setMode(2).setDocumentCount(1) + .setContractPrice(BigDecimal.ONE) + .setExpectedUseTime(LocalDateTime.now().plusDays(1).withNano(0)) + .setExpectedReturnTime(LocalDateTime.now().plusDays(1).plusHours(2).withNano(0)) + .setActualUseTime(null).setActualReturnTime(null).setStatus(-1).setUseStatus(0) + .setProcessInstanceId(null).setFileUrls(Collections.emptyList()).setCreateTime(LocalDateTime.now().minusDays(1))); + } + + /** + * 构造具有指定保管人的可用印章 + * + * @return 未入库的测试对象 + */ + private static OaSealDO randomSealDO() { + return randomPojo(OaSealDO.class, o -> o.setId(30L).setType(1).setStatus(0).setKeeperUserId(40L).setKeeperDeptId(20L) + .setSort(0)); + } +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/seal/OaSealServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/seal/OaSealServiceImplTest.java new file mode 100644 index 0000000000..79c410cc94 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/seal/OaSealServiceImplTest.java @@ -0,0 +1,242 @@ +package cn.iocoder.yudao.module.oa.service.seal; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.controller.admin.seal.vo.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.seal.OaSealDO; +import cn.iocoder.yudao.module.oa.dal.mysql.seal.OaSealMapper; +import cn.iocoder.yudao.module.oa.dal.redis.no.OaNoRedisDAO; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; + +import static cn.iocoder.yudao.framework.common.util.object.BeanUtils.toBean; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaSealServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaSealServiceImpl.class) +public class OaSealServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaSealService sealService; + + @Resource + private OaSealMapper sealMapper; + + @MockitoBean + private OaNoRedisDAO noRedisDAO; + @MockitoBean + private AdminUserApi adminUserApi; + @MockitoBean + private DeptApi deptApi; + @MockitoBean + private OaSealApplyService sealApplyService; + + @Test + public void testDeleteSeal_inUse() { + // mock 数据 + OaSealDO record = randomSealDO(); + sealMapper.insert(record); + // mock 方法 + when(sealApplyService.getSealApplyCountBySealId(record.getId())).thenReturn(1L); + + // 调用,并断言异常 + assertServiceException(() -> sealService.deleteSeal(record.getId()), SEAL_IN_USE); + assertNotNull(sealMapper.selectById(record.getId())); + } + + @Test + public void testCreateSeal() { + // mock 单号生成 + when(noRedisDAO.generate(OaNoRedisDAO.SEAL_NO_PREFIX)).thenReturn("SEAL20260912000001"); + // 准备参数 + OaSealSaveReqVO reqVO = toBean(randomSealDO(), OaSealSaveReqVO.class).setStatus(1); + + // 调用 + Long id = sealService.createSeal(reqVO); + // 断言 + OaSealDO seal = sealMapper.selectById(id); + assertEquals("SEAL20260912000001", seal.getNo()); + assertEquals(1, seal.getStatus()); + assertEquals(reqVO.getDeptId(), seal.getDeptId()); + verify(deptApi).validateDeptList(Collections.singleton(30L)); + verify(adminUserApi).validateUser(10L); + } + + @Test + public void testUpdateSeal_keeperChanged() { + // mock 数据 + OaSealDO seal = randomSealDO(); + sealMapper.insert(seal); + // 准备参数 + OaSealSaveReqVO reqVO = toBean(seal, OaSealSaveReqVO.class).setKeeperUserId(11L); + + // 调用 + sealService.updateSeal(reqVO); + // 断言 + assertEquals(11L, sealMapper.selectById(seal.getId()).getKeeperUserId()); + assertEquals(seal.getNo(), sealMapper.selectById(seal.getId()).getNo()); + verifyNoInteractions(noRedisDAO); + verify(adminUserApi).validateUser(11L); + } + + @Test + public void testDeleteSeal() { + // mock 数据 + OaSealDO seal = randomSealDO(); + sealMapper.insert(seal); + + // 调用 + sealService.deleteSeal(seal.getId()); + // 断言 + assertNull(sealMapper.selectById(seal.getId())); + assertServiceException(() -> sealService.deleteSeal(seal.getId()), SEAL_NOT_EXISTS); + } + + @Test + public void testUpdateSeal_status() { + // mock 数据 + OaSealDO seal = randomSealDO(); + sealMapper.insert(seal); + + // 调用 + sealService.updateSeal(toBean(seal, OaSealSaveReqVO.class).setStatus(1)); + // 断言 + assertEquals(1, sealMapper.selectById(seal.getId()).getStatus()); + assertEquals(seal.getDisableTime(), sealMapper.selectById(seal.getId()).getDisableTime()); + } + + @Test + public void testUpdateSeal_status_inUseToAvailable() { + // mock 数据 + OaSealDO seal = randomSealDO().setStatus(2); + sealMapper.insert(seal); + + // 调用 + sealService.updateSeal(toBean(seal, OaSealSaveReqVO.class).setStatus(0)); + // 断言 + assertEquals(0, sealMapper.selectById(seal.getId()).getStatus()); + } + + @Test + public void testGetSealPage_deptId() { + // mock 数据 + OaSealDO seal = randomSealDO().setDeptId(30L).setKeeperDeptId(20L); + sealMapper.insert(seal); + sealMapper.insert(randomSealDO().setDeptId(31L).setKeeperDeptId(30L)); + // 准备参数 + OaSealPageReqVO reqVO = new OaSealPageReqVO().setDeptId(30L); + + // 调用 + PageResult pageResult = sealService.getSealPage(reqVO); + // 断言 + assertEquals(1L, pageResult.getTotal()); + assertEquals(seal.getId(), CollUtil.getFirst(pageResult.getList()).getId()); + } + + @Test + public void testGetSealPage_deptIdEmpty() { + // mock 数据 + sealMapper.insert(randomSealDO().setDeptId(30L)); + sealMapper.insert(randomSealDO().setDeptId(31L)); + // 准备参数 + OaSealPageReqVO reqVO = new OaSealPageReqVO(); + + // 调用 + PageResult pageResult = sealService.getSealPage(reqVO); + // 断言 + assertEquals(2L, pageResult.getTotal()); + } + + @Test + public void testGetSealPage_sort() { + // mock 数据:编号和插入顺序均不能替代显示顺序,相同顺序按编号倒序 + sealMapper.insert(randomSealDO().setId(10L).setSort(20)); + sealMapper.insert(randomSealDO().setId(20L).setSort(10)); + sealMapper.insert(randomSealDO().setId(30L).setSort(10)); + // 准备参数 + OaSealPageReqVO reqVO = new OaSealPageReqVO(); + + // 调用 + PageResult pageResult = sealService.getSealPage(reqVO); + // 断言 + assertEquals(Arrays.asList(30L, 20L, 10L), + CollectionUtils.convertList( + pageResult.getList(), OaSealDO::getId)); + } + + @Test + public void testGetSealPage_filters() { + // mock 数据:分别排除类型、保管人和三个时间范围不匹配的印章 + LocalDateTime beginTime = LocalDateTime.of(2026, 9, 1, 0, 0); + LocalDateTime endTime = LocalDateTime.of(2026, 9, 30, 23, 59, 59); + OaSealDO seal = randomSealDO().setType(1).setKeeperUserId(10L) + .setPurchaseTime(beginTime).setEnableTime(endTime).setDisableTime(endTime); + sealMapper.insert(seal); + sealMapper.insert(toBean(seal, OaSealDO.class).setId(null).setNo("FILTER-TYPE").setType(2)); + sealMapper.insert(toBean(seal, OaSealDO.class).setId(null).setNo("FILTER-KEEPER").setKeeperUserId(11L)); + sealMapper.insert(toBean(seal, OaSealDO.class).setId(null).setNo("FILTER-PURCHASE") + .setPurchaseTime(beginTime.minusSeconds(1))); + sealMapper.insert(toBean(seal, OaSealDO.class).setId(null).setNo("FILTER-ENABLE") + .setEnableTime(endTime.plusSeconds(1))); + sealMapper.insert(toBean(seal, OaSealDO.class).setId(null).setNo("FILTER-DISABLE") + .setDisableTime(endTime.plusSeconds(1))); + // 准备参数 + OaSealPageReqVO reqVO = new OaSealPageReqVO().setType(1).setKeeperUserId(10L) + .setPurchaseTime(new LocalDateTime[]{beginTime, endTime}) + .setEnableTime(new LocalDateTime[]{beginTime, endTime}) + .setDisableTime(new LocalDateTime[]{beginTime, endTime}); + + // 调用 + PageResult pageResult = sealService.getSealPage(reqVO); + // 断言:时间范围包含首尾边界 + assertEquals(1L, pageResult.getTotal()); + assertEquals(seal.getId(), CollUtil.getFirst(pageResult.getList()).getId()); + } + + @Test + public void testUpdateSeal_deptId() { + // mock 数据 + OaSealDO seal = randomSealDO(); + sealMapper.insert(seal); + // 准备参数 + OaSealSaveReqVO reqVO = toBean(seal, OaSealSaveReqVO.class).setDeptId(31L); + + // 调用 + sealService.updateSeal(reqVO); + // 断言:变更所属部门不修改保管部门 + OaSealDO updated = sealMapper.selectById(seal.getId()); + assertEquals(31L, updated.getDeptId()); + assertEquals(seal.getKeeperDeptId(), updated.getKeeperDeptId()); + verify(deptApi).validateDeptList(Collections.singleton(31L)); + } + + // ========== 随机对象 ========== + + /** + * 构造具有指定保管人的可用印章 + * + * @return 未入库的测试对象 + */ + private static OaSealDO randomSealDO() { + return randomPojo(OaSealDO.class, seal -> seal.setId(null).setType(1).setCategory(1) + .setDeptId(30L).setKeeperUserId(10L).setKeeperDeptId(20L).setStatus(0).setSort(0)); + } +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyApplyServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyApplyServiceImplTest.java new file mode 100644 index 0000000000..b19cfe3611 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyApplyServiceImplTest.java @@ -0,0 +1,454 @@ +package cn.iocoder.yudao.module.oa.service.supply; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.apply.*; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.issue.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.supply.*; +import cn.iocoder.yudao.module.oa.dal.mysql.supply.*; +import cn.iocoder.yudao.module.oa.dal.redis.no.OaNoRedisDAO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import jakarta.validation.ConstraintViolationException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.Map; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaSupplyApplyServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import({OaSupplyApplyServiceImpl.class, ValidationAutoConfiguration.class}) +public class OaSupplyApplyServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaSupplyApplyService supplyApplyService; + + @Resource + private OaSupplyApplyMapper supplyApplyMapper; + @Resource + private OaSupplyApplyItemMapper supplyApplyItemMapper; + @Resource + private PlatformTransactionManager transactionManager; + + @MockitoBean + private OaSupplyItemService supplyItemService; + @MockitoBean + private AdminUserApi adminUserApi; + @MockitoBean + private OaNoRedisDAO noRedisDAO; + @MockitoBean + private BpmProcessInstanceApi processInstanceApi; + + @Test + public void testGetPendingSupplyItemCount_statusAndDeletion() { + // mock 数据 + OaSupplyApplyItemDO pending = randomSupplyApplyItemDO().setItemId(10L).setStatus(0); + supplyApplyItemMapper.insert(pending); + supplyApplyItemMapper.insert(randomSupplyApplyItemDO().setItemId(10L).setStatus(1)); + supplyApplyItemMapper.insert(randomSupplyApplyItemDO().setItemId(11L).setStatus(0)); + + // 调用,并断言 + assertEquals(1L, supplyApplyService.getPendingSupplyItemCount(10L)); + supplyApplyItemMapper.deleteById(pending.getId()); + assertEquals(0L, supplyApplyService.getPendingSupplyItemCount(10L)); + } + + @Test + public void testHasUnreturnedSupplyItem_quantityAndType() { + // mock 数据:借用品已发放 3 件,已归还 1 件 + OaSupplyApplyItemDO item = randomSupplyApplyItemDO().setItemId(10L).setManageType(2) + .setIssuedQuantity(3).setReturnedQuantity(1); + supplyApplyItemMapper.insert(item); + + // 调用,并断言 + assertEquals(1L, supplyApplyService.getUnreturnedSupplyItemCount(10L)); + assertEquals(0L, supplyApplyService.getUnreturnedSupplyItemCount(11L)); + supplyApplyItemMapper.updateById(new OaSupplyApplyItemDO().setId(item.getId()).setReturnedQuantity(3)); + assertEquals(0L, supplyApplyService.getUnreturnedSupplyItemCount(10L)); + // 消耗品发放后不要求归还 + supplyApplyItemMapper.insert(randomSupplyApplyItemDO().setItemId(10L).setManageType(1) + .setIssuedQuantity(3).setReturnedQuantity(0)); + assertEquals(0L, supplyApplyService.getUnreturnedSupplyItemCount(10L)); + } + + @Test + public void testGetSupplyApply_notExists() { + + // 调用,并断言 + assertNull(supplyApplyService.getSupplyApply(-1L)); + } + + @Test + public void testGetSupplyApplyMap_empty() { + + // 调用,并断言 + assertTrue(supplyApplyService.getSupplyApplyMap(Collections.emptyList()).isEmpty()); + } + + @Test + public void testGetSupplyApplyMap_success() { + // mock 数据 + OaSupplyApplyDO apply = randomSupplyApplyDO(); + supplyApplyMapper.insert(apply); + supplyApplyMapper.insert(randomSupplyApplyDO()); + + // 调用 + Map applies = supplyApplyService.getSupplyApplyMap(Collections.singletonList(apply.getId())); + + // 断言 + assertEquals(1, applies.size()); + assertEquals(apply.getNo(), applies.get(apply.getId()).getNo()); + } + + @Test + public void testCreateSupplyApply_success() { + // 准备参数 + OaSupplyApplySaveReqVO reqVO = new OaSupplyApplySaveReqVO().setApplyTime(LocalDateTime.now()) + .setUseType(1).setPickupMethod(1).setReason("办公领用").setFileUrls(Collections.emptyList()) + .setItems(Collections.singletonList(new OaSupplyApplySaveReqVO.Item().setItemId(10L).setApplyQuantity(2))); + // mock 方法 + when(adminUserApi.validateUser(1L)).thenReturn(new AdminUserRespDTO().setId(1L).setDeptId(100L)); + when(noRedisDAO.generate(anyString())).thenReturn("YP-TEST"); + when(supplyItemService.getSupplyItemMap(anyCollection())).thenReturn(Collections.singletonMap(10L, + new OaSupplyItemDO().setId(10L).setName("白板笔").setUnit("支").setManageType(1).setStatus(0))); + + // 调用 + Long id = supplyApplyService.createSupplyApply(reqVO, 1L); + + // 断言 + assertEquals(-1, supplyApplyMapper.selectById(id).getStatus()); + OaSupplyApplyItemDO item = CollUtil.getFirst(supplyApplyItemMapper.selectListByApplyId(id)); + assertEquals("白板笔", item.getItemName()); + assertEquals(2, item.getApplyQuantity()); + verify(supplyItemService, never()).updateSupplyItemStockQuantity(anyLong(), anyInt()); + } + + @Test + public void testCreateSupplyApply_duplicateNo() { + // mock 数据 + supplyApplyMapper.insert(randomSupplyApplyDO().setNo("YP-TEST")); + // 准备参数 + OaSupplyApplySaveReqVO reqVO = new OaSupplyApplySaveReqVO().setApplyTime(LocalDateTime.now()) + .setUseType(1).setPickupMethod(1).setReason("办公领用").setFileUrls(Collections.emptyList()) + .setItems(Collections.singletonList(new OaSupplyApplySaveReqVO.Item().setItemId(10L).setApplyQuantity(2))); + // mock 方法 + when(adminUserApi.validateUser(1L)).thenReturn(new AdminUserRespDTO().setId(1L).setDeptId(100L)); + when(noRedisDAO.generate(anyString())).thenReturn("YP-TEST"); + when(supplyItemService.getSupplyItemMap(anyCollection())).thenReturn(Collections.singletonMap(10L, + new OaSupplyItemDO().setId(10L).setName("白板笔").setUnit("支").setManageType(1).setStatus(0))); + + // 调用,并断言:申请单号冲突不能提示为物品编号冲突 + assertServiceException(() -> supplyApplyService.createSupplyApply(reqVO, 1L), SUPPLY_APPLY_NO_DUPLICATE); + } + + @Test + public void testCreateSupplyApply_emptyDraft() { + // 准备参数 + OaSupplyApplySaveReqVO reqVO = new OaSupplyApplySaveReqVO(); + + // 调用,并断言 + assertThrows(ConstraintViolationException.class, () -> supplyApplyService.createSupplyApply(reqVO, 1L)); + verifyNoInteractions(supplyItemService, adminUserApi, noRedisDAO, processInstanceApi); + } + + @Test + public void testCreateSupplyApply_quantityMissing() { + // 准备参数 + OaSupplyApplySaveReqVO reqVO = new OaSupplyApplySaveReqVO().setApplyTime(LocalDateTime.now()) + .setUseType(1).setPickupMethod(1).setReason("办公领用").setItems(Collections.singletonList( + new OaSupplyApplySaveReqVO.Item().setItemId(10L))); + + // 调用,并断言 + assertThrows(ConstraintViolationException.class, () -> supplyApplyService.createSupplyApply(reqVO, 1L)); + verifyNoInteractions(supplyItemService, adminUserApi, noRedisDAO, processInstanceApi); + } + + @Test + public void testUpdateSupplyApply_missingInformation() { + // mock 数据 + OaSupplyApplyDO apply = randomSupplyApplyDO().setStatus(3).setProcessInstanceId("old-process"); + supplyApplyMapper.insert(apply); + supplyApplyItemMapper.insert(randomSupplyApplyItemDO().setApplyId(apply.getId())); + // 准备参数 + OaSupplyApplySaveReqVO reqVO = new OaSupplyApplySaveReqVO().setId(apply.getId()); + + // 调用,并断言 + assertThrows(ConstraintViolationException.class, () -> supplyApplyService.updateSupplyApply(reqVO, 1L)); + + // 断言 + OaSupplyApplyDO actual = supplyApplyMapper.selectById(apply.getId()); + assertEquals(apply.getApplyTime(), actual.getApplyTime()); + assertEquals(apply.getUseType(), actual.getUseType()); + assertEquals(apply.getPickupMethod(), actual.getPickupMethod()); + assertEquals(apply.getReason(), actual.getReason()); + assertEquals(3, actual.getStatus()); + assertEquals("old-process", actual.getProcessInstanceId()); + assertEquals(apply.getDeptId(), actual.getDeptId()); + assertEquals(apply.getNo(), actual.getNo()); + assertEquals(1, supplyApplyItemMapper.selectListByApplyId(apply.getId()).size()); + } + + @Test + public void testUpdateSupplyApply_nullFieldsIgnored() { + // mock 数据 + OaSupplyApplyDO apply = randomSupplyApplyDO().setStatus(3).setProcessInstanceId("old-process") + .setRemark("原备注").setFileUrls(Collections.singletonList("https://example.com/test.pdf")); + supplyApplyMapper.insert(apply); + // 准备参数 + OaSupplyApplySaveReqVO reqVO = new OaSupplyApplySaveReqVO().setId(apply.getId()) + .setApplyTime(LocalDateTime.of(2026, 9, 15, 0, 0)).setUseType(1).setPickupMethod(1) + .setReason("更新事由").setItems(Collections.singletonList( + new OaSupplyApplySaveReqVO.Item().setItemId(10L).setApplyQuantity(2))); + // mock 方法 + when(supplyItemService.getSupplyItemMap(anyCollection())).thenReturn(Collections.singletonMap(10L, + new OaSupplyItemDO().setId(10L).setName("白板笔").setManageType(1).setStatus(0))); + + // 调用 + supplyApplyService.updateSupplyApply(reqVO, 1L); + + // 断言 + OaSupplyApplyDO actual = supplyApplyMapper.selectById(apply.getId()); + assertEquals(reqVO.getReason(), actual.getReason()); + assertEquals(reqVO.getApplyTime(), actual.getApplyTime()); + assertEquals(apply.getRemark(), actual.getRemark()); + assertEquals(apply.getFileUrls(), actual.getFileUrls()); + assertEquals(apply.getStatus(), actual.getStatus()); + assertEquals(apply.getProcessInstanceId(), actual.getProcessInstanceId()); + } + + @Test + public void testUpdateSupplyApply_completeDraft() { + // mock 数据 + OaSupplyApplyDO apply = randomSupplyApplyDO().setStatus(-1).setApplyTime(null).setReason(null); + supplyApplyMapper.insert(apply); + // 准备参数 + OaSupplyApplySaveReqVO reqVO = new OaSupplyApplySaveReqVO().setId(apply.getId()) + .setApplyTime(LocalDateTime.of(2026, 9, 15, 0, 0)).setUseType(1).setPickupMethod(1) + .setReason("补全草稿").setFileUrls(Collections.singletonList("https://example.com/test.pdf")) + .setItems(Collections.singletonList(new OaSupplyApplySaveReqVO.Item().setItemId(10L).setApplyQuantity(2))); + // mock 方法 + when(supplyItemService.getSupplyItemMap(anyCollection())).thenReturn(Collections.singletonMap(10L, + new OaSupplyItemDO().setId(10L).setName("白板笔").setManageType(1).setStatus(0))); + when(processInstanceApi.createProcessInstance(eq(1L), any())).thenReturn("process-complete"); + + // 调用 + supplyApplyService.updateSupplyApply(reqVO, 1L); + String processId = supplyApplyService.submitSupplyApply(apply.getId(), 1L); + + // 断言 + OaSupplyApplyDO actual = supplyApplyMapper.selectById(apply.getId()); + assertEquals(reqVO.getApplyTime(), actual.getApplyTime()); + assertEquals(reqVO.getReason(), actual.getReason()); + assertEquals(reqVO.getFileUrls(), actual.getFileUrls()); + assertEquals("process-complete", processId); + assertEquals(1, actual.getStatus()); + } + + @ParameterizedTest + @ValueSource(strings = {"applyTime", "useType", "pickupMethod", "reason"}) + public void testCreateSupplyApply_missingInformation(String field) { + // mock 数据 + OaSupplyApplySaveReqVO apply = new OaSupplyApplySaveReqVO().setApplyTime(LocalDateTime.now()) + .setUseType(1).setPickupMethod(1).setReason("办公领用") + .setItems(Collections.singletonList(new OaSupplyApplySaveReqVO.Item().setItemId(10L).setApplyQuantity(2))); + switch (field) { + case "applyTime": + apply.setApplyTime(null); + break; + case "useType": + apply.setUseType(null); + break; + case "pickupMethod": + apply.setPickupMethod(null); + break; + case "reason": + apply.setReason(" "); + break; + } + + // 调用,并断言 + assertThrows(ConstraintViolationException.class, () -> supplyApplyService.createSupplyApply(apply, 1L)); + verifyNoInteractions(processInstanceApi, noRedisDAO, supplyItemService); + } + + @Test + public void testCreateSupplyApply_emptyItems() { + // 准备参数 + OaSupplyApplySaveReqVO reqVO = new OaSupplyApplySaveReqVO().setApplyTime(LocalDateTime.now()) + .setUseType(1).setPickupMethod(1).setReason("办公领用").setItems(Collections.emptyList()); + + // 调用,并断言 + assertThrows(ConstraintViolationException.class, () -> supplyApplyService.createSupplyApply(reqVO, 1L)); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testCreateSupplyApply_invalidQuantity() { + // 准备参数 + OaSupplyApplySaveReqVO reqVO = new OaSupplyApplySaveReqVO().setApplyTime(LocalDateTime.now()) + .setUseType(1).setPickupMethod(1).setReason("办公领用") + .setItems(Collections.singletonList(new OaSupplyApplySaveReqVO.Item().setItemId(10L).setApplyQuantity(0))); + + // 调用,并断言 + assertThrows(ConstraintViolationException.class, () -> supplyApplyService.createSupplyApply(reqVO, 1L)); + verifyNoInteractions(processInstanceApi); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void testSubmitSupplyApply_itemUnavailable(boolean deleted) { + // mock 数据 + OaSupplyApplyDO apply = randomSupplyApplyDO().setStatus(-1); + supplyApplyMapper.insert(apply); + supplyApplyItemMapper.insert(randomSupplyApplyItemDO().setApplyId(apply.getId())); + // mock 方法 + when(supplyItemService.getSupplyItemMap(anyCollection())).thenReturn(deleted ? Collections.emptyMap() + : Collections.singletonMap(10L, new OaSupplyItemDO().setId(10L).setStatus(1))); + + // 调用,并断言异常 + assertServiceException(() -> supplyApplyService.submitSupplyApply(apply.getId(), 1L), SUPPLY_APPLY_ITEM_INVALID); + assertEquals(-1, supplyApplyMapper.selectById(apply.getId()).getStatus()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testSubmitSupplyApply_success() { + // mock 数据 + OaSupplyApplyDO apply = randomSupplyApplyDO().setStatus(-1); + supplyApplyMapper.insert(apply); + supplyApplyItemMapper.insert(randomSupplyApplyItemDO().setApplyId(apply.getId())); + // mock 方法 + when(supplyItemService.getSupplyItemMap(anyCollection())).thenReturn(Collections.singletonMap(10L, + new OaSupplyItemDO().setId(10L).setStatus(0))); + when(processInstanceApi.createProcessInstance(eq(1L), any())).thenReturn("process-1"); + + // 调用 + String processId = supplyApplyService.submitSupplyApply(apply.getId(), 1L); + + // 断言 + assertEquals("process-1", processId); + assertEquals(1, supplyApplyMapper.selectById(apply.getId()).getStatus()); + verify(supplyItemService, never()).updateSupplyItemStockQuantity(anyLong(), anyInt()); + } + + @Test + public void testSubmitSupplyApply_failureRollback() { + // mock 数据 + OaSupplyApplyDO apply = randomSupplyApplyDO().setStatus(-1); + supplyApplyMapper.insert(apply); + supplyApplyItemMapper.insert(randomSupplyApplyItemDO().setApplyId(apply.getId())); + // mock 方法 + when(supplyItemService.getSupplyItemMap(anyCollection())).thenReturn(Collections.singletonMap(10L, + new OaSupplyItemDO().setId(10L).setStatus(0))); + when(processInstanceApi.createProcessInstance(eq(1L), any())).thenThrow(new IllegalStateException("流程未部署")); + + // 调用,并断言 + assertThrows(IllegalStateException.class, () -> new TransactionTemplate(transactionManager) + .executeWithoutResult(status -> supplyApplyService.submitSupplyApply(apply.getId(), 1L))); + assertEquals(-1, supplyApplyMapper.selectById(apply.getId()).getStatus()); + } + + @Test + public void testUpdateSupplyApplyStatus_approve() { + // mock 数据 + OaSupplyApplyDO apply = randomSupplyApplyDO().setStatus(1).setProcessInstanceId("process-1"); + supplyApplyMapper.insert(apply); + OaSupplyApplyItemDO item = randomSupplyApplyItemDO().setApplyId(apply.getId()).setStatus(-1); + supplyApplyItemMapper.insert(item); + + // 调用 + supplyApplyService.updateSupplyApplyStatus(apply.getId(), "process-1", 2); + + // 断言 + assertEquals(2, supplyApplyMapper.selectById(apply.getId()).getStatus()); + assertEquals(0, supplyApplyItemMapper.selectById(item.getId()).getStatus()); + } + + @Test + public void testUpdateSupplyApplyStatus_oldProcessIgnored() { + // mock 数据 + OaSupplyApplyDO apply = randomSupplyApplyDO().setStatus(1).setProcessInstanceId("new-process"); + supplyApplyMapper.insert(apply); + + // 调用 + supplyApplyService.updateSupplyApplyStatus(apply.getId(), "old-process", 2); + + // 断言 + assertEquals(1, supplyApplyMapper.selectById(apply.getId()).getStatus()); + } + + @Test + public void testUpdateSupplyApplyStatus_repeatApprove() { + // mock 数据 + OaSupplyApplyDO apply = randomSupplyApplyDO().setStatus(2).setProcessInstanceId("process-1"); + supplyApplyMapper.insert(apply); + OaSupplyApplyItemDO item = randomSupplyApplyItemDO().setApplyId(apply.getId()).setStatus(2).setIssuedQuantity(3); + supplyApplyItemMapper.insert(item); + + // 调用 + supplyApplyService.updateSupplyApplyStatus(apply.getId(), "process-1", 2); + + // 断言:同一流程重复通过,不重置已发放明细 + assertEquals(2, supplyApplyMapper.selectById(apply.getId()).getStatus()); + OaSupplyApplyItemDO dbItem = supplyApplyItemMapper.selectById(item.getId()); + assertEquals(2, dbItem.getStatus()); + assertEquals(3, dbItem.getIssuedQuantity()); + } + + + @Test + public void testDeleteSupplyApply_notOwner() { + // mock 数据 + OaSupplyApplyDO apply = randomSupplyApplyDO().setStatus(-1); + supplyApplyMapper.insert(apply); + + // 调用,并断言异常 + assertServiceException(() -> supplyApplyService.deleteSupplyApply(apply.getId(), 2L), SUPPLY_APPLY_ACCESS_DENIED); + assertNotNull(supplyApplyMapper.selectById(apply.getId())); + } + + // ========== 随机对象 ========== + + /** + * 构造测试数据,固定业务字段的合法取值。 + * + * @return 未入库的测试对象 + */ + private static OaSupplyApplyDO randomSupplyApplyDO() { + return randomPojo(OaSupplyApplyDO.class, apply -> apply.setId(null).setStatus(2) + .setDeptId(100L).setUseType(1).setPickupMethod(1).setFileUrls(Collections.emptyList()) + .setProcessInstanceId(null).setCreator("1").setDeleted(false)); + } + + /** + * 构造测试数据,固定业务字段的合法取值。 + * + * @return 未入库的测试对象 + */ + private static OaSupplyApplyItemDO randomSupplyApplyItemDO() { + return randomPojo(OaSupplyApplyItemDO.class, item -> item.setId(null).setItemId(10L) + .setStatus(0).setManageType(2).setApplyQuantity(3).setIssuedQuantity(0) + .setReturnedQuantity(0).setDeleted(false)); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyIssueServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyIssueServiceImplTest.java new file mode 100644 index 0000000000..eab49f775d --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyIssueServiceImplTest.java @@ -0,0 +1,230 @@ +package cn.iocoder.yudao.module.oa.service.supply; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.apply.*; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.issue.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.supply.*; +import cn.iocoder.yudao.module.oa.dal.mysql.supply.OaSupplyApplyItemMapper; +import cn.iocoder.yudao.module.oa.dal.mysql.supply.OaSupplyApplyMapper; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.Collections; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaSupplyIssueServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaSupplyIssueServiceImpl.class) +public class OaSupplyIssueServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaSupplyIssueService supplyIssueService; + + @Resource + private OaSupplyApplyItemMapper supplyApplyItemMapper; + @Resource + private OaSupplyApplyMapper supplyApplyMapper; // 验证分页关联查询 + @Resource + private PlatformTransactionManager transactionManager; + + @MockitoBean + private OaSupplyApplyService supplyApplyService; + @MockitoBean + private OaSupplyItemService supplyItemService; + @MockitoBean + private AdminUserApi adminUserApi; + + @Test + public void testIssueSupply_success() { + // mock 数据 + OaSupplyApplyDO apply = new OaSupplyApplyDO().setId(1L).setStatus(2); + OaSupplyApplyItemDO item = randomSupplyApplyItemDO().setApplyId(apply.getId()); + supplyApplyItemMapper.insert(item); + // mock 方法 + when(supplyApplyService.getSupplyApply(apply.getId())).thenReturn(apply); + // 准备参数 + OaSupplyIssueReqVO reqVO = new OaSupplyIssueReqVO().setId(item.getId()).setIssuedQuantity(3); + + // 调用 + supplyIssueService.issueSupply(reqVO, 1L); + + // 断言 + OaSupplyApplyItemDO dbItem = supplyApplyItemMapper.selectById(item.getId()); + assertEquals(3, dbItem.getIssuedQuantity()); + assertEquals(2, dbItem.getStatus()); + verify(supplyItemService).updateSupplyItemStockQuantity(10L, -3); + assertServiceException(() -> supplyIssueService.issueSupply(reqVO, 1L), SUPPLY_APPLY_STATUS_INVALID); + } + + @Test + public void testIssueSupply_consumable() { + // mock 数据 + OaSupplyApplyDO apply = new OaSupplyApplyDO().setId(1L).setStatus(2); + OaSupplyApplyItemDO item = randomSupplyApplyItemDO().setApplyId(apply.getId()).setManageType(1); + supplyApplyItemMapper.insert(item); + // mock 方法 + when(supplyApplyService.getSupplyApply(apply.getId())).thenReturn(apply); + + // 调用 + supplyIssueService.issueSupply(new OaSupplyIssueReqVO().setId(item.getId()).setIssuedQuantity(2), 1L); + + // 断言 + assertEquals(1, supplyApplyItemMapper.selectById(item.getId()).getStatus()); + } + + @Test + public void testIssueSupply_notApproved() { + // mock 数据 + OaSupplyApplyDO apply = new OaSupplyApplyDO().setId(1L).setStatus(1); + OaSupplyApplyItemDO item = randomSupplyApplyItemDO().setApplyId(apply.getId()); + supplyApplyItemMapper.insert(item); + // mock 方法 + when(supplyApplyService.getSupplyApply(apply.getId())).thenReturn(apply); + + // 调用,并断言异常 + assertServiceException(() -> supplyIssueService.issueSupply(new OaSupplyIssueReqVO() + .setId(item.getId()).setIssuedQuantity(2), 1L), SUPPLY_APPLY_STATUS_INVALID); + verifyNoInteractions(supplyItemService); + } + + @Test + public void testReturnSupply_partialThenFull() { + // mock 数据 + OaSupplyApplyItemDO item = randomSupplyApplyItemDO().setApplyId(1L).setStatus(2).setIssuedQuantity(3); + supplyApplyItemMapper.insert(item); + + // 调用 + supplyIssueService.returnSupply(new OaSupplyReturnReqVO().setId(item.getId()).setQuantity(1)); + + // 断言 + assertEquals(1, supplyApplyItemMapper.selectById(item.getId()).getReturnedQuantity()); + assertEquals(2, supplyApplyItemMapper.selectById(item.getId()).getStatus()); + + // 调用剩余归还 + supplyIssueService.returnSupply(new OaSupplyReturnReqVO().setId(item.getId()).setQuantity(2)); + assertEquals(3, supplyApplyItemMapper.selectById(item.getId()).getReturnedQuantity()); + assertEquals(3, supplyApplyItemMapper.selectById(item.getId()).getStatus()); + verify(supplyItemService).updateSupplyItemStockQuantity(10L, 1); + verify(supplyItemService).updateSupplyItemStockQuantity(10L, 2); + } + + @Test + public void testReturnSupply_excessive() { + // mock 数据 + OaSupplyApplyItemDO item = randomSupplyApplyItemDO().setApplyId(1L).setStatus(2).setIssuedQuantity(3) + .setReturnedQuantity(2); + supplyApplyItemMapper.insert(item); + + // 调用,并断言异常 + assertServiceException(() -> supplyIssueService.returnSupply(new OaSupplyReturnReqVO() + .setId(item.getId()).setQuantity(2)), SUPPLY_APPLY_QTY_INVALID); + verifyNoInteractions(supplyItemService); + } + + @Test + public void testGetSupplyIssuePage_creatorNotFound() { + // 准备参数 + OaSupplyIssuePageReqVO reqVO = new OaSupplyIssuePageReqVO().setCreatorName("不存在的用户"); + // mock 方法 + when(adminUserApi.getUserListByNickname(reqVO.getCreatorName())).thenReturn(Collections.emptyList()); + + // 调用 + PageResult page = supplyIssueService.getSupplyIssuePage(reqVO); + + // 断言 + assertEquals(0L, page.getTotal()); + assertTrue(page.getList().isEmpty()); + } + + @Test + public void testGetSupplyIssuePage_creator() { + // mock 数据 + OaSupplyApplyDO apply = randomPojo(OaSupplyApplyDO.class, o -> o.setId(null).setStatus(2) + .setDeptId(100L).setUseType(1).setPickupMethod(1).setFileUrls(Collections.emptyList()) + .setProcessInstanceId(null).setCreator("1").setDeleted(false)); + supplyApplyMapper.insert(apply); + OaSupplyApplyItemDO item = randomSupplyApplyItemDO().setApplyId(apply.getId()); + supplyApplyItemMapper.insert(item); + OaSupplyApplyDO otherApply = randomPojo(OaSupplyApplyDO.class, o -> o.setId(null).setStatus(2) + .setDeptId(100L).setUseType(1).setPickupMethod(1).setFileUrls(Collections.emptyList()) + .setProcessInstanceId(null).setCreator("2").setDeleted(false)); + supplyApplyMapper.insert(otherApply); + supplyApplyItemMapper.insert(randomSupplyApplyItemDO().setApplyId(otherApply.getId())); + // 准备参数 + OaSupplyIssuePageReqVO reqVO = new OaSupplyIssuePageReqVO().setCreatorName("芋道"); + // mock 方法 + when(adminUserApi.getUserListByNickname(reqVO.getCreatorName())) + .thenReturn(Collections.singletonList(new AdminUserRespDTO().setId(1L))); + + // 调用 + PageResult page = supplyIssueService.getSupplyIssuePage(reqVO); + + // 断言 + assertEquals(1L, page.getTotal()); + assertEquals(item.getId(), CollUtil.getFirst(page.getList()).getId()); + } + + @Test + public void testIssueSupply_applyNotExists() { + // mock 数据 + OaSupplyApplyItemDO item = randomSupplyApplyItemDO().setApplyId(1L); + supplyApplyItemMapper.insert(item); + // 准备参数 + OaSupplyIssueReqVO reqVO = new OaSupplyIssueReqVO().setId(item.getId()).setIssuedQuantity(1); + + // 调用,并断言异常 + assertServiceException(() -> supplyIssueService.issueSupply(reqVO, 1L), SUPPLY_APPLY_NOT_EXISTS); + assertEquals(0, supplyApplyItemMapper.selectById(item.getId()).getIssuedQuantity()); + verifyNoInteractions(supplyItemService); + } + + @Test + public void testIssueSupply_stockFailureRollback() { + // mock 数据 + OaSupplyApplyItemDO item = randomSupplyApplyItemDO().setApplyId(1L); + supplyApplyItemMapper.insert(item); + // 准备参数 + OaSupplyIssueReqVO reqVO = new OaSupplyIssueReqVO().setId(item.getId()).setIssuedQuantity(3); + // mock 方法 + when(supplyApplyService.getSupplyApply(1L)).thenReturn(new OaSupplyApplyDO().setId(1L).setStatus(2)); + doThrow(new IllegalStateException("库存不足")).when(supplyItemService).updateSupplyItemStockQuantity(10L, -3); + + // 调用,并断言 + assertThrows(IllegalStateException.class, () -> new TransactionTemplate(transactionManager) + .executeWithoutResult(status -> supplyIssueService.issueSupply(reqVO, 1L))); + OaSupplyApplyItemDO actual = supplyApplyItemMapper.selectById(item.getId()); + assertEquals(0, actual.getStatus()); + assertEquals(0, actual.getIssuedQuantity()); + } + + // ========== 随机对象 ========== + + /** + * 构造测试数据,固定业务字段的合法取值。 + * + * @return 未入库的测试对象 + */ + private static OaSupplyApplyItemDO randomSupplyApplyItemDO() { + return randomPojo(OaSupplyApplyItemDO.class, item -> item.setId(null).setItemId(10L) + .setStatus(0).setManageType(2).setApplyQuantity(3).setIssuedQuantity(0) + .setReturnedQuantity(0).setDeleted(false)); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyItemServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyItemServiceImplTest.java new file mode 100644 index 0000000000..240b2cbe31 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/supply/OaSupplyItemServiceImplTest.java @@ -0,0 +1,242 @@ +package cn.iocoder.yudao.module.oa.service.supply; + +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.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.controller.admin.supply.vo.item.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.supply.OaSupplyItemDO; +import cn.iocoder.yudao.module.oa.dal.mysql.supply.OaSupplyItemMapper; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.math.BigDecimal; +import java.util.Collections; +import java.util.Map; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link OaSupplyItemServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaSupplyItemServiceImpl.class) +public class OaSupplyItemServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaSupplyItemService supplyItemService; + + @Resource + private OaSupplyItemMapper supplyItemMapper; + + @MockitoBean + private OaSupplyApplyService supplyApplyService; + @MockitoBean + private DeptApi deptApi; + + @Test + public void testDeleteSupplyItem_pendingIssue() { + // mock 数据 + OaSupplyItemDO item = randomSupplyItemDO(); + supplyItemMapper.insert(item); + // mock 方法 + Mockito.when(supplyApplyService.getPendingSupplyItemCount(item.getId())).thenReturn(1L); + + // 调用,并断言异常 + assertServiceException(() -> supplyItemService.deleteSupplyItem(item.getId()), SUPPLY_ITEM_PENDING_ISSUE); + assertNotNull(supplyItemMapper.selectById(item.getId())); + } + + @Test + public void testDeleteSupplyItem_noOutstandingItems() { + // mock 数据 + OaSupplyItemDO item = randomSupplyItemDO(); + supplyItemMapper.insert(item); + + // 调用 + supplyItemService.deleteSupplyItem(item.getId()); + // 断言 + assertNull(supplyItemMapper.selectById(item.getId())); + } + + @Test + public void testDeleteSupplyItem_unreturned() { + // mock 数据 + OaSupplyItemDO item = randomSupplyItemDO(); + supplyItemMapper.insert(item); + // mock 方法 + Mockito.when(supplyApplyService.getUnreturnedSupplyItemCount(item.getId())).thenReturn(1L); + + // 调用,并断言异常 + assertServiceException(() -> supplyItemService.deleteSupplyItem(item.getId()), SUPPLY_ITEM_NOT_RETURNED); + assertNotNull(supplyItemMapper.selectById(item.getId())); + } + + @Test + public void testCreateSupplyItem_success() { + // 准备参数 + OaSupplyItemSaveReqVO reqVO = BeanUtils.toBean(randomSupplyItemDO(), OaSupplyItemSaveReqVO.class); + + // 调用 + Long id = supplyItemService.createSupplyItem(reqVO); + + // 断言 + OaSupplyItemDO item = supplyItemMapper.selectById(id); + assertEquals(reqVO.getCategory(), item.getCategory()); + assertEquals(reqVO.getPicUrl(), item.getPicUrl()); + assertEquals(reqVO.getStockQuantity(), item.getStockQuantity()); + } + + @Test + public void testGetSupplyItem_notExists() { + + // 调用,并断言 + assertNull(supplyItemService.getSupplyItem(-1L)); + } + + @Test + public void testUpdateSupplyItem_success() { + // mock 数据 + OaSupplyItemDO item = randomSupplyItemDO(); + supplyItemMapper.insert(item); + // 准备参数 + OaSupplyItemSaveReqVO reqVO = BeanUtils.toBean(item, OaSupplyItemSaveReqVO.class) + .setNo("NEW-CODE").setStockQuantity(8).setStatus(1); + + // 调用 + supplyItemService.updateSupplyItem(reqVO); + + // 断言 + OaSupplyItemDO dbItem = supplyItemMapper.selectById(item.getId()); + assertEquals("NEW-CODE", dbItem.getNo()); + assertEquals(8, dbItem.getStockQuantity()); + assertEquals(1, dbItem.getStatus()); + } + + @Test + public void testCreateSupplyItem_noDuplicate() { + // mock 数据 + OaSupplyItemDO item = randomSupplyItemDO(); + supplyItemMapper.insert(item); + // 准备参数 + OaSupplyItemSaveReqVO reqVO = BeanUtils.toBean(item, OaSupplyItemSaveReqVO.class).setId(null); + + // 调用,并断言异常 + assertServiceException(() -> supplyItemService.createSupplyItem(reqVO), SUPPLY_ITEM_NO_DUPLICATE); + } + + @Test + public void testGetSupplyItemPage_categoryAndSort() { + // mock 数据 + OaSupplyItemDO first = randomSupplyItemDO().setSort(1); + supplyItemMapper.insert(first); + supplyItemMapper.insert(randomSupplyItemDO().setSort(2)); + supplyItemMapper.insert(randomSupplyItemDO().setCategory(2).setSort(0)); + // 准备参数 + OaSupplyItemPageReqVO reqVO = new OaSupplyItemPageReqVO().setCategory(1); + + // 调用 + PageResult page = supplyItemService.getSupplyItemPage(reqVO); + + // 断言 + assertEquals(2L, page.getTotal()); + assertEquals(first.getId(), CollUtil.getFirst(page.getList()).getId()); + } + + @Test + public void testStockInSupplyItem_success() { + // mock 数据 + OaSupplyItemDO item = randomSupplyItemDO(); + supplyItemMapper.insert(item); + // 准备参数 + OaSupplyItemStockInReqVO reqVO = new OaSupplyItemStockInReqVO().setId(item.getId()).setQuantity(3); + + // 调用 + supplyItemService.stockInSupplyItem(reqVO); + + // 断言 + assertEquals(8, supplyItemMapper.selectById(item.getId()).getStockQuantity()); + } + + @Test + public void testUpdateSupplyItemStockQuantity_insufficient() { + // mock 数据 + OaSupplyItemDO item = randomSupplyItemDO(); + supplyItemMapper.insert(item); + + // 调用,并断言异常 + assertServiceException(() -> supplyItemService.updateSupplyItemStockQuantity(item.getId(), -6), + SUPPLY_STOCK_INSUFFICIENT); + assertEquals(5, supplyItemMapper.selectById(item.getId()).getStockQuantity()); + } + + @Test + public void testDeleteSupplyItem_success() { + // mock 数据 + OaSupplyItemDO item = randomSupplyItemDO(); + supplyItemMapper.insert(item); + + // 调用 + supplyItemService.deleteSupplyItem(item.getId()); + + // 断言 + assertNull(supplyItemMapper.selectById(item.getId())); + } + + @Test + public void testGetSupplyItemMap_success() { + // mock 数据 + OaSupplyItemDO item = randomSupplyItemDO(); + supplyItemMapper.insert(item); + supplyItemMapper.insert(randomSupplyItemDO()); + + // 调用 + Map items = supplyItemService.getSupplyItemMap(Collections.singletonList(item.getId())); + + // 断言 + assertEquals(1, items.size()); + assertEquals(item.getName(), items.get(item.getId()).getName()); + } + + @Test + public void testGetSupplyItemMap_empty() { + + // 调用,并断言 + assertTrue(supplyItemService.getSupplyItemMap(Collections.emptyList()).isEmpty()); + } + + @Test + public void testUpdateSupplyItemStockQuantity_deductToZero() { + // mock 数据 + OaSupplyItemDO item = randomSupplyItemDO(); + supplyItemMapper.insert(item); + + // 调用 + supplyItemService.updateSupplyItemStockQuantity(item.getId(), -5); + + // 断言 + assertEquals(0, supplyItemMapper.selectById(item.getId()).getStockQuantity()); + } + + // ========== 随机对象 ========== + + /** + * 构造测试数据,固定业务字段的合法取值。 + * + * @return 未入库的测试对象 + */ + private static OaSupplyItemDO randomSupplyItemDO() { + return randomPojo(OaSupplyItemDO.class, item -> item.setId(null).setDeptId(100L).setCategory(1) + .setManageType(2).setStatus(0).setSort(0).setStockQuantity(5).setMinStockQuantity(1) + .setReferencePrice(BigDecimal.ONE).setDeleted(false)); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/task/OaTaskServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/task/OaTaskServiceImplTest.java new file mode 100644 index 0000000000..e01f1b60e5 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/task/OaTaskServiceImplTest.java @@ -0,0 +1,591 @@ +package cn.iocoder.yudao.module.oa.service.task; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.controller.admin.task.vo.OaTaskFeedbackReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.task.vo.OaTaskPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.task.vo.OaTaskSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.task.OaTaskDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.task.OaTaskLogDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.task.OaTaskReceiverDO; +import cn.iocoder.yudao.module.oa.dal.mysql.task.OaTaskLogMapper; +import cn.iocoder.yudao.module.oa.dal.mysql.task.OaTaskMapper; +import cn.iocoder.yudao.module.oa.dal.mysql.task.OaTaskReceiverMapper; +import cn.iocoder.yudao.module.oa.enums.task.OaTaskStatusEnum; +import cn.iocoder.yudao.module.oa.enums.task.OaTaskTypeEnum; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.TASK_ACCESS_DENIED; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.TASK_RECEIVED_DELETE_DENIED; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.TASK_STATUS_TRANSITION_INVALID; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; + +/** + * {@link OaTaskServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaTaskServiceImpl.class) +public class OaTaskServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaTaskServiceImpl taskService; + + @Resource + private OaTaskMapper taskMapper; + @Resource + private OaTaskReceiverMapper taskReceiverMapper; + @Resource + private OaTaskLogMapper taskLogMapper; + + @MockitoBean + private AdminUserApi adminUserApi; + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void testFeedbackTask_publisherAsReceiverOnlyUpdatesSelf() { + // mock 数据 + OaTaskDO task = buildTaskDO(10L); + taskMapper.insert(task); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(task.getId()).setUserId(10L).setStatus(1)); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(task.getId()).setUserId(20L).setStatus(1)); + + // 调用:发布人在接收人入口提交自己的进度 + taskService.feedbackTask(new OaTaskFeedbackReqVO().setTaskId(task.getId()) + .setPublisher(false).setStatus(4), 10L); + // 断言:其他人的状态和总体最低进度不被覆盖 + assertEquals(4, taskReceiverMapper.selectByTaskIdAndUserId(task.getId(), 10L).getStatus()); + assertEquals(1, taskReceiverMapper.selectByTaskIdAndUserId(task.getId(), 20L).getStatus()); + assertEquals(1, taskMapper.selectById(task.getId()).getStatus()); + } + + @Test + public void testFeedbackTask_receiverCannotUsePublisherEntry() { + // mock 数据 + OaTaskDO task = buildTaskDO(10L); + taskMapper.insert(task); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(task.getId()).setUserId(20L).setStatus(1)); + + // 调用,并断言异常 + assertServiceException(() -> taskService.feedbackTask(new OaTaskFeedbackReqVO() + .setTaskId(task.getId()).setPublisher(true).setStatus(5), 20L), TASK_ACCESS_DENIED); + assertEquals(1, taskReceiverMapper.selectByTaskIdAndUserId(task.getId(), 20L).getStatus()); + } + + @Test + public void testCreateTask_success() { + // 准备参数 + Long publisherUserId = randomLongId(); + Long firstReceiverUserId = randomLongId(); + Long secondReceiverUserId = randomLongId(); + OaTaskSaveReqVO reqVO = buildTaskSaveReqVO(Arrays.asList(firstReceiverUserId, + secondReceiverUserId)); + + // 调用 + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(publisherUserId), new MockHttpServletRequest()); + Long taskId = taskService.createTask(reqVO, publisherUserId); + + // 断言 + OaTaskDO task = taskMapper.selectById(taskId); + verify(adminUserApi).validateUserList(reqVO.getReceiverUserIds()); + assertEquals(publisherUserId.toString(), task.getCreator()); + assertEquals(publisherUserId.toString(), task.getUpdater()); + assertEquals(OaTaskStatusEnum.NEW.getStatus(), task.getStatus()); + assertFalse(task.getTop()); + assertFalse(task.getCanceled()); + List receivers = taskReceiverMapper.selectListByTaskId(taskId); + assertEquals(Arrays.asList(firstReceiverUserId, secondReceiverUserId), + convertList(receivers, OaTaskReceiverDO::getUserId)); + } + + @Test + public void testCreateTask_selectedInitialState() { + // 准备参数 + Long userId = randomLongId(); + Long receiverUserId = randomLongId(); + OaTaskSaveReqVO reqVO = buildTaskSaveReqVO(Arrays.asList(receiverUserId)) + .setStatus(OaTaskStatusEnum.COMPLETED.getStatus()).setTop(true).setCanceled(true); + + // 调用 + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(userId), new MockHttpServletRequest()); + Long taskId = taskService.createTask(reqVO, userId); + + // 断言 + OaTaskDO task = taskMapper.selectById(taskId); + assertEquals(OaTaskStatusEnum.COMPLETED.getStatus(), task.getStatus()); + assertTrue(task.getTop()); + assertTrue(task.getCanceled()); + assertEquals(task.getStatus(), taskReceiverMapper.selectByTaskIdAndUserId(taskId, receiverUserId).getStatus()); + } + + @Test + public void testUpdateTask_overwriteReceiverStatus() { + // mock 数据 + Long publisherUserId = randomLongId(); + Long retainedReceiverUserId = randomLongId(); + Long removedReceiverUserId = randomLongId(); + Long addedReceiverUserId = randomLongId(); + OaTaskDO task = buildTaskDO(publisherUserId).setStatus(OaTaskStatusEnum.IN_PROGRESS.getStatus()); + taskMapper.insert(task); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(task.getId()).setUserId(retainedReceiverUserId) + .setStatus(OaTaskStatusEnum.IN_PROGRESS.getStatus())); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(task.getId()).setUserId(removedReceiverUserId) + .setStatus(OaTaskStatusEnum.SUBMITTED.getStatus())); + // 准备参数 + OaTaskSaveReqVO reqVO = buildTaskSaveReqVO(Arrays.asList(retainedReceiverUserId, addedReceiverUserId)) + .setId(task.getId()).setTitle("修改后的任务").setStatus(OaTaskStatusEnum.SUBMITTED.getStatus()); + + // 调用 + taskService.updateTask(reqVO, publisherUserId); + + // 断言 + assertEquals("修改后的任务", taskMapper.selectById(task.getId()).getTitle()); + assertEquals(publisherUserId.toString(), taskMapper.selectById(task.getId()).getCreator()); + assertEquals(OaTaskStatusEnum.SUBMITTED.getStatus(), taskMapper.selectById(task.getId()).getStatus()); + assertEquals(OaTaskStatusEnum.SUBMITTED.getStatus(), taskReceiverMapper + .selectByTaskIdAndUserId(task.getId(), retainedReceiverUserId).getStatus()); + assertEquals(OaTaskStatusEnum.SUBMITTED.getStatus(), taskReceiverMapper + .selectByTaskIdAndUserId(task.getId(), addedReceiverUserId).getStatus()); + assertNull(taskReceiverMapper.selectByTaskIdAndUserId(task.getId(), removedReceiverUserId)); + } + + @Test + public void testUpdateTask_removeReceiverTwice() { + // mock 数据 + Long publisherUserId = randomLongId(); + Long retainedUserId = randomLongId(); + Long removedUserId = randomLongId(); + OaTaskDO task = buildTaskDO(publisherUserId); + taskMapper.insert(task); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(task.getId()).setUserId(retainedUserId) + .setStatus(OaTaskStatusEnum.NEW.getStatus())); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(task.getId()).setUserId(removedUserId) + .setStatus(OaTaskStatusEnum.NEW.getStatus())); + // 准备参数 + OaTaskSaveReqVO reqVO = buildTaskSaveReqVO(Collections.singletonList(retainedUserId)).setId(task.getId()); + + // 调用:移除、重新加入、再次移除同一接收人 + taskService.updateTask(reqVO, publisherUserId); + taskService.updateTask(reqVO.setReceiverUserIds(Arrays.asList(retainedUserId, removedUserId)), publisherUserId); + taskService.updateTask(reqVO.setReceiverUserIds(Collections.singletonList(retainedUserId)), publisherUserId); + // 断言 + assertNull(taskReceiverMapper.selectByTaskIdAndUserId(task.getId(), removedUserId)); + assertEquals(Collections.singletonList(retainedUserId), + convertList(taskReceiverMapper.selectListByTaskId(task.getId()), OaTaskReceiverDO::getUserId)); + } + + @Test + public void testFeedbackTask_receiverUpdateAndOverallMinimum() { + // mock 数据 + Long publisherUserId = randomLongId(); + Long firstReceiverUserId = randomLongId(); + Long secondReceiverUserId = randomLongId(); + OaTaskDO task = buildTaskDO(publisherUserId); + taskMapper.insert(task); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(task.getId()).setUserId(firstReceiverUserId) + .setStatus(OaTaskStatusEnum.NEW.getStatus())); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(task.getId()).setUserId(secondReceiverUserId) + .setStatus(OaTaskStatusEnum.IN_PROGRESS.getStatus())); + // 准备参数 + OaTaskFeedbackReqVO reqVO = new OaTaskFeedbackReqVO().setPublisher(false).setTaskId(task.getId()) + .setStatus(OaTaskStatusEnum.RECEIVED.getStatus()).setContent("已接收任务"); + + // 调用 + taskService.feedbackTask(reqVO, firstReceiverUserId); + + // 断言 + assertEquals(OaTaskStatusEnum.RECEIVED.getStatus(), taskReceiverMapper + .selectByTaskIdAndUserId(task.getId(), firstReceiverUserId).getStatus()); + assertEquals(OaTaskStatusEnum.RECEIVED.getStatus(), taskMapper.selectById(task.getId()).getStatus()); + OaTaskLogDO log = CollUtil.getFirst(taskLogMapper.selectListByTaskId(task.getId())); + assertEquals(firstReceiverUserId, log.getUserId()); + assertEquals("已接收任务", log.getContent()); + } + + @Test + public void testFeedbackTask_receiverCanSkipStatus() { + // mock 数据 + Long receiverUserId = randomLongId(); + OaTaskDO task = buildTaskDO(randomLongId()); + taskMapper.insert(task); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(task.getId()).setUserId(receiverUserId) + .setStatus(OaTaskStatusEnum.NEW.getStatus())); + // 准备参数 + OaTaskFeedbackReqVO reqVO = new OaTaskFeedbackReqVO().setPublisher(false).setTaskId(task.getId()) + .setStatus(OaTaskStatusEnum.IN_PROGRESS.getStatus()).setContent("跳过接收状态"); + + // 调用 + taskService.feedbackTask(reqVO, receiverUserId); + + // 断言 + assertEquals(reqVO.getStatus(), taskReceiverMapper + .selectByTaskIdAndUserId(task.getId(), receiverUserId).getStatus()); + } + + @Test + public void testFeedbackTask_receiverCannotCompleteOrResubmit() { + // mock 数据 + Long userId = randomLongId(); + OaTaskDO task = buildTaskDO(randomLongId()); + taskMapper.insert(task); + OaTaskReceiverDO receiver = new OaTaskReceiverDO().setTaskId(task.getId()).setUserId(userId) + .setStatus(OaTaskStatusEnum.IN_PROGRESS.getStatus()); + taskReceiverMapper.insert(receiver); + // 准备参数 + OaTaskFeedbackReqVO reqVO = new OaTaskFeedbackReqVO().setPublisher(false).setTaskId(task.getId()) + .setStatus(OaTaskStatusEnum.COMPLETED.getStatus()).setContent("反馈进度"); + + // 调用,并断言:接收人不能验收完成 + assertServiceException(() -> taskService.feedbackTask(reqVO, userId), TASK_STATUS_TRANSITION_INVALID); + + // 调用:提交前可以回退,再直接提交 + taskService.feedbackTask(reqVO.setStatus(OaTaskStatusEnum.NEW.getStatus()), userId); + assertEquals(OaTaskStatusEnum.NEW.getStatus(), taskReceiverMapper.selectById(receiver.getId()).getStatus()); + taskService.feedbackTask(reqVO.setStatus(OaTaskStatusEnum.SUBMITTED.getStatus()), userId); + + // 断言:已提交后不能再次反馈 + assertServiceException(() -> taskService.feedbackTask(reqVO, userId), TASK_STATUS_TRANSITION_INVALID); + taskReceiverMapper.updateById(receiver.setStatus(OaTaskStatusEnum.COMPLETED.getStatus())); + assertServiceException(() -> taskService.feedbackTask(reqVO, userId), TASK_STATUS_TRANSITION_INVALID); + } + + @Test + public void testFeedbackTask_publisherSyncAllReceivers() { + // mock 数据 + Long publisherUserId = randomLongId(); + OaTaskDO task = buildTaskDO(publisherUserId); + taskMapper.insert(task); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(task.getId()).setUserId(randomLongId()) + .setStatus(OaTaskStatusEnum.NEW.getStatus())); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(task.getId()).setUserId(randomLongId()) + .setStatus(OaTaskStatusEnum.RECEIVED.getStatus())); + // 准备参数 + OaTaskFeedbackReqVO reqVO = new OaTaskFeedbackReqVO().setPublisher(true).setTaskId(task.getId()) + .setStatus(OaTaskStatusEnum.COMPLETED.getStatus()).setContent("任务验收完成"); + + // 调用 + taskService.feedbackTask(reqVO, publisherUserId); + + // 断言 + assertEquals(OaTaskStatusEnum.COMPLETED.getStatus(), taskMapper.selectById(task.getId()).getStatus()); + assertTrue(taskReceiverMapper.selectListByTaskId(task.getId()).stream() + .allMatch(receiver -> OaTaskStatusEnum.COMPLETED.getStatus().equals(receiver.getStatus()))); + } + + @Test + public void testDeleteTask_onlyPublisherAndCascade() { + // mock 数据 + Long publisherUserId = randomLongId(); + OaTaskDO task = buildTaskDO(publisherUserId); + taskMapper.insert(task); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(task.getId()).setUserId(randomLongId()) + .setStatus(OaTaskStatusEnum.NEW.getStatus())); + taskLogMapper.insert(new OaTaskLogDO().setTaskId(task.getId()).setUserId(publisherUserId) + .setStatus(OaTaskStatusEnum.NEW.getStatus()).setContent("任务已发布")); + + // 调用,并断言非发布人无法删除 + assertServiceException(() -> taskService.deleteTask(task.getId(), randomLongId()), TASK_ACCESS_DENIED); + + // 调用 + taskService.deleteTask(task.getId(), publisherUserId); + + // 断言 + assertNull(taskMapper.selectById(task.getId())); + assertTrue(taskReceiverMapper.selectListByTaskId(task.getId()).isEmpty()); + assertTrue(taskLogMapper.selectListByTaskId(task.getId()).isEmpty()); + } + + @Test + public void testDeleteReceivedTask_onlyCurrentRelationship() { + // mock 数据 + Long currentUserId = randomLongId(); + Long otherUserId = randomLongId(); + OaTaskDO task = buildTaskDO(randomLongId()).setCanceled(true); + taskMapper.insert(task); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(task.getId()).setUserId(currentUserId) + .setStatus(OaTaskStatusEnum.NEW.getStatus())); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(task.getId()).setUserId(otherUserId) + .setStatus(OaTaskStatusEnum.IN_PROGRESS.getStatus())); + + // 调用 + taskService.deleteReceivedTask(task.getId(), currentUserId); + + // 断言 + assertNull(taskReceiverMapper.selectByTaskIdAndUserId(task.getId(), currentUserId)); + assertEquals(OaTaskStatusEnum.IN_PROGRESS.getStatus(), taskReceiverMapper + .selectByTaskIdAndUserId(task.getId(), otherUserId).getStatus()); + assertEquals(OaTaskStatusEnum.IN_PROGRESS.getStatus(), taskMapper.selectById(task.getId()).getStatus()); + } + + @Test + public void testDeleteReceivedTask_notCanceled() { + // mock 数据 + Long currentUserId = randomLongId(); + OaTaskDO task = buildTaskDO(randomLongId()); + taskMapper.insert(task); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(task.getId()).setUserId(currentUserId) + .setStatus(OaTaskStatusEnum.NEW.getStatus())); + + // 调用,并断言异常 + assertServiceException(() -> taskService.deleteReceivedTask(task.getId(), currentUserId), + TASK_RECEIVED_DELETE_DENIED); + } + + @Test + public void testGetTaskPage_publishedAndReceivedScopes() { + // mock 数据 + Long publisherUserId = randomLongId(); + Long receiverUserId = randomLongId(); + OaTaskDO publishedTask = buildTaskDO(publisherUserId).setTitle("我发布的任务"); + taskMapper.insert(publishedTask); + OaTaskDO receivedTask = buildTaskDO(randomLongId()).setTitle("我的任务"); + taskMapper.insert(receivedTask); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(receivedTask.getId()).setUserId(receiverUserId) + .setStatus(OaTaskStatusEnum.RECEIVED.getStatus())); + taskMapper.insert(buildTaskDO(randomLongId()).setTitle("无关任务")); + + // 准备参数 + OaTaskPageReqVO publishedPageReqVO = new OaTaskPageReqVO(); + OaTaskPageReqVO receivedPageReqVO = new OaTaskPageReqVO() + .setStatus(OaTaskStatusEnum.RECEIVED.getStatus()); + + // 调用 + PageResult publishedPage = taskService.getPublishedTaskPage(publishedPageReqVO, publisherUserId); + PageResult receivedPage = taskService.getReceivedTaskPage(receivedPageReqVO, receiverUserId); + + // 断言 + assertEquals(Collections.singletonList(publishedTask.getId()), + convertList(publishedPage.getList(), OaTaskDO::getId)); + assertEquals(Collections.singletonList(receivedTask.getId()), + convertList(receivedPage.getList(), OaTaskDO::getId)); + } + + @Test + public void testGetTaskStatistics() { + // mock 数据 + Long firstUserId = randomLongId(); + Long secondUserId = randomLongId(); + taskMapper.insert(buildTaskDO(firstUserId).setStatus(OaTaskStatusEnum.COMPLETED.getStatus())); + taskMapper.insert(buildTaskDO(firstUserId).setStatus(OaTaskStatusEnum.COMPLETED.getStatus())); + taskMapper.insert(buildTaskDO(secondUserId).setStatus(OaTaskStatusEnum.COMPLETED.getStatus())); + taskMapper.insert(buildTaskDO(secondUserId).setStatus(OaTaskStatusEnum.NEW.getStatus())); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(randomLongId()).setUserId(firstUserId) + .setStatus(OaTaskStatusEnum.NEW.getStatus())); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(randomLongId()).setUserId(firstUserId) + .setStatus(OaTaskStatusEnum.COMPLETED.getStatus())); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(randomLongId()).setUserId(firstUserId) + .setStatus(OaTaskStatusEnum.COMPLETED.getStatus())); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(randomLongId()).setUserId(secondUserId) + .setStatus(OaTaskStatusEnum.COMPLETED.getStatus())); + + // 调用 + Map statusCountMap = taskService.getTaskStatusCountMap(firstUserId); + Map completedCountMap = taskService.getCompletedTaskCountMapByUserId(); + + // 断言 + assertEquals(1L, statusCountMap.get(OaTaskStatusEnum.NEW.getStatus())); + assertEquals(2L, statusCountMap.get(OaTaskStatusEnum.COMPLETED.getStatus())); + assertEquals(Arrays.asList(firstUserId, secondUserId), new ArrayList<>(completedCountMap.keySet())); + assertEquals(2L, completedCountMap.get(firstUserId)); + assertEquals(1L, completedCountMap.get(secondUserId)); + } + + @Test + public void testUpdateTask_topAndCanceledPreserveProgressAndRestore() { + // mock 数据 + Long publisherUserId = randomLongId(); + Long receiverUserId = randomLongId(); + OaTaskDO task = buildTaskDO(publisherUserId).setStatus(OaTaskStatusEnum.IN_PROGRESS.getStatus()); + taskMapper.insert(task); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(task.getId()).setUserId(receiverUserId) + .setStatus(OaTaskStatusEnum.IN_PROGRESS.getStatus())); + + // 准备参数 + OaTaskSaveReqVO reqVO = buildTaskSaveReqVO(Arrays.asList(receiverUserId)) + .setId(task.getId()).setTop(true).setCanceled(true) + .setStatus(OaTaskStatusEnum.IN_PROGRESS.getStatus()); + + // 调用:表单置顶和取消任务不改变进度,取消期间禁止反馈 + taskService.updateTask(reqVO, publisherUserId); + + // 断言 + assertTrue(taskMapper.selectById(task.getId()).getCanceled()); + assertTrue(taskMapper.selectById(task.getId()).getTop()); + assertEquals(OaTaskStatusEnum.IN_PROGRESS.getStatus(), taskMapper.selectById(task.getId()).getStatus()); + assertServiceException(() -> taskService.feedbackTask(new OaTaskFeedbackReqVO().setPublisher(false) + .setTaskId(task.getId()).setStatus(OaTaskStatusEnum.SUBMITTED.getStatus()), receiverUserId), + TASK_STATUS_TRANSITION_INVALID); + + // 调用:恢复任务保留取消前的进度 + taskService.updateTask(reqVO.setTop(false).setCanceled(false), publisherUserId); + + // 断言 + assertFalse(taskMapper.selectById(task.getId()).getCanceled()); + assertFalse(taskMapper.selectById(task.getId()).getTop()); + assertEquals(OaTaskStatusEnum.IN_PROGRESS.getStatus(), taskMapper.selectById(task.getId()).getStatus()); + assertEquals(OaTaskStatusEnum.IN_PROGRESS.getStatus(), + taskReceiverMapper.selectByTaskIdAndUserId(task.getId(), receiverUserId).getStatus()); + } + + @Test + public void testUpdateTask_notCreator() { + // mock 数据 + OaTaskDO task = buildTaskDO(randomLongId()); + taskMapper.insert(task); + + // 调用,并断言非创建人无权取消任务 + OaTaskSaveReqVO reqVO = buildTaskSaveReqVO(Arrays.asList(randomLongId())) + .setId(task.getId()).setTop(true).setCanceled(true); + assertServiceException(() -> taskService.updateTask(reqVO, randomLongId()), + TASK_ACCESS_DENIED); + assertFalse(taskMapper.selectById(task.getId()).getCanceled()); + } + + @Test + public void testGetReceivedTaskPage_filterCreator() { + // mock 数据 + Long publisherUserId = randomLongId(); + Long receiverUserId = randomLongId(); + OaTaskDO task = buildTaskDO(publisherUserId); + OaTaskDO otherTask = buildTaskDO(randomLongId()); + taskMapper.insert(task); + taskMapper.insert(otherTask); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(task.getId()).setUserId(receiverUserId) + .setStatus(OaTaskStatusEnum.NEW.getStatus())); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(otherTask.getId()).setUserId(receiverUserId) + .setStatus(OaTaskStatusEnum.NEW.getStatus())); + + // 准备参数 + OaTaskPageReqVO reqVO = new OaTaskPageReqVO().setPublisherUserId(publisherUserId); + + // 调用 + PageResult page = taskService.getReceivedTaskPage(reqVO, receiverUserId); + + // 断言 + assertEquals(Collections.singletonList(task.getId()), convertList(page.getList(), OaTaskDO::getId)); + } + + @Test + public void testGetPublishedTaskPage_filters() { + // mock 数据 + Long userId = randomLongId(); + LocalDateTime createTime = LocalDateTime.now().minusDays(2).withNano(0); + OaTaskDO task = buildTaskDO(userId).setTitle("验收任务").setCanceled(true); + task.setCreateTime(createTime.minusDays(1)); + task.setPublishTime(createTime); + taskMapper.insert(task); + + // 准备参数 + OaTaskPageReqVO reqVO = new OaTaskPageReqVO().setTitle("验收") + .setType(task.getType()).setStatus(task.getStatus()).setCanceled(true) + .setPublishTime(new LocalDateTime[]{createTime, createTime}); + + // 调用,并断言全部条件匹配 + assertEquals(1, taskService.getPublishedTaskPage(reqVO, userId).getTotal()); + + // 断言标题、类型、状态、取消和时间条件分别生效 + assertEquals(0, taskService.getPublishedTaskPage(reqVO.setTitle("不匹配"), userId).getTotal()); + reqVO.setTitle("验收"); + assertEquals(0, taskService.getPublishedTaskPage(reqVO.setType(2), userId).getTotal()); + reqVO.setType(task.getType()); + assertEquals(0, taskService.getPublishedTaskPage(reqVO.setStatus(OaTaskStatusEnum.COMPLETED.getStatus()), + userId).getTotal()); + reqVO.setStatus(task.getStatus()); + assertEquals(0, taskService.getPublishedTaskPage(reqVO.setCanceled(false), userId).getTotal()); + reqVO.setCanceled(true); + assertEquals(0, taskService.getPublishedTaskPage(reqVO.setPublishTime( + new LocalDateTime[]{createTime.plusDays(1), createTime.plusDays(2)}), userId).getTotal()); + } + + @Test + public void testGetTaskReceiverListMap() { + // mock 数据 + OaTaskReceiverDO first = new OaTaskReceiverDO().setTaskId(1L).setUserId(10L).setStatus(1); + taskReceiverMapper.insert(first); + OaTaskReceiverDO second = new OaTaskReceiverDO().setTaskId(1L).setUserId(11L).setStatus(1); + taskReceiverMapper.insert(second); + taskReceiverMapper.insert(new OaTaskReceiverDO().setTaskId(2L).setUserId(12L).setStatus(1)); + + // 调用 + Map> receiverListMap = taskService.getTaskReceiverListMap( + Collections.singletonList(1L)); + + // 断言 + assertEquals(1, receiverListMap.size()); + assertEquals(2, receiverListMap.get(1L).size()); + assertTrue(convertList(receiverListMap.get(1L), OaTaskReceiverDO::getUserId) + .containsAll(Arrays.asList(10L, 11L))); + } + + @Test + public void testGetTaskReceiverListMap_empty() { + + // 调用 + Map> receiverListMap = taskService.getTaskReceiverListMap( + Collections.emptyList()); + + // 断言 + assertTrue(CollUtil.isEmpty(receiverListMap)); + } + + // ========== 随机对象 ========== + + /** + * 构造开始时间晚于当前时间的新建任务参数。 + * + * @param receiverUserIds 接收人编号列表 + * @return 保存参数 + */ + private static OaTaskSaveReqVO buildTaskSaveReqVO(List receiverUserIds) { + LocalDateTime startTime = LocalDateTime.now().plusDays(1); + return new OaTaskSaveReqVO().setType(OaTaskTypeEnum.WORK.getType()).setTitle("完成 OA 任务管理迁移") + .setStatus(OaTaskStatusEnum.NEW.getStatus()).setTop(false).setCanceled(false) + .setDescription("完成任务管理的前后端实现和自动化测试") + .setStartTime(startTime).setEndTime(startTime.plusDays(7)).setReceiverUserIds(receiverUserIds); + } + + /** + * 构造开始时间晚于当前时间、未取消的新建任务。 + * + * @param publisherUserId 发布人编号 + * @return 未入库的测试对象 + */ + private static OaTaskDO buildTaskDO(Long publisherUserId) { + LocalDateTime startTime = LocalDateTime.now().plusDays(1); + OaTaskDO task = new OaTaskDO().setType(OaTaskTypeEnum.WORK.getType()) + .setStatus(OaTaskStatusEnum.NEW.getStatus()).setTitle("完成 OA 任务管理迁移") + .setDescription("完成任务管理的前后端实现和自动化测试") + .setStartTime(startTime).setEndTime(startTime.plusDays(7)).setTop(false).setCanceled(false); + task.setCreator(publisherUserId.toString()); + return task; + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelApplyServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelApplyServiceImplTest.java new file mode 100644 index 0000000000..0f7eef6b5a --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelApplyServiceImplTest.java @@ -0,0 +1,631 @@ +package cn.iocoder.yudao.module.oa.service.travel; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.dict.core.DictFrameworkUtils; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi; +import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO; +import cn.iocoder.yudao.module.oa.controller.admin.travel.vo.apply.*; +import cn.iocoder.yudao.module.oa.controller.admin.travel.vo.apply.OaTravelApplySubmitReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.travel.OaTravelApplyDO; +import cn.iocoder.yudao.module.oa.dal.mysql.travel.OaTravelApplyMapper; +import cn.iocoder.yudao.module.oa.dal.redis.no.OaNoRedisDAO; +import cn.iocoder.yudao.module.oa.enums.attendance.OaAttendanceTypeEnum; +import cn.iocoder.yudao.module.oa.service.attendance.OaAttendanceService; +import cn.iocoder.yudao.module.system.api.dict.DictDataApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import jakarta.validation.ConstraintViolationException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import java.util.UUID; + +import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.getMonthDateTimeRange; +import static cn.iocoder.yudao.framework.common.util.object.BeanUtils.toBean; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaTravelApplyServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import({OaTravelApplyServiceImpl.class, ValidationAutoConfiguration.class}) +public class OaTravelApplyServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaTravelApplyService travelApplyService; + + @Resource + private OaTravelApplyMapper travelApplyMapper; + + @MockitoBean + private OaAttendanceService attendanceService; + @MockitoBean + private OaNoRedisDAO noRedisDAO; + @MockitoBean + private AdminUserApi adminUserApi; + @MockitoBean + private DictDataApi dictDataApi; + @MockitoBean + private BpmProcessInstanceApi processInstanceApi; + @BeforeEach + public void before() { + DictFrameworkUtils.init(dictDataApi); + DictFrameworkUtils.clearCache(); + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(10L).setUserType(2), + new MockHttpServletRequest()); + } + + @AfterEach + public void tearDown() { + DictFrameworkUtils.clearCache(); + SecurityContextHolder.clearContext(); + } + + @Test + public void testValidateApprovedTravelApply_reimburseStatus() { + // mock 数据 + OaTravelApplyDO apply = randomTravelApplyDO().setStatus(2).setReimburseStatus(false); + apply.setCreator("10"); + travelApplyMapper.insert(apply); + + // 调用,并断言:未报销可关联,报销完成后不能再关联 + assertEquals(apply.getId(), travelApplyService.validateApprovedTravelApply(apply.getId(), 10L).getId()); + travelApplyMapper.updateById(new OaTravelApplyDO().setId(apply.getId()).setReimburseStatus(true)); + assertServiceException(() -> travelApplyService.validateApprovedTravelApply(apply.getId(), 10L), + TRAVEL_APPLY_ALREADY_REIMBURSED); + } + + @Test + public void testUpdateTravelApplyStatus_createAttendance() { + // mock 数据 + OaTravelApplyDO apply = randomTravelApplyDO() + .setStatus(1).setDays(2).setStartTime(LocalDateTime.of(2026, 9, 1, 8, 0)); + apply.setCreator("10"); + travelApplyMapper.insert(apply); + + // 调用:审批通过后生成考勤明细 + travelApplyService.updateTravelApplyStatus(apply.getId(), 2); + // 断言 + assertEquals(2, travelApplyMapper.selectById(apply.getId()).getStatus()); + verify(attendanceService).createApplyAttendance(10L, + OaAttendanceTypeEnum.TRAVEL, + apply.getStartTime()); + } + + @Test + public void testUpdateTravelApplyStatus_rejectWithoutAttendance() { + // mock 数据 + OaTravelApplyDO apply = randomTravelApplyDO().setStatus(1); + travelApplyMapper.insert(apply); + + // 调用 + travelApplyService.updateTravelApplyStatus(apply.getId(), 3); + // 断言 + verifyNoInteractions(attendanceService); + } + + @Test + public void testCreateTravelApply_duplicateNo() { + // mock 数据 + OaTravelApplyDO record = randomTravelApplyDO(); + travelApplyMapper.insert(record); + // mock 方法 + when(noRedisDAO.generate(anyString())).thenReturn(record.getNo()); + when(adminUserApi.validateUser(10L)).thenReturn(new AdminUserRespDTO().setId(10L).setDeptId(20L)); + + // 调用,并断言异常 + assertServiceException(() -> travelApplyService.createTravelApply(toBean(randomTravelApplyDO(), OaTravelApplySaveReqVO.class), 10L), + TRAVEL_NO_DUPLICATE); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testSubmitTravelApply_savedContentAndAssignees() { + // mock 数据 + OaTravelApplyDO record = randomTravelApplyDO().setDays(2); + travelApplyMapper.insert(record); + // 准备参数 + OaTravelApplySubmitReqVO reqVO = new OaTravelApplySubmitReqVO().setId(record.getId()) + .setStartUserSelectAssignees(Collections.singletonMap("approve", Collections.singletonList(20L))); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("new-process"); + + // 调用 + travelApplyService.submitTravelApply(reqVO, 10L); + + // 断言:仅提交审批,不覆盖正文及明细 + OaTravelApplyDO updated = travelApplyMapper.selectById(record.getId()); + assertEquals(record.getReason(), updated.getReason()); + assertEquals(record.getItems(), updated.getItems()); + assertEquals(record.getFileUrls(), updated.getFileUrls()); + verify(processInstanceApi).createProcessInstance(eq(10L), argThat(dto -> + Integer.valueOf(2).equals(dto.getVariables().get("days")) + && reqVO.getStartUserSelectAssignees().equals(dto.getStartUserSelectAssignees()))); + } + + @Test + public void testSubmitTravelApply_notOwner() { + // mock 数据 + OaTravelApplyDO record = randomTravelApplyDO(); + travelApplyMapper.insert(record); + // 准备参数 + OaTravelApplySubmitReqVO reqVO = new OaTravelApplySubmitReqVO().setId(record.getId()); + + // 调用,并断言异常 + assertServiceException(() -> travelApplyService.submitTravelApply(reqVO, 20L), TRAVEL_ACCESS_DENIED); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testCreateTravelApply_jsonAndDays() { + // 准备参数 + OaTravelApplySaveReqVO reqVO = toBean(randomTravelApplyDO(), OaTravelApplySaveReqVO.class); + // mock 方法 + when(noRedisDAO.generate(anyString())).thenReturn("TRAVEL-NEW"); + when(adminUserApi.validateUser(10L)).thenReturn(new AdminUserRespDTO().setId(10L).setDeptId(20L)); + + // 调用 + Long id = travelApplyService.createTravelApply(reqVO, 10L); + + // 断言:内联转换保留明细、附件和天数计算 + OaTravelApplyDO record = travelApplyMapper.selectById(id); + assertEquals(1, record.getDays()); + assertEquals(reqVO.getItems().size(), record.getItems().size()); + assertEquals(reqVO.getFileUrls(), record.getFileUrls()); + assertEquals("TRAVEL-NEW", record.getNo()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testUpdateTravelApply_notExists() { + // 准备参数 + OaTravelApplySaveReqVO reqVO = toBean(randomTravelApplyDO(), OaTravelApplySaveReqVO.class).setId(999L); + + // 调用,并断言:修改不存在的单据不会转为新增 + assertServiceException(() -> travelApplyService.updateTravelApply(reqVO, 10L), TRAVEL_NOT_EXISTS); + verifyNoInteractions(noRedisDAO, processInstanceApi); + } + + @Test + public void testSubmitTravelApply_unsaved() { + // 准备参数 + OaTravelApplySubmitReqVO reqVO = new OaTravelApplySubmitReqVO().setId(999L); + + // 调用,并断言异常 + assertServiceException(() -> travelApplyService.submitTravelApply(reqVO, 10L), TRAVEL_NOT_EXISTS); + verifyNoInteractions(noRedisDAO, processInstanceApi); + } + + @Test + public void testCreateTravelApply_nullItem() { + // 准备参数 + OaTravelApplySaveReqVO reqVO = toBean(randomTravelApplyDO(), OaTravelApplySaveReqVO.class) + .setItems(Collections.singletonList(null)); + + // 调用,并断言:空明细由级联校验拦截,不进入字典查询及保存 + assertThrows(ConstraintViolationException.class, () -> travelApplyService.createTravelApply(reqVO, 10L)); + verifyNoInteractions(dictDataApi, noRedisDAO, processInstanceApi); + } + + @Test + public void testCreateTravelApply_invalidItemDates() { + // 准备参数 + OaTravelApplySaveReqVO reqVO = toBean(randomTravelApplyDO(), OaTravelApplySaveReqVO.class) + .setItems(Collections.singletonList( + new OaTravelApplySaveReqVO.Item().setStartTime(LocalDateTime.of(2026, 9, 15, 0, 0)) + .setEndTime(LocalDateTime.of(2026, 9, 14, 0, 0)))); + + // 调用,并断言 + assertThrows(ConstraintViolationException.class, () -> travelApplyService.createTravelApply(reqVO, 10L)); + verifyNoInteractions(dictDataApi, noRedisDAO, processInstanceApi); + } + + @Test + public void testCreateTravelApply_emptyDraft() { + // 准备参数 + OaTravelApplySaveReqVO reqVO = new OaTravelApplySaveReqVO(); + + // 调用,并断言:草稿保存也必须填写完整内容 + assertThrows(ConstraintViolationException.class, () -> travelApplyService.createTravelApply(reqVO, 10L)); + verifyNoInteractions(noRedisDAO, processInstanceApi); + } + + @Test + public void testUpdateTravelApply_jsonAndDays() { + // mock 数据 + OaTravelApplyDO record = randomTravelApplyDO(); + travelApplyMapper.insert(record); + // 准备参数 + OaTravelApplySaveReqVO reqVO = toBean(record, OaTravelApplySaveReqVO.class) + .setStartTime(LocalDateTime.of(2026, 12, 31, 0, 0)) + .setEndTime(LocalDateTime.of(2027, 1, 2, 0, 0)) + .setFileUrls(Collections.singletonList("https://example.com/receipt.pdf")); + + // 调用 + travelApplyService.updateTravelApply(reqVO, 10L); + + // 断言:跨年按实际时长计算天数,JSON 明细正常读取 + OaTravelApplyDO updated = travelApplyMapper.selectById(record.getId()); + assertEquals(2, updated.getDays()); + assertEquals(1, updated.getItems().size()); + assertEquals(reqVO.getFileUrls(), updated.getFileUrls()); + assertEquals(record.getNo(), updated.getNo()); + } + + @Test + public void testUpdateTravelApply_missingRequiredFields() { + // mock 数据 + OaTravelApplyDO record = randomTravelApplyDO(); + travelApplyMapper.insert(record); + // 准备参数 + OaTravelApplySaveReqVO reqVO = toBean(record, OaTravelApplySaveReqVO.class).setReason(null); + + // 调用,并断言:修改不能清空必填内容,已有数据保持不变 + assertThrows(ConstraintViolationException.class, () -> travelApplyService.updateTravelApply(reqVO, 10L)); + OaTravelApplyDO updated = travelApplyMapper.selectById(record.getId()); + assertEquals(record.getReason(), updated.getReason()); + assertEquals(record.getStatus(), updated.getStatus()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testCreateTravelApply_invalidDates() { + // 准备参数 + OaTravelApplySaveReqVO reqVO = toBean(randomTravelApplyDO(), OaTravelApplySaveReqVO.class).setId(null) + .setStartTime(LocalDateTime.of(2026, 9, 15, 0, 0)).setEndTime(LocalDateTime.of(2026, 9, 14, 0, 0)); + + // 调用,并断言 + assertThrows(ConstraintViolationException.class, () -> travelApplyService.createTravelApply(reqVO, 10L)); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2}) + public void testUpdateTravelApply_notEditable(Integer status) { + // mock 数据 + OaTravelApplyDO record = randomTravelApplyDO().setStatus(status); + travelApplyMapper.insert(record); + // 准备参数 + OaTravelApplySaveReqVO reqVO = toBean(record, OaTravelApplySaveReqVO.class); + + // 调用,并断言异常 + assertServiceException(() -> travelApplyService.updateTravelApply(reqVO, 10L), TRAVEL_STATUS_INVALID); + } + + @Test + public void testCreateTravelApply_missingFields() { + // 准备参数 + OaTravelApplySaveReqVO reqVO = toBean(randomTravelApplyDO().setReason(null) + .setStartTime(null), OaTravelApplySaveReqVO.class); + + // 调用,并断言:必填校验在保存时完成,不生成单号或发起审批 + assertThrows(ConstraintViolationException.class, () -> travelApplyService.createTravelApply(reqVO, 10L)); + verifyNoInteractions(noRedisDAO, processInstanceApi); + } + + @Test + public void testCreateTravelApply_invalidEstimatedPrice() { + // 准备参数 + OaTravelApplySaveReqVO reqVO = toBean(randomTravelApplyDO(), OaTravelApplySaveReqVO.class) + .setEstimatedPrice(new BigDecimal("-1")); + + // 调用,并断言:保存入口拦截非法金额 + assertThrows(ConstraintViolationException.class, () -> travelApplyService.createTravelApply(reqVO, 10L)); + verifyNoInteractions(noRedisDAO, processInstanceApi); + } + + @Test + public void testCreateTravelApply_invalidTransportType() { + // 准备参数 + OaTravelApplySaveReqVO reqVO = toBean(randomTravelApplyDO(), OaTravelApplySaveReqVO.class) + .setItems(Collections.singletonList( + new OaTravelApplySaveReqVO.Item().setTransportType(999))); + // mock 方法 + when(dictDataApi.getDictDataList(anyString())).thenReturn(Collections.emptyList()); + + // 调用,并断言:通过 InDict 校验交通方式,不再手工调用字典校验 API + assertThrows(ConstraintViolationException.class, () -> travelApplyService.createTravelApply(reqVO, 10L)); + verify(dictDataApi, never()).validateDictDataList(anyString(), anyCollection()); + verifyNoInteractions(noRedisDAO, processInstanceApi); + } + + @Test + public void testCreateTravelApply_missingItems() { + // 准备参数 + OaTravelApplySaveReqVO reqVO = toBean(randomTravelApplyDO() + .setItems(Collections.emptyList()), OaTravelApplySaveReqVO.class); + + // 调用,并断言:必填校验在保存时完成,不生成单号或发起审批 + assertThrows(ConstraintViolationException.class, () -> travelApplyService.createTravelApply(reqVO, 10L)); + verifyNoInteractions(noRedisDAO, processInstanceApi); + } + + @ParameterizedTest + @ValueSource(ints = {-1, 3, 4}) + public void testSubmitTravelApply_resubmit(Integer status) { + // mock 数据 + OaTravelApplyDO record = randomTravelApplyDO().setStatus(status).setProcessInstanceId("old-process"); + travelApplyMapper.insert(record); + // 准备参数 + OaTravelApplySubmitReqVO reqVO = new OaTravelApplySubmitReqVO().setId(record.getId()); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("new-process"); + + // 调用 + travelApplyService.submitTravelApply(reqVO, 10L); + Long id = record.getId(); + + // 断言:不重复创建单据,使用新的审批实例 + OaTravelApplyDO updated = travelApplyMapper.selectById(id); + assertEquals(record.getId(), id); + assertEquals(1, updated.getStatus()); + assertEquals("new-process", updated.getProcessInstanceId()); + verifyNoInteractions(noRedisDAO); + } + + @Test + public void testSubmitTravelApply_rollback() { + // mock 数据 + OaTravelApplyDO record = randomTravelApplyDO().setProcessInstanceId(null); + travelApplyMapper.insert(record); + // 准备参数 + OaTravelApplySubmitReqVO reqVO = new OaTravelApplySubmitReqVO().setId(record.getId()); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenThrow(new IllegalStateException("流程未部署")); + + // 调用,并断言:失败不遗留审批中单据,也不覆盖草稿 + assertThrows(IllegalStateException.class, () -> travelApplyService.submitTravelApply(reqVO, 10L)); + OaTravelApplyDO updated = travelApplyMapper.selectById(record.getId()); + assertEquals(-1, updated.getStatus()); + assertEquals(record.getReason(), updated.getReason()); + } + + @Test + public void testSubmitTravelApply_synchronousCallback() { + // mock 数据 + OaTravelApplyDO record = randomTravelApplyDO(); + travelApplyMapper.insert(record); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenAnswer(invocation -> { + BpmProcessInstanceCreateReqDTO dto = invocation.getArgument(1); + travelApplyService.updateTravelApplyStatus(Long.valueOf(dto.getBusinessKey()), 2); + return "sync-process"; + }); + + // 调用 + travelApplyService.submitTravelApply(new OaTravelApplySubmitReqVO().setId(record.getId()), 10L); + + // 断言:绑定流程编号不会把同步审批通过回退成审批中 + OaTravelApplyDO updated = travelApplyMapper.selectById(record.getId()); + assertEquals(2, updated.getStatus()); + assertEquals(false, updated.getReimburseStatus()); + } + + @Test + public void testCancelTravelApply_running() { + // mock 数据 + OaTravelApplyDO record = randomTravelApplyDO().setStatus(1).setProcessInstanceId("running"); + travelApplyMapper.insert(record); + + // 调用 + travelApplyService.cancelTravelApply(record.getId(), 10L); + + // 断言:通过 BPM 撤回,由事件回写状态 + verify(processInstanceApi).cancelProcessInstanceByStartUser(eq(10L), eq("running"), anyString()); + } + + @ParameterizedTest + @ValueSource(ints = {-1, 3, 4}) + public void testDeleteTravelApply_editable(Integer status) { + // mock 数据 + OaTravelApplyDO record = randomTravelApplyDO().setStatus(status); + travelApplyMapper.insert(record); + + // 调用 + travelApplyService.deleteTravelApply(record.getId(), 10L); + // 断言 + assertNull(travelApplyMapper.selectById(record.getId())); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2}) + public void testDeleteTravelApply_notEditable(Integer status) { + // mock 数据 + OaTravelApplyDO record = randomTravelApplyDO().setStatus(status); + travelApplyMapper.insert(record); + + // 调用,并断言异常 + assertServiceException(() -> travelApplyService.deleteTravelApply(record.getId(), 10L), TRAVEL_STATUS_INVALID); + } + + @Test + public void testGetTravelApply() { + // mock 数据 + OaTravelApplyDO record = randomTravelApplyDO(); + travelApplyMapper.insert(record); + + // 调用,并断言:查询对齐用印,不在 Service 增加本人或流程参与人判断 + assertEquals(record.getId(), travelApplyService.getTravelApply(record.getId()).getId()); + assertNull(travelApplyService.getTravelApply(-1L)); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testUpdateAndDeleteTravelApply_notOwner() { + // mock 数据 + OaTravelApplyDO record = randomTravelApplyDO(); + travelApplyMapper.insert(record); + + // 调用,并断言异常 + assertServiceException(() -> travelApplyService.updateTravelApply(toBean(record, OaTravelApplySaveReqVO.class), 20L), + TRAVEL_ACCESS_DENIED); + assertServiceException(() -> travelApplyService.deleteTravelApply(record.getId(), 20L), TRAVEL_ACCESS_DENIED); + } + + @Test + public void testGetTravelApplyPage_onlyOwner() { + // mock 数据 + OaTravelApplyDO mine = randomTravelApplyDO().setNo("MINE"); + travelApplyMapper.insert(mine); + OaTravelApplyDO other = randomTravelApplyDO().setNo("OTHER"); + other.setCreator("20"); + travelApplyMapper.insert(other); + + // 调用 + PageResult page = travelApplyService.getTravelApplyPage(10L, new OaTravelApplyPageReqVO()); + // 断言 + assertEquals(1L, page.getTotal()); + assertEquals(mine.getId(), CollUtil.getFirst(page.getList()).getId()); + } + + @Test + public void testUpdateTravelApplyStatus_notExists() { + + // 调用,并断言异常 + assertServiceException(() -> travelApplyService.updateTravelApplyStatus(999L, 2), TRAVEL_NOT_EXISTS); + } + + @ParameterizedTest + @ValueSource(ints = {2, 3, 4}) + public void testUpdateTravelApplyStatus(Integer status) { + // mock 数据 + OaTravelApplyDO record = randomTravelApplyDO().setStatus(1).setProcessInstanceId("current"); + travelApplyMapper.insert(record); + + // 调用 + travelApplyService.updateTravelApplyStatus(record.getId(), status); + + // 断言:按业务编号更新结果,不重新绑定流程编号 + OaTravelApplyDO updated = travelApplyMapper.selectById(record.getId()); + assertEquals(status, updated.getStatus()); + assertEquals("current", updated.getProcessInstanceId()); + } + + @Test + public void testGetTravelApplyMap() { + // mock 数据 + OaTravelApplyDO record = randomTravelApplyDO(); + travelApplyMapper.insert(record); + + // 调用 + Map applies = travelApplyService.getTravelApplyMap( + Arrays.asList(record.getId(), 999L)); + + // 断言:按编号映射,忽略不存在的申请 + assertEquals(1, applies.size()); + assertEquals(record.getNo(), applies.get(record.getId()).getNo()); + assertEquals(Collections.emptyMap(), travelApplyService.getTravelApplyMap(Collections.emptyList())); + } + + @Test + public void testGetApprovedTravelDaysMap() { + // mock 数据:月初申请和月末开始的跨月申请 + LocalDateTime startTime = LocalDateTime.of(2026, 9, 1, 0, 0); + travelApplyMapper.insert(randomTravelApplyDO().setStatus(2).setStartTime(startTime).setDays(2)); + travelApplyMapper.insert(randomTravelApplyDO().setStatus(2).setStartTime(startTime.plusMonths(1).minusSeconds(1)) + .setEndTime(startTime.plusMonths(1).plusDays(2)).setDays(3)); + // 排除非通过、前后月份、其他用户和已删除的申请 + for (Integer status : Arrays.asList(-1, 1, 3, 4)) { + travelApplyMapper.insert(randomTravelApplyDO().setStatus(status).setStartTime(startTime).setDays(10)); + } + travelApplyMapper.insert(randomTravelApplyDO().setStatus(2).setStartTime(startTime.minusSeconds(1)).setDays(10)); + travelApplyMapper.insert(randomTravelApplyDO().setStatus(2).setStartTime(startTime.plusMonths(1)).setDays(10)); + OaTravelApplyDO otherUserApply = randomTravelApplyDO().setStatus(2).setStartTime(startTime).setDays(10); + otherUserApply.setCreator("99"); + travelApplyMapper.insert(otherUserApply); + OaTravelApplyDO deletedApply = randomTravelApplyDO().setStatus(2).setStartTime(startTime).setDays(10); + deletedApply.setDeleted(true); + travelApplyMapper.insert(deletedApply); + // 准备参数 + LocalDateTime[] monthTime = getMonthDateTimeRange(2026, 9); + + // 调用 + Map result = travelApplyService.getApprovedTravelDaysMap(Arrays.asList(10L, 20L), monthTime); + + // 断言:按开始月份计入全部天数,不拆分跨月天数 + assertEquals(Collections.singletonMap(10L, 5), result); + assertTrue(travelApplyService.getApprovedTravelDaysMap(Collections.emptyList(), monthTime).isEmpty()); + } + + @ParameterizedTest + @CsvSource({"0,0", "120,1", "1440,1", "1441,2", "2880,2"}) + public void testCreateAndUpdateTravelApply_elapsedDays(Long minutes, Integer expectedDays) { + // 准备参数:跨午夜不足一天、整天及超出整天的时间边界 + LocalDateTime startTime = LocalDateTime.of(2026, 9, 30, 23, 0); + OaTravelApplySaveReqVO reqVO = toBean(randomTravelApplyDO(), OaTravelApplySaveReqVO.class) + .setStartTime(startTime).setEndTime(startTime.plusMinutes(minutes)); + // mock 方法 + when(noRedisDAO.generate(anyString())).thenReturn("TRAVEL-DAYS"); + when(adminUserApi.validateUser(10L)).thenReturn(new AdminUserRespDTO().setId(10L).setDeptId(20L)); + + // 调用 + Long id = travelApplyService.createTravelApply(reqVO, 10L); + + // 断言:按经过时长向上取整,不额外增加首尾日期 + assertEquals(expectedDays, travelApplyMapper.selectById(id).getDays()); + + // 调用:修改开始、结束时间后重新计算 + reqVO.setId(id).setStartTime(startTime.plusMonths(1)).setEndTime(startTime.plusMonths(1).plusMinutes(minutes)); + travelApplyService.updateTravelApply(reqVO, 10L); + + // 断言 + assertEquals(expectedDays, travelApplyMapper.selectById(id).getDays()); + } + + @Test + public void testCreateTravelApply_sameDayEndBeforeStart() { + // 准备参数:同一天的结束时间早于开始时间 + LocalDateTime startTime = LocalDateTime.of(2026, 9, 30, 10, 0); + OaTravelApplySaveReqVO reqVO = toBean(randomTravelApplyDO(), OaTravelApplySaveReqVO.class) + .setStartTime(startTime).setEndTime(startTime.minusHours(1)); + + // 调用,并断言:不能保存负时长的出差 + assertThrows(ConstraintViolationException.class, + () -> travelApplyService.createTravelApply(reqVO, 10L)); + verifyNoInteractions(noRedisDAO, processInstanceApi); + } + + // ========== 随机对象 ========== + + /** + * 构造测试数据,固定业务字段的合法取值。 + * + * @return 未入库的测试对象 + */ + private static OaTravelApplyDO randomTravelApplyDO() { + OaTravelApplyDO record = randomPojo(OaTravelApplyDO.class, item -> item.setId(null) + .setNo("TRAVEL-" + UUID.randomUUID()).setDeptId(20L) + .setStartTime(LocalDateTime.of(2026, 9, 14, 0, 0)).setEndTime(LocalDateTime.of(2026, 9, 15, 0, 0)) + .setStatus(-1).setProcessInstanceId(null).setReimburseStatus(false) + .setEstimatedPrice(BigDecimal.ZERO) + .setItems(Collections.singletonList(new OaTravelApplyDO.Item())) + .setFileUrls(Collections.singletonList("https://example.com/receipt.pdf"))); + record.setCreator("10"); + return record; + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelReimbursementServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelReimbursementServiceImplTest.java new file mode 100644 index 0000000000..deb9743782 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/travel/OaTravelReimbursementServiceImplTest.java @@ -0,0 +1,602 @@ +package cn.iocoder.yudao.module.oa.service.travel; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.dict.core.DictFrameworkUtils; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi; +import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO; +import cn.iocoder.yudao.module.oa.controller.admin.travel.vo.reimbursement.*; +import cn.iocoder.yudao.module.oa.controller.admin.travel.vo.reimbursement.OaTravelReimbursementSubmitReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.travel.OaTravelReimbursementDO; +import cn.iocoder.yudao.module.oa.dal.mysql.travel.OaTravelReimbursementMapper; +import cn.iocoder.yudao.module.oa.dal.redis.no.OaNoRedisDAO; +import cn.iocoder.yudao.module.system.api.dict.DictDataApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import jakarta.validation.ConstraintViolationException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.UUID; + +import static cn.iocoder.yudao.framework.common.util.object.BeanUtils.toBean; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaTravelReimbursementServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import({OaTravelReimbursementServiceImpl.class, ValidationAutoConfiguration.class}) +public class OaTravelReimbursementServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaTravelReimbursementService travelReimbursementService; + + @Resource + private OaTravelReimbursementMapper travelReimbursementMapper; + + @MockitoBean + private OaNoRedisDAO noRedisDAO; + @MockitoBean + private AdminUserApi adminUserApi; + @MockitoBean + private DictDataApi dictDataApi; + @MockitoBean + private BpmProcessInstanceApi processInstanceApi; + @MockitoBean + private OaTravelApplyService travelApplyService; + + @BeforeEach + public void before() { + DictFrameworkUtils.init(dictDataApi); + DictFrameworkUtils.clearCache(); + SecurityFrameworkUtils.setLoginUser(new LoginUser().setId(10L).setUserType(2), + new MockHttpServletRequest()); + } + + @AfterEach + public void tearDown() { + DictFrameworkUtils.clearCache(); + SecurityContextHolder.clearContext(); + } + + @Test + public void testCreateTravelReimbursement_duplicateNo() { + // mock 数据 + OaTravelReimbursementDO record = randomTravelReimbursementDO(); + travelReimbursementMapper.insert(record); + // mock 方法 + when(noRedisDAO.generate(anyString())).thenReturn(record.getNo()); + when(adminUserApi.validateUser(10L)).thenReturn(new AdminUserRespDTO().setId(10L).setDeptId(20L)); + + // 调用,并断言异常 + assertServiceException(() -> travelReimbursementService.createTravelReimbursement(toBean(randomTravelReimbursementDO(), OaTravelReimbursementSaveReqVO.class), 10L), + TRAVEL_NO_DUPLICATE); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testSubmitTravelReimbursement_savedContentAndAssignees() { + // mock 数据 + OaTravelReimbursementDO record = randomTravelReimbursementDO().setDays(2); + travelReimbursementMapper.insert(record); + // 准备参数 + OaTravelReimbursementSubmitReqVO reqVO = new OaTravelReimbursementSubmitReqVO().setId(record.getId()) + .setStartUserSelectAssignees(Collections.singletonMap("approve", Collections.singletonList(20L))); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("new-process"); + + // 调用 + travelReimbursementService.submitTravelReimbursement(reqVO, 10L); + + // 断言:仅提交审批,不覆盖正文及明细 + OaTravelReimbursementDO updated = travelReimbursementMapper.selectById(record.getId()); + assertEquals(record.getReason(), updated.getReason()); + assertEquals(record.getItems(), updated.getItems()); + assertEquals(record.getFileUrls(), updated.getFileUrls()); + verify(processInstanceApi).createProcessInstance(eq(10L), argThat(dto -> + Integer.valueOf(2).equals(dto.getVariables().get("days")) + && reqVO.getStartUserSelectAssignees().equals(dto.getStartUserSelectAssignees()))); + } + + @Test + public void testSubmitTravelReimbursement_notOwner() { + // mock 数据 + OaTravelReimbursementDO record = randomTravelReimbursementDO(); + travelReimbursementMapper.insert(record); + // 准备参数 + OaTravelReimbursementSubmitReqVO reqVO = new OaTravelReimbursementSubmitReqVO().setId(record.getId()); + + // 调用,并断言异常 + assertServiceException(() -> travelReimbursementService.submitTravelReimbursement(reqVO, 20L), TRAVEL_ACCESS_DENIED); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testCreateTravelReimbursement_jsonAndDays() { + // 准备参数 + OaTravelReimbursementSaveReqVO reqVO = toBean(randomTravelReimbursementDO(), OaTravelReimbursementSaveReqVO.class); + // mock 方法 + when(noRedisDAO.generate(anyString())).thenReturn("TRAVEL-NEW"); + when(adminUserApi.validateUser(10L)).thenReturn(new AdminUserRespDTO().setId(10L).setDeptId(20L)); + + // 调用 + Long id = travelReimbursementService.createTravelReimbursement(reqVO, 10L); + + // 断言:新增保存明细、附件并计算天数和总金额 + OaTravelReimbursementDO record = travelReimbursementMapper.selectById(id); + assertEquals(1, record.getDays()); + assertEquals(reqVO.getItems().size(), record.getItems().size()); + assertEquals(reqVO.getFileUrls(), record.getFileUrls()); + assertEquals(new BigDecimal("0.00"), record.getTotalPrice()); + assertEquals("TRAVEL-NEW", record.getNo()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testUpdateTravelReimbursement_notExists() { + // 准备参数 + OaTravelReimbursementSaveReqVO reqVO = toBean(randomTravelReimbursementDO(), OaTravelReimbursementSaveReqVO.class) + .setId(999L); + + // 调用,并断言:修改不存在的单据不会转为新增 + assertServiceException(() -> travelReimbursementService.updateTravelReimbursement(reqVO, 10L), TRAVEL_NOT_EXISTS); + verifyNoInteractions(noRedisDAO, processInstanceApi); + } + + @Test + public void testSubmitTravelReimbursement_unsaved() { + // 准备参数 + OaTravelReimbursementSubmitReqVO reqVO = new OaTravelReimbursementSubmitReqVO().setId(999L); + + // 调用,并断言异常 + assertServiceException(() -> travelReimbursementService.submitTravelReimbursement(reqVO, 10L), TRAVEL_NOT_EXISTS); + verifyNoInteractions(noRedisDAO, processInstanceApi); + } + + @Test + public void testCreateTravelReimbursement_nullItem() { + // 准备参数 + OaTravelReimbursementSaveReqVO reqVO = toBean(randomTravelReimbursementDO(), OaTravelReimbursementSaveReqVO.class) + .setItems(Collections.singletonList(null)); + + // 调用,并断言:空明细由级联校验拦截,不进入字典查询及保存 + assertThrows(ConstraintViolationException.class, () -> travelReimbursementService.createTravelReimbursement(reqVO, 10L)); + verifyNoInteractions(dictDataApi, noRedisDAO, processInstanceApi); + } + + @Test + public void testCreateTravelReimbursement_emptyDraft() { + // 准备参数 + OaTravelReimbursementSaveReqVO reqVO = new OaTravelReimbursementSaveReqVO(); + + // 调用,并断言:草稿保存也必须填写完整内容 + assertThrows(ConstraintViolationException.class, () -> travelReimbursementService.createTravelReimbursement(reqVO, 10L)); + verifyNoInteractions(noRedisDAO, processInstanceApi); + } + + @Test + public void testUpdateTravelReimbursement_jsonAndDays() { + // mock 数据 + OaTravelReimbursementDO record = randomTravelReimbursementDO(); + travelReimbursementMapper.insert(record); + // 准备参数 + OaTravelReimbursementSaveReqVO reqVO = toBean(record, OaTravelReimbursementSaveReqVO.class) + .setStartTime(LocalDateTime.of(2026, 12, 31, 0, 0)) + .setEndTime(LocalDateTime.of(2027, 1, 2, 0, 0)) + .setFileUrls(Collections.singletonList("https://example.com/receipt.pdf")); + + // 调用 + travelReimbursementService.updateTravelReimbursement(reqVO, 10L); + + // 断言:跨年按实际时长计算天数,JSON 明细正常读取 + OaTravelReimbursementDO updated = travelReimbursementMapper.selectById(record.getId()); + assertEquals(2, updated.getDays()); + assertEquals(1, updated.getItems().size()); + assertEquals(reqVO.getFileUrls(), updated.getFileUrls()); + assertEquals(record.getNo(), updated.getNo()); + } + + @Test + public void testUpdateTravelReimbursement_missingRequiredFields() { + // mock 数据 + OaTravelReimbursementDO record = randomTravelReimbursementDO(); + travelReimbursementMapper.insert(record); + // 准备参数 + OaTravelReimbursementSaveReqVO reqVO = toBean(record, OaTravelReimbursementSaveReqVO.class).setReason(null); + + // 调用,并断言:修改不能清空必填内容,已有数据保持不变 + assertThrows(ConstraintViolationException.class, () -> travelReimbursementService.updateTravelReimbursement(reqVO, 10L)); + OaTravelReimbursementDO updated = travelReimbursementMapper.selectById(record.getId()); + assertEquals(record.getReason(), updated.getReason()); + assertEquals(record.getStatus(), updated.getStatus()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testCreateTravelReimbursement_invalidDates() { + // 准备参数 + OaTravelReimbursementSaveReqVO reqVO = toBean(randomTravelReimbursementDO(), OaTravelReimbursementSaveReqVO.class) + .setId(null) + .setStartTime(LocalDateTime.of(2026, 9, 15, 0, 0)).setEndTime(LocalDateTime.of(2026, 9, 14, 0, 0)); + + // 调用,并断言 + assertThrows(ConstraintViolationException.class, () -> travelReimbursementService.createTravelReimbursement(reqVO, 10L)); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2}) + public void testUpdateTravelReimbursement_notEditable(Integer status) { + // mock 数据 + OaTravelReimbursementDO record = randomTravelReimbursementDO().setStatus(status); + travelReimbursementMapper.insert(record); + // 准备参数 + OaTravelReimbursementSaveReqVO reqVO = toBean(record, OaTravelReimbursementSaveReqVO.class); + + // 调用,并断言异常 + assertServiceException(() -> travelReimbursementService.updateTravelReimbursement(reqVO, 10L), TRAVEL_STATUS_INVALID); + } + + @Test + public void testCreateTravelReimbursement_missingFields() { + // 准备参数 + OaTravelReimbursementSaveReqVO reqVO = toBean(randomTravelReimbursementDO().setReason(null) + .setStartTime(null), OaTravelReimbursementSaveReqVO.class); + + // 调用,并断言:必填校验在保存时完成,不生成单号或发起审批 + assertThrows(ConstraintViolationException.class, () -> travelReimbursementService.createTravelReimbursement(reqVO, 10L)); + verifyNoInteractions(noRedisDAO, processInstanceApi); + } + + @Test + public void testCreateTravelReimbursement_missingItems() { + // 准备参数 + OaTravelReimbursementSaveReqVO reqVO = toBean(randomTravelReimbursementDO() + .setItems(Collections.emptyList()), OaTravelReimbursementSaveReqVO.class); + + // 调用,并断言:必填校验在保存时完成,不生成单号或发起审批 + assertThrows(ConstraintViolationException.class, () -> travelReimbursementService.createTravelReimbursement(reqVO, 10L)); + verifyNoInteractions(noRedisDAO, processInstanceApi); + } + + @ParameterizedTest + @ValueSource(ints = {-1, 3, 4}) + public void testSubmitTravelReimbursement_resubmit(Integer status) { + // mock 数据 + OaTravelReimbursementDO record = randomTravelReimbursementDO().setStatus(status).setProcessInstanceId("old-process"); + travelReimbursementMapper.insert(record); + // 准备参数 + OaTravelReimbursementSubmitReqVO reqVO = new OaTravelReimbursementSubmitReqVO().setId(record.getId()); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("new-process"); + + // 调用 + travelReimbursementService.submitTravelReimbursement(reqVO, 10L); + Long id = record.getId(); + + // 断言:不重复创建单据,使用新的审批实例 + OaTravelReimbursementDO updated = travelReimbursementMapper.selectById(id); + assertEquals(record.getId(), id); + assertEquals(1, updated.getStatus()); + assertEquals("new-process", updated.getProcessInstanceId()); + verifyNoInteractions(noRedisDAO); + } + + @Test + public void testSubmitTravelReimbursement_rollback() { + // mock 数据 + OaTravelReimbursementDO record = randomTravelReimbursementDO().setProcessInstanceId(null); + travelReimbursementMapper.insert(record); + // 准备参数 + OaTravelReimbursementSubmitReqVO reqVO = new OaTravelReimbursementSubmitReqVO().setId(record.getId()); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenThrow(new IllegalStateException("流程未部署")); + + // 调用,并断言:失败不遗留审批中单据,也不覆盖草稿 + assertThrows(IllegalStateException.class, () -> travelReimbursementService.submitTravelReimbursement(reqVO, 10L)); + OaTravelReimbursementDO updated = travelReimbursementMapper.selectById(record.getId()); + assertEquals(-1, updated.getStatus()); + assertEquals(record.getReason(), updated.getReason()); + } + + @Test + public void testSubmitTravelReimbursement_synchronousCallback() { + // mock 数据 + OaTravelReimbursementDO record = randomTravelReimbursementDO(); + travelReimbursementMapper.insert(record); + // mock 方法 + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenAnswer(invocation -> { + BpmProcessInstanceCreateReqDTO dto = invocation.getArgument(1); + travelReimbursementService.updateTravelReimbursementStatus(Long.valueOf(dto.getBusinessKey()), 2); + return "sync-process"; + }); + + // 调用 + travelReimbursementService.submitTravelReimbursement(new OaTravelReimbursementSubmitReqVO().setId(record.getId()), 10L); + + // 断言:绑定流程编号不会把同步审批通过回退成审批中 + OaTravelReimbursementDO updated = travelReimbursementMapper.selectById(record.getId()); + assertEquals(2, updated.getStatus()); + assertEquals(false, updated.getPayStatus()); + } + + @Test + public void testCancelTravelReimbursement_running() { + // mock 数据 + OaTravelReimbursementDO record = randomTravelReimbursementDO().setStatus(1).setProcessInstanceId("running"); + travelReimbursementMapper.insert(record); + + // 调用 + travelReimbursementService.cancelTravelReimbursement(record.getId(), 10L); + + // 断言:通过 BPM 撤回,由事件回写状态 + verify(processInstanceApi).cancelProcessInstanceByStartUser(eq(10L), eq("running"), anyString()); + } + + @ParameterizedTest + @ValueSource(ints = {-1, 3, 4}) + public void testDeleteTravelReimbursement_editable(Integer status) { + // mock 数据 + OaTravelReimbursementDO record = randomTravelReimbursementDO().setStatus(status); + travelReimbursementMapper.insert(record); + + // 调用 + travelReimbursementService.deleteTravelReimbursement(record.getId(), 10L); + // 断言 + assertNull(travelReimbursementMapper.selectById(record.getId())); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2}) + public void testDeleteTravelReimbursement_notEditable(Integer status) { + // mock 数据 + OaTravelReimbursementDO record = randomTravelReimbursementDO().setStatus(status); + travelReimbursementMapper.insert(record); + + // 调用,并断言异常 + assertServiceException(() -> travelReimbursementService.deleteTravelReimbursement(record.getId(), 10L), + TRAVEL_STATUS_INVALID); + } + + @Test + public void testGetTravelReimbursement() { + // mock 数据 + OaTravelReimbursementDO record = randomTravelReimbursementDO(); + travelReimbursementMapper.insert(record); + + // 调用,并断言:查询对齐用印,不在 Service 增加本人或流程参与人判断 + assertEquals(record.getId(), travelReimbursementService.getTravelReimbursement(record.getId()).getId()); + assertNull(travelReimbursementService.getTravelReimbursement(-1L)); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testUpdateAndDeleteTravelReimbursement_notOwner() { + // mock 数据 + OaTravelReimbursementDO record = randomTravelReimbursementDO(); + travelReimbursementMapper.insert(record); + + // 调用,并断言异常 + assertServiceException(() -> travelReimbursementService.updateTravelReimbursement(toBean(record, OaTravelReimbursementSaveReqVO.class), 20L), + TRAVEL_ACCESS_DENIED); + assertServiceException(() -> travelReimbursementService.deleteTravelReimbursement(record.getId(), 20L), + TRAVEL_ACCESS_DENIED); + } + + @Test + public void testGetTravelReimbursementPage_onlyOwner() { + // mock 数据 + OaTravelReimbursementDO mine = randomTravelReimbursementDO().setNo("MINE"); + travelReimbursementMapper.insert(mine); + OaTravelReimbursementDO other = randomTravelReimbursementDO().setNo("OTHER"); + other.setCreator("20"); + travelReimbursementMapper.insert(other); + + // 调用 + PageResult page = travelReimbursementService.getTravelReimbursementPage(10L, new OaTravelReimbursementPageReqVO()); + // 断言 + assertEquals(1L, page.getTotal()); + assertEquals(mine.getId(), CollUtil.getFirst(page.getList()).getId()); + } + + @Test + public void testUpdateTravelReimbursementStatus_notExists() { + + // 调用,并断言异常 + assertServiceException(() -> travelReimbursementService.updateTravelReimbursementStatus(999L, 2), TRAVEL_NOT_EXISTS); + } + + @ParameterizedTest + @ValueSource(ints = {2, 3, 4}) + public void testUpdateTravelReimbursementStatus(Integer status) { + // mock 数据 + OaTravelReimbursementDO record = randomTravelReimbursementDO().setStatus(1).setProcessInstanceId("current"); + travelReimbursementMapper.insert(record); + + // 调用 + travelReimbursementService.updateTravelReimbursementStatus(record.getId(), status); + + // 断言:按业务编号更新结果,不重新绑定流程编号 + OaTravelReimbursementDO updated = travelReimbursementMapper.selectById(record.getId()); + assertEquals(status, updated.getStatus()); + assertEquals("current", updated.getProcessInstanceId()); + } + + @Test + public void testCreateTravelReimbursement_invalidExpenseType() { + // 准备参数 + OaTravelReimbursementSaveReqVO reqVO = toBean(randomTravelReimbursementDO(), OaTravelReimbursementSaveReqVO.class) + .setItems(Collections.singletonList( + new OaTravelReimbursementSaveReqVO.Item().setExpenseType(999))); + // mock 方法 + when(dictDataApi.getDictDataList(anyString())).thenReturn(Collections.emptyList()); + + // 调用,并断言:保存入口通过 InDict 校验费用类型 + assertThrows(ConstraintViolationException.class, () -> travelReimbursementService.createTravelReimbursement(reqVO, 10L)); + verify(dictDataApi, never()).validateDictDataList(anyString(), anyCollection()); + verifyNoInteractions(noRedisDAO, processInstanceApi); + } + + @Test + public void testCreateTravelReimbursement_missingPrice() { + // 准备参数 + OaTravelReimbursementSaveReqVO reqVO = toBean(randomTravelReimbursementDO() + .setItems(Collections.singletonList(new OaTravelReimbursementDO.Item())), OaTravelReimbursementSaveReqVO.class); + + // 调用,并断言:必填校验在保存时完成,不生成单号或发起审批 + assertThrows(ConstraintViolationException.class, () -> travelReimbursementService.createTravelReimbursement(reqVO, 10L)); + verifyNoInteractions(noRedisDAO, processInstanceApi); + } + + @Test + public void testCreateTravelReimbursement_missingFiles() { + // 准备参数 + OaTravelReimbursementSaveReqVO reqVO = toBean(randomTravelReimbursementDO() + .setFileUrls(Collections.emptyList()), OaTravelReimbursementSaveReqVO.class); + + // 调用,并断言:必填校验在保存时完成,不生成单号或发起审批 + assertThrows(ConstraintViolationException.class, () -> travelReimbursementService.createTravelReimbursement(reqVO, 10L)); + verifyNoInteractions(noRedisDAO, processInstanceApi); + } + + @Test + public void testUpdateTravelReimbursement_totalPrice() { + // mock 数据 + OaTravelReimbursementDO record = randomTravelReimbursementDO(); + travelReimbursementMapper.insert(record); + // 准备参数 + OaTravelReimbursementSaveReqVO reqVO = toBean(record, OaTravelReimbursementSaveReqVO.class); + reqVO.setItems(Arrays.asList( + new OaTravelReimbursementSaveReqVO.Item().setPrice(new BigDecimal("0.10")), + new OaTravelReimbursementSaveReqVO.Item().setPrice(new BigDecimal("0.20")))); + + // 调用 + travelReimbursementService.updateTravelReimbursement(reqVO, 10L); + // 断言:汇总使用十进制,避免金额浮点误差 + assertEquals(new BigDecimal("0.30"), travelReimbursementMapper.selectById(record.getId()).getTotalPrice()); + } + + @Test + public void testCreateTravelReimbursement_negativePrice() { + // 准备参数 + OaTravelReimbursementSaveReqVO reqVO = toBean(randomTravelReimbursementDO(), OaTravelReimbursementSaveReqVO.class) + .setItems(Collections.singletonList(new OaTravelReimbursementSaveReqVO.Item().setPrice(BigDecimal.ONE.negate()))); + + // 调用,并断言 + assertThrows(ConstraintViolationException.class, () -> travelReimbursementService.createTravelReimbursement(reqVO, 10L)); + } + + @Test + public void testUpdateTravelReimbursement_selectedApply() { + // mock 数据 + OaTravelReimbursementDO record = randomTravelReimbursementDO(); + travelReimbursementMapper.insert(record); + // 准备参数 + OaTravelReimbursementSaveReqVO reqVO = toBean(record, OaTravelReimbursementSaveReqVO.class); + reqVO.setTravelApplyId(100L); + + // 调用 + travelReimbursementService.updateTravelReimbursement(reqVO, 10L); + // 断言:关联申请由申请 Service 校验,不直接操作其他聚合的 Mapper + verify(travelApplyService).validateApprovedTravelApply(100L, 10L); + assertEquals(100L, travelReimbursementMapper.selectById(record.getId()).getTravelApplyId()); + } + + @Test + public void testUpdateTravelReimbursementStatus_approved() { + // mock 数据 + OaTravelReimbursementDO record = randomTravelReimbursementDO() + .setStatus(1).setProcessInstanceId("current").setTravelApplyId(100L); + travelReimbursementMapper.insert(record); + + // 调用 + travelReimbursementService.updateTravelReimbursementStatus(record.getId(), 2); + travelReimbursementService.updateTravelReimbursementStatus(record.getId(), 2); + // 断言:审批通过只标记已报销,不视作已支付;重复回调不重复更新关联申请 + OaTravelReimbursementDO actual = travelReimbursementMapper.selectById(record.getId()); + assertEquals(2, actual.getStatus()); + assertEquals(false, actual.getPayStatus()); + verify(travelApplyService, times(1)).updateTravelApplyReimburseStatus(100L); + } + + @ParameterizedTest + @CsvSource({"0,0", "120,1", "1440,1", "1441,2", "2880,2"}) + public void testCreateAndUpdateTravelReimbursement_elapsedDays(Long minutes, Integer expectedDays) { + // 准备参数:跨午夜不足一天、整天及超出整天的时间边界 + LocalDateTime startTime = LocalDateTime.of(2026, 9, 30, 23, 0); + OaTravelReimbursementSaveReqVO reqVO = toBean(randomTravelReimbursementDO(), OaTravelReimbursementSaveReqVO.class) + .setStartTime(startTime).setEndTime(startTime.plusMinutes(minutes)); + // mock 方法 + when(noRedisDAO.generate(anyString())).thenReturn("TRAVEL-DAYS"); + when(adminUserApi.validateUser(10L)).thenReturn(new AdminUserRespDTO().setId(10L).setDeptId(20L)); + + // 调用 + Long id = travelReimbursementService.createTravelReimbursement(reqVO, 10L); + + // 断言:按经过时长向上取整,不额外增加首尾日期 + assertEquals(expectedDays, travelReimbursementMapper.selectById(id).getDays()); + + // 调用:修改开始、结束时间后重新计算 + reqVO.setId(id).setStartTime(startTime.plusMonths(1)).setEndTime(startTime.plusMonths(1).plusMinutes(minutes)); + travelReimbursementService.updateTravelReimbursement(reqVO, 10L); + + // 断言 + assertEquals(expectedDays, travelReimbursementMapper.selectById(id).getDays()); + } + + @Test + public void testCreateTravelReimbursement_sameDayEndBeforeStart() { + // 准备参数:同一天的结束时间早于开始时间 + LocalDateTime startTime = LocalDateTime.of(2026, 9, 30, 10, 0); + OaTravelReimbursementSaveReqVO reqVO = toBean(randomTravelReimbursementDO(), OaTravelReimbursementSaveReqVO.class) + .setStartTime(startTime).setEndTime(startTime.minusHours(1)); + + // 调用,并断言:不能保存负时长的出差 + assertThrows(ConstraintViolationException.class, + () -> travelReimbursementService.createTravelReimbursement(reqVO, 10L)); + verifyNoInteractions(noRedisDAO, processInstanceApi); + } + + // ========== 随机对象 ========== + + /** + * 构造测试数据,固定业务字段的合法取值。 + * + * @return 未入库的测试对象 + */ + private static OaTravelReimbursementDO randomTravelReimbursementDO() { + OaTravelReimbursementDO record = randomPojo(OaTravelReimbursementDO.class, item -> item.setId(null) + .setNo("TRAVEL-" + UUID.randomUUID()).setDeptId(20L) + .setStartTime(LocalDateTime.of(2026, 9, 14, 0, 0)).setEndTime(LocalDateTime.of(2026, 9, 15, 0, 0)) + .setStatus(-1).setProcessInstanceId(null).setPayStatus(false) + .setTravelApplyId(null).setTotalPrice(BigDecimal.ZERO) + .setItems(Collections.singletonList(new OaTravelReimbursementDO.Item().setPrice(BigDecimal.ZERO))) + .setFileUrls(Collections.singletonList("https://example.com/receipt.pdf"))); + record.setCreator("10"); + return record; + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleApplyServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleApplyServiceImplTest.java new file mode 100644 index 0000000000..a98ad80b8f --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleApplyServiceImplTest.java @@ -0,0 +1,511 @@ +package cn.iocoder.yudao.module.oa.service.vehicle; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; +import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi; +import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO; +import cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.apply.OaVehicleApplyPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.apply.OaVehicleApplySaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.OaVehicleApplyDO; +import cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.OaVehicleDO; +import cn.iocoder.yudao.module.oa.dal.mysql.vehicle.OaVehicleApplyMapper; +import cn.iocoder.yudao.module.oa.dal.redis.no.OaNoRedisDAO; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import cn.iocoder.yudao.module.oa.enums.vehicle.OaVehicleStatusEnum; +import cn.iocoder.yudao.module.oa.service.vehicle.listener.OaVehicleApplyStatusListener; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; + +import static cn.iocoder.yudao.framework.common.util.object.BeanUtils.toBean; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaVehicleApplyServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import({OaVehicleApplyServiceImpl.class, OaVehicleApplyStatusListener.class}) +public class OaVehicleApplyServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaVehicleApplyService vehicleApplyService; + + @Resource + private OaVehicleApplyMapper vehicleApplyMapper; + @Resource + private ApplicationEventPublisher eventPublisher; + + @MockitoBean + private OaNoRedisDAO noRedisDAO; + @MockitoBean + private OaVehicleService vehicleService; + @MockitoBean + private AdminUserApi adminUserApi; + @MockitoBean + private BpmProcessInstanceApi processInstanceApi; + + @Test + public void testGetVehicleApply_success() { + // mock 数据 + OaVehicleApplyDO record = randomVehicleApplyDO().setProcessInstanceId("detail-process"); + vehicleApplyMapper.insert(record); + + // 调用,并断言:详情查询不依赖审批参与关系 + assertEquals(record.getId(), vehicleApplyService.getVehicleApply(record.getId()).getId()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testGetVehicleApply_notExists() { + + // 调用,并断言 + assertNull(vehicleApplyService.getVehicleApply(-1L)); + } + + @Test + public void testSubmitVehicleApply_notExists() { + + // 调用,并断言异常 + assertServiceException(() -> vehicleApplyService.submitVehicleApply(-1L, 1L), VEHICLE_APPLY_NOT_EXISTS); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testUpdateVehicleApply_clearFiles() { + // mock 数据 + OaVehicleApplyDO record = randomVehicleApplyDO() + .setFileUrls(Arrays.asList("https://example.com/a.pdf", "https://example.com/b.docx")); + vehicleApplyMapper.insert(record); + // 准备参数 + OaVehicleApplySaveReqVO reqVO = toBean(record, OaVehicleApplySaveReqVO.class) + .setFileUrls(Collections.emptyList()); + // mock 方法 + when(vehicleService.validateVehicleExists(30L)).thenReturn(randomVehicleDO()); + when(vehicleService.validateVehicleExists(30L)).thenReturn(randomVehicleDO()); + + // 调用 + vehicleApplyService.updateVehicleApply(reqVO, 10L); + + // 断言 + assertEquals(Collections.emptyList(), vehicleApplyMapper.selectById(record.getId()).getFileUrls()); + } + + @Test + public void testCreateVehicleApply_noDuplicate() { + // mock 单号生成 + when(noRedisDAO.generate(OaNoRedisDAO.VEHICLE_APPLY_NO_PREFIX)).thenReturn("YC12345678"); + // mock 数据 + OaVehicleApplyDO apply = randomVehicleApplyDO().setNo("YC12345678"); + vehicleApplyMapper.insert(apply); + // 准备参数 + OaVehicleApplySaveReqVO reqVO = toBean(randomVehicleApplyDO(), OaVehicleApplySaveReqVO.class); + // mock 方法 + when(vehicleService.validateVehicleExists(30L)).thenReturn(randomVehicleDO()); + when(adminUserApi.validateUser(10L)).thenReturn(new AdminUserRespDTO().setId(10L).setDeptId(20L)); + + // 调用,并断言异常 + assertServiceException(() -> vehicleApplyService.createVehicleApply(reqVO, 10L), VEHICLE_APPLY_NO_DUPLICATE); + } + + @Test + public void testUpdateVehicleApply_preserveNo() { + // mock 数据 + OaVehicleApplyDO apply = randomVehicleApplyDO().setNo("YC12345678"); + vehicleApplyMapper.insert(apply); + OaVehicleApplyDO other = randomVehicleApplyDO().setNo("YC87654321"); + vehicleApplyMapper.insert(other); + // 准备参数 + OaVehicleApplySaveReqVO reqVO = toBean(apply, OaVehicleApplySaveReqVO.class); + // mock 方法 + when(vehicleService.validateVehicleExists(30L)).thenReturn(randomVehicleDO()); + + // 调用,并断言原单号不变 + vehicleApplyService.updateVehicleApply(reqVO, 10L); + verifyNoInteractions(noRedisDAO); + assertEquals(apply.getNo(), vehicleApplyMapper.selectById(apply.getId()).getNo()); + } + + @Test + public void testCreateVehicleApply() { + // mock 单号生成 + when(noRedisDAO.generate(OaNoRedisDAO.VEHICLE_APPLY_NO_PREFIX)).thenReturn("VEHICLE_APPLY20260912000001"); + // 准备参数 + OaVehicleApplySaveReqVO reqVO = toBean(randomVehicleApplyDO(), OaVehicleApplySaveReqVO.class); + reqVO.setId(999L); + reqVO.setFileUrls(Arrays.asList("https://example.com/a.pdf", "https://example.com/b.docx")); + // mock 方法 + when(vehicleService.validateVehicleExists(30L)).thenReturn(randomVehicleDO()); + when(adminUserApi.validateUser(10L)).thenReturn(new AdminUserRespDTO().setId(10L).setDeptId(20L)); + + // 调用 + Long id = vehicleApplyService.createVehicleApply(reqVO, 10L); + + // 断言 + OaVehicleApplyDO apply = vehicleApplyMapper.selectById(id); + assertNotEquals(999L, id); + assertEquals("VEHICLE_APPLY20260912000001", apply.getNo()); + assertEquals(10L, apply.getUserId()); + assertEquals(20L, apply.getDeptId()); + assertEquals(-1, apply.getStatus()); + assertEquals(0, apply.getReturnStatus()); + assertNull(apply.getProcessInstanceId()); + assertEquals(reqVO.getFileUrls(), apply.getFileUrls()); + } + + @Test + public void testSubmitVehicleApply_duplicate() { + // mock 数据 + OaVehicleApplyDO apply = randomVehicleApplyDO().setPassenger("测试随行人"); + vehicleApplyMapper.insert(apply); + // mock 方法 + when(vehicleService.validateVehicleExists(30L)).thenReturn(randomVehicleDO()); + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenAnswer(invocation -> { + // BPM 创建流程时会追加系统变量,传入的变量 Map 必须可修改 + BpmProcessInstanceCreateReqDTO reqDTO = invocation.getArgument(1); + reqDTO.getVariables().put("START_USER_ID", 10L); + return "process-1"; + }); + + // 调用 + assertEquals("process-1", vehicleApplyService.submitVehicleApply(apply.getId(), 10L)); + assertServiceException(() -> vehicleApplyService.submitVehicleApply(apply.getId(), 10L), VEHICLE_APPLY_STATUS_INVALID); + + // 断言 + OaVehicleApplyDO actual = vehicleApplyMapper.selectById(apply.getId()); + assertEquals(1, actual.getStatus()); + assertEquals("测试随行人", actual.getPassenger()); + verify(processInstanceApi, times(1)).createProcessInstance(eq(10L), any()); + } + + @Test + public void testSubmitVehicleApply_overlap() { + // mock 数据 + OaVehicleApplyDO occupied = randomVehicleApplyDO().setStatus(1); + vehicleApplyMapper.insert(occupied); + OaVehicleApplyDO apply = randomVehicleApplyDO(); + vehicleApplyMapper.insert(apply); + // mock 方法 + when(vehicleService.validateVehicleExists(30L)).thenReturn(randomVehicleDO()); + + // 调用,并断言异常 + assertServiceException(() -> vehicleApplyService.submitVehicleApply(apply.getId(), 10L), + VEHICLE_APPLY_TIME_CONFLICT); + verifyNoInteractions(processInstanceApi); + assertEquals(-1, vehicleApplyMapper.selectById(apply.getId()).getStatus()); + } + + @Test + public void testSubmitVehicleApply_adjacentTime() { + // mock 数据 + OaVehicleApplyDO occupied = randomVehicleApplyDO().setStatus(2).setReturnStatus(1); + vehicleApplyMapper.insert(occupied); + OaVehicleApplyDO apply = randomVehicleApplyDO().setStartTime(occupied.getEndTime()) + .setEndTime(occupied.getEndTime().plusHours(1)); + vehicleApplyMapper.insert(apply); + // mock 方法 + when(vehicleService.validateVehicleExists(30L)).thenReturn(randomVehicleDO()); + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("adjacent"); + + // 调用,并断言 + assertEquals("adjacent", vehicleApplyService.submitVehicleApply(apply.getId(), 10L)); + } + + @Test + public void testSubmitVehicleApply_rejectedDoesNotOccupy() { + // mock 数据 + vehicleApplyMapper.insert(randomVehicleApplyDO().setStatus(3)); + vehicleApplyMapper.insert(randomVehicleApplyDO().setStatus(4)); + vehicleApplyMapper.insert(randomVehicleApplyDO().setStatus(2).setReturnStatus(3)); + OaVehicleApplyDO apply = randomVehicleApplyDO(); + vehicleApplyMapper.insert(apply); + // mock 方法 + when(vehicleService.validateVehicleExists(30L)).thenReturn(randomVehicleDO()); + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("released"); + + // 调用,并断言 + assertEquals("released", vehicleApplyService.submitVehicleApply(apply.getId(), 10L)); + } + + @Test + public void testSubmitVehicleApply_processFailureRollback() { + // mock 数据 + OaVehicleApplyDO apply = randomVehicleApplyDO(); + vehicleApplyMapper.insert(apply); + // mock 方法 + when(vehicleService.validateVehicleExists(30L)).thenReturn(randomVehicleDO()); + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenThrow(new IllegalStateException("未发布模型")); + + // 调用 + assertThrows(IllegalStateException.class, () -> vehicleApplyService.submitVehicleApply(apply.getId(), 10L)); + + // 断言 + assertEquals(-1, vehicleApplyMapper.selectById(apply.getId()).getStatus()); + assertNull(vehicleApplyMapper.selectById(apply.getId()).getProcessInstanceId()); + } + + @ParameterizedTest + @EnumSource(OaVehicleStatusEnum.class) + public void testCreateVehicleApply_vehicleStatus(OaVehicleStatusEnum status) { + // 准备参数 + OaVehicleApplySaveReqVO reqVO = toBean(randomVehicleApplyDO(), OaVehicleApplySaveReqVO.class); + // mock 方法 + when(vehicleService.validateVehicleExists(30L)).thenReturn(randomVehicleDO().setStatus(status.getStatus())); + when(adminUserApi.validateUser(10L)).thenReturn(new AdminUserRespDTO().setId(10L).setDeptId(20L)); + when(noRedisDAO.generate(OaNoRedisDAO.VEHICLE_APPLY_NO_PREFIX)).thenReturn("YC202609120001"); + + // 调用 + Long id = vehicleApplyService.createVehicleApply(reqVO, 10L); + + // 断言:草稿允许选择任意状态的车辆,不发起审批 + OaVehicleApplyDO apply = vehicleApplyMapper.selectById(id); + assertEquals(reqVO.getVehicleId(), apply.getVehicleId()); + assertEquals(-1, apply.getStatus()); + assertNull(apply.getProcessInstanceId()); + verifyNoInteractions(processInstanceApi); + } + + @ParameterizedTest + @EnumSource(OaVehicleStatusEnum.class) + public void testUpdateVehicleApply_vehicleStatus(OaVehicleStatusEnum status) { + // mock 数据 + OaVehicleApplyDO apply = randomVehicleApplyDO(); + vehicleApplyMapper.insert(apply); + // 准备参数 + OaVehicleApplySaveReqVO reqVO = toBean(apply, OaVehicleApplySaveReqVO.class).setReason("调整用车事由"); + // mock 方法 + when(vehicleService.validateVehicleExists(30L)).thenReturn(randomVehicleDO().setStatus(status.getStatus())); + + // 调用 + vehicleApplyService.updateVehicleApply(reqVO, 10L); + + // 断言:车辆状态变化不影响编辑草稿 + OaVehicleApplyDO actual = vehicleApplyMapper.selectById(apply.getId()); + assertEquals(reqVO.getReason(), actual.getReason()); + assertEquals(-1, actual.getStatus()); + verifyNoInteractions(processInstanceApi); + } + + @ParameterizedTest + @EnumSource(value = OaVehicleStatusEnum.class, names = {"DISABLED", "IN_USE"}) + public void testSubmitVehicleApply_notIdleVehicle(OaVehicleStatusEnum status) { + // mock 数据 + OaVehicleApplyDO apply = randomVehicleApplyDO(); + vehicleApplyMapper.insert(apply); + // mock 方法 + when(vehicleService.validateVehicleExists(30L)).thenReturn(randomVehicleDO().setStatus(status.getStatus())); + + // 调用,并断言异常 + assertServiceException(() -> vehicleApplyService.submitVehicleApply(apply.getId(), 10L), VEHICLE_NOT_ENABLED); + verifyNoInteractions(processInstanceApi); + OaVehicleApplyDO actual = vehicleApplyMapper.selectById(apply.getId()); + assertEquals(-1, actual.getStatus()); + assertNull(actual.getProcessInstanceId()); + } + + @Test + public void testSubmitVehicleApply_pastTime() { + // mock 数据 + OaVehicleApplyDO apply = randomVehicleApplyDO().setStartTime(LocalDateTime.now().minusHours(1)); + vehicleApplyMapper.insert(apply); + // mock 方法 + when(vehicleService.validateVehicleExists(30L)).thenReturn(randomVehicleDO()); + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("past-time"); + + // 调用,并断言:允许补录已开始的用车申请 + assertEquals("past-time", vehicleApplyService.submitVehicleApply(apply.getId(), 10L)); + assertEquals(1, vehicleApplyMapper.selectById(apply.getId()).getStatus()); + } + + @Test + public void testUpdateVehicleApplyStatus_approveAndIdempotent() { + // mock 数据 + OaVehicleApplyDO apply = randomVehicleApplyDO().setStatus(1).setProcessInstanceId("approved"); + vehicleApplyMapper.insert(apply); + + // 调用 + vehicleApplyService.updateVehicleApplyStatus(apply.getId(), 2); + vehicleApplyService.updateVehicleApplyStatus(apply.getId(), 2); + + // 断言 + assertEquals(2, vehicleApplyMapper.selectById(apply.getId()).getStatus()); + assertEquals(1, vehicleApplyMapper.selectById(apply.getId()).getReturnStatus()); + assertEquals("approved", vehicleApplyMapper.selectById(apply.getId()).getProcessInstanceId()); + } + + @Test + public void testUpdateVehicleApplyStatus_returnedNotReset() { + // mock 数据 + OaVehicleApplyDO apply = randomVehicleApplyDO().setStatus(2).setReturnStatus(3); + vehicleApplyMapper.insert(apply); + + // 调用 + vehicleApplyService.updateVehicleApplyStatus(apply.getId(), 2); + vehicleApplyService.updateVehicleApplyStatus(apply.getId(), 3); + + // 断言 + OaVehicleApplyDO actual = vehicleApplyMapper.selectById(apply.getId()); + assertEquals(2, actual.getStatus()); + assertEquals(3, actual.getReturnStatus()); + } + + @Test + public void testSubmitVehicleApply_synchronousApproval() { + // mock 数据 + OaVehicleApplyDO apply = randomVehicleApplyDO(); + vehicleApplyMapper.insert(apply); + // mock 方法 + when(vehicleService.validateVehicleExists(30L)).thenReturn(randomVehicleDO()); + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenAnswer(invocation -> { + eventPublisher.publishEvent(new BpmProcessInstanceStatusEvent(this) + .setId("auto-approved").setBusinessKey(apply.getId().toString()) + .setProcessDefinitionKey(BpmModelConstants.VEHICLE_APPLY).setStatus(2)); + return "auto-approved"; + }); + + // 调用 + vehicleApplyService.submitVehicleApply(apply.getId(), 10L); + + // 断言 + assertEquals(2, vehicleApplyMapper.selectById(apply.getId()).getStatus()); + assertEquals("auto-approved", vehicleApplyMapper.selectById(apply.getId()).getProcessInstanceId()); + } + + @Test + public void testGetVehicleApply_ownerAndPageScope() { + // mock 数据 + OaVehicleApplyDO own = randomVehicleApplyDO(); + vehicleApplyMapper.insert(own); + OaVehicleApplyDO other = randomVehicleApplyDO().setUserId(11L); + vehicleApplyMapper.insert(other); + + // 调用,并断言 + assertEquals(1, vehicleApplyService.getVehicleApplyPage(10L, new OaVehicleApplyPageReqVO()).getTotal()); + assertEquals(other.getId(), vehicleApplyService.getVehicleApply(other.getId()).getId()); + assertServiceException(() -> vehicleApplyService.deleteVehicleApply(other.getId(), 10L), VEHICLE_APPLY_NOT_OWNER); + } + + @Test + public void testGetVehicleApplyPage_filters() { + // mock 数据 + LocalDateTime createTime = LocalDateTime.of(2026, 9, 12, 12, 0); + OaVehicleApplyDO apply = randomVehicleApplyDO().setNo("YC202609120001") + .setVehicleNo("沪A12345").setDeptId(40L).setStatus(2).setReturnStatus(1); + apply.setCreateTime(createTime); + vehicleApplyMapper.insert(apply); + // 准备参数 + OaVehicleApplyPageReqVO reqVO = new OaVehicleApplyPageReqVO().setNo("20260912") + .setVehicleNo("12345").setDeptId(40L).setStatus(2).setReturnStatus(1) + .setCreateTime(new LocalDateTime[]{createTime, createTime}); + + // 调用,并断言:筛选不扩大本人范围,部门和创建时间独立生效 + assertEquals(1, vehicleApplyService.getVehicleApplyPage(10L, reqVO).getTotal()); + assertEquals(0, vehicleApplyService.getVehicleApplyPage(11L, reqVO).getTotal()); + reqVO.setDeptId(41L); + assertEquals(0, vehicleApplyService.getVehicleApplyPage(10L, reqVO).getTotal()); + reqVO.setDeptId(40L).setCreateTime(new LocalDateTime[]{createTime.plusSeconds(1), createTime.plusDays(1)}); + assertEquals(0, vehicleApplyService.getVehicleApplyPage(10L, reqVO).getTotal()); + reqVO.setCreateTime(null).setVehicleNo("99999"); + assertEquals(0, vehicleApplyService.getVehicleApplyPage(10L, reqVO).getTotal()); + } + + @Test + public void testUpdateVehicleApply_nullPassengerAndStatusGuard() { + // mock 数据 + OaVehicleApplyDO apply = randomVehicleApplyDO().setPassenger("外部客户张三"); + vehicleApplyMapper.insert(apply); + // 准备参数 + OaVehicleApplySaveReqVO reqVO = toBean(apply, OaVehicleApplySaveReqVO.class).setPassenger(null); + // mock 方法 + when(vehicleService.validateVehicleExists(30L)).thenReturn(randomVehicleDO()); + + // 调用 + vehicleApplyService.updateVehicleApply(reqVO, 10L); + + // 断言 + assertEquals("外部客户张三", vehicleApplyMapper.selectById(apply.getId()).getPassenger()); + vehicleApplyMapper.updateById(new OaVehicleApplyDO().setId(apply.getId()).setStatus(1)); + assertServiceException(() -> vehicleApplyService.updateVehicleApply(reqVO, 10L), VEHICLE_APPLY_STATUS_INVALID); + assertServiceException(() -> vehicleApplyService.deleteVehicleApply(apply.getId(), 10L), VEHICLE_APPLY_STATUS_INVALID); + } + + @Test + public void testCancelVehicleApply() { + // mock 数据 + OaVehicleApplyDO apply = randomVehicleApplyDO().setStatus(1).setProcessInstanceId("cancel"); + vehicleApplyMapper.insert(apply); + // mock 方法 + doAnswer(invocation -> { + eventPublisher.publishEvent(new BpmProcessInstanceStatusEvent(this) + .setId("cancel").setBusinessKey(apply.getId().toString()) + .setProcessDefinitionKey(BpmModelConstants.VEHICLE_APPLY).setStatus(4)); + return null; + }).when(processInstanceApi).cancelProcessInstanceByStartUser(10L, "cancel", "申请人取消用车申请"); + + // 调用 + vehicleApplyService.cancelVehicleApply(apply.getId(), 10L); + + // 断言 + assertEquals(4, vehicleApplyMapper.selectById(apply.getId()).getStatus()); + assertEquals(0, vehicleApplyMapper.selectById(apply.getId()).getReturnStatus()); + } + + @Test + public void testUpdateVehicleApplyReturnStatus_duplicate() { + // mock 数据 + OaVehicleApplyDO apply = randomVehicleApplyDO().setStatus(2).setReturnStatus(1); + vehicleApplyMapper.insert(apply); + + // 调用 + vehicleApplyService.updateVehicleApplyReturnStatus(apply.getId(), 1, 2); + + // 断言:已进入还车中的申请不能再次占用 + assertEquals(2, vehicleApplyMapper.selectById(apply.getId()).getReturnStatus()); + assertServiceException(() -> vehicleApplyService.updateVehicleApplyReturnStatus(apply.getId(), 1, 2), + VEHICLE_RETURN_STATUS_INVALID); + // 模拟另一请求持有旧状态时,数据库条件更新同样不会覆盖当前状态 + assertEquals(0, vehicleApplyMapper.updateReturnStatusByIdAndReturnStatus(apply.getId(), 1, 2)); + } + + // ========== 随机对象 ========== + + /** + * 构造具有合法用车时段和固定业务关联的用车申请。 + * + * @return 未入库的测试对象 + */ + private static OaVehicleApplyDO randomVehicleApplyDO() { + return randomPojo(OaVehicleApplyDO.class, apply -> apply.setId(null) + .setVehicleId(30L).setVehicleNo("沪A12345").setUserId(10L).setDeptId(20L) + .setStartTime(LocalDateTime.now().plusDays(1).withHour(9).withMinute(0).withSecond(0).withNano(0)) + .setEndTime(LocalDateTime.now().plusDays(1).withHour(10).withMinute(0).withSecond(0).withNano(0)) + .setPassenger(null) + .setStatus(-1).setReturnStatus(0).setProcessInstanceId(null)); + } + + /** + * 构造可用车辆。 + * + * @return 未入库的测试对象 + */ + private static OaVehicleDO randomVehicleDO() { + return randomPojo(OaVehicleDO.class, vehicle -> vehicle.setId(30L).setNo("沪A12345") + .setStatus(0)); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleReturnServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleReturnServiceImplTest.java new file mode 100644 index 0000000000..25314cca16 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleReturnServiceImplTest.java @@ -0,0 +1,560 @@ +package cn.iocoder.yudao.module.oa.service.vehicle; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; +import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi; +import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO; +import cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.returning.*; +import cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.*; +import cn.iocoder.yudao.module.oa.dal.mysql.vehicle.OaVehicleApplyMapper; +import cn.iocoder.yudao.module.oa.dal.mysql.vehicle.OaVehicleMapper; +import cn.iocoder.yudao.module.oa.dal.mysql.vehicle.OaVehicleReturnMapper; +import cn.iocoder.yudao.module.oa.dal.redis.no.OaNoRedisDAO; +import cn.iocoder.yudao.module.oa.enums.BpmModelConstants; +import cn.iocoder.yudao.module.oa.service.vehicle.listener.OaVehicleReturnStatusListener; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; + +import static cn.iocoder.yudao.framework.common.util.object.BeanUtils.toBean; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaVehicleReturnServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import({OaVehicleReturnServiceImpl.class, OaVehicleReturnStatusListener.class}) +public class OaVehicleReturnServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaVehicleReturnService vehicleReturnService; + + @Resource + private OaVehicleReturnMapper vehicleReturnMapper; + // 分页筛选验证关联查询,使用真实关联 Mapper 准备数据 + @Resource + private OaVehicleApplyMapper vehicleApplyMapper; + @Resource + private OaVehicleMapper vehicleMapper; + @Resource + private ApplicationEventPublisher eventPublisher; + + @MockitoBean + private OaNoRedisDAO noRedisDAO; + @MockitoBean + private OaVehicleApplyService vehicleApplyService; + @MockitoBean + private BpmProcessInstanceApi processInstanceApi; + + @Test + public void testCreateVehicleReturn_actualTripDiffersFromPlan() { + // mock 数据:实际提前出车,不能用计划时间代替事实 + OaVehicleApplyDO apply = randomVehicleApplyDO(); + OaVehicleReturnSaveReqVO reqVO = toBean(randomVehicleReturnDO(), OaVehicleReturnSaveReqVO.class) + .setActualStartTime(apply.getStartTime().minusHours(2)).setActualReturnTime(apply.getStartTime().minusHours(1)) + .setStartLocation("客户现场").setReason("现场支持").setPassenger("李四"); + // mock 方法 + when(vehicleApplyService.validateVehicleApplyExists(20L)).thenReturn(apply); + when(noRedisDAO.generate(OaNoRedisDAO.VEHICLE_RETURN_NO_PREFIX)).thenReturn("HC202609170001"); + + // 调用 + Long id = vehicleReturnService.createVehicleReturn(reqVO, 10L); + // 断言 + OaVehicleReturnDO result = vehicleReturnMapper.selectById(id); + assertEquals(reqVO.getActualStartTime(), result.getActualStartTime()); + assertEquals("客户现场", result.getStartLocation()); + assertEquals("现场支持", result.getReason()); + assertEquals("李四", result.getPassenger()); + } + + @Test + public void testGetVehicleReturnPage_filters() { + // mock 数据 + LocalDateTime createTime = LocalDateTime.of(2026, 9, 12, 12, 0); + OaVehicleDO vehicle = new OaVehicleDO().setId(30L).setNo("沪A12345").setName("商务车").setType("商务车") + .setCategory("商务接待用车").setSeatCount(7).setStatus(0).setSort(0); + vehicleMapper.insert(vehicle); + OaVehicleApplyDO apply = randomVehicleApplyDO().setNo("YC202609120001"); + vehicleApplyMapper.insert(apply); + OaVehicleReturnDO record = randomVehicleReturnDO().setNo("HC202609120001"); + record.setCreateTime(createTime); + vehicleReturnMapper.insert(record); + // 准备参数 + OaVehicleReturnPageReqVO reqVO = new OaVehicleReturnPageReqVO().setNo("HC20260912") + .setVehicleNo("12345").setApplyNo("YC20260912").setDeptId(40L) + .setCreateTime(new LocalDateTime[]{createTime, createTime}); + + // 调用,并断言:关联筛选命中同一条还车记录,不扩大本人范围 + assertEquals(1, vehicleReturnService.getVehicleReturnPage(10L, reqVO).getTotal()); + assertEquals(record.getId(), CollUtil.getFirst(vehicleReturnService.getVehicleReturnPage(10L, reqVO).getList()).getId()); + assertEquals(0, vehicleReturnService.getVehicleReturnPage(11L, reqVO).getTotal()); + reqVO.setVehicleNo("99999"); + assertEquals(0, vehicleReturnService.getVehicleReturnPage(10L, reqVO).getTotal()); + reqVO.setVehicleNo("12345").setApplyNo("not-exists"); + assertEquals(0, vehicleReturnService.getVehicleReturnPage(10L, reqVO).getTotal()); + reqVO.setApplyNo("YC20260912").setDeptId(41L); + assertEquals(0, vehicleReturnService.getVehicleReturnPage(10L, reqVO).getTotal()); + reqVO.setDeptId(40L).setCreateTime(new LocalDateTime[]{createTime.plusSeconds(1), createTime.plusDays(1)}); + assertEquals(0, vehicleReturnService.getVehicleReturnPage(10L, reqVO).getTotal()); + // 未填写关联筛选时,无关联数据的历史记录仍可查询 + vehicleReturnMapper.insert(randomVehicleReturnDO().setApplyId(999L).setVehicleId(999L)); + assertEquals(2, vehicleReturnService.getVehicleReturnPage(10L, new OaVehicleReturnPageReqVO()).getTotal()); + } + + @Test + public void testGetVehicleReturn_success() { + // mock 数据 + OaVehicleReturnDO record = randomVehicleReturnDO().setProcessInstanceId("detail-process"); + vehicleReturnMapper.insert(record); + + // 调用,并断言:详情查询不依赖审批参与关系 + assertEquals(record.getId(), vehicleReturnService.getVehicleReturn(record.getId()).getId()); + verifyNoInteractions(processInstanceApi); + } + + @Test + public void testGetVehicleReturn_notExists() { + + // 调用,并断言 + assertNull(vehicleReturnService.getVehicleReturn(-1L)); + } + + @Test + public void testUpdateVehicleReturn_clearFiles() { + // mock 数据 + OaVehicleReturnDO record = randomVehicleReturnDO() + .setFileUrls(Arrays.asList("https://example.com/a.pdf", "https://example.com/b.docx")); + vehicleReturnMapper.insert(record); + // 准备参数 + OaVehicleReturnSaveReqVO reqVO = toBean(record, OaVehicleReturnSaveReqVO.class) + .setFileUrls(Collections.emptyList()); + // mock 方法 + when(vehicleApplyService.validateVehicleApplyExists(20L)).thenReturn(randomVehicleApplyDO()); + + // 调用 + vehicleReturnService.updateVehicleReturn(reqVO, 10L); + + // 断言 + assertEquals(Collections.emptyList(), vehicleReturnMapper.selectById(record.getId()).getFileUrls()); + } + + @Test + public void testCreateVehicleReturn() { + // mock 单号生成 + when(noRedisDAO.generate(OaNoRedisDAO.VEHICLE_RETURN_NO_PREFIX)).thenReturn("VEHICLE_RETURN20260912000001"); + // 准备参数 + OaVehicleReturnSaveReqVO reqVO = toBean(randomVehicleReturnDO(), OaVehicleReturnSaveReqVO.class); + reqVO.setFileUrls(Arrays.asList("https://example.com/a.pdf", "https://example.com/b.docx")); + // mock 方法 + when(vehicleApplyService.validateVehicleApplyExists(20L)).thenReturn(randomVehicleApplyDO()); + + // 调用 + Long id = vehicleReturnService.createVehicleReturn(reqVO, 10L); + + // 断言 + OaVehicleReturnDO actual = vehicleReturnMapper.selectById(id); + assertEquals("VEHICLE_RETURN20260912000001", actual.getNo()); + assertEquals(30L, actual.getVehicleId()); + assertEquals(10L, actual.getUserId()); + assertEquals(40L, actual.getDeptId()); + assertEquals(-1, actual.getStatus()); + assertNull(actual.getProcessInstanceId()); + assertEquals(reqVO.getFileUrls(), actual.getFileUrls()); + verify(vehicleApplyService, never()).updateVehicleApplyReturnStatus(any(), any(), any()); + } + + @Test + public void testCreateVehicleReturn_noDuplicate() { + // mock 单号生成 + when(noRedisDAO.generate(OaNoRedisDAO.VEHICLE_RETURN_NO_PREFIX)).thenReturn("HC12345678"); + // mock 数据 + OaVehicleReturnDO other = randomVehicleReturnDO().setApplyId(21L).setNo("HC12345678"); + vehicleReturnMapper.insert(other); + // 准备参数 + OaVehicleReturnSaveReqVO reqVO = toBean(randomVehicleReturnDO(), OaVehicleReturnSaveReqVO.class); + // mock 方法 + when(vehicleApplyService.validateVehicleApplyExists(20L)).thenReturn(randomVehicleApplyDO()); + + // 调用,并断言异常 + assertServiceException(() -> vehicleReturnService.createVehicleReturn(reqVO, 10L), VEHICLE_RETURN_NO_DUPLICATE); + } + + @Test + public void testUpdateVehicleReturn_preserveNo() { + // mock 数据 + OaVehicleReturnDO vehicleReturn = randomVehicleReturnDO().setNo("HC12345678"); + vehicleReturnMapper.insert(vehicleReturn); + OaVehicleReturnDO other = randomVehicleReturnDO().setApplyId(21L).setNo("HC87654321"); + vehicleReturnMapper.insert(other); + // 准备参数 + OaVehicleReturnSaveReqVO reqVO = toBean(vehicleReturn, OaVehicleReturnSaveReqVO.class); + // mock 方法 + when(vehicleApplyService.validateVehicleApplyExists(20L)).thenReturn(randomVehicleApplyDO()); + + // 调用,并断言原单号不变 + vehicleReturnService.updateVehicleReturn(reqVO, 10L); + verifyNoInteractions(noRedisDAO); + assertEquals(vehicleReturn.getNo(), vehicleReturnMapper.selectById(vehicleReturn.getId()).getNo()); + } + + @Test + public void testCreateVehicleReturn_duplicateDraft() { + // mock 单号生成 + when(noRedisDAO.generate(OaNoRedisDAO.VEHICLE_RETURN_NO_PREFIX)).thenReturn("VEHICLE_RETURN20260912000001"); + // mock 数据 + vehicleReturnMapper.insert(randomVehicleReturnDO()); + // mock 方法 + when(vehicleApplyService.validateVehicleApplyExists(20L)).thenReturn(randomVehicleApplyDO()); + // 准备参数 + OaVehicleReturnSaveReqVO reqVO = toBean(randomVehicleReturnDO(), OaVehicleReturnSaveReqVO.class); + + // 调用 + Long id = vehicleReturnService.createVehicleReturn(reqVO, 10L); + // 断言 + assertNotNull(id); + assertEquals(2L, vehicleReturnMapper.selectCount()); + } + + @Test + public void testCreateVehicleReturn_afterRejected() { + // mock 单号生成 + when(noRedisDAO.generate(OaNoRedisDAO.VEHICLE_RETURN_NO_PREFIX)).thenReturn("VEHICLE_RETURN20260912000001"); + // mock 数据 + vehicleReturnMapper.insert(randomVehicleReturnDO().setStatus(3)); + vehicleReturnMapper.insert(randomVehicleReturnDO().setStatus(4)); + // mock 方法 + when(vehicleApplyService.validateVehicleApplyExists(20L)).thenReturn(randomVehicleApplyDO()); + // 准备参数 + OaVehicleReturnSaveReqVO reqVO = toBean(randomVehicleReturnDO(), OaVehicleReturnSaveReqVO.class); + + // 调用,并断言 + assertNotNull(vehicleReturnService.createVehicleReturn(reqVO, 10L)); + } + + @Test + public void testCreateVehicleReturn_beforeDeparture() { + // mock 单号生成 + when(noRedisDAO.generate(OaNoRedisDAO.VEHICLE_RETURN_NO_PREFIX)).thenReturn("VEHICLE_RETURN20260912000001"); + // mock 方法 + OaVehicleApplyDO apply = randomVehicleApplyDO(); + when(vehicleApplyService.validateVehicleApplyExists(20L)).thenReturn(apply); + // 准备参数 + OaVehicleReturnSaveReqVO reqVO = toBean(randomVehicleReturnDO(), OaVehicleReturnSaveReqVO.class) + .setActualReturnTime(apply.getStartTime().minusHours(1)); + + // 调用,并断言异常 + assertServiceException(() -> vehicleReturnService.createVehicleReturn(reqVO, 10L), VEHICLE_RETURN_TIME_INVALID); + } + + @Test + public void testCreateVehicleReturn_notApproved() { + // mock 单号生成 + when(noRedisDAO.generate(OaNoRedisDAO.VEHICLE_RETURN_NO_PREFIX)).thenReturn("VEHICLE_RETURN20260912000001"); + // mock 方法 + when(vehicleApplyService.validateVehicleApplyExists(20L)).thenReturn(randomVehicleApplyDO().setStatus(1)); + // 准备参数 + OaVehicleReturnSaveReqVO reqVO = toBean(randomVehicleReturnDO(), OaVehicleReturnSaveReqVO.class); + + // 调用,并断言异常 + assertServiceException(() -> vehicleReturnService.createVehicleReturn(reqVO, 10L), VEHICLE_RETURN_STATUS_INVALID); + } + + @Test + public void testSubmitVehicleReturn_duplicate() { + // mock 数据 + OaVehicleReturnDO vehicleReturn = randomVehicleReturnDO(); + vehicleReturnMapper.insert(vehicleReturn); + // mock 方法 + when(vehicleApplyService.validateVehicleApplyExists(20L)).thenReturn(randomVehicleApplyDO()); + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenAnswer(invocation -> { + // BPM 创建流程时会追加系统变量,传入的变量 Map 必须可修改 + BpmProcessInstanceCreateReqDTO reqDTO = invocation.getArgument(1); + reqDTO.getVariables().put("START_USER_ID", 10L); + return "return-process"; + }); + + // 调用 + assertEquals("return-process", vehicleReturnService.submitVehicleReturn(vehicleReturn.getId(), 10L)); + assertServiceException(() -> vehicleReturnService.submitVehicleReturn(vehicleReturn.getId(), 10L), + VEHICLE_RETURN_STATUS_INVALID); + + // 断言 + assertEquals(1, vehicleReturnMapper.selectById(vehicleReturn.getId()).getStatus()); + verify(vehicleApplyService, times(1)).updateVehicleApplyReturnStatus(20L, 1, 2); + verify(processInstanceApi, times(1)).createProcessInstance(eq(10L), any()); + } + + @Test + public void testSubmitVehicleReturn_processFailureRollback() { + // mock 数据 + OaVehicleReturnDO vehicleReturn = randomVehicleReturnDO(); + vehicleReturnMapper.insert(vehicleReturn); + // mock 方法 + when(vehicleApplyService.validateVehicleApplyExists(20L)).thenReturn(randomVehicleApplyDO()); + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenThrow(new IllegalStateException("模型未发布")); + + // 调用 + assertThrows(IllegalStateException.class, () -> vehicleReturnService.submitVehicleReturn(vehicleReturn.getId(), 10L)); + + // 断言 + assertEquals(-1, vehicleReturnMapper.selectById(vehicleReturn.getId()).getStatus()); + assertNull(vehicleReturnMapper.selectById(vehicleReturn.getId()).getProcessInstanceId()); + } + + @Test + public void testUpdateVehicleReturnStatus_approvedAndIdempotent() { + // mock 数据 + OaVehicleReturnDO vehicleReturn = randomVehicleReturnDO().setStatus(1).setProcessInstanceId("approved"); + vehicleReturnMapper.insert(vehicleReturn); + + // 调用 + vehicleReturnService.updateVehicleReturnStatus(vehicleReturn.getId(), 2); + vehicleReturnService.updateVehicleReturnStatus(vehicleReturn.getId(), 2); + vehicleReturnService.updateVehicleReturnStatus(vehicleReturn.getId(), 3); + + // 断言 + assertEquals(2, vehicleReturnMapper.selectById(vehicleReturn.getId()).getStatus()); + assertEquals("approved", vehicleReturnMapper.selectById(vehicleReturn.getId()).getProcessInstanceId()); + verify(vehicleApplyService, times(1)).updateVehicleApplyReturnStatus(20L, 2, 3); + verifyNoMoreInteractions(vehicleApplyService); + } + + @Test + public void testUpdateVehicleReturnStatus_applyUpdateFailure() { + // mock 数据 + OaVehicleReturnDO vehicleReturn = randomVehicleReturnDO().setStatus(1).setProcessInstanceId("approved"); + vehicleReturnMapper.insert(vehicleReturn); + // mock 方法 + doThrow(new IllegalStateException("原用车单更新失败")).when(vehicleApplyService) + .updateVehicleApplyReturnStatus(20L, 2, 3); + + // 调用,并断言 + assertThrows(IllegalStateException.class, + () -> vehicleReturnService.updateVehicleReturnStatus(vehicleReturn.getId(), 2)); + assertEquals(1, vehicleReturnMapper.selectById(vehicleReturn.getId()).getStatus()); + } + + @Test + public void testUpdateVehicleReturnStatus_rejected() { + // mock 数据 + OaVehicleReturnDO vehicleReturn = randomVehicleReturnDO().setStatus(1).setProcessInstanceId("rejected"); + vehicleReturnMapper.insert(vehicleReturn); + + // 调用 + vehicleReturnService.updateVehicleReturnStatus(vehicleReturn.getId(), 3); + + // 断言 + verify(vehicleApplyService).updateVehicleApplyReturnStatus(20L, 2, 1); + assertEquals(3, vehicleReturnMapper.selectById(vehicleReturn.getId()).getStatus()); + } + + @Test + public void testSubmitVehicleReturn_synchronousApproval() { + // mock 数据 + OaVehicleReturnDO vehicleReturn = randomVehicleReturnDO(); + vehicleReturnMapper.insert(vehicleReturn); + // mock 方法 + when(vehicleApplyService.validateVehicleApplyExists(20L)).thenReturn(randomVehicleApplyDO()); + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenAnswer(invocation -> { + eventPublisher.publishEvent(new BpmProcessInstanceStatusEvent(this).setId("auto") + .setProcessDefinitionKey(BpmModelConstants.VEHICLE_RETURN) + .setBusinessKey(vehicleReturn.getId().toString()).setStatus(2)); + return "auto"; + }); + + // 调用 + vehicleReturnService.submitVehicleReturn(vehicleReturn.getId(), 10L); + + // 断言 + assertEquals(2, vehicleReturnMapper.selectById(vehicleReturn.getId()).getStatus()); + assertEquals("auto", vehicleReturnMapper.selectById(vehicleReturn.getId()).getProcessInstanceId()); + } + + @Test + public void testUpdateVehicleReturn_changeApply() { + // mock 数据 + OaVehicleReturnDO vehicleReturn = randomVehicleReturnDO(); + vehicleReturnMapper.insert(vehicleReturn); + // 准备参数 + OaVehicleReturnSaveReqVO reqVO = toBean(vehicleReturn, OaVehicleReturnSaveReqVO.class).setApplyId(21L); + // mock 方法 + when(vehicleApplyService.validateVehicleApplyExists(21L)).thenReturn(randomVehicleApplyDO() + .setId(21L).setVehicleId(31L).setDeptId(41L)); + + // 调用 + vehicleReturnService.updateVehicleReturn(reqVO, 10L); + + // 断言 + OaVehicleReturnDO actual = vehicleReturnMapper.selectById(vehicleReturn.getId()); + assertEquals(21L, actual.getApplyId()); + assertEquals(31L, actual.getVehicleId()); + assertEquals(41L, actual.getDeptId()); + assertEquals(10L, actual.getUserId()); + } + + @Test + public void testGetVehicleReturn_ownerScope() { + // mock 数据 + OaVehicleReturnDO vehicleReturn = randomVehicleReturnDO().setUserId(11L); + vehicleReturnMapper.insert(vehicleReturn); + + // 调用,并断言 + assertEquals(0, vehicleReturnService.getVehicleReturnPage(10L, new OaVehicleReturnPageReqVO()).getTotal()); + assertEquals(vehicleReturn.getId(), vehicleReturnService.getVehicleReturn(vehicleReturn.getId()).getId()); + assertServiceException(() -> vehicleReturnService.deleteVehicleReturn(vehicleReturn.getId(), 10L), + VEHICLE_RETURN_NOT_OWNER); + } + + @Test + public void testCancelVehicleReturn() { + // mock 数据 + OaVehicleReturnDO vehicleReturn = randomVehicleReturnDO().setStatus(1).setProcessInstanceId("cancel"); + vehicleReturnMapper.insert(vehicleReturn); + // mock 方法 + doAnswer(invocation -> { + eventPublisher.publishEvent(new BpmProcessInstanceStatusEvent(this).setId("cancel") + .setProcessDefinitionKey(BpmModelConstants.VEHICLE_RETURN) + .setBusinessKey(vehicleReturn.getId().toString()).setStatus(4)); + return null; + }).when(processInstanceApi).cancelProcessInstanceByStartUser(10L, "cancel", "申请人取消还车申请"); + + // 调用 + vehicleReturnService.cancelVehicleReturn(vehicleReturn.getId(), 10L); + + // 断言 + assertEquals(4, vehicleReturnMapper.selectById(vehicleReturn.getId()).getStatus()); + verify(vehicleApplyService).updateVehicleApplyReturnStatus(20L, 2, 1); + } + + @Test + public void testDeleteVehicleReturn_onlyDraft() { + // mock 数据 + OaVehicleReturnDO draft = randomVehicleReturnDO(); + vehicleReturnMapper.insert(draft); + OaVehicleReturnDO running = randomVehicleReturnDO().setStatus(1); + vehicleReturnMapper.insert(running); + + // 调用 + vehicleReturnService.deleteVehicleReturn(draft.getId(), 10L); + + // 断言 + assertNull(vehicleReturnMapper.selectById(draft.getId())); + assertServiceException(() -> vehicleReturnService.deleteVehicleReturn(running.getId(), 10L), + VEHICLE_RETURN_STATUS_INVALID); + } + + @Test + public void testCreateVehicleReturn_futureTime() { + // mock 单号生成 + when(noRedisDAO.generate(OaNoRedisDAO.VEHICLE_RETURN_NO_PREFIX)).thenReturn("VEHICLE_RETURN20260912000001"); + // mock 方法 + when(vehicleApplyService.validateVehicleApplyExists(20L)).thenReturn(randomVehicleApplyDO()); + // 准备参数 + OaVehicleReturnSaveReqVO reqVO = toBean(randomVehicleReturnDO(), OaVehicleReturnSaveReqVO.class) + .setActualReturnTime(LocalDateTime.now().plusHours(1)); + + // 调用,并断言异常 + assertServiceException(() -> vehicleReturnService.createVehicleReturn(reqVO, 10L), VEHICLE_RETURN_TIME_INVALID); + } + + @Test + public void testUpdateVehicleReturn_changeApplyNotOwner() { + // mock 数据 + OaVehicleReturnDO vehicleReturn = randomVehicleReturnDO(); + vehicleReturnMapper.insert(vehicleReturn); + // 准备参数 + OaVehicleReturnSaveReqVO reqVO = toBean(vehicleReturn, OaVehicleReturnSaveReqVO.class).setApplyId(21L); + // mock 方法 + when(vehicleApplyService.validateVehicleApplyExists(21L)).thenReturn(randomVehicleApplyDO().setId(21L).setUserId(11L)); + + // 调用,并断言异常 + assertServiceException(() -> vehicleReturnService.updateVehicleReturn(reqVO, 10L), VEHICLE_RETURN_NOT_OWNER); + assertEquals(20L, vehicleReturnMapper.selectById(vehicleReturn.getId()).getApplyId()); + } + + @Test + public void testUpdateVehicleReturn_nullDeptIgnored() { + // mock 数据 + OaVehicleReturnDO vehicleReturn = randomVehicleReturnDO(); + vehicleReturnMapper.insert(vehicleReturn); + // 准备参数 + OaVehicleReturnSaveReqVO reqVO = toBean(vehicleReturn, OaVehicleReturnSaveReqVO.class).setApplyId(21L); + // mock 方法 + when(vehicleApplyService.validateVehicleApplyExists(21L)).thenReturn(randomVehicleApplyDO().setId(21L).setDeptId(null)); + + // 调用 + vehicleReturnService.updateVehicleReturn(reqVO, 10L); + + // 断言:空部门遵循 updateById 默认忽略策略 + OaVehicleReturnDO actual = vehicleReturnMapper.selectById(vehicleReturn.getId()); + assertEquals(21L, actual.getApplyId()); + assertEquals(vehicleReturn.getDeptId(), actual.getDeptId()); + } + + @Test + public void testSubmitVehicleReturn_multipleDrafts() { + // mock 数据 + OaVehicleReturnDO first = randomVehicleReturnDO(); + vehicleReturnMapper.insert(first); + OaVehicleReturnDO second = randomVehicleReturnDO(); + vehicleReturnMapper.insert(second); + // mock 方法:第一张提交后,原用车单进入还车中 + OaVehicleApplyDO apply = randomVehicleApplyDO(); + when(vehicleApplyService.validateVehicleApplyExists(20L)).thenReturn(apply); + doAnswer(invocation -> { + apply.setReturnStatus(2); + return null; + }).when(vehicleApplyService).updateVehicleApplyReturnStatus(20L, 1, 2); + when(processInstanceApi.createProcessInstance(eq(10L), any())).thenReturn("first-return"); + + // 调用 + vehicleReturnService.submitVehicleReturn(first.getId(), 10L); + + // 断言:另一草稿不能再次发起,还车流程只有一个 + assertServiceException(() -> vehicleReturnService.submitVehicleReturn(second.getId(), 10L), + VEHICLE_RETURN_STATUS_INVALID); + assertEquals(-1, vehicleReturnMapper.selectById(second.getId()).getStatus()); + verify(processInstanceApi, times(1)).createProcessInstance(eq(10L), any()); + } + + // ========== 随机对象 ========== + + /** + * 构造尚未提交的还车申请。 + * + * @return 未入库的测试对象 + */ + private static OaVehicleReturnDO randomVehicleReturnDO() { + return randomPojo(OaVehicleReturnDO.class, record -> record.setId(null).setApplyId(20L) + .setVehicleId(30L).setUserId(10L).setDeptId(40L) + .setActualStartTime(LocalDateTime.now().minusHours(2)) + .setActualReturnTime(LocalDateTime.now().minusHours(1)) + .setStatus(-1).setProcessInstanceId(null)); + } + + /** + * 构造具有合法用车时段和固定业务关联的用车申请。 + * + * @return 未入库的测试对象 + */ + private static OaVehicleApplyDO randomVehicleApplyDO() { + return randomPojo(OaVehicleApplyDO.class, apply -> apply.setId(20L) + .setVehicleId(30L).setUserId(10L).setDeptId(40L) + .setStartTime(LocalDateTime.now().minusHours(3)).setEndTime(LocalDateTime.now()) + .setStatus(2).setReturnStatus(1)); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleServiceImplTest.java new file mode 100644 index 0000000000..5ac5fade0c --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/vehicle/OaVehicleServiceImplTest.java @@ -0,0 +1,217 @@ +package cn.iocoder.yudao.module.oa.service.vehicle; + +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.OaVehiclePageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.vehicle.vo.OaVehicleSaveReqVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.vehicle.OaVehicleDO; +import cn.iocoder.yudao.module.oa.dal.mysql.vehicle.OaVehicleMapper; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Collections; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link OaVehicleServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(OaVehicleServiceImpl.class) +public class OaVehicleServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaVehicleService vehicleService; + + @Resource + private OaVehicleMapper vehicleMapper; + + @MockitoBean + private DeptApi deptApi; + @MockitoBean + private OaVehicleApplyService vehicleApplyService; + + @Test + public void testDeleteVehicle_inUse() { + // mock 数据 + OaVehicleDO record = randomVehicleDO(); + vehicleMapper.insert(record); + // mock 方法 + when(vehicleApplyService.getVehicleApplyCountByVehicleId(record.getId())).thenReturn(1L); + + // 调用,并断言异常 + assertServiceException(() -> vehicleService.deleteVehicle(record.getId()), VEHICLE_IN_USE); + assertNotNull(vehicleMapper.selectById(record.getId())); + } + + @Test + public void testGetVehiclePage_filters() { + // mock 数据 + LocalDateTime expireTime = LocalDateTime.of(2026, 9, 12, 12, 0); + OaVehicleDO vehicle = randomVehicleDO().setType("商务车").setBrandModel("别克 GL8") + .setCompulsoryInsuranceExpireTime(expireTime).setCommercialInsuranceExpireTime(expireTime) + .setInspectionExpireTime(expireTime); + vehicleMapper.insert(vehicle); + // 准备参数 + OaVehiclePageReqVO reqVO = new OaVehiclePageReqVO().setType("商务").setBrandModel("GL8") + .setCompulsoryInsuranceExpireTime(new LocalDateTime[]{expireTime, expireTime}) + .setCommercialInsuranceExpireTime(new LocalDateTime[]{expireTime, expireTime}) + .setInspectionExpireTime(new LocalDateTime[]{expireTime, expireTime}); + + // 调用,并断言:组合筛选包含时间边界,各个条件独立生效 + assertEquals(1, vehicleService.getVehiclePage(reqVO).getTotal()); + assertEquals(0, vehicleService.getVehiclePage(BeanUtils.toBean(reqVO, OaVehiclePageReqVO.class) + .setType("货车")).getTotal()); + assertEquals(0, vehicleService.getVehiclePage(BeanUtils.toBean(reqVO, OaVehiclePageReqVO.class) + .setBrandModel("宝马")).getTotal()); + LocalDateTime[] outsideTime = {expireTime.plusSeconds(1), expireTime.plusDays(1)}; + assertEquals(0, vehicleService.getVehiclePage(BeanUtils.toBean(reqVO, OaVehiclePageReqVO.class) + .setCompulsoryInsuranceExpireTime(outsideTime)).getTotal()); + assertEquals(0, vehicleService.getVehiclePage(BeanUtils.toBean(reqVO, OaVehiclePageReqVO.class) + .setCommercialInsuranceExpireTime(outsideTime)).getTotal()); + assertEquals(0, vehicleService.getVehiclePage(BeanUtils.toBean(reqVO, OaVehiclePageReqVO.class) + .setInspectionExpireTime(outsideTime)).getTotal()); + } + + @Test + public void testUpdateVehicleStatus() { + // mock 数据 + OaVehicleDO vehicle = randomVehicleDO(); + vehicleMapper.insert(vehicle); + // 准备参数 + OaVehicleSaveReqVO reqVO = BeanUtils.toBean(vehicle, OaVehicleSaveReqVO.class).setStatus(2); + + // 调用 + vehicleService.updateVehicle(reqVO); + vehicleService.updateVehicle(reqVO); + + // 断言 + assertEquals(2, vehicleMapper.selectById(vehicle.getId()).getStatus()); + } + + @Test + public void testDeleteVehicle_success() { + // mock 数据 + OaVehicleDO vehicle = randomVehicleDO(); + vehicleMapper.insert(vehicle); + + // 调用 + vehicleService.deleteVehicle(vehicle.getId()); + + // 断言 + assertNull(vehicleMapper.selectById(vehicle.getId())); + assertServiceException(() -> vehicleService.validateVehicleExists(vehicle.getId()), VEHICLE_NOT_EXISTS); + } + + @Test + public void testDeleteVehicle_notExists() { + + // 调用,并断言异常 + assertServiceException(() -> vehicleService.deleteVehicle(-1L), VEHICLE_NOT_EXISTS); + } + + @Test + public void testUpdateVehicle_inUseToIdle() { + // mock 数据 + OaVehicleDO vehicle = randomVehicleDO().setStatus(2); + vehicleMapper.insert(vehicle); + // 准备参数 + OaVehicleSaveReqVO reqVO = BeanUtils.toBean(vehicle, OaVehicleSaveReqVO.class).setStatus(0); + + // 调用 + vehicleService.updateVehicle(reqVO); + // 断言 + assertEquals(0, vehicleMapper.selectById(vehicle.getId()).getStatus()); + } + + @Test + public void testCreateVehicle_deptAndImage() { + // 准备参数 + OaVehicleSaveReqVO reqVO = BeanUtils.toBean(randomVehicleDO(), OaVehicleSaveReqVO.class) + .setDeptId(103L).setPicUrl("https://example.com/vehicle.png").setStatus(2); + + // 调用 + Long id = vehicleService.createVehicle(reqVO); + // 断言 + OaVehicleDO vehicle = vehicleMapper.selectById(id); + assertEquals(103L, vehicle.getDeptId()); + assertEquals(reqVO.getPicUrl(), vehicle.getPicUrl()); + assertEquals(2, vehicle.getStatus()); + verify(deptApi).validateDeptList(Collections.singleton(103L)); + } + + @Test + public void testUpdateVehicle_deptAndImage() { + // mock 数据 + OaVehicleDO vehicle = randomVehicleDO(); + vehicleMapper.insert(vehicle); + // 准备参数 + OaVehicleSaveReqVO reqVO = BeanUtils.toBean(vehicle, OaVehicleSaveReqVO.class) + .setDeptId(103L).setPicUrl("https://example.com/new.png"); + + // 调用 + vehicleService.updateVehicle(reqVO); + // 断言 + OaVehicleDO updatedVehicle = vehicleMapper.selectById(vehicle.getId()); + assertEquals(103L, updatedVehicle.getDeptId()); + assertEquals(reqVO.getPicUrl(), updatedVehicle.getPicUrl()); + verify(deptApi).validateDeptList(Collections.singleton(103L)); + } + + @Test + public void testUpdateVehicle_clearDeptAndImage() { + // mock 数据 + OaVehicleDO vehicle = randomVehicleDO().setDeptId(103L).setPicUrl("https://example.com/old.png"); + vehicleMapper.insert(vehicle); + // 准备参数 + OaVehicleSaveReqVO reqVO = BeanUtils.toBean(vehicle, OaVehicleSaveReqVO.class) + .setDeptId(null).setPicUrl(""); + + // 调用 + vehicleService.updateVehicle(reqVO); + // 断言 + OaVehicleDO updatedVehicle = vehicleMapper.selectById(vehicle.getId()); + assertNull(updatedVehicle.getDeptId()); + assertEquals("", updatedVehicle.getPicUrl()); + verifyNoInteractions(deptApi); + } + + @Test + public void testCreateVehicle_deletedPlateCanBeReused() { + // mock 数据 + OaVehicleDO vehicle = randomVehicleDO(); + vehicleMapper.insert(vehicle); + OaVehicleSaveReqVO reqVO = BeanUtils.toBean(vehicle, OaVehicleSaveReqVO.class).setId(null); + + // 调用,并断言:有效车牌仍由后端查重 + assertServiceException(() -> vehicleService.createVehicle(reqVO), VEHICLE_NO_DUPLICATE); + vehicleService.deleteVehicle(vehicle.getId()); + Long newId = vehicleService.createVehicle(reqVO); + assertEquals(vehicle.getNo(), vehicleMapper.selectById(newId).getNo()); + assertNotEquals(vehicle.getId(), newId); + } + + // ========== 随机对象 ========== + + /** + * 构造可用车辆。 + * + * @return 未入库的测试对象 + */ + private static OaVehicleDO randomVehicleDO() { + return randomPojo(OaVehicleDO.class, vehicle -> vehicle.setId(null).setNo("沪A12345") + .setName("商务车").setType("1").setSeatCount(5).setBarePrice(BigDecimal.ZERO) + .setStatus(0).setSort(0)); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/workreport/OaWorkReportServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/workreport/OaWorkReportServiceImplTest.java new file mode 100644 index 0000000000..0e6bffd194 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/workreport/OaWorkReportServiceImplTest.java @@ -0,0 +1,558 @@ +package cn.iocoder.yudao.module.oa.service.workreport; + +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.oa.controller.admin.workreport.vo.OaWorkReportPageReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.workreport.vo.OaWorkReportSaveReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.workreport.vo.OaWorkReportStatisticsReqVO; +import cn.iocoder.yudao.module.oa.controller.admin.workreport.vo.OaWorkReportStatisticsRespVO; +import cn.iocoder.yudao.module.oa.dal.dataobject.workreport.OaWorkReportDO; +import cn.iocoder.yudao.module.oa.dal.mysql.workreport.OaWorkReportMapper; +import cn.iocoder.yudao.module.oa.dal.redis.no.OaNoRedisDAO; +import cn.iocoder.yudao.module.oa.enums.workreport.OaWorkReportTypeEnum; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import jakarta.validation.ConstraintViolationException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.oa.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * {@link OaWorkReportServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import({OaWorkReportServiceImpl.class, ValidationAutoConfiguration.class}) +public class OaWorkReportServiceImplTest extends BaseDbUnitTest { + + @Resource + private OaWorkReportServiceImpl workReportService; + + @Resource + private OaWorkReportMapper workReportMapper; + + @MockitoBean + private AdminUserApi adminUserApi; + @MockitoBean + private DeptApi deptApi; + @MockitoBean + private PermissionApi permissionApi; + @MockitoBean + private OaNoRedisDAO noRedisDAO; + + @Test + public void testCreateWorkReport_textSummaryWithoutItems() { + // 准备参数 + OaWorkReportSaveReqVO reqVO = new OaWorkReportSaveReqVO().setType(1) + .setStartTime(LocalDateTime.of(2026, 9, 17, 0, 0)).setEndTime(LocalDateTime.of(2026, 9, 17, 23, 59)) + .setSummary("今天完成客户需求核对").setWorkItems(Collections.emptyList()).setPlanItems(Collections.emptyList()); + // mock 方法 + when(adminUserApi.validateUser(1L)).thenReturn(new AdminUserRespDTO().setId(1L).setDeptId(10L)); + when(noRedisDAO.generate(OaNoRedisDAO.WORK_REPORT_NO_PREFIX)).thenReturn("WR20260917000001"); + + // 调用 + Long id = workReportService.createWorkReport(reqVO, 1L); + // 断言 + assertEquals(reqVO.getSummary(), workReportMapper.selectById(id).getSummary()); + assertTrue(workReportMapper.selectById(id).getWorkItems().isEmpty()); + } + + @Test + public void testCreateWorkReport_noDuplicate() { + // mock 数据 + OaWorkReportDO report = randomWorkReportDO().setNo("WR20260916000001"); + workReportMapper.insert(report); + // 准备参数 + OaWorkReportSaveReqVO reqVO = BeanUtils.toBean(report, OaWorkReportSaveReqVO.class).setId(null); + // mock 方法 + when(adminUserApi.validateUser(1L)).thenReturn(new AdminUserRespDTO().setId(1L).setDeptId(10L)); + when(noRedisDAO.generate(OaNoRedisDAO.WORK_REPORT_NO_PREFIX)).thenReturn(report.getNo()); + + // 调用,并断言异常 + assertServiceException(() -> workReportService.createWorkReport(reqVO, 1L), WORK_REPORT_NO_DUPLICATE); + assertEquals(1L, workReportMapper.selectCount()); + } + + @Test + public void testCreateWorkReport_emptyDraft() { + // 准备参数 + OaWorkReportSaveReqVO reqVO = new OaWorkReportSaveReqVO().setType(1) + .setStartTime(LocalDateTime.of(2026, 9, 7, 0, 0)) + .setEndTime(LocalDateTime.of(2026, 9, 7, 23, 59, 59)) + .setWorkItems(Collections.emptyList()).setPlanItems(Collections.emptyList()); + + // 调用,并断言 + assertThrows(ConstraintViolationException.class, () -> workReportService.createWorkReport(reqVO, 1L)); + verifyNoInteractions(adminUserApi, noRedisDAO); + } + + @Test + public void testCreateWorkReport_customPeriod() { + // 准备参数:周报、月报均允许调整实际汇报日期 + when(adminUserApi.validateUser(1L)).thenReturn(new AdminUserRespDTO().setId(1L).setDeptId(10L)); + when(noRedisDAO.generate(OaNoRedisDAO.WORK_REPORT_NO_PREFIX)) + .thenReturn("WR20260913000001", "WR20260913000002"); + for (Integer type : Arrays.asList(2, 3)) { + OaWorkReportSaveReqVO reqVO = new OaWorkReportSaveReqVO().setType(type) + .setStartTime(LocalDateTime.of(2026, 9, 9, 0, 0)) + .setEndTime(LocalDateTime.of(2026, 9, 10, 23, 59, 59)) + .setWorkItems(Collections.emptyList()) + .setPlanItems(Collections.singletonList(new OaWorkReportSaveReqVO.PlanItem().setContent("下期计划"))); + + // 调用 + Long id = workReportService.createWorkReport(reqVO, 1L); + + // 断言:保留实际日期,以开始日期确定统计周期 + OaWorkReportDO report = workReportMapper.selectById(id); + assertEquals(reqVO.getStartTime(), report.getStartTime()); + assertEquals(reqVO.getEndTime(), report.getEndTime()); + assertEquals(type, report.getType()); + } + } + + @Test + public void testCreateWorkReport_invalidPeriod() { + // 准备参数 + OaWorkReportSaveReqVO reqVO = new OaWorkReportSaveReqVO().setType(2) + .setStartTime(LocalDateTime.of(2026, 9, 10, 0, 0)) + .setEndTime(LocalDateTime.of(2026, 9, 9, 0, 0)) + .setWorkItems(Collections.emptyList()).setPlanItems(Collections.emptyList()); + + // 调用,并断言 + assertThrows(ConstraintViolationException.class, () -> workReportService.createWorkReport(reqVO, 1L)); + verifyNoInteractions(noRedisDAO); + } + + @Test + public void testUpdateWorkReport_emptyContent() { + // mock 数据 + OaWorkReportDO report = randomWorkReportDO(); + workReportMapper.insert(report); + // 准备参数 + OaWorkReportSaveReqVO reqVO = new OaWorkReportSaveReqVO().setId(report.getId()).setType(report.getType()) + .setStartTime(report.getStartTime()).setEndTime(report.getEndTime()) + .setWorkItems(Collections.emptyList()).setPlanItems(Collections.emptyList()); + + // 调用,并断言 + assertThrows(ConstraintViolationException.class, () -> workReportService.updateWorkReport(reqVO, 1L)); + assertEquals(1, workReportMapper.selectById(report.getId()).getStatus()); + assertEquals(report.getWorkItems(), workReportMapper.selectById(report.getId()).getWorkItems()); + } + + @Test + public void testSubmitAndCancelWorkReport_success() { + // mock 数据 + OaWorkReportDO report = randomWorkReportDO(); + workReportMapper.insert(report); + + // 调用,并断言:提交后不能修改或删除,取消后恢复草稿 + workReportService.submitWorkReport(report.getId(), 1L); + assertEquals(2, workReportMapper.selectById(report.getId()).getStatus()); + assertServiceException(() -> workReportService.deleteWorkReport(report.getId(), 1L), + WORK_REPORT_STATUS_INVALID); + assertServiceException(() -> workReportService.updateWorkReport( + BeanUtils.toBean(report, OaWorkReportSaveReqVO.class), 1L), WORK_REPORT_STATUS_INVALID); + workReportService.cancelWorkReport(report.getId(), 1L); + assertEquals(1, workReportMapper.selectById(report.getId()).getStatus()); + workReportService.deleteWorkReport(report.getId(), 1L); + assertNull(workReportMapper.selectById(report.getId())); + } + + @Test + public void testDeleteWorkReport_notOwner() { + // mock 数据 + OaWorkReportDO report = randomWorkReportDO(); + workReportMapper.insert(report); + + // 调用,并断言异常 + assertServiceException(() -> workReportService.deleteWorkReport(report.getId(), 2L), + WORK_REPORT_ACCESS_DENIED); + } + + @Test + public void testGetWorkReport_managerCannotReadDraft() { + // mock 数据 + OaWorkReportDO report = randomWorkReportDO(); + workReportMapper.insert(report); + // mock 方法 + when(adminUserApi.getUserListBySubordinate(2L)).thenReturn(Collections.singletonList( + new AdminUserRespDTO().setId(1L))); + when(permissionApi.hasAnyPermissions(2L, "oa:work-report:statistics")).thenReturn(true); + + // 调用,并断言异常 + assertServiceException(() -> workReportService.getWorkReport(report.getId(), 2L), + WORK_REPORT_ACCESS_DENIED); + workReportService.submitWorkReport(report.getId(), 1L); + assertEquals(report.getId(), workReportService.getWorkReport(report.getId(), 2L).getId()); + } + + @Test + public void testGetWorkReportStatistics_partialWeekAndZeroReports() { + // mock 数据:同一员工同一周期多份汇报只计算一次填写 + OaWorkReportDO report = randomWorkReportDO().setType(2).setStatus(2); + workReportMapper.insert(report); + workReportMapper.insert(randomWorkReportDO().setType(2).setStatus(2)); + // 准备参数:从周三开始,仍包含这一周的汇报 + OaWorkReportStatisticsReqVO reqVO = new OaWorkReportStatisticsReqVO().setType(2) + .setStartTime(LocalDateTime.of(2026, 9, 9, 0, 0)).setEndTime(LocalDateTime.of(2026, 9, 9, 23, 59, 59)) + .setQueryStartTime(LocalDateTime.of(2026, 9, 7, 0, 0)).setQueryEndTime(LocalDateTime.of(2026, 9, 13, 23, 59, 59)); + // mock 方法 + when(adminUserApi.getUserListBySubordinate(9L)).thenReturn(Arrays.asList( + new AdminUserRespDTO().setId(1L).setDeptId(10L).setStatus(0), + new AdminUserRespDTO().setId(2L).setDeptId(10L).setStatus(0), + new AdminUserRespDTO().setId(3L).setDeptId(10L).setStatus(1))); + when(deptApi.getDeptMap(anyCollection())).thenReturn(Collections.emptyMap()); + + // 调用 + OaWorkReportStatisticsRespVO result = workReportService.getWorkReportStatistics(reqVO, 9L); + + // 断言 + assertEquals(2, result.getUserCount()); + assertEquals(2, result.getExpectedCount()); + assertEquals(1, result.getSubmittedCount()); + assertEquals(1, result.getMissingCount()); + assertEquals(1, result.getUsers().get(0).getExpectedCount()); + assertEquals(1, result.getUsers().get(0).getSubmittedCount()); + assertEquals(1, result.getUsers().get(1).getExpectedCount()); + assertEquals(0, result.getUsers().get(1).getSubmittedCount()); + assertEquals(2, result.getUsers().get(0).getSubmittedReports().size()); + } + + @Test + public void testGetWorkReportStatistics_partialMonthAndCancelled() { + // mock 数据 + workReportMapper.insert(randomWorkReportDO().setType(3).setStatus(2) + .setStartTime(LocalDateTime.of(2026, 8, 1, 0, 0))); + workReportMapper.insert(randomWorkReportDO().setType(3).setStatus(1)); + // 准备参数 + OaWorkReportStatisticsReqVO reqVO = new OaWorkReportStatisticsReqVO().setType(3) + .setStartTime(LocalDateTime.of(2026, 8, 20, 0, 0)).setEndTime(LocalDateTime.of(2026, 9, 9, 23, 59, 59)) + .setQueryStartTime(LocalDateTime.of(2026, 8, 1, 0, 0)).setQueryEndTime(LocalDateTime.of(2026, 9, 30, 23, 59, 59)); + // mock 方法 + when(adminUserApi.getUserListBySubordinate(9L)).thenReturn(Collections.singletonList( + new AdminUserRespDTO().setId(1L).setDeptId(10L).setStatus(0))); + when(deptApi.getDeptMap(anyCollection())).thenReturn(Collections.emptyMap()); + + // 调用 + OaWorkReportStatisticsRespVO result = workReportService.getWorkReportStatistics(reqVO, 9L); + + // 断言 + assertEquals(2, result.getExpectedCount()); + assertEquals(1, result.getSubmittedCount()); + assertEquals(Collections.singletonList("2026-09"), result.getUsers().get(0).getMissingPeriodKeys()); + } + + @Test + public void testGetWorkReportPage_filters() { + // mock 数据 + OaWorkReportDO report = randomWorkReportDO().setType(2).setNo("WR-FILTER"); + workReportMapper.insert(report); + workReportMapper.insert(randomWorkReportDO().setType(2).setNo("OTHER")); + OaWorkReportDO otherReport = randomWorkReportDO().setType(2).setNo("WR-FILTER-OTHER"); + otherReport.setCreator("2"); + workReportMapper.insert(otherReport); + // 准备参数 + OaWorkReportPageReqVO reqVO = new OaWorkReportPageReqVO().setType(2).setNo("WR-FILTER") + .setDeptId(10L).setPeriodTime(new LocalDateTime[]{LocalDateTime.of(2026, 9, 7, 0, 0), + LocalDateTime.of(2026, 9, 13, 23, 59, 59)}) + .setStartTime(LocalDateTime.of(2026, 9, 7, 0, 0)).setEndTime(LocalDateTime.of(2026, 9, 7, 23, 59, 59)); + + // 调用,并断言 + assertEquals(1L, workReportService.getWorkReportPage(reqVO, 1L).getTotal()); + reqVO.setPeriodTime(new LocalDateTime[]{LocalDateTime.of(2026, 9, 14, 0, 0), + LocalDateTime.of(2026, 9, 20, 23, 59, 59)}); + assertEquals(0L, workReportService.getWorkReportPage(reqVO, 1L).getTotal()); + reqVO.setPeriodTime(new LocalDateTime[]{LocalDateTime.of(2026, 8, 1, 0, 0), + LocalDateTime.of(2026, 8, 31, 23, 59, 59)}); + assertEquals(0L, workReportService.getWorkReportPage(reqVO, 1L).getTotal()); + reqVO.setPeriodTime(new LocalDateTime[]{LocalDateTime.of(2026, 9, 1, 0, 0), + LocalDateTime.of(2026, 9, 30, 23, 59, 59)}); + assertEquals(1L, workReportService.getWorkReportPage(reqVO, 1L).getTotal()); + reqVO.setPeriodTime(null); + assertEquals(1L, workReportService.getWorkReportPage(reqVO, 1L).getTotal()); + } + + @Test + public void testCreateWorkReport_calendarWeek() { + // 准备参数 + OaWorkReportSaveReqVO reqVO = new OaWorkReportSaveReqVO().setType(2) + .setStartTime(LocalDateTime.of(2027, 1, 4, 0, 0)) + .setEndTime(LocalDateTime.of(2027, 1, 10, 23, 59, 59)) + .setWorkItems(Collections.emptyList()) + .setPlanItems(Collections.singletonList(new OaWorkReportSaveReqVO.PlanItem().setContent("下期计划"))); + when(adminUserApi.validateUser(1L)).thenReturn(new AdminUserRespDTO().setId(1L).setDeptId(10L)); + when(noRedisDAO.generate(OaNoRedisDAO.WORK_REPORT_NO_PREFIX)).thenReturn("WR20260913000001"); + + // 调用 + Long id = workReportService.createWorkReport(reqVO, 1L); + + // 断言:包含 1 月 1 日的周为当年第 1 周,跨年同一周使用相同编号 + OaWorkReportDO report = workReportMapper.selectById(id); + assertEquals("2027-02 工作周报", report.getTitle()); + assertEquals("2027-01", OaWorkReportTypeEnum.WEEKLY.formatPeriod(LocalDateTime.of(2027, 1, 1, 0, 0))); + assertEquals("2027-01", OaWorkReportTypeEnum.WEEKLY.formatPeriod(LocalDateTime.of(2026, 12, 28, 0, 0))); + } + + @Test + public void testUpdateWorkReport_preserveNo() { + // mock 数据 + OaWorkReportDO report = randomWorkReportDO().setNo("WR20260913000001"); + workReportMapper.insert(report); + // 准备参数 + OaWorkReportSaveReqVO reqVO = new OaWorkReportSaveReqVO().setId(report.getId()).setType(3) + .setStartTime(LocalDateTime.of(2026, 8, 20, 0, 0)) + .setEndTime(LocalDateTime.of(2026, 8, 25, 23, 59, 59)) + .setPlanItems(Collections.singletonList(new OaWorkReportSaveReqVO.PlanItem().setContent("下期计划"))); + + // 调用 + workReportService.updateWorkReport(reqVO, 1L); + + // 断言 + OaWorkReportDO updatedReport = workReportMapper.selectById(report.getId()); + assertEquals("2026-08 工作月报", updatedReport.getTitle()); + assertEquals(report.getNo(), updatedReport.getNo()); + verifyNoInteractions(noRedisDAO); + } + + @Test + public void testGetWorkReportStatistics_deptScope() { + // 准备参数 + OaWorkReportStatisticsReqVO reqVO = new OaWorkReportStatisticsReqVO().setType(1).setDeptId(10L) + .setStartTime(LocalDateTime.of(2026, 9, 7, 0, 0)).setEndTime(LocalDateTime.of(2026, 9, 7, 23, 59, 59)) + .setQueryStartTime(LocalDateTime.of(2026, 9, 7, 0, 0)).setQueryEndTime(LocalDateTime.of(2026, 9, 7, 23, 59, 59)); + // mock 方法 + when(deptApi.getChildDeptList(10L)).thenReturn(Collections.singletonList(new DeptRespDTO().setId(11L))); + when(adminUserApi.getUserListBySubordinate(9L)).thenReturn(Arrays.asList( + new AdminUserRespDTO().setId(4L).setDeptId(11L).setStatus(0), + new AdminUserRespDTO().setId(1L).setDeptId(10L).setStatus(0), + new AdminUserRespDTO().setId(2L).setDeptId(12L).setStatus(0), + new AdminUserRespDTO().setId(3L).setDeptId(10L).setStatus(1))); + when(deptApi.getDeptMap(anyCollection())).thenReturn(Collections.emptyMap()); + + // 调用 + OaWorkReportStatisticsRespVO result = workReportService.getWorkReportStatistics(reqVO, 9L); + + // 断言:仅保留本部门和子部门启用员工,无汇报员工仍参与统计,编号升序 + assertEquals(2, result.getUserCount()); + assertEquals(2, result.getMissingCount()); + assertEquals(1L, result.getUsers().get(0).getUserId()); + assertEquals(4L, result.getUsers().get(1).getUserId()); + } + + @Test + public void testGetWorkReportStatistics_invalidDateRange() { + // 准备参数 + OaWorkReportStatisticsReqVO reqVO = new OaWorkReportStatisticsReqVO().setType(1) + .setStartTime(LocalDateTime.of(2026, 9, 9, 0, 0)).setEndTime(LocalDateTime.of(2026, 9, 7, 23, 59, 59)) + .setQueryStartTime(LocalDateTime.of(2026, 9, 9, 0, 0)).setQueryEndTime(LocalDateTime.of(2026, 9, 7, 23, 59, 59)); + + // 调用,并断言:在进入业务查询前拒绝倒置的统计日期 + assertThrows(ConstraintViolationException.class, () -> workReportService.getWorkReportStatistics(reqVO, 9L)); + verifyNoInteractions(adminUserApi, deptApi); + } + + @Test + public void testGetWorkReportPage_wholeDayBoundary() { + // mock 数据:开始、结束日期筛选都应包含当天零点及最后一秒 + workReportMapper.insert(randomWorkReportDO().setStartTime(LocalDateTime.of(2026, 9, 7, 0, 0)) + .setEndTime(LocalDateTime.of(2026, 9, 7, 0, 0))); + workReportMapper.insert(randomWorkReportDO().setStartTime(LocalDateTime.of(2026, 9, 7, 23, 59, 59)) + .setEndTime(LocalDateTime.of(2026, 9, 7, 23, 59, 59))); + workReportMapper.insert(randomWorkReportDO().setStartTime(LocalDateTime.of(2026, 9, 8, 0, 0)) + .setEndTime(LocalDateTime.of(2026, 9, 8, 0, 0))); + // 准备参数:传入带时分秒的时间,仍按所选自然日筛选 + OaWorkReportPageReqVO reqVO = new OaWorkReportPageReqVO() + .setStartTime(LocalDateTime.of(2026, 9, 7, 12, 0)) + .setEndTime(LocalDateTime.of(2026, 9, 7, 12, 0)); + + // 调用,并断言 + assertEquals(2L, workReportService.getWorkReportPage(reqVO, 1L).getTotal()); + reqVO.setStartTime(null); + assertEquals(2L, workReportService.getWorkReportPage(reqVO, 1L).getTotal()); + reqVO.setEndTime(null); + assertEquals(3L, workReportService.getWorkReportPage(reqVO, 1L).getTotal()); + } + + @Test + public void testGetWorkReportStatistics_naturalDayBoundary() { + // mock 数据:完整查询时间范围包含首日零点和末日最后一秒 + workReportMapper.insert(randomWorkReportDO().setStatus(2) + .setStartTime(LocalDateTime.of(2026, 9, 7, 0, 0))); + workReportMapper.insert(randomWorkReportDO().setStatus(2) + .setStartTime(LocalDateTime.of(2026, 9, 8, 23, 59, 59))); + workReportMapper.insert(randomWorkReportDO().setStatus(2) + .setStartTime(LocalDateTime.of(2026, 9, 9, 0, 0))); + // 准备参数:即使开始时刻晚于结束时刻,两个自然日都应参与统计 + OaWorkReportStatisticsReqVO reqVO = new OaWorkReportStatisticsReqVO().setType(1) + .setStartTime(LocalDateTime.of(2026, 9, 7, 12, 0)) + .setEndTime(LocalDateTime.of(2026, 9, 8, 9, 0)) + .setQueryStartTime(LocalDateTime.of(2026, 9, 7, 0, 0)) + .setQueryEndTime(LocalDateTime.of(2026, 9, 8, 23, 59, 59)); + // mock 方法 + when(adminUserApi.getUserListBySubordinate(9L)).thenReturn(Collections.singletonList( + new AdminUserRespDTO().setId(1L).setDeptId(10L).setStatus(0))); + when(deptApi.getDeptMap(anyCollection())).thenReturn(Collections.emptyMap()); + + // 调用 + OaWorkReportStatisticsRespVO result = workReportService.getWorkReportStatistics(reqVO, 9L); + + // 断言 + assertEquals(2, result.getExpectedCount()); + assertEquals(2, result.getSubmittedCount()); + assertEquals(0, result.getMissingCount()); + assertEquals(2, result.getUsers().get(0).getSubmittedReports().size()); + } + + @Test + public void testGetWorkReportStatistics_weekendReports() { + // mock 数据:周末已提交报告保留明细,草稿及范围外报告不展示 + OaWorkReportDO report = randomWorkReportDO().setStatus(2) + .setStartTime(LocalDateTime.of(2026, 3, 22, 0, 0)); + workReportMapper.insert(report); + workReportMapper.insert(randomWorkReportDO().setStatus(1).setStartTime(report.getStartTime())); + workReportMapper.insert(randomWorkReportDO().setStatus(2).setStartTime(report.getStartTime().plusDays(1))); + // 准备参数 + OaWorkReportStatisticsReqVO reqVO = new OaWorkReportStatisticsReqVO().setType(1) + .setStartTime(report.getStartTime()).setEndTime(report.getStartTime().plusDays(1).minusSeconds(1)) + .setQueryStartTime(report.getStartTime()).setQueryEndTime(report.getStartTime().plusDays(1).minusSeconds(1)); + // mock 方法 + when(adminUserApi.getUserListBySubordinate(9L)).thenReturn(Collections.singletonList( + new AdminUserRespDTO().setId(1L).setDeptId(10L).setStatus(0))); + when(deptApi.getDeptMap(anyCollection())).thenReturn(Collections.emptyMap()); + + // 调用 + OaWorkReportStatisticsRespVO result = workReportService.getWorkReportStatistics(reqVO, 9L); + + // 断言:既有应填周期完成数量不变,周末明细仍可查看 + assertEquals(0, result.getExpectedCount()); + assertEquals(0, result.getSubmittedCount()); + assertEquals(0, result.getMissingCount()); + assertEquals(1, result.getUsers().get(0).getSubmittedReports().size()); + assertEquals(report.getId(), result.getUsers().get(0).getSubmittedReports().get(0).getId()); + } + + @ParameterizedTest + @CsvSource({"2026-05-01T00:00:00, 1", "2026-05-09T00:00:00, 0", "2099-01-05T00:00:00, 1"}) + public void testGetWorkReportStatistics_selectedDateRange(String date, int expectedCount) { + // 准备参数:工作日假期仍应填、周六不应填,未来工作日也在所选范围内 + LocalDateTime startTime = LocalDateTime.parse(date); + LocalDateTime endTime = startTime.plusDays(1).minusSeconds(1); + OaWorkReportStatisticsReqVO reqVO = new OaWorkReportStatisticsReqVO().setType(1) + .setStartTime(startTime).setEndTime(endTime).setQueryStartTime(startTime).setQueryEndTime(endTime); + // mock 方法 + when(adminUserApi.getUserListBySubordinate(9L)).thenReturn(Collections.singletonList( + new AdminUserRespDTO().setId(1L).setDeptId(10L).setStatus(0))); + when(deptApi.getDeptMap(anyCollection())).thenReturn(Collections.emptyMap()); + + // 调用 + OaWorkReportStatisticsRespVO result = workReportService.getWorkReportStatistics(reqVO, 9L); + + // 断言 + assertEquals(expectedCount, result.getExpectedCount()); + assertEquals(expectedCount, result.getMissingCount()); + assertEquals(expectedCount, result.getUsers().get(0).getMissingPeriodKeys().size()); + } + + @Test + public void testCreateWorkReport_invalidWorkItem() { + // 准备参数 + OaWorkReportSaveReqVO reqVO = BeanUtils.toBean(randomWorkReportDO(), OaWorkReportSaveReqVO.class) + .setWorkItems(Collections.singletonList(new OaWorkReportSaveReqVO.WorkItem() + .setContent("填写工作汇报").setProgress(101))); + + // 调用,并断言:无效工作项在进入新增业务前被拒绝,不生成单号 + assertThrows(ConstraintViolationException.class, () -> workReportService.createWorkReport(reqVO, 1L)); + verifyNoInteractions(noRedisDAO, adminUserApi); + } + + @Test + public void testUpdateWorkReport_invalidPlanItem() { + // mock 数据 + OaWorkReportDO report = randomWorkReportDO(); + workReportMapper.insert(report); + // 准备参数 + OaWorkReportSaveReqVO reqVO = BeanUtils.toBean(report, OaWorkReportSaveReqVO.class) + .setPlanItems(Collections.singletonList(new OaWorkReportSaveReqVO.PlanItem().setContent(" "))); + + // 调用,并断言:拒绝空白计划,不修改已有草稿 + assertThrows(ConstraintViolationException.class, () -> workReportService.updateWorkReport(reqVO, 1L)); + assertTrue(workReportMapper.selectById(report.getId()).getPlanItems().isEmpty()); + } + + @Test + public void testCreateWorkReport_withItems() { + // 准备参数 + OaWorkReportSaveReqVO reqVO = BeanUtils.toBean(randomWorkReportDO(), OaWorkReportSaveReqVO.class) + .setWorkItems(Arrays.asList(new OaWorkReportSaveReqVO.WorkItem().setContent("工作一").setProgress(0), + new OaWorkReportSaveReqVO.WorkItem().setContent("工作二").setProgress(100))) + .setPlanItems(Collections.singletonList(new OaWorkReportSaveReqVO.PlanItem().setContent("下期计划"))); + // mock 方法 + when(adminUserApi.validateUser(1L)).thenReturn(new AdminUserRespDTO().setId(1L).setDeptId(10L)); + when(noRedisDAO.generate(OaNoRedisDAO.WORK_REPORT_NO_PREFIX)).thenReturn("WR20260913000003"); + + // 调用 + Long id = workReportService.createWorkReport(reqVO, 1L); + + // 断言:内嵌 VO 转为 DO 后以 JSON 保存,回读保留内容、进度及顺序 + OaWorkReportDO report = workReportMapper.selectById(id); + assertEquals(BeanUtils.toBean(reqVO.getWorkItems(), OaWorkReportDO.WorkItem.class), report.getWorkItems()); + assertEquals(BeanUtils.toBean(reqVO.getPlanItems(), OaWorkReportDO.PlanItem.class), report.getPlanItems()); + } + + @Test + public void testUpdateWorkReport_withItems() { + // mock 数据 + OaWorkReportDO report = randomWorkReportDO(); + workReportMapper.insert(report); + // 准备参数 + OaWorkReportSaveReqVO reqVO = BeanUtils.toBean(report, OaWorkReportSaveReqVO.class) + .setWorkItems(Collections.singletonList(new OaWorkReportSaveReqVO.WorkItem() + .setContent("更新工作内容").setProgress(50))) + .setPlanItems(Arrays.asList(new OaWorkReportSaveReqVO.PlanItem().setContent("计划一"), + new OaWorkReportSaveReqVO.PlanItem().setContent("计划二"))); + + // 调用 + workReportService.updateWorkReport(reqVO, 1L); + + // 断言 + OaWorkReportDO updatedReport = workReportMapper.selectById(report.getId()); + assertEquals(BeanUtils.toBean(reqVO.getWorkItems(), OaWorkReportDO.WorkItem.class), updatedReport.getWorkItems()); + assertEquals(BeanUtils.toBean(reqVO.getPlanItems(), OaWorkReportDO.PlanItem.class), updatedReport.getPlanItems()); + } + + // ========== 随机对象 ========== + + /** + * 构造包含已完成工作项的日报草稿。 + * + * @return 未入库的测试对象 + */ + private static OaWorkReportDO randomWorkReportDO() { + return randomPojo(OaWorkReportDO.class, report -> report.setId(null) + .setType(1).setStatus(1).setDeptId(10L) + .setStartTime(LocalDateTime.of(2026, 9, 7, 0, 0)) + .setEndTime(LocalDateTime.of(2026, 9, 7, 23, 59, 59)) + .setWorkItems(Collections.singletonList(new OaWorkReportDO.WorkItem() + .setContent("完成工作汇报迁移").setProgress(100))) + .setPlanItems(Collections.emptyList()).setFileUrls(Collections.emptyList()).setCreator("1")); + } +} diff --git a/yudao-module-oa/src/test/resources/application-unit-test.yaml b/yudao-module-oa/src/test/resources/application-unit-test.yaml new file mode 100644 index 0000000000..f0fcc544dc --- /dev/null +++ b/yudao-module-oa/src/test/resources/application-unit-test.yaml @@ -0,0 +1,36 @@ +spring: + main: + lazy-initialization: true + banner-mode: off + allow-bean-definition-overriding: true + +--- #################### 数据库相关配置 #################### + +spring: + datasource: + name: ruoyi-vue-pro + url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value,day,year,month; + driver-class-name: org.h2.Driver + username: sa + password: + druid: + async-init: true + initial-size: 1 + sql: + init: + schema-locations: classpath:/sql/create_tables.sql + + data: + redis: + host: 127.0.0.1 + port: 16379 + database: 0 + +mybatis: + lazy-initialization: true + +--- #################### 芋道相关配置 #################### + +yudao: + info: + base-package: cn.iocoder.yudao.module diff --git a/yudao-module-oa/src/test/resources/sql/clean.sql b/yudao-module-oa/src/test/resources/sql/clean.sql new file mode 100644 index 0000000000..d001a400c0 --- /dev/null +++ b/yudao-module-oa/src/test/resources/sql/clean.sql @@ -0,0 +1,57 @@ +DELETE FROM "oa_mail_account"; +DELETE FROM "oa_mail_provider"; +DELETE FROM "oa_attendance"; +DELETE FROM "oa_announcement_receiver"; +DELETE FROM "oa_announcement"; +DELETE FROM "oa_task_log"; +DELETE FROM "oa_task_receiver"; +DELETE FROM "oa_task"; +DELETE FROM "oa_vote_record"; +DELETE FROM "oa_vote_option"; +DELETE FROM "oa_discussion_like"; +DELETE FROM "oa_discussion_reply"; +DELETE FROM "oa_discussion"; +DELETE FROM "oa_contact_share"; +DELETE FROM "oa_contact"; +DELETE FROM "oa_contact_category"; +DELETE FROM "oa_note_receiver"; +DELETE FROM "oa_note"; +DELETE FROM "oa_note_category"; +DELETE FROM "oa_plan"; +DELETE FROM "oa_work_report"; +DELETE FROM "oa_schedule_participant"; +DELETE FROM "oa_schedule"; +DELETE FROM oa_mail_message; +DELETE FROM oa_mail_folder; + +DELETE FROM oa_seal_apply; +DELETE FROM oa_seal; + +DELETE FROM oa_file_node; + +DELETE FROM oa_file_permission; +DELETE FROM oa_file_favorite; + +DELETE FROM oa_vehicle_apply; +DELETE FROM oa_vehicle_return; +DELETE FROM oa_vehicle; + +DELETE FROM oa_supply_apply_item; +DELETE FROM oa_supply_apply; +DELETE FROM oa_supply_item; + +DELETE FROM "oa_meeting_room_booking"; +DELETE FROM "oa_meeting_room"; + +DELETE FROM oa_reimbursement; +DELETE FROM oa_overtime_apply; +DELETE FROM oa_leave_apply; +DELETE FROM oa_regular_apply; +DELETE FROM oa_resign_apply; + +DELETE FROM "oa_official_doc_receive"; +DELETE FROM "oa_official_doc_send"; +DELETE FROM "oa_official_doc_template"; + +DELETE FROM oa_travel_apply; +DELETE FROM oa_travel_reimbursement; diff --git a/yudao-module-oa/src/test/resources/sql/create_tables.sql b/yudao-module-oa/src/test/resources/sql/create_tables.sql new file mode 100644 index 0000000000..1ae8f9e8ff --- /dev/null +++ b/yudao-module-oa/src/test/resources/sql/create_tables.sql @@ -0,0 +1,879 @@ +CREATE TABLE IF NOT EXISTS "oa_attendance" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "user_id" bigint NOT NULL, "type" tinyint NOT NULL, "status" tinyint NOT NULL, + "attendance_time" timestamp NOT NULL, "attendance_ip" varchar(64), "remark" varchar(500), + "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + + +CREATE TABLE IF NOT EXISTS "oa_schedule" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "type" tinyint NOT NULL, + "priority" tinyint NOT NULL, + "title" varchar(255) NOT NULL, + "description" varchar(1000), + "start_time" timestamp NOT NULL, + "end_time" timestamp NOT NULL, + "remind" bit NOT NULL DEFAULT FALSE, + "reminded" bit NOT NULL DEFAULT FALSE, + "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "oa_schedule_participant" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "schedule_id" bigint NOT NULL, "user_id" bigint NOT NULL, + "read_status" bit NOT NULL DEFAULT FALSE, "read_time" timestamp, + "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "oa_plan" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "type" tinyint NOT NULL, "status" tinyint NOT NULL, + "title" varchar(100) NOT NULL, "label" varchar(255), "content" text NOT NULL, + "summary" text, "comment" text, "start_time" timestamp NOT NULL, "end_time" timestamp NOT NULL, + "file_urls" varchar(5000), + "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "oa_work_report" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "no" varchar(64) NOT NULL, "type" tinyint NOT NULL, "status" tinyint NOT NULL, + "dept_id" bigint, + "start_time" timestamp NOT NULL, "end_time" timestamp NOT NULL, + "title" varchar(255) NOT NULL, "summary" text, "plan" text, "problem" text, + "work_items" varchar(10000), "plan_items" varchar(10000), "file_urls" varchar(5000), + "remark" varchar(1000), + "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id"), + KEY work_report_idx_no (`tenant_id`, `no`) +); + +CREATE TABLE IF NOT EXISTS "oa_note_category" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "user_id" bigint NOT NULL, "name" varchar(255) NOT NULL, "sort" int NOT NULL DEFAULT 0, + "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "oa_note" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "category_id" bigint, "type" tinyint NOT NULL, + "priority" tinyint NOT NULL, "title" varchar(255) NOT NULL, "content" text, + "favorite" bit NOT NULL DEFAULT FALSE, "file_urls" varchar(5000), + "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "oa_note_receiver" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "note_id" bigint NOT NULL, "user_id" bigint NOT NULL, + "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "oa_contact_category" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "user_id" bigint NOT NULL, "name" varchar(50) NOT NULL, "sort" int NOT NULL DEFAULT 0, + "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "oa_contact" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "category_id" bigint, "name" varchar(50) NOT NULL, + "pinyin" varchar(100), "sex" tinyint, "mobile" varchar(20), "email" varchar(100), + "address" varchar(255), "company_name" varchar(100), "company_phone" varchar(30), + "avatar" varchar(512), "remark" varchar(500), + "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "oa_contact_share" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "contact_id" bigint NOT NULL, "user_id" bigint NOT NULL, "category_id" bigint, + "handle_status" bit NOT NULL DEFAULT FALSE, + "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "oa_task" ( + "publish_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "type" tinyint NOT NULL, "status" tinyint NOT NULL, + "title" varchar(255) NOT NULL, "description" varchar(2000) NOT NULL, "comment" varchar(1000), + "start_time" timestamp NOT NULL, "end_time" timestamp NOT NULL, + "top" bit NOT NULL DEFAULT FALSE, "canceled" bit NOT NULL DEFAULT FALSE, + "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "oa_task_receiver" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "task_id" bigint NOT NULL, "user_id" bigint NOT NULL, "status" tinyint NOT NULL, + "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "oa_task_log" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "task_id" bigint NOT NULL, "user_id" bigint NOT NULL, "status" tinyint, "content" varchar(1000), + "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "oa_announcement" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "type" tinyint NOT NULL, "priority" tinyint NOT NULL, + "title" varchar(255) NOT NULL, "content" text, "url" varchar(512), + "top" bit NOT NULL DEFAULT FALSE, + "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "oa_announcement_receiver" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "announcement_id" bigint NOT NULL, "receiver_user_id" bigint NOT NULL, + "read_status" bit NOT NULL DEFAULT FALSE, + "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id"), + KEY announcement_receiver_idx_announcement_id_receiver_user_id (`announcement_id`, `receiver_user_id`, `deleted`) +); + +CREATE TABLE IF NOT EXISTS "oa_discussion" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "user_id" bigint NOT NULL, "type" tinyint NOT NULL, "title" varchar(255) NOT NULL, + "content" text, "file_urls" varchar(5000), "visit_count" int NOT NULL DEFAULT 0, + "vote_multiple" bit, "vote_start_time" timestamp, "vote_end_time" timestamp, + "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "oa_discussion_reply" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "discussion_id" bigint NOT NULL, "user_id" bigint NOT NULL, "parent_id" bigint NOT NULL DEFAULT 0, + "reply_user_id" bigint, "content" text NOT NULL, + "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "oa_discussion_like" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "discussion_id" bigint, "reply_id" bigint, "user_id" bigint NOT NULL, + "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "oa_vote_option" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "discussion_id" bigint NOT NULL, "title" varchar(200) NOT NULL, "color" varchar(20), + "sort" int NOT NULL DEFAULT 0, + "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "oa_vote_record" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "discussion_id" bigint NOT NULL, "option_id" bigint NOT NULL, "user_id" bigint NOT NULL, + "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id"), + KEY vote_record_idx_option_id_user_id (`option_id`, `user_id`, `deleted`) +); + +CREATE INDEX IF NOT EXISTS "idx_schedule_id_user_id" ON "oa_schedule_participant" ("schedule_id", "user_id", "deleted"); +CREATE INDEX IF NOT EXISTS "idx_note_id_user_id" ON "oa_note_receiver" ("note_id", "user_id", "deleted"); + +-- 企业邮箱:仅保存服务配置和账号,不缓存远端邮件 +CREATE TABLE IF NOT EXISTS oa_mail_provider ( + id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + name varchar(255) NOT NULL, + imap json NOT NULL, + smtp json NOT NULL, + status tinyint NOT NULL, + creator varchar(64) DEFAULT '', + create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater varchar(64) DEFAULT '', + update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted bit NOT NULL DEFAULT FALSE, + tenant_id bigint NOT NULL DEFAULT 0, + PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS oa_seal ( + id bigint NOT NULL AUTO_INCREMENT, + `dept_id` bigint NOT NULL, + `no` varchar(64) NOT NULL, + `name` varchar(128) NOT NULL, + `type` int NOT NULL, + `category` int DEFAULT NULL, + `keeper_user_id` bigint NOT NULL, + `keeper_dept_id` bigint NOT NULL, + `status` int NOT NULL DEFAULT 0, + `purchase_time` datetime DEFAULT NULL, + `enable_time` datetime DEFAULT NULL, + `disable_time` datetime DEFAULT NULL, + `pic_url` varchar(2048) DEFAULT NULL, + `sort` int NOT NULL DEFAULT 0, + `remark` varchar(500) DEFAULT NULL, + creator varchar(64) DEFAULT '', + create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater varchar(64) DEFAULT '', + update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted bit NOT NULL DEFAULT FALSE, + tenant_id bigint NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY seal_idx_no (`tenant_id`, `no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='印章'; + +CREATE TABLE IF NOT EXISTS oa_seal_apply ( + id bigint NOT NULL AUTO_INCREMENT, + `no` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `dept_id` bigint DEFAULT NULL, + `seal_id` bigint NOT NULL, + `seal_no` varchar(64) NOT NULL, + `seal_name` varchar(128) NOT NULL, + `seal_type` tinyint DEFAULT NULL COMMENT '印章类型快照', + `keeper_dept_id` bigint DEFAULT NULL COMMENT '保管部门编号快照', + `keeper_user_id` bigint NOT NULL, + `reason` varchar(500) NOT NULL, + `type` tinyint NOT NULL, + `mode` tinyint NOT NULL, + `document_title` varchar(255) DEFAULT NULL, + `document_type` varchar(64) DEFAULT NULL, + `document_count` int NOT NULL, + `contract_price` decimal(18,2) DEFAULT NULL, + `contract_party` varchar(255) DEFAULT NULL, + `expected_use_time` datetime NOT NULL, + `actual_use_time` datetime DEFAULT NULL, + `expected_return_time` datetime DEFAULT NULL, + `actual_return_time` datetime DEFAULT NULL, + `use_status` tinyint NOT NULL DEFAULT 0, + `status` tinyint NOT NULL DEFAULT -1, + `process_instance_id` varchar(64) DEFAULT NULL, + `urgent` bit NOT NULL DEFAULT FALSE, + file_urls varchar(12000) DEFAULT NULL, + `remark` varchar(500) DEFAULT NULL, + creator varchar(64) DEFAULT '', + create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater varchar(64) DEFAULT '', + update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted bit NOT NULL DEFAULT FALSE, + tenant_id bigint NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY seal_apply_idx_no (`tenant_id`, `no`), + KEY seal_apply_idx_process_instance (`tenant_id`, `process_instance_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用印申请'; + +CREATE TABLE IF NOT EXISTS oa_mail_account ( + id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + provider_id bigint NOT NULL, + mail varchar(255) NOT NULL, + username varchar(255) NOT NULL, + password text NOT NULL, + default_status bit NOT NULL, + status tinyint NOT NULL, + creator varchar(64) DEFAULT '', + create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater varchar(64) DEFAULT '', + update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted bit NOT NULL DEFAULT FALSE, + tenant_id bigint NOT NULL DEFAULT 0, + PRIMARY KEY (id) +); + +-- 企业邮箱:手动同步的文件夹与邮件索引,不保存正文、凭据和附件 +CREATE TABLE IF NOT EXISTS oa_mail_folder ( + id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + account_id bigint NOT NULL, + name varchar(255) NOT NULL, + type varchar(20) NOT NULL, + uid_validity bigint DEFAULT NULL, + sync_time datetime DEFAULT NULL, + available bit NOT NULL DEFAULT TRUE, + creator varchar(64) DEFAULT '', + create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater varchar(64) DEFAULT '', + update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted bit NOT NULL DEFAULT FALSE, + tenant_id bigint NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY mail_folder_idx_account_folder (`tenant_id`, `account_id`, `name`) +); + +CREATE TABLE IF NOT EXISTS oa_mail_message ( + id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + account_id bigint NOT NULL, + folder_id bigint NOT NULL, + uid_validity bigint NOT NULL, + uid bigint NOT NULL, + subject longtext NOT NULL, + content clob DEFAULT NULL, + reply_tos varchar(20000) DEFAULT NULL, + attachments varchar(20000) DEFAULT NULL, + sender text, + recipients varchar(20000), + ccs varchar(20000), + receive_time datetime DEFAULT NULL, + read_status bit NOT NULL DEFAULT FALSE, + has_attach bit NOT NULL DEFAULT FALSE, + size int DEFAULT NULL, + creator varchar(64) DEFAULT '', + create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater varchar(64) DEFAULT '', + update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted bit NOT NULL DEFAULT FALSE, + tenant_id bigint NOT NULL DEFAULT 0, + PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS oa_file_node ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + parent_id bigint NOT NULL DEFAULT 0, + type tinyint NOT NULL, + name varchar(255) NOT NULL, + extension varchar(64), + category tinyint, + size bigint DEFAULT 0, + url varchar(2048), + dept_id bigint, + status tinyint NOT NULL DEFAULT 0, + creator varchar(64) DEFAULT '', + create_time timestamp DEFAULT CURRENT_TIMESTAMP, + updater varchar(64) DEFAULT '', + update_time timestamp DEFAULT CURRENT_TIMESTAMP, + deleted bit DEFAULT FALSE, + tenant_id bigint DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS oa_file_permission ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + node_id bigint NOT NULL, + subject_type tinyint NOT NULL, + subject_id bigint NOT NULL, + level tinyint NOT NULL, + inherit bit NOT NULL, + expire_time timestamp, + creator varchar(64) DEFAULT '', + create_time timestamp DEFAULT CURRENT_TIMESTAMP, + updater varchar(64) DEFAULT '', + update_time timestamp DEFAULT CURRENT_TIMESTAMP, + deleted bit DEFAULT FALSE, + tenant_id bigint DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS oa_file_favorite ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + node_id bigint NOT NULL, + user_id bigint NOT NULL, + creator varchar(64) DEFAULT '', + create_time timestamp DEFAULT CURRENT_TIMESTAMP, + updater varchar(64) DEFAULT '', + update_time timestamp DEFAULT CURRENT_TIMESTAMP, + deleted bit DEFAULT FALSE, + tenant_id bigint DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS oa_vehicle ( + id bigint NOT NULL AUTO_INCREMENT PRIMARY KEY, + no varchar(32) NOT NULL, + name varchar(128) NOT NULL, + type varchar(64) NOT NULL, + category varchar(64), + brand_model varchar(128), + seat_count int NOT NULL, + bare_price decimal(18,2) NOT NULL DEFAULT 0, + compulsory_insurance_expire_time datetime, + commercial_insurance_expire_time datetime, + inspection_expire_time datetime, + pic_url varchar(2048), + dept_id bigint, + status tinyint NOT NULL DEFAULT 0, + sort int NOT NULL DEFAULT 0, + remark varchar(500), + creator varchar(64) DEFAULT '', + create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater varchar(64) DEFAULT '', + update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted bit NOT NULL DEFAULT FALSE, + tenant_id bigint NOT NULL DEFAULT 0, + KEY vehicle_idx_no (`tenant_id`, `no`) +); + +CREATE TABLE IF NOT EXISTS oa_vehicle_return ( + id bigint NOT NULL AUTO_INCREMENT PRIMARY KEY, + no varchar(64) NOT NULL, + apply_id bigint NOT NULL, + vehicle_id bigint NOT NULL, + user_id bigint NOT NULL, + dept_id bigint, + actual_start_time datetime, + start_location varchar(255), + reason varchar(500), + passenger varchar(500), + actual_return_time datetime, + return_location varchar(255), + status tinyint NOT NULL DEFAULT -1, + process_instance_id varchar(64), + file_urls varchar(12000) DEFAULT NULL, + remark varchar(500), + creator varchar(64) DEFAULT '', + create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater varchar(64) DEFAULT '', + update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted bit NOT NULL DEFAULT FALSE, + tenant_id bigint NOT NULL DEFAULT 0, + KEY vehicle_return_idx_process_instance (`tenant_id`, `process_instance_id`), + KEY vehicle_return_idx_no (`tenant_id`, `no`) +); + +CREATE TABLE IF NOT EXISTS oa_vehicle_apply ( + id bigint NOT NULL AUTO_INCREMENT PRIMARY KEY, + no varchar(64) NOT NULL, + vehicle_id bigint, + vehicle_no varchar(32), + user_id bigint NOT NULL, + dept_id bigint, + start_time datetime, + end_time datetime, + start_location varchar(255), + end_location varchar(255), + reason varchar(500), + passenger varchar(500), + status tinyint NOT NULL DEFAULT -1, + return_status tinyint NOT NULL DEFAULT 0, + process_instance_id varchar(64), + file_urls varchar(12000) DEFAULT NULL, + remark varchar(500), + creator varchar(64) DEFAULT '', + create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater varchar(64) DEFAULT '', + update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted bit NOT NULL DEFAULT FALSE, + tenant_id bigint NOT NULL DEFAULT 0, + KEY vehicle_apply_idx_no (`tenant_id`, `no`), + KEY vehicle_apply_idx_process_instance (`tenant_id`, `process_instance_id`) +); + +CREATE TABLE IF NOT EXISTS "oa_supply_item" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "dept_id" bigint NOT NULL, + "name" varchar(128) NOT NULL, + "no" varchar(64) DEFAULT NULL, + "category" int NOT NULL, + "manage_type" int NOT NULL, + "model" varchar(128), + "unit" varchar(32), + "reference_price" decimal(18,2), + "stock_quantity" int NOT NULL DEFAULT 0, + "min_stock_quantity" int NOT NULL DEFAULT 0, + "pic_url" varchar(2048), + "status" int NOT NULL DEFAULT 0, + "sort" int NOT NULL DEFAULT 0, + "remark" varchar(500), + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id"), + KEY supply_item_idx_no (`tenant_id`, `no`, `deleted`) +); + +CREATE TABLE IF NOT EXISTS "oa_supply_apply" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "no" varchar(64) NOT NULL, + "dept_id" bigint NOT NULL, + "apply_time" timestamp, + "use_type" int, + "pickup_method" int, + "reason" varchar(500), + "status" int NOT NULL DEFAULT -1, + "process_instance_id" varchar(64), + "file_urls" varchar(10000), + "remark" varchar(500), + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id"), + KEY supply_apply_idx_no (`tenant_id`, `no`, `deleted`), + KEY supply_apply_idx_creator (`tenant_id`, `creator`, `deleted`, `id`) +); + +CREATE TABLE IF NOT EXISTS "oa_supply_apply_item" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "apply_id" bigint NOT NULL, + "item_id" bigint NOT NULL, + "item_name" varchar(128) NOT NULL, + "model" varchar(128), + "unit" varchar(32), + "manage_type" int NOT NULL, + "apply_quantity" int, + "issued_quantity" int NOT NULL DEFAULT 0, + "returned_quantity" int NOT NULL DEFAULT 0, + "status" int NOT NULL DEFAULT -1, + "issue_user_id" bigint, + "issue_time" timestamp, + "issue_remark" varchar(500), + "return_remark" varchar(500), + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id"), + KEY supply_apply_item_idx_apply_id (`tenant_id`, `apply_id`, `deleted`, `id`), + KEY supply_apply_item_idx_item_id (`tenant_id`, `item_id`, `deleted`) +); + + + + + + + + + +CREATE TABLE IF NOT EXISTS "oa_meeting_room" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(100) NOT NULL, + "location" varchar(255) NOT NULL, + "type" tinyint NOT NULL, + "manager_user_id" bigint NOT NULL, + "status" tinyint NOT NULL, + "pic_url" varchar(1024), + "seat_count" int, + "equipments" varchar(5000), + "allow_booking" bit NOT NULL, + "need_approval" bit NOT NULL, + "booking_scope" tinyint NOT NULL, + "booking_user_ids" varchar(5000), + "sort" int NOT NULL, + "remark" varchar(200), + "file_urls" varchar(5000), + "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "oa_meeting_room_booking" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "no" varchar(64) NOT NULL, + "room_id" bigint NOT NULL, + "title" varchar(200) NOT NULL, + "start_time" timestamp NOT NULL, + "end_time" timestamp NOT NULL, + "moderator_user_id" bigint NOT NULL, + "attendee_user_ids" varchar(5000), + "reminder_type" tinyint NOT NULL, + "reminded" bit NOT NULL DEFAULT FALSE, + "description" varchar(500), + "dept_id" bigint, + "status" tinyint NOT NULL, + "process_instance_id" varchar(64), + "need_approval" bit, + "use_status" tinyint NOT NULL, + "remark" varchar(500), + "file_urls" varchar(5000), + "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id"), + KEY meeting_room_booking_idx_no (`tenant_id`, `no`) +); + +CREATE TABLE IF NOT EXISTS oa_reimbursement ( + id bigint NOT NULL AUTO_INCREMENT, + title varchar(255) DEFAULT NULL, + urgency int DEFAULT NULL, + reason text DEFAULT NULL, + witness_user_id bigint DEFAULT NULL, + customer_name varchar(255) DEFAULT NULL, + payment_method int DEFAULT NULL, + invoice_count int DEFAULT NULL, + total_price decimal(18,2) DEFAULT NULL, + status int DEFAULT NULL, + process_instance_id varchar(255) DEFAULT NULL, + items varchar DEFAULT NULL, + file_urls varchar DEFAULT NULL, + creator varchar(64) DEFAULT '', + create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater varchar(64) DEFAULT '', + update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted bit NOT NULL DEFAULT 0, + tenant_id bigint NOT NULL DEFAULT 0, + PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS oa_overtime_apply ( + id bigint NOT NULL AUTO_INCREMENT, + title varchar(255) DEFAULT NULL, + urgency int DEFAULT NULL, + type int DEFAULT NULL, + start_time datetime DEFAULT NULL, + end_time datetime DEFAULT NULL, + days decimal(10,1) DEFAULT NULL, + reason text DEFAULT NULL, + status int DEFAULT NULL, + process_instance_id varchar(255) DEFAULT NULL, + creator varchar(64) DEFAULT '', + create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater varchar(64) DEFAULT '', + update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted bit NOT NULL DEFAULT 0, + tenant_id bigint NOT NULL DEFAULT 0, + PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS oa_leave_apply ( + id bigint NOT NULL AUTO_INCREMENT, + title varchar(255) DEFAULT NULL, + urgency int DEFAULT NULL, + type int DEFAULT NULL, + start_time datetime DEFAULT NULL, + end_time datetime DEFAULT NULL, + days int DEFAULT NULL, + reason text DEFAULT NULL, + status int DEFAULT NULL, + process_instance_id varchar(255) DEFAULT NULL, + file_urls varchar DEFAULT NULL, + creator varchar(64) DEFAULT '', + create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater varchar(64) DEFAULT '', + update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted bit NOT NULL DEFAULT 0, + tenant_id bigint NOT NULL DEFAULT 0, + PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS oa_regular_apply ( + id bigint NOT NULL AUTO_INCREMENT, + title varchar(255) DEFAULT NULL, + urgency int DEFAULT NULL, + start_time datetime DEFAULT NULL, + end_time datetime DEFAULT NULL, + days int DEFAULT NULL, + experience varchar(255) DEFAULT NULL, + understanding varchar(255) DEFAULT NULL, + growth varchar(255) DEFAULT NULL, + deficiency varchar(255) DEFAULT NULL, + improvement varchar(255) DEFAULT NULL, + suggestion varchar(255) DEFAULT NULL, + status int DEFAULT NULL, + process_instance_id varchar(255) DEFAULT NULL, + creator varchar(64) DEFAULT '', + create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater varchar(64) DEFAULT '', + update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted bit NOT NULL DEFAULT 0, + tenant_id bigint NOT NULL DEFAULT 0, + PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS oa_resign_apply ( + id bigint NOT NULL AUTO_INCREMENT, + title varchar(255) DEFAULT NULL, + urgency int DEFAULT NULL, + reason text DEFAULT NULL, + handover_user_id bigint DEFAULT NULL, + unfinished_work text DEFAULT NULL, + has_pending_reimbursement bit DEFAULT NULL, + suggestion varchar(255) DEFAULT NULL, + status int DEFAULT NULL, + process_instance_id varchar(255) DEFAULT NULL, + creator varchar(64) DEFAULT '', + create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater varchar(64) DEFAULT '', + update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted bit NOT NULL DEFAULT 0, + tenant_id bigint NOT NULL DEFAULT 0, + PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS oa_official_doc_template ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(128) NOT NULL, + "authority_name" varchar(255) NOT NULL, + "font_size" int NOT NULL, + "no_prefix" varchar(64) NULL, + "seal_pic_url" varchar(2048) NULL, + "separator_type" int NOT NULL, + "status" int NOT NULL, + "sort" int NOT NULL, + "remark" varchar(500) NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS oa_official_doc_send ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "no" varchar(64) NULL, + "template_id" bigint NOT NULL, + "title" varchar(255) NOT NULL, + "no_prefix" varchar(64) NULL, + "year" int NULL, + "sequence" int NULL, + "document_no" varchar(64) NULL, + "secrecy_level" int NOT NULL, + "urgency_level" int NOT NULL, + "disclosure_type" int NOT NULL, + "issue_time" timestamp NOT NULL, + "send_dept_id" bigint NOT NULL, + "main_dept_ids" varchar(20000) NOT NULL, + "copy_dept_ids" varchar(20000) NULL, + "signer_user_id" bigint NULL, + "content" text NULL, + "file_urls" varchar(20000) NULL, + "formal_file_url" varchar(2048) NULL, + "remark" varchar(500) NULL, + "status" int NULL, + "process_instance_id" varchar(64) NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS oa_official_doc_receive ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "no" varchar(64) NULL, + "send_id" bigint NULL, + "receive_type" int NOT NULL, + "title" varchar(255) NULL, + "document_no" varchar(64) NULL, + "secrecy_level" int NULL, + "urgency_level" int NULL, + "receive_time" timestamp NULL, + "receive_dept_id" bigint NOT NULL, + "handler_user_id" bigint NULL, + "instruction" text NULL, + "result" text NULL, + "deadline_time" timestamp NULL, + "summary" text NULL, + "remark" varchar(500) NULL, + "file_urls" varchar(20000) NULL, + "formal_file_url" varchar(2048) NULL, + "handle_status" int NULL, + "status" int NULL, + "process_instance_id" varchar(64) NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +-- 出差管理初始化表结构,仅包含已确认的两张单据。 + +CREATE TABLE IF NOT EXISTS oa_travel_apply ( + id bigint NOT NULL AUTO_INCREMENT, + no varchar(64) NOT NULL, + dept_id bigint DEFAULT NULL, + reason text DEFAULT NULL, + start_time datetime DEFAULT NULL, + end_time datetime DEFAULT NULL, + days int DEFAULT NULL, + companion varchar(500) DEFAULT NULL, + estimated_price decimal(18,2) DEFAULT NULL, + reimburse_status tinyint NOT NULL DEFAULT 0, + status tinyint NOT NULL DEFAULT -1, + process_instance_id varchar(64) DEFAULT NULL, + remark text DEFAULT NULL, + items varchar DEFAULT NULL, + file_urls varchar DEFAULT NULL, + creator varchar(64) DEFAULT '', + create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater varchar(64) DEFAULT '', + update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted bit NOT NULL DEFAULT 0, + tenant_id bigint NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY travel_apply_idx_no (`tenant_id`, `no`) +) COMMENT='出差申请'; + +CREATE TABLE IF NOT EXISTS oa_travel_reimbursement ( + id bigint NOT NULL AUTO_INCREMENT, + no varchar(64) NOT NULL, + dept_id bigint DEFAULT NULL, + reason text DEFAULT NULL, + start_time datetime DEFAULT NULL, + end_time datetime DEFAULT NULL, + days int DEFAULT NULL, + travel_apply_id bigint DEFAULT NULL, + total_price decimal(18,2) DEFAULT NULL, + pay_status tinyint NOT NULL DEFAULT 0, + status tinyint NOT NULL DEFAULT -1, + process_instance_id varchar(64) DEFAULT NULL, + remark text DEFAULT NULL, + items varchar DEFAULT NULL, + file_urls varchar DEFAULT NULL, + creator varchar(64) DEFAULT '', + create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater varchar(64) DEFAULT '', + update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted bit NOT NULL DEFAULT 0, + tenant_id bigint NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY travel_reimbursement_idx_no (`tenant_id`, `no`) +) COMMENT='出差报销'; diff --git a/yudao-module-pms/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemServiceImpl.java b/yudao-module-pms/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemServiceImpl.java index 50c63525b7..299403272c 100644 --- a/yudao-module-pms/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemServiceImpl.java +++ b/yudao-module-pms/src/main/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemServiceImpl.java @@ -6,6 +6,7 @@ import cn.hutool.core.util.ObjectUtil; 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.framework.common.util.object.PageUtils; import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils; import cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo.PmsWorkbenchPageReqVO; import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusRespVO; @@ -579,9 +580,7 @@ public class PmsWorkItemServiceImpl implements PmsWorkItemService { if (Boolean.TRUE.equals(pageReqVO.getPlanningOnly()) && Boolean.TRUE.equals(pageReqVO.getUnplannedOnly())) { List workItems = workItemMapper.selectListByPlanning(pageReqVO); workItemUserSortService.sortWorkItemList(workItems, pageReqVO.getProjectId(), userId); - int fromIndex = Math.min((pageReqVO.getPageNo() - 1) * pageReqVO.getPageSize(), workItems.size()); - int toIndex = Math.min(fromIndex + pageReqVO.getPageSize(), workItems.size()); - return new PageResult<>(new ArrayList<>(workItems.subList(fromIndex, toIndex)), (long) workItems.size()); + return PageUtils.buildPageResult(pageReqVO, workItems); } // 3. 查询其他工作项分页 diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApi.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApi.java index ad30655f22..5fad86d778 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApi.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApi.java @@ -72,6 +72,14 @@ public interface AdminUserApi { */ List getUserListByNickname(String nickname); + /** + * 获得指定状态的用户列表 + * + * @param status 用户状态 + * @return 用户列表 + */ + List getUserListByStatus(Integer status); + /** * 获得用户 Map * @@ -92,10 +100,9 @@ public interface AdminUserApi { * 2. 用户被禁用 * * @param id 用户编号 + * @return 有效的用户对象信息 */ - default void validateUser(Long id) { - validateUserList(Collections.singleton(id)); - } + AdminUserRespDTO validateUser(Long id); /** * 校验用户们是否有效。如下情况,视为无效: diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApiImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApiImpl.java index dc790ed355..a0af97df08 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApiImpl.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/user/AdminUserApiImpl.java @@ -93,6 +93,18 @@ public class AdminUserApiImpl implements AdminUserApi { return BeanUtils.toBean(users, AdminUserRespDTO.class); } + @Override + public List getUserListByStatus(Integer status) { + List users = userService.getUserListByStatus(status); + return BeanUtils.toBean(users, AdminUserRespDTO.class); + } + + @Override + public AdminUserRespDTO validateUser(Long id) { + AdminUserDO user = userService.validateUser(id); + return BeanUtils.toBean(user, AdminUserRespDTO.class); + } + @Override public void validateUserList(Collection ids) { userService.validateUserList(ids); diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/user/AdminUserService.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/user/AdminUserService.java index 69904cc720..25a93d08c1 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/user/AdminUserService.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/user/AdminUserService.java @@ -176,6 +176,14 @@ public interface AdminUserService { */ void validateUserList(Collection ids); + /** + * 校验用户存在且未被禁用 + * + * @param id 用户编号 + * @return 有效的用户 + */ + AdminUserDO validateUser(Long id); + /** * 获得用户 Map * diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/user/AdminUserServiceImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/user/AdminUserServiceImpl.java index 8159ee89bc..3899a32fce 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/user/AdminUserServiceImpl.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/user/AdminUserServiceImpl.java @@ -359,6 +359,18 @@ public class AdminUserServiceImpl implements AdminUserService { } + @Override + public AdminUserDO validateUser(Long id) { + AdminUserDO user = userMapper.selectById(id); + if (user == null) { + throw exception(USER_NOT_EXISTS); + } + if (!CommonStatusEnum.ENABLE.getStatus().equals(user.getStatus())) { + throw exception(USER_IS_DISABLE, user.getNickname()); + } + return user; + } + @Override public void validateUserList(Collection ids) { if (CollUtil.isEmpty(ids)) { diff --git a/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/user/AdminUserServiceImplTest.java b/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/user/AdminUserServiceImplTest.java index 5d4cd400f9..b9f0dec463 100644 --- a/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/user/AdminUserServiceImplTest.java +++ b/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/user/AdminUserServiceImplTest.java @@ -740,6 +740,35 @@ public class AdminUserServiceImplTest extends BaseDbUnitTest { assertEquals(user, result.get(0)); } + @Test + public void testValidateUser_success() { + // mock 数据 + AdminUserDO user = randomAdminUserDO().setStatus(CommonStatusEnum.ENABLE.getStatus()); + userMapper.insert(user); + + // 调用 + AdminUserDO result = userService.validateUser(user.getId()); + + // 断言 + assertEquals(user, result); + } + + @Test + public void testValidateUser_notFound() { + // 调用,并断言异常 + assertServiceException(() -> userService.validateUser(randomLongId()), USER_NOT_EXISTS); + } + + @Test + public void testValidateUser_notEnable() { + // mock 数据 + AdminUserDO user = randomAdminUserDO().setStatus(CommonStatusEnum.DISABLE.getStatus()); + userMapper.insert(user); + + // 调用,并断言异常 + assertServiceException(() -> userService.validateUser(user.getId()), USER_IS_DISABLE, user.getNickname()); + } + @Test public void testValidateUserList_success() { // mock 数据 From 58a1c1d5fd3324312867b6427906cc8f55e51405 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sat, 19 Sep 2026 10:59:00 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat=EF=BC=88oa=EF=BC=89=EF=BC=9AREADME?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 75 +++++----- .../banner/core/BannerApplicationRunner.java | 38 ++++-- .../core/handler/GlobalExceptionHandler.java | 128 +++++++++++------- .../server/controller/DefaultController.java | 48 +++++-- 4 files changed, 176 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index 3ece47e310..2c39ec1936 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ | 【完整版】[ruoyi-vue-pro](https://gitee.com/zhijiantianya/ruoyi-vue-pro) | [`master`](https://gitee.com/zhijiantianya/ruoyi-vue-pro/tree/master/) 分支 | [`master-jdk17`](https://gitee.com/zhijiantianya/ruoyi-vue-pro/tree/master-jdk17/) 分支 | [`master-jdk25`](https://gitee.com/zhijiantianya/ruoyi-vue-pro/tree/master-jdk25/) 分支 | | 【精简版】[yudao-boot-mini](https://gitee.com/yudaocode/yudao-boot-mini) | [`master`](https://gitee.com/yudaocode/yudao-boot-mini/tree/master/) 分支 | [`master-jdk17`](https://gitee.com/yudaocode/yudao-boot-mini/tree/master-jdk17/) 分支 | [`master-jdk25`](https://gitee.com/yudaocode/yudao-boot-mini/tree/master-jdk25/) 分支 | -* 【完整版】:包括系统功能、基础设施、会员中心、数据报表、工作流程、商城系统、微信公众号、CRM、ERP、WMS、MES、HRM、FMS、PMS、IM 即时通讯、AI 大模型、IoT 物联网等功能 -* 【精简版】:只包括系统功能、基础设施功能,不包括会员中心、数据报表、工作流程、商城系统、微信公众号、CRM、ERP、WMS、MES、HRM、FMS、PMS、IM 即时通讯、AI 大模型、IoT 物联网等功能 +* 【完整版】:包括系统功能、基础设施、工作流程、支付系统、数据报表、微信公众号、商城系统、会员中心、ERP、WMS、CRM、MES、HRM、FMS、PMS、OA 协同办公、AI 大模型、IoT 物联网、IM 即时通讯等功能 +* 【精简版】:只包括系统功能、基础设施功能,不包括工作流程、支付系统、数据报表、微信公众号、商城系统、会员中心、ERP、WMS、CRM、MES、HRM、FMS、PMS、OA 协同办公、AI 大模型、IoT 物联网、IM 即时通讯等功能 可参考 [《迁移文档》](https://doc.iocoder.cn/migrate-module/) ,只需要 5-10 分钟,即可将【完整版】按需迁移到【精简版】 @@ -112,7 +112,7 @@ * 通用模块(必选):系统功能、基础设施 * 通用模块(可选):工作流程、支付系统、数据报表、会员中心 -* 业务系统(按需):Mall 电子商城、OA 办公自动化、ERP 企业资源计划系统、WMS 仓库管理系统、CRM 客户关系管理、CMS 内容管理系统、MES 执行制造系统、HRM 人力资源管理、FMS 财务管理、PMS 项目管理、AI 大模型平台、IoT 物联网系统、IM 即时通讯系统、Mobile 手机移动端、Report 数据大屏 +* 业务系统(按需):Mall 电子商城、ERP 企业资源计划系统、WMS 仓库管理系统、CRM 客户关系管理、CMS 内容管理系统、MES 执行制造系统、HRM 人力资源管理、FMS 财务管理、PMS 项目管理、OA 办公自动化、AI 大模型平台、IoT 物联网系统、IM 即时通讯系统、Mobile 手机移动端、Report 数据大屏 > 友情提示:本项目基于 RuoYi-Vue 修改,**重构优化**后端的代码,**美化**前端的界面。 > @@ -147,6 +147,31 @@ ![功能图](/.image/common/system-feature.png) +### 基础设施 + +| | 功能 | 描述 | +|-----|-----------|----------------------------------------------| +| 🚀 | 代码生成 | 前后端代码的生成(Java、Vue、SQL、单元测试),支持 CRUD 下载 | +| 🚀 | 系统接口 | 基于 Swagger 自动生成相关的 RESTful API 接口文档 | +| 🚀 | 数据库文档 | 基于 Screw 自动生成数据库文档,支持导出 Word、HTML、MD 格式 | +| | 表单构建 | 拖动表单元素生成相应的 HTML 代码,支持导出 JSON、Vue 文件 | +| 🚀 | 配置管理 | 对系统动态配置常用参数,支持 SpringBoot 加载 | +| ⭐️ | 定时任务 | 在线(添加、修改、删除)任务调度包含执行结果日志 | +| 🚀 | 文件服务 | 支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、数据库等 | +| 🚀 | WebSocket | 提供 WebSocket 接入示例,支持一对一、一对多发送方式 | +| 🚀 | API 日志 | 包括 RESTful API 访问日志、异常日志两部分,方便排查 API 相关的问题 | +| | MySQL 监控 | 监视当前系统数据库连接池状态,可进行分析SQL找出系统性能瓶颈 | +| | Redis 监控 | 监控 Redis 数据库的使用情况,使用的 Redis Key 管理 | +| 🚀 | 消息队列 | 基于 Redis 实现消息队列,Stream 提供集群消费,Pub/Sub 提供广播消费 | +| 🚀 | Java 监控 | 基于 Spring Boot Admin 实现 Java 应用的监控 | +| 🚀 | 链路追踪 | 接入 SkyWalking 组件,实现链路追踪 | +| 🚀 | 日志中心 | 接入 SkyWalking 组件,实现日志中心 | +| 🚀 | 服务保障 | 基于 Redis 实现分布式锁、幂等、限流功能,满足高并发场景 | +| 🚀 | 日志服务 | 轻量级日志中心,查看远程服务器的日志 | +| 🚀 | 单元测试 | 基于 JUnit + Mockito 实现单元测试,保证功能的正确性、代码的质量等 | + +![功能图](/.image/common/infra-feature.png) + ### 工作流程 ![功能图](/.image/common/bpm-feature.png) @@ -198,31 +223,6 @@ | 🚀 | 回调通知 | 查看支付回调业务的【支付】【退款】的通知结果 | | 🚀 | 接入示例 | 提供接入支付系统的【支付】【退款】的功能实战 | -### 基础设施 - -| | 功能 | 描述 | -|-----|-----------|----------------------------------------------| -| 🚀 | 代码生成 | 前后端代码的生成(Java、Vue、SQL、单元测试),支持 CRUD 下载 | -| 🚀 | 系统接口 | 基于 Swagger 自动生成相关的 RESTful API 接口文档 | -| 🚀 | 数据库文档 | 基于 Screw 自动生成数据库文档,支持导出 Word、HTML、MD 格式 | -| | 表单构建 | 拖动表单元素生成相应的 HTML 代码,支持导出 JSON、Vue 文件 | -| 🚀 | 配置管理 | 对系统动态配置常用参数,支持 SpringBoot 加载 | -| ⭐️ | 定时任务 | 在线(添加、修改、删除)任务调度包含执行结果日志 | -| 🚀 | 文件服务 | 支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、数据库等 | -| 🚀 | WebSocket | 提供 WebSocket 接入示例,支持一对一、一对多发送方式 | -| 🚀 | API 日志 | 包括 RESTful API 访问日志、异常日志两部分,方便排查 API 相关的问题 | -| | MySQL 监控 | 监视当前系统数据库连接池状态,可进行分析SQL找出系统性能瓶颈 | -| | Redis 监控 | 监控 Redis 数据库的使用情况,使用的 Redis Key 管理 | -| 🚀 | 消息队列 | 基于 Redis 实现消息队列,Stream 提供集群消费,Pub/Sub 提供广播消费 | -| 🚀 | Java 监控 | 基于 Spring Boot Admin 实现 Java 应用的监控 | -| 🚀 | 链路追踪 | 接入 SkyWalking 组件,实现链路追踪 | -| 🚀 | 日志中心 | 接入 SkyWalking 组件,实现日志中心 | -| 🚀 | 服务保障 | 基于 Redis 实现分布式锁、幂等、限流功能,满足高并发场景 | -| 🚀 | 日志服务 | 轻量级日志中心,查看远程服务器的日志 | -| 🚀 | 单元测试 | 基于 JUnit + Mockito 实现单元测试,保证功能的正确性、代码的质量等 | - -![功能图](/.image/common/infra-feature.png) - ### 数据报表 | | 功能 | 描述 | @@ -316,6 +316,14 @@ ![预览图](/.image/common/pms-preview.png) +### OA 协同办公 + +演示地址: + +![功能图](/.image/common/oa-feature.png) + +![预览图](/.image/common/oa-preview.png) + ### AI 大模型 演示地址: @@ -355,23 +363,24 @@ | `yudao-framework` | Java 框架拓展 | | `yudao-server` | 管理后台 + 用户 APP 的服务端 | | `yudao-module-system` | 系统功能的 Module 模块 | -| `yudao-module-member` | 会员中心的 Module 模块 | | `yudao-module-infra` | 基础设施的 Module 模块 | | `yudao-module-bpm` | 工作流程的 Module 模块 | | `yudao-module-pay` | 支付系统的 Module 模块 | +| `yudao-module-report` | 大屏报表 Module 模块 | +| `yudao-module-mp` | 微信公众号的 Module 模块 | | `yudao-module-mall` | 商城系统的 Module 模块 | +| `yudao-module-member` | 会员中心的 Module 模块 | | `yudao-module-erp` | ERP 系统的 Module 模块 | +| `yudao-module-wms` | WMS 系统的 Module 模块 | | `yudao-module-crm` | CRM 系统的 Module 模块 | | `yudao-module-mes` | MES 系统的 Module 模块 | -| `yudao-module-wms` | WMS 系统的 Module 模块 | | `yudao-module-hrm` | HRM 人力资源管理的 Module 模块 | | `yudao-module-fms` | FMS 财务管理的 Module 模块 | | `yudao-module-pms` | PMS 项目管理的 Module 模块 | -| `yudao-module-im` | IM 即时通讯的 Module 模块 | +| `yudao-module-oa` | OA 协同办公的 Module 模块 | | `yudao-module-ai` | AI 大模型的 Module 模块 | | `yudao-module-iot` | IoT 物联网的 Module 模块 | -| `yudao-module-mp` | 微信公众号的 Module 模块 | -| `yudao-module-report` | 大屏报表 Module 模块 | +| `yudao-module-im` | IM 即时通讯的 Module 模块 | ### 框架 diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/banner/core/BannerApplicationRunner.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/banner/core/BannerApplicationRunner.java index 92092bbaec..1d5922594c 100644 --- a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/banner/core/BannerApplicationRunner.java +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/banner/core/BannerApplicationRunner.java @@ -30,13 +30,21 @@ public class BannerApplicationRunner implements ApplicationRunner { "https://doc.iocoder.cn", "https://t.zsxq.com/02Yf6M7Qn"); + // 工作流 + if (isNotPresent("cn.iocoder.yudao.module.bpm.framework.flowable.config.BpmFlowableConfiguration")) { + System.out.println("[工作流模块 yudao-module-bpm - 已禁用][参考 https://doc.iocoder.cn/bpm/ 开启]"); + } + // 支付平台 + if (isNotPresent("cn.iocoder.yudao.module.pay.framework.pay.config.PayConfiguration")) { + System.out.println("[支付系统 yudao-module-pay - 已禁用][参考 https://doc.iocoder.cn/pay/build/ 开启]"); + } // 数据报表 if (isNotPresent("cn.iocoder.yudao.module.report.framework.security.config.SecurityConfiguration")) { System.out.println("[报表模块 yudao-module-report - 已禁用][参考 https://doc.iocoder.cn/report/ 开启]"); } - // 工作流 - if (isNotPresent("cn.iocoder.yudao.module.bpm.framework.flowable.config.BpmFlowableConfiguration")) { - System.out.println("[工作流模块 yudao-module-bpm - 已禁用][参考 https://doc.iocoder.cn/bpm/ 开启]"); + // 微信公众号 + if (isNotPresent("cn.iocoder.yudao.module.mp.framework.mp.config.MpConfiguration")) { + System.out.println("[微信公众号 yudao-module-mp - 已禁用][参考 https://doc.iocoder.cn/mp/build/ 开启]"); } // 商城系统 if (isNotPresent("cn.iocoder.yudao.module.trade.framework.web.config.TradeWebConfiguration")) { @@ -50,10 +58,6 @@ public class BannerApplicationRunner implements ApplicationRunner { if (isNotPresent("cn.iocoder.yudao.module.wms.framework.web.config.WmsWebConfiguration")) { System.out.println("[WMS 仓库管理系统 yudao-module-wms - 已禁用][参考 https://doc.iocoder.cn/wms/build/ 开启]"); } - // PMS 系统 - if (isNotPresent("cn.iocoder.yudao.module.pms.framework.web.config.PmsWebConfiguration")) { - System.out.println("[PMS 项目管理系统 yudao-module-pms - 已禁用][请在 yudao-server 中开启]"); - } // CRM 系统 if (isNotPresent("cn.iocoder.yudao.module.crm.framework.web.config.CrmWebConfiguration")) { System.out.println("[CRM 系统 yudao-module-crm - 已禁用][参考 https://doc.iocoder.cn/crm/build/ 开启]"); @@ -62,13 +66,21 @@ public class BannerApplicationRunner implements ApplicationRunner { if (isNotPresent("cn.iocoder.yudao.module.mes.framework.web.config.MesWebConfiguration")) { System.out.println("[MES 系统 yudao-module-mes - 已禁用][参考 https://doc.iocoder.cn/mes/build/ 开启]"); } - // 微信公众号 - if (isNotPresent("cn.iocoder.yudao.module.mp.framework.mp.config.MpConfiguration")) { - System.out.println("[微信公众号 yudao-module-mp - 已禁用][参考 https://doc.iocoder.cn/mp/build/ 开启]"); + // HRM 人力资源管理系统 + if (isNotPresent("cn.iocoder.yudao.module.hrm.framework.web.config.HrmWebConfiguration")) { + System.out.println("[HRM 人力资源管理系统 yudao-module-hrm - 已禁用][参考 https://doc.iocoder.cn/hrm/build/ 开启]"); } - // 支付平台 - if (isNotPresent("cn.iocoder.yudao.module.pay.framework.pay.config.PayConfiguration")) { - System.out.println("[支付系统 yudao-module-pay - 已禁用][参考 https://doc.iocoder.cn/pay/build/ 开启]"); + // FMS 财务管理系统 + if (isNotPresent("cn.iocoder.yudao.module.fms.framework.web.config.FmsWebConfiguration")) { + System.out.println("[FMS 财务管理系统 yudao-module-fms - 已禁用][参考 https://doc.iocoder.cn/fms/build/ 开启]"); + } + // PMS 系统 + if (isNotPresent("cn.iocoder.yudao.module.pms.framework.web.config.PmsWebConfiguration")) { + System.out.println("[PMS 项目管理系统 yudao-module-pms - 已禁用][参考 https://doc.iocoder.cn/pms/build/ 开启]"); + } + // OA 办公自动化 + if (isNotPresent("cn.iocoder.yudao.module.oa.framework.config.OaProperties")) { + System.out.println("[OA 协同办公 yudao-module-oa - 已禁用][参考 https://doc.iocoder.cn/oa/build/ 开启]"); } // AI 大模型 if (isNotPresent("cn.iocoder.yudao.module.ai.framework.web.config.AiWebConfiguration")) { diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java index 9def364111..8edf7c06ae 100644 --- a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java @@ -400,78 +400,102 @@ public class GlobalExceptionHandler { if (!message.contains("doesn't exist")) { return null; } - // 1. 数据报表 - if (message.contains("report_")) { - log.error("[报表模块 yudao-module-report - 表结构未导入][参考 https://cloud.iocoder.cn/report/ 开启]"); - return CommonResult.error(NOT_IMPLEMENTED.getCode(), - "[报表模块 yudao-module-report - 表结构未导入][参考 https://cloud.iocoder.cn/report/ 开启]"); - } - // 2. 工作流 + // 1. 工作流 if (message.contains("bpm_")) { log.error("[工作流模块 yudao-module-bpm - 表结构未导入][参考 https://cloud.iocoder.cn/bpm/ 开启]"); return CommonResult.error(NOT_IMPLEMENTED.getCode(), "[工作流模块 yudao-module-bpm - 表结构未导入][参考 https://cloud.iocoder.cn/bpm/ 开启]"); } - // 3. 微信公众号 - if (message.contains("mp_")) { - log.error("[微信公众号 yudao-module-mp - 表结构未导入][参考 https://cloud.iocoder.cn/mp/build/ 开启]"); - return CommonResult.error(NOT_IMPLEMENTED.getCode(), - "[微信公众号 yudao-module-mp - 表结构未导入][参考 https://cloud.iocoder.cn/mp/build/ 开启]"); - } - // 4. 商城系统 - if (StrUtil.containsAny(message, "product_", "promotion_", "trade_")) { - log.error("[商城系统 yudao-module-mall - 已禁用][参考 https://cloud.iocoder.cn/mall/build/ 开启]"); - return CommonResult.error(NOT_IMPLEMENTED.getCode(), - "[商城系统 yudao-module-mall - 已禁用][参考 https://cloud.iocoder.cn/mall/build/ 开启]"); - } - // 5. ERP 系统 - if (message.contains("erp_")) { - log.error("[ERP 系统 yudao-module-erp - 表结构未导入][参考 https://cloud.iocoder.cn/erp/build/ 开启]"); - return CommonResult.error(NOT_IMPLEMENTED.getCode(), - "[ERP 系统 yudao-module-erp - 表结构未导入][参考 https://cloud.iocoder.cn/erp/build/ 开启]"); - } - // 6. WMS 仓库管理系统 - if (message.contains("wms_")) { - log.error("[WMS 仓库管理系统 yudao-module-wms - 表结构未导入][参考 https://doc.iocoder.cn/wms/build/ 开启]"); - return CommonResult.error(NOT_IMPLEMENTED.getCode(), - "[WMS 仓库管理系统 yudao-module-wms - 表结构未导入][参考 https://doc.iocoder.cn/wms/build/ 开启]"); - } - // 7. CRM 系统 - if (message.contains("crm_")) { - log.error("[CRM 系统 yudao-module-crm - 表结构未导入][参考 https://cloud.iocoder.cn/crm/build/ 开启]"); - return CommonResult.error(NOT_IMPLEMENTED.getCode(), - "[CRM 系统 yudao-module-crm - 表结构未导入][参考 https://cloud.iocoder.cn/crm/build/ 开启]"); - } - // 8. MES 系统 - if (message.contains("mes_")) { - log.error("[MES 系统 yudao-module-mes - 表结构未导入][参考 https://doc.iocoder.cn/mes/build/ 开启]"); - return CommonResult.error(NOT_IMPLEMENTED.getCode(), - "[MES 系统 yudao-module-mes - 表结构未导入][参考 https://doc.iocoder.cn/mes/build/ 开启]"); - } - // 9. IM 即时通讯 - if (message.contains("im_")) { - log.error("[IM 即时通讯 yudao-module-im - 表结构未导入][参考 https://doc.iocoder.cn/im/build/ 开启]"); - return CommonResult.error(NOT_IMPLEMENTED.getCode(), - "[IM 即时通讯 yudao-module-im - 表结构未导入][参考 https://doc.iocoder.cn/im/build/ 开启]"); - } - // 10. 支付平台 + // 2. 支付平台 if (message.contains("pay_")) { log.error("[支付模块 yudao-module-pay - 表结构未导入][参考 https://cloud.iocoder.cn/pay/build/ 开启]"); return CommonResult.error(NOT_IMPLEMENTED.getCode(), "[支付模块 yudao-module-pay - 表结构未导入][参考 https://cloud.iocoder.cn/pay/build/ 开启]"); } - // 11. AI 大模型 + // 3. 数据报表 + if (message.contains("report_")) { + log.error("[报表模块 yudao-module-report - 表结构未导入][参考 https://cloud.iocoder.cn/report/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[报表模块 yudao-module-report - 表结构未导入][参考 https://cloud.iocoder.cn/report/ 开启]"); + } + // 4. 微信公众号 + if (message.contains("mp_")) { + log.error("[微信公众号 yudao-module-mp - 表结构未导入][参考 https://cloud.iocoder.cn/mp/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[微信公众号 yudao-module-mp - 表结构未导入][参考 https://cloud.iocoder.cn/mp/build/ 开启]"); + } + // 5. 商城系统 + if (StrUtil.containsAny(message, "product_", "promotion_", "trade_")) { + log.error("[商城系统 yudao-module-mall - 表结构未导入][参考 https://cloud.iocoder.cn/mall/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[商城系统 yudao-module-mall - 表结构未导入][参考 https://cloud.iocoder.cn/mall/build/ 开启]"); + } + // 6. ERP 系统 + if (message.contains("erp_")) { + log.error("[ERP 系统 yudao-module-erp - 表结构未导入][参考 https://cloud.iocoder.cn/erp/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[ERP 系统 yudao-module-erp - 表结构未导入][参考 https://cloud.iocoder.cn/erp/build/ 开启]"); + } + // 7. WMS 仓库管理系统 + if (message.contains("wms_")) { + log.error("[WMS 仓库管理系统 yudao-module-wms - 表结构未导入][参考 https://doc.iocoder.cn/wms/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[WMS 仓库管理系统 yudao-module-wms - 表结构未导入][参考 https://doc.iocoder.cn/wms/build/ 开启]"); + } + // 8. CRM 系统 + if (message.contains("crm_")) { + log.error("[CRM 系统 yudao-module-crm - 表结构未导入][参考 https://cloud.iocoder.cn/crm/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[CRM 系统 yudao-module-crm - 表结构未导入][参考 https://cloud.iocoder.cn/crm/build/ 开启]"); + } + // 9. MES 系统 + if (message.contains("mes_")) { + log.error("[MES 系统 yudao-module-mes - 表结构未导入][参考 https://doc.iocoder.cn/mes/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[MES 系统 yudao-module-mes - 表结构未导入][参考 https://doc.iocoder.cn/mes/build/ 开启]"); + } + // 10. HRM 人力资源管理系统 + if (message.contains("hrm_")) { + log.error("[HRM 人力资源管理系统 yudao-module-hrm - 表结构未导入][参考 https://doc.iocoder.cn/hrm/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[HRM 人力资源管理系统 yudao-module-hrm - 表结构未导入][参考 https://doc.iocoder.cn/hrm/build/ 开启]"); + } + // 11. FMS 财务管理系统 + if (message.contains("fms_")) { + log.error("[FMS 财务管理系统 yudao-module-fms - 表结构未导入][参考 https://doc.iocoder.cn/fms/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[FMS 财务管理系统 yudao-module-fms - 表结构未导入][参考 https://doc.iocoder.cn/fms/build/ 开启]"); + } + // 12. PMS 项目管理系统 + if (message.contains("pms_")) { + log.error("[PMS 项目管理系统 yudao-module-pms - 表结构未导入][参考 https://doc.iocoder.cn/pms/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[PMS 项目管理系统 yudao-module-pms - 表结构未导入][参考 https://doc.iocoder.cn/pms/build/ 开启]"); + } + // 13. OA 协同办公 + if (message.contains("oa_")) { + log.error("[OA 协同办公 yudao-module-oa - 表结构未导入][参考 https://doc.iocoder.cn/oa/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[OA 协同办公 yudao-module-oa - 表结构未导入][参考 https://doc.iocoder.cn/oa/build/ 开启]"); + } + // 14. AI 大模型 if (message.contains("ai_")) { log.error("[AI 大模型 yudao-module-ai - 表结构未导入][参考 https://cloud.iocoder.cn/ai/build/ 开启]"); return CommonResult.error(NOT_IMPLEMENTED.getCode(), "[AI 大模型 yudao-module-ai - 表结构未导入][参考 https://cloud.iocoder.cn/ai/build/ 开启]"); } - // 12. IoT 物联网 + // 15. IoT 物联网 if (message.contains("iot_")) { log.error("[IoT 物联网 yudao-module-iot - 表结构未导入][参考 https://doc.iocoder.cn/iot/build/ 开启]"); return CommonResult.error(NOT_IMPLEMENTED.getCode(), "[IoT 物联网 yudao-module-iot - 表结构未导入][参考 https://doc.iocoder.cn/iot/build/ 开启]"); } + // 16. IM 即时通讯 + if (message.contains("im_")) { + log.error("[IM 即时通讯 yudao-module-im - 表结构未导入][参考 https://doc.iocoder.cn/im/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[IM 即时通讯 yudao-module-im - 表结构未导入][参考 https://doc.iocoder.cn/im/build/ 开启]"); + } return null; } diff --git a/yudao-server/src/main/java/cn/iocoder/yudao/server/controller/DefaultController.java b/yudao-server/src/main/java/cn/iocoder/yudao/server/controller/DefaultController.java index a3ddaf694a..4650b1452f 100644 --- a/yudao-server/src/main/java/cn/iocoder/yudao/server/controller/DefaultController.java +++ b/yudao-server/src/main/java/cn/iocoder/yudao/server/controller/DefaultController.java @@ -26,6 +26,18 @@ public class DefaultController { "[工作流模块 yudao-module-bpm - 已禁用][参考 https://doc.iocoder.cn/bpm/ 开启]"); } + @RequestMapping(value = { "/admin-api/pay/**"}) + public CommonResult pay404() { + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[支付模块 yudao-module-pay - 已禁用][参考 https://doc.iocoder.cn/pay/build/ 开启]"); + } + + @RequestMapping(value = { "/admin-api/report/**"}) + public CommonResult report404() { + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[报表模块 yudao-module-report - 已禁用][参考 https://doc.iocoder.cn/report/ 开启]"); + } + @RequestMapping("/admin-api/mp/**") public CommonResult mp404() { return CommonResult.error(NOT_IMPLEMENTED.getCode(), @@ -52,12 +64,6 @@ public class DefaultController { "[WMS 仓库管理系统 yudao-module-wms - 已禁用][参考 https://doc.iocoder.cn/wms/build/ 开启]"); } - @RequestMapping("/admin-api/pms/**") - public CommonResult pms404() { - return CommonResult.error(NOT_IMPLEMENTED.getCode(), - "[PMS 项目管理系统 yudao-module-pms - 已禁用][请在 yudao-server 中开启]"); - } - @RequestMapping("/admin-api/crm/**") public CommonResult crm404() { return CommonResult.error(NOT_IMPLEMENTED.getCode(), @@ -70,22 +76,28 @@ public class DefaultController { "[MES 系统 yudao-module-mes - 已禁用][参考 https://doc.iocoder.cn/mes/build/ 开启]"); } - @RequestMapping(value = { "/admin-api/im/**"}) - public CommonResult im404() { + @RequestMapping("/admin-api/hrm/**") + public CommonResult hrm404() { return CommonResult.error(NOT_IMPLEMENTED.getCode(), - "[IM 即时通讯 yudao-module-im - 已禁用][参考 https://doc.iocoder.cn/im/build/ 开启]"); + "[HRM 人力资源管理系统 yudao-module-hrm - 已禁用][参考 https://doc.iocoder.cn/hrm/build/ 开启]"); } - @RequestMapping(value = { "/admin-api/report/**"}) - public CommonResult report404() { + @RequestMapping("/admin-api/fms/**") + public CommonResult fms404() { return CommonResult.error(NOT_IMPLEMENTED.getCode(), - "[报表模块 yudao-module-report - 已禁用][参考 https://doc.iocoder.cn/report/ 开启]"); + "[FMS 财务管理系统 yudao-module-fms - 已禁用][参考 https://doc.iocoder.cn/fms/build/ 开启]"); } - @RequestMapping(value = { "/admin-api/pay/**"}) - public CommonResult pay404() { + @RequestMapping("/admin-api/pms/**") + public CommonResult pms404() { return CommonResult.error(NOT_IMPLEMENTED.getCode(), - "[支付模块 yudao-module-pay - 已禁用][参考 https://doc.iocoder.cn/pay/build/ 开启]"); + "[PMS 项目管理系统 yudao-module-pms - 已禁用][参考 https://doc.iocoder.cn/pms/build/ 开启]"); + } + + @RequestMapping("/admin-api/oa/**") + public CommonResult oa404() { + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[OA 协同办公 yudao-module-oa - 已禁用][参考 https://doc.iocoder.cn/oa/build/ 开启]"); } @RequestMapping(value = { "/admin-api/ai/**"}) @@ -100,6 +112,12 @@ public class DefaultController { "[IoT 物联网 yudao-module-iot - 已禁用][参考 https://doc.iocoder.cn/iot/build/ 开启]"); } + @RequestMapping(value = { "/admin-api/im/**"}) + public CommonResult im404() { + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[IM 即时通讯 yudao-module-im - 已禁用][参考 https://doc.iocoder.cn/im/build/ 开启]"); + } + /** * 测试接口:打印 query、header、body */ From 5871789dd90f3840c2ca6d9ca9636f9584d46db8 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sat, 19 Sep 2026 10:59:09 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat=EF=BC=88oa=EF=BC=89=EF=BC=9AREADME?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .image/common/oa-feature.png | Bin 0 -> 314138 bytes .image/common/oa-preview.png | Bin 0 -> 93454 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 .image/common/oa-feature.png create mode 100644 .image/common/oa-preview.png diff --git a/.image/common/oa-feature.png b/.image/common/oa-feature.png new file mode 100644 index 0000000000000000000000000000000000000000..e276412c00b50f9d22f0fb6caf3a668ac99bcb29 GIT binary patch literal 314138 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%0A^z`JzL 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

{!&MLoP#(5&1%=(NpN8i95z89M{05hRj&hJ%i| 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

Y)Zv`LB6Pfk=R=ll$!h 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 Hu0mjfb>pwh literal 0 HcmV?d00001 diff --git a/.image/common/oa-preview.png b/.image/common/oa-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..cec427328d8b8856abb1d962870d2a371f5777ba GIT binary patch literal 93454 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

!(3d*kQo!=;V-L4XLIip8iDNO@Gy> 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