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 数据