From 09426f8fc601d4b82a0c6125e6764dfed50a4b8d Mon Sep 17 00:00:00 2001 From: YunaiV Date: Thu, 29 Oct 2026 21:01:44 +0800 Subject: [PATCH] fix: 适配 CRM 列间 CSV 查询并完善 OA、MES 细节 --- yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtils.java | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtilsTest.java | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-crm/src/main/resources/mapper/statistics/CrmStatisticsRankMapper.xml | 4 ++-- yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/cal/plan/MesCalPlanTeamController.java | 20 ++++++++------------ yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/controller/admin/cal/plan/MesCalPlanTeamControllerTest.java | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/cal/plan/MesCalPlanServiceImplTest.java | 31 +++++++++++++++++++++++++++++++ yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/file/OaFileNodeMapper.java | 10 +++++++--- yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingCancelExpiredJob.java | 30 ++++++++++++++++++++++++++++++ yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingReminderJob.java | 6 ++++-- yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingSendReminderJob.java | 30 ++++++++++++++++++++++++++++++ yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocReceiveServiceImpl.java | 3 +-- yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocSendServiceImpl.java | 3 +-- yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingCancelExpiredJobTest.java | 38 ++++++++++++++++++++++++++++++++++++++ yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingReminderJobTest.java | 42 ++++++++++++++++++++++++++++++++++++++++++ yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingSendReminderJobTest.java | 38 ++++++++++++++++++++++++++++++++++++++ yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/file/OaFileNodeServiceImplTest.java | 27 +++++++++++++++++++++++++++ 16 files changed, 510 insertions(+), 24 deletions(-) create mode 100644 yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/controller/admin/cal/plan/MesCalPlanTeamControllerTest.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingCancelExpiredJob.java create mode 100644 yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingSendReminderJob.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingCancelExpiredJobTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingReminderJobTest.java create mode 100644 yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingSendReminderJobTest.java diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtils.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtils.java index 930dd5b..d9f2d83 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtils.java +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtils.java @@ -18,7 +18,7 @@ import java.util.regex.Pattern; // TODO DONE @AI:方法注释;已补齐方言重载与内部辅助方法的参数、返回值及边界。 // TODO DONE @AI:多余的方法,可以考虑去掉;当然,保留也可以。。。已核对生产 Java/XML 调用,现有方法均有用途,保留。 /** - * 日期统计、行数限制、分批删除 SQL 片段的方言工具类 + * 日期统计、逗号分隔列表关联、行数限制、分批删除 SQL 片段的方言工具类 * * 1. 每次生成 SQL 时,按当前执行时路由到的数据源选择方言(按数据源实例缓存),不使用启动时固定的 databaseId 或主库 * 2. 只有明确识别的 PostgreSQL、Oracle、SQL Server 2012+ 输出新片段;其它已识别的数据库(MySQL、MariaDB、OceanBase、H2、 @@ -405,6 +405,52 @@ public class SqlDialectUtils { return dialect == Dialect.ORACLE ? "FROM DUAL" : ""; } + // ========== 逗号分隔列表 ========== + + /** + * 两列之间的 FIND_IN_SET 关联条件:数值 ID 列的值是否为逗号分隔列表列中的一个元素;原有片段为 FIND_IN_SET(valueColumn, listColumn) + * + * 1. 列表按英文逗号分隔、元素两侧不含空格,与 FIND_IN_SET 一致;元素重复时,同一行仍只匹配一次 + * 2. 任一列为 NULL 时不匹配 + * 3. 原有片段在 MySQL 的 ON / WHERE 中按非 0 为真;其它沿用原有片段的数据库是否支持 FIND_IN_SET,未经验收 + * + * 列与参数值比较时,使用 {@link MyBatisUtils#findInSet(String, java.util.Collection)} + * + * @param valueColumn 数值类型的 ID 列名,例如 cc.id、contact_id + * @param listColumn 逗号分隔的列表列名,例如 cfur.contact_ids + * @return SQL 条件片段 + */ + public static String findInSetColumn(String valueColumn, String listColumn) { + return findInSetColumn(getDialect(), valueColumn, listColumn); + } + + /** + * 按指定方言生成两列之间的 FIND_IN_SET 关联条件;两侧补充逗号,避免 1 匹配到 11、21 等相邻 ID。 + * + * @param dialect 已识别的数据库方言 + * @param valueColumn 代码中固定的数值 ID 列名,SQL Server 按 varchar(20) 转换,可容纳 bigint + * @param listColumn 代码中固定的逗号分隔列表列名 + * @return SQL 条件片段 + */ + static String findInSetColumn(Dialect dialect, String valueColumn, String listColumn) { + checkIdColumn(valueColumn); + checkColumn(listColumn); + switch (dialect) { + case POSTGRESQL: + return "POSITION(',' || CAST(" + valueColumn + " AS VARCHAR) || ',' IN ',' || " + listColumn + " || ',') > 0"; + case ORACLE: + // Oracle 拼接时 NULL 按空串处理,需要排除 NULL,否则会匹配到列表中的空元素 + return "(" + valueColumn + " IS NOT NULL AND INSTR(',' || " + listColumn + " || ',', ',' || " + + valueColumn + " || ',') > 0)"; + case SQLSERVER: + // SQL Server 2012–2016 可关闭 CONCAT_NULL_YIELDS_NULL,显式排除 NULL ID,避免匹配列表中的空元素 + return "(" + valueColumn + " IS NOT NULL AND CHARINDEX(',' + CAST(" + valueColumn + + " AS varchar(20)) + ',', ',' + " + listColumn + " + ',') > 0)"; + default: + return "FIND_IN_SET(" + valueColumn + ", " + listColumn + ")"; + } + } + // ========== 行数限制 ========== /** @@ -660,6 +706,19 @@ public class SqlDialectUtils { } /** + * 校验代码中固定的 ID 列名:在列名校验的基础上,最后一段需要为 id 或以 _id 结尾,避免用于任意业务字符串列。 + * + * @param column 待校验的列名,可带表别名前缀 + */ + private static void checkIdColumn(String column) { + checkColumn(column); + String name = column.substring(column.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT); + if (!name.equals("id") && !name.endsWith("_id")) { + throw new IllegalArgumentException("Invalid id column name: " + column); + } + } + + /** * 校验 MyBatis 参数名或属性路径,拒绝占位符逃逸与 SQL 片段。 * * @param param 待校验的参数名 diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtilsTest.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtilsTest.java index feebc04..8453303 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtilsTest.java +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtilsTest.java @@ -29,6 +29,7 @@ import java.sql.SQLException; import java.time.LocalDateTime; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.function.BiFunction; @@ -213,6 +214,118 @@ public class SqlDialectUtilsTest { } } + // ========== 逗号分隔列表 ========== + + @Test + public void testFindInSetColumn() { + // 原有片段保持不变,不追加 <> 0 + assertEquals("FIND_IN_SET(cc.id, cfur.contact_ids)", + SqlDialectUtils.findInSetColumn(Dialect.ORIGINAL, "cc.id", "cfur.contact_ids")); + assertEquals("POSITION(',' || CAST(cc.id AS VARCHAR) || ',' IN ',' || cfur.contact_ids || ',') > 0", + SqlDialectUtils.findInSetColumn(Dialect.POSTGRESQL, "cc.id", "cfur.contact_ids")); + assertEquals("(cc.id IS NOT NULL AND INSTR(',' || cfur.contact_ids || ',', ',' || cc.id || ',') > 0)", + SqlDialectUtils.findInSetColumn(Dialect.ORACLE, "cc.id", "cfur.contact_ids")); + assertEquals("(cc.id IS NOT NULL AND CHARINDEX(',' + CAST(cc.id AS varchar(20)) + ',', ',' + cfur.contact_ids + ',') > 0)", + SqlDialectUtils.findInSetColumn(Dialect.SQLSERVER, "cc.id", "cfur.contact_ids")); + // 不带表别名、以 _id 结尾的 ID 列 + assertEquals("FIND_IN_SET(contact_id, contact_ids)", + SqlDialectUtils.findInSetColumn(Dialect.ORIGINAL, "contact_id", "contact_ids")); + assertEquals("(t.CONTACT_ID IS NOT NULL AND CHARINDEX(',' + CAST(t.CONTACT_ID AS varchar(20)) + ',', ',' + t.contact_ids + ',') > 0)", + SqlDialectUtils.findInSetColumn(Dialect.SQLSERVER, "t.CONTACT_ID", "t.contact_ids")); + } + + @Test + public void testFindInSetColumn_invalidColumn() { + for (Dialect dialect : Dialect.values()) { + // 值列:只能是 ID 列名,不能是表达式、字面量或业务字符串列 + assertThrows(IllegalArgumentException.class, + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.id) OR (1=1", "cfur.contact_ids")); + assertThrows(IllegalArgumentException.class, + () -> SqlDialectUtils.findInSetColumn(dialect, "'1'", "cfur.contact_ids")); + assertThrows(IllegalArgumentException.class, + () -> SqlDialectUtils.findInSetColumn(dialect, "1", "cfur.contact_ids")); + assertThrows(IllegalArgumentException.class, + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.name", "cfur.contact_ids")); + assertThrows(IllegalArgumentException.class, + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.idx", "cfur.contact_ids")); + assertThrows(IllegalArgumentException.class, + () -> SqlDialectUtils.findInSetColumn(dialect, "id.name", "cfur.contact_ids")); + assertThrows(IllegalArgumentException.class, + () -> SqlDialectUtils.findInSetColumn(dialect, "", "cfur.contact_ids")); + assertThrows(IllegalArgumentException.class, + () -> SqlDialectUtils.findInSetColumn(dialect, null, "cfur.contact_ids")); + // 列表列 + assertThrows(IllegalArgumentException.class, + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.id", "cfur.contact_ids) OR (1=1")); + assertThrows(IllegalArgumentException.class, + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.id", "'1,2,3'")); + assertThrows(IllegalArgumentException.class, + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.id", "cfur.contact_ids;drop table t")); + assertThrows(IllegalArgumentException.class, + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.id", "")); + assertThrows(IllegalArgumentException.class, + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.id", null)); + } + } + + /** + * 与 CRM 跟进次数排行相同的 LEFT JOIN 条件,所有方言均需可被 JSqlParser 解析 + */ + @Test + public void testFindInSetColumn_parsedByJSqlParser() throws Exception { + for (Dialect dialect : Dialect.values()) { + String sql = "SELECT COUNT(DISTINCT cc.id) AS count, cc.owner_user_id FROM crm_follow_up_record cfur" + + " LEFT JOIN crm_contact cc ON " + SqlDialectUtils.findInSetColumn(dialect, "cc.id", "cfur.contact_ids") + + " WHERE cfur.deleted = 0 AND cc.deleted = 0 AND cc.owner_user_id IN (?, ?)" + + " AND cfur.create_time BETWEEN ? AND ? GROUP BY cc.owner_user_id"; + assertNotNull(CCJSqlParserUtil.parse(sql), dialect + " " + sql); + } + } + + @Test + public void testBoundSql_findInSetColumn() throws Exception { + FakeDataSource postgresql = new FakeDataSource("PostgreSQL", "jdbc:postgresql://127.0.0.1:5432/a", 17); + FakeDataSource oracle = new FakeDataSource("Oracle", "jdbc:oracle:thin:@127.0.0.1:1521:xe", 21); + FakeDataSource sqlServer = new FakeDataSource("Microsoft SQL Server", "jdbc:sqlserver://127.0.0.1:1433", 16); + FakeDataSource mysql = new FakeDataSource("MySQL", "jdbc:mysql://127.0.0.1:3306/a", 8); + mockDynamicRoutingDataSource(Map.of("master", postgresql, "oracle", oracle, "sqlserver", sqlServer, "mysql", mysql)); + // 与 CrmStatisticsRankMapper.xml 的 selectFollowCustomerCountRank 相同 + SqlSource sqlSource = new XMLLanguageDriver().createSqlSource(new Configuration(), + "", Map.class); + Map params = new HashMap<>(); + params.put("userIds", List.of(1L, 2L)); + params.put("times", new LocalDateTime[]{LocalDateTime.of(2026, 1, 1, 0, 0), + LocalDateTime.of(2026, 1, 31, 23, 59, 59)}); + // 依次切换:主库 PostgreSQL、Oracle、SQL Server、MySQL + Map routes = new LinkedHashMap<>(); + routes.put("master", Dialect.POSTGRESQL); + routes.put("oracle", Dialect.ORACLE); + routes.put("sqlserver", Dialect.SQLSERVER); + routes.put("mysql", Dialect.ORIGINAL); + + for (Map.Entry route : routes.entrySet()) { + DynamicDataSourceContextHolder.push(route.getKey()); + BoundSql boundSql = sqlSource.getBoundSql(params); + String sql = normalize(boundSql.getSql()); + String fragment = SqlDialectUtils.findInSetColumn(route.getValue(), "cc.id", "cfur.contact_ids"); + assertTrue(sql.startsWith("SELECT COUNT(DISTINCT cc.id) AS count, cc.owner_user_id FROM crm_follow_up_record cfur" + + " LEFT JOIN crm_contact cc ON " + fragment + " WHERE cfur.deleted = 0 AND cc.deleted = 0"), sql); + assertTrue(sql.endsWith("AND cfur.create_time between ? and ? GROUP BY cc.owner_user_id"), sql); + // 列对列的片段不引入参数:只有 2 个用户编号与 2 个时间 + List parameterMappings = boundSql.getParameterMappings(); + assertEquals(4, parameterMappings.size(), sql); + assertEquals("times[0]", parameterMappings.get(2).getProperty()); + assertEquals("times[1]", parameterMappings.get(3).getProperty()); + assertNotNull(CCJSqlParserUtil.parse(sql), route.getValue() + " " + sql); + } + } + // ========== 行数限制 ========== @Test diff --git a/yudao-module-crm/src/main/resources/mapper/statistics/CrmStatisticsRankMapper.xml b/yudao-module-crm/src/main/resources/mapper/statistics/CrmStatisticsRankMapper.xml index 56d3c51..e652503 100644 --- a/yudao-module-crm/src/main/resources/mapper/statistics/CrmStatisticsRankMapper.xml +++ b/yudao-module-crm/src/main/resources/mapper/statistics/CrmStatisticsRankMapper.xml @@ -89,7 +89,7 @@ resultType="cn.iocoder.yudao.module.crm.controller.admin.statistics.vo.rank.CrmStatisticsRankRespVO"> SELECT COUNT(1) AS count, cc.owner_user_id FROM crm_follow_up_record cfur - LEFT JOIN crm_contact cc ON FIND_IN_SET(cc.id, cfur.contact_ids) + LEFT JOIN crm_contact cc ON ${@cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils@findInSetColumn('cc.id', 'cfur.contact_ids')} WHERE cfur.deleted = 0 AND cc.deleted = 0 AND cc.owner_user_id in @@ -104,7 +104,7 @@ resultType="cn.iocoder.yudao.module.crm.controller.admin.statistics.vo.rank.CrmStatisticsRankRespVO"> SELECT COUNT(DISTINCT cc.id) AS count, cc.owner_user_id FROM crm_follow_up_record cfur - LEFT JOIN crm_contact cc ON FIND_IN_SET(cc.id, cfur.contact_ids) + LEFT JOIN crm_contact cc ON ${@cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils@findInSetColumn('cc.id', 'cfur.contact_ids')} WHERE cfur.deleted = 0 AND cc.deleted = 0 AND cc.owner_user_id in diff --git a/yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/cal/plan/MesCalPlanTeamController.java b/yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/cal/plan/MesCalPlanTeamController.java index 7170ac5..fb9a717 100644 --- a/yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/cal/plan/MesCalPlanTeamController.java +++ b/yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/cal/plan/MesCalPlanTeamController.java @@ -23,6 +23,7 @@ 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.MapUtils.findAndThen; @Tag(name = "管理后台 - MES 计划班组关联") @RestController @@ -58,19 +59,14 @@ public class MesCalPlanTeamController { public CommonResult> getPlanTeamListByPlan(@RequestParam("planId") Long planId) { List list = planTeamService.getPlanTeamListByPlanId(planId); List respList = BeanUtils.toBean(list, MesCalPlanTeamRespVO.class); - // 拼装班组编码/名称 - // TODO @AI:if return - if (CollUtil.isNotEmpty(respList)) { - Map teamMap = teamService.getTeamMap( - convertList(respList, MesCalPlanTeamRespVO::getTeamId)); - respList.forEach(resp -> { - // TODO @AI:findand then - MesCalTeamDO team = teamMap.get(resp.getTeamId()); - if (team != null) { - resp.setTeamCode(team.getCode()).setTeamName(team.getName()); - } - }); + if (CollUtil.isEmpty(respList)) { + return success(respList); } + // 拼装班组编码/名称 + Map teamMap = teamService.getTeamMap( + convertList(respList, MesCalPlanTeamRespVO::getTeamId)); + respList.forEach(resp -> findAndThen(teamMap, resp.getTeamId(), + team -> resp.setTeamCode(team.getCode()).setTeamName(team.getName()))); return success(respList); } diff --git a/yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/controller/admin/cal/plan/MesCalPlanTeamControllerTest.java b/yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/controller/admin/cal/plan/MesCalPlanTeamControllerTest.java new file mode 100644 index 0000000..3723dc1 --- /dev/null +++ b/yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/controller/admin/cal/plan/MesCalPlanTeamControllerTest.java @@ -0,0 +1,78 @@ +package cn.iocoder.yudao.module.mes.controller.admin.cal.plan; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.mes.controller.admin.cal.plan.vo.team.MesCalPlanTeamRespVO; +import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanTeamDO; +import cn.iocoder.yudao.module.mes.dal.dataobject.cal.team.MesCalTeamDO; +import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanTeamService; +import cn.iocoder.yudao.module.mes.service.cal.team.MesCalTeamService; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link MesCalPlanTeamController} 的单元测试类 + * + * @author 芋道源码 + */ +public class MesCalPlanTeamControllerTest extends BaseMockitoUnitTest { + + @InjectMocks + private MesCalPlanTeamController planTeamController; + + @Mock + private MesCalPlanTeamService planTeamService; + @Mock + private MesCalTeamService teamService; + + @Test + public void testGetPlanTeamListByPlan_empty() { + // mock 方法 + when(planTeamService.getPlanTeamListByPlanId(1L)).thenReturn(Collections.emptyList()); + + // 调用 + CommonResult> result = planTeamController.getPlanTeamListByPlan(1L); + // 断言:空列表直接返回,不查询班组 + assertTrue(result.isSuccess()); + assertTrue(result.getData().isEmpty()); + verifyNoInteractions(teamService); + } + + @Test + public void testGetPlanTeamListByPlan_fillTeam() { + // mock 方法:班组 20 不存在 + when(planTeamService.getPlanTeamListByPlanId(1L)).thenReturn(List.of( + MesCalPlanTeamDO.builder().id(3L).planId(1L).teamId(20L).remark("缺失").build(), + MesCalPlanTeamDO.builder().id(2L).planId(1L).teamId(10L).remark("存在").build())); + when(teamService.getTeamMap(List.of(20L, 10L))).thenReturn(Map.of( + 10L, MesCalTeamDO.builder().id(10L).code("T001").name("A组").build())); + + // 调用 + CommonResult> result = planTeamController.getPlanTeamListByPlan(1L); + // 断言:保持原列表顺序;存在的班组回填编码、名称,缺失的班组保持为空 + assertTrue(result.isSuccess()); + List list = result.getData(); + assertEquals(2, list.size()); + MesCalPlanTeamRespVO missing = list.get(0); + assertEquals(3L, missing.getId()); + assertEquals(20L, missing.getTeamId()); + assertEquals("缺失", missing.getRemark()); + assertNull(missing.getTeamCode()); + assertNull(missing.getTeamName()); + MesCalPlanTeamRespVO exists = list.get(1); + assertEquals(2L, exists.getId()); + assertEquals(10L, exists.getTeamId()); + assertEquals("T001", exists.getTeamCode()); + assertEquals("A组", exists.getTeamName()); + verify(teamService).getTeamMap(List.of(20L, 10L)); + } + +} diff --git a/yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/cal/plan/MesCalPlanServiceImplTest.java b/yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/cal/plan/MesCalPlanServiceImplTest.java index 83fe34e..8e44554 100644 --- a/yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/cal/plan/MesCalPlanServiceImplTest.java +++ b/yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/cal/plan/MesCalPlanServiceImplTest.java @@ -179,6 +179,37 @@ public class MesCalPlanServiceImplTest extends BaseDbUnitTest { } @Test + public void testGetPlanListByStatusAndDay_boundary() { + Integer status = MesCalPlanStatusEnum.CONFIRMED.getStatus(); + // mock 数据:开始时间为当天午夜前的最后时刻(H2 timestamp 精度为微秒) + MesCalPlanDO lastMicro = randomPlan(LocalDateTime.of(2026, 10, 19, 23, 59, 59, 999_999_000), + LocalDateTime.of(2026, 10, 31, 0, 0), status); + planMapper.insert(lastMicro); + MesCalPlanDO lastSecond = randomPlan(LocalDateTime.of(2026, 10, 19, 23, 59, 59), + LocalDateTime.of(2026, 10, 31, 0, 0), status); + planMapper.insert(lastSecond); + // mock 数据:开始时间为次日零点,不包含当天 + planMapper.insert(randomPlan(LocalDateTime.of(2026, 10, 20, 0, 0), + LocalDateTime.of(2026, 10, 31, 0, 0), status)); + // mock 数据:结束时间为当天零点,包含当天 + MesCalPlanDO endAtStart = randomPlan(LocalDateTime.of(2026, 10, 1, 0, 0), + LocalDateTime.of(2026, 10, 19, 0, 0), status); + planMapper.insert(endAtStart); + // mock 数据:结束时间为当天零点之前,不包含当天 + planMapper.insert(randomPlan(LocalDateTime.of(2026, 10, 1, 0, 0), + LocalDateTime.of(2026, 10, 18, 23, 59, 59, 999_999_000), status)); + + // 调用 + List list = planService.getPlanListByStatusAndDay(status, LocalDate.of(2026, 10, 19)); + + // 断言:午夜前的时刻、结束时间为当天零点均包含;次日零点、前一天均不包含;按 id 升序返回 + assertEquals(SetUtils.asSet(lastMicro.getId(), lastSecond.getId(), endAtStart.getId()), + convertSet(list, MesCalPlanDO::getId)); + List ids = list.stream().map(MesCalPlanDO::getId).toList(); + assertEquals(ids.stream().sorted().toList(), ids); + } + + @Test public void testDeletePlan_notCascadeDeleteTeamShift() { // mock 数据:插入一条草稿状态的排班计划 MesCalPlanDO plan = randomPojo(MesCalPlanDO.class, o -> { 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 index d8feb2e..eccb83a 100644 --- 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 @@ -37,9 +37,13 @@ public interface OaFileNodeMapper extends BaseMapperX { } 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(); + QueryWrapperX query = new QueryWrapperX<>(); + query.select("SUM(size) AS size").eq("creator", creator).eq("type", type); + OaFileNodeDO sum = selectOne(query); + if (sum == null || sum.getSize() == null) { + return 0L; + } + return sum.getSize(); } default PageResult selectPageByParentIdAndCreatorAndStatus( diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingCancelExpiredJob.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingCancelExpiredJob.java new file mode 100644 index 0000000..571fd5c --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingCancelExpiredJob.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; + +/** + * OA 会议室过期预定取消 Job + * + * 取消已过结束时间、审批通过但尚未开始的会议预定 + * + * @author 芋道源码 + */ +@Component +public class OaMeetingRoomBookingCancelExpiredJob implements JobHandler { + + @Resource + private OaMeetingRoomBookingService meetingRoomBookingService; + + @Override + @TenantJob + public String execute(String param) { + int count = meetingRoomBookingService.cancelExpiredMeetingRoomBookings(); + return StrUtil.format("取消过期未开始会议 {} 条", count); + } + +} 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 index 842650c..c218cb4 100644 --- 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 @@ -7,9 +7,11 @@ import cn.iocoder.yudao.module.oa.service.meetingroom.OaMeetingRoomBookingServic import jakarta.annotation.Resource; import org.springframework.stereotype.Component; -// TODO @AI:是不是分开 job 噢?因为毕竟是 remind; /** - * OA 会议开始提醒与过期预定处理 Job + * OA 会议开始提醒与过期预定处理 Job(兼容入口) + * + * 按原顺序先取消过期预定,再发送会议提醒。新配置请使用 {@link OaMeetingRoomBookingCancelExpiredJob}、 + * {@link OaMeetingRoomBookingSendReminderJob};迁移时先停用本 Job 再启用两个新 Job,新旧 Job 不能同时启用 * * @author 芋道源码 */ diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingSendReminderJob.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingSendReminderJob.java new file mode 100644 index 0000000..62aa136 --- /dev/null +++ b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingSendReminderJob.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; + +/** + * OA 会议开始提醒 Job + * + * 发送已通过且待使用会议的开始提醒 + * + * @author 芋道源码 + */ +@Component +public class OaMeetingRoomBookingSendReminderJob implements JobHandler { + + @Resource + private OaMeetingRoomBookingService meetingRoomBookingService; + + @Override + @TenantJob + public String execute(String param) { + int count = meetingRoomBookingService.sendMeetingRoomBookingReminders(); + return StrUtil.format("发送会议提醒 {} 条", count); + } + +} 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 index d8222f3..8ea3d9f 100644 --- 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 @@ -213,8 +213,7 @@ public class OaOfficialDocReceiveServiceImpl implements OaOfficialDocReceiveServ // 1.2 校验收文归属 validateOfficialDocReceiveOwner(receive, userId); // 1.3 未提交、审批不通过和已取消的单据允许删除 - // TODO @AI:notEqualsAny - if (!ObjectUtils.equalsAny(receive.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus(), + if (ObjectUtils.notEqualsAny(receive.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus(), BpmProcessInstanceStatusEnum.REJECT.getStatus(), BpmProcessInstanceStatusEnum.CANCEL.getStatus())) { throw exception(OFFICIAL_DOC_STATUS_INVALID); } 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 index 9baacb4..ee0b541 100644 --- 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 @@ -96,8 +96,7 @@ public class OaOfficialDocSendServiceImpl implements OaOfficialDocSendService { // 1.2 校验发文归属 validateOfficialDocSendOwner(send, userId); // 1.3 未提交、审批不通过和已取消的单据允许删除 - // TODO @AI:notEqualsAny - if (!ObjectUtils.equalsAny(send.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus(), + if (ObjectUtils.notEqualsAny(send.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus(), BpmProcessInstanceStatusEnum.REJECT.getStatus(), BpmProcessInstanceStatusEnum.CANCEL.getStatus())) { throw exception(OFFICIAL_DOC_STATUS_INVALID); } diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingCancelExpiredJobTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingCancelExpiredJobTest.java new file mode 100644 index 0000000..e8fa225 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingCancelExpiredJobTest.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.module.oa.job.meetingroom; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.oa.service.meetingroom.OaMeetingRoomBookingService; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +/** + * {@link OaMeetingRoomBookingCancelExpiredJob} 的单元测试类 + * + * @author 芋道源码 + */ +public class OaMeetingRoomBookingCancelExpiredJobTest extends BaseMockitoUnitTest { + + @InjectMocks + private OaMeetingRoomBookingCancelExpiredJob job; + + @Mock + private OaMeetingRoomBookingService meetingRoomBookingService; + + @Test + public void testExecute() { + // mock 方法 + when(meetingRoomBookingService.cancelExpiredMeetingRoomBookings()).thenReturn(2); + + // 调用 + String result = job.execute(null); + // 断言:只取消过期预定,不发送会议提醒 + assertEquals("取消过期未开始会议 2 条", result); + verify(meetingRoomBookingService).cancelExpiredMeetingRoomBookings(); + verifyNoMoreInteractions(meetingRoomBookingService); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingReminderJobTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingReminderJobTest.java new file mode 100644 index 0000000..88b14e0 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingReminderJobTest.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.oa.job.meetingroom; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.oa.service.meetingroom.OaMeetingRoomBookingService; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +/** + * {@link OaMeetingRoomBookingReminderJob} 的单元测试类 + * + * @author 芋道源码 + */ +public class OaMeetingRoomBookingReminderJobTest extends BaseMockitoUnitTest { + + @InjectMocks + private OaMeetingRoomBookingReminderJob job; + + @Mock + private OaMeetingRoomBookingService meetingRoomBookingService; + + @Test + public void testExecute() { + // mock 方法 + when(meetingRoomBookingService.cancelExpiredMeetingRoomBookings()).thenReturn(1); + when(meetingRoomBookingService.sendMeetingRoomBookingReminders()).thenReturn(4); + + // 调用 + String result = job.execute(null); + // 断言:兼容入口仍按原顺序,先取消过期预定,再发送会议提醒 + assertEquals("取消过期未开始会议 1 条,发送会议提醒 4 条", result); + InOrder inOrder = inOrder(meetingRoomBookingService); + inOrder.verify(meetingRoomBookingService).cancelExpiredMeetingRoomBookings(); + inOrder.verify(meetingRoomBookingService).sendMeetingRoomBookingReminders(); + verifyNoMoreInteractions(meetingRoomBookingService); + } + +} diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingSendReminderJobTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingSendReminderJobTest.java new file mode 100644 index 0000000..7f70995 --- /dev/null +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingSendReminderJobTest.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.module.oa.job.meetingroom; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.oa.service.meetingroom.OaMeetingRoomBookingService; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +/** + * {@link OaMeetingRoomBookingSendReminderJob} 的单元测试类 + * + * @author 芋道源码 + */ +public class OaMeetingRoomBookingSendReminderJobTest extends BaseMockitoUnitTest { + + @InjectMocks + private OaMeetingRoomBookingSendReminderJob job; + + @Mock + private OaMeetingRoomBookingService meetingRoomBookingService; + + @Test + public void testExecute() { + // mock 方法 + when(meetingRoomBookingService.sendMeetingRoomBookingReminders()).thenReturn(3); + + // 调用 + String result = job.execute(null); + // 断言:只发送会议提醒,不取消过期预定 + assertEquals("发送会议提醒 3 条", result); + verify(meetingRoomBookingService).sendMeetingRoomBookingReminders(); + verifyNoMoreInteractions(meetingRoomBookingService); + } + +} 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 index d91ddd9..689f24b 100644 --- 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 @@ -13,6 +13,7 @@ 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 com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import jakarta.annotation.Resource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -510,6 +511,32 @@ public class OaFileNodeServiceImplTest extends BaseDbUnitTest { } @Test + public void testGetFileStorage_usedSizeNullAndDeleted() { + // mock 数据:本人文件的 size 为 NULL,已删除的文件不计入 + OaFileNodeDO nullSize = randomFileNodeDO(1L, 0L, 0).setType(1); + nullSize.setCreator("1"); + fileNodeMapper.insert(nullSize); + fileNodeMapper.update(null, new LambdaUpdateWrapper().setSql("size = NULL") + .eq(OaFileNodeDO::getId, 1L)); + OaFileNodeDO deleted = randomFileNodeDO(2L, 0L, 0).setType(1).setSize(500L); + deleted.setCreator("1"); + fileNodeMapper.insert(deleted); + fileNodeMapper.deleteById(2L); + assertNull(fileNodeMapper.selectById(1L).getSize()); + assertNull(fileNodeMapper.selectById(2L)); + + // 调用,并断言:size 均为 NULL 时为 0 + assertEquals(0L, fileNodeService.getFileStorage(1L).getUsedSize()); + + // mock 数据:NULL 与有值的文件并存 + OaFileNodeDO normal = randomFileNodeDO(3L, 0L, 0).setType(1).setSize(100L); + normal.setCreator("1"); + fileNodeMapper.insert(normal); + // 调用,并断言:只累加有值的文件 + assertEquals(100L, fileNodeService.getFileStorage(1L).getUsedSize()); + } + + @Test public void testGetFileStorage_sharedCounts() { // mock 数据:本人目录多个接收人只计一次,共享给我的子文件不重复计数 OaFileNodeDO own = randomFileNodeDO(1L, 0L, 0); -- libgit2 0.21.4