Commit 09426f8fc601d4b82a0c6125e6764dfed50a4b8d

Authored by YunaiV
1 parent 4dba84b7

fix: 适配 CRM 列间 CSV 查询并完善 OA、MES 细节

Showing 16 changed files with 510 additions and 24 deletions
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; @@ -18,7 +18,7 @@ import java.util.regex.Pattern;
18 // TODO DONE @AI:方法注释;已补齐方言重载与内部辅助方法的参数、返回值及边界。 18 // TODO DONE @AI:方法注释;已补齐方言重载与内部辅助方法的参数、返回值及边界。
19 // TODO DONE @AI:多余的方法,可以考虑去掉;当然,保留也可以。。。已核对生产 Java/XML 调用,现有方法均有用途,保留。 19 // TODO DONE @AI:多余的方法,可以考虑去掉;当然,保留也可以。。。已核对生产 Java/XML 调用,现有方法均有用途,保留。
20 /** 20 /**
21 - * 日期统计、行数限制、分批删除 SQL 片段的方言工具类 21 + * 日期统计、逗号分隔列表关联、行数限制、分批删除 SQL 片段的方言工具类
22 * 22 *
23 * 1. 每次生成 SQL 时,按当前执行时路由到的数据源选择方言(按数据源实例缓存),不使用启动时固定的 databaseId 或主库 23 * 1. 每次生成 SQL 时,按当前执行时路由到的数据源选择方言(按数据源实例缓存),不使用启动时固定的 databaseId 或主库
24 * 2. 只有明确识别的 PostgreSQL、Oracle、SQL Server 2012+ 输出新片段;其它已识别的数据库(MySQL、MariaDB、OceanBase、H2、 24 * 2. 只有明确识别的 PostgreSQL、Oracle、SQL Server 2012+ 输出新片段;其它已识别的数据库(MySQL、MariaDB、OceanBase、H2、
@@ -405,6 +405,52 @@ public class SqlDialectUtils { @@ -405,6 +405,52 @@ public class SqlDialectUtils {
405 return dialect == Dialect.ORACLE ? "FROM DUAL" : ""; 405 return dialect == Dialect.ORACLE ? "FROM DUAL" : "";
406 } 406 }
407 407
  408 + // ========== 逗号分隔列表 ==========
  409 +
  410 + /**
  411 + * 两列之间的 FIND_IN_SET 关联条件:数值 ID 列的值是否为逗号分隔列表列中的一个元素;原有片段为 FIND_IN_SET(valueColumn, listColumn)
  412 + *
  413 + * 1. 列表按英文逗号分隔、元素两侧不含空格,与 FIND_IN_SET 一致;元素重复时,同一行仍只匹配一次
  414 + * 2. 任一列为 NULL 时不匹配
  415 + * 3. 原有片段在 MySQL 的 ON / WHERE 中按非 0 为真;其它沿用原有片段的数据库是否支持 FIND_IN_SET,未经验收
  416 + *
  417 + * 列与参数值比较时,使用 {@link MyBatisUtils#findInSet(String, java.util.Collection)}
  418 + *
  419 + * @param valueColumn 数值类型的 ID 列名,例如 cc.id、contact_id
  420 + * @param listColumn 逗号分隔的列表列名,例如 cfur.contact_ids
  421 + * @return SQL 条件片段
  422 + */
  423 + public static String findInSetColumn(String valueColumn, String listColumn) {
  424 + return findInSetColumn(getDialect(), valueColumn, listColumn);
  425 + }
  426 +
  427 + /**
  428 + * 按指定方言生成两列之间的 FIND_IN_SET 关联条件;两侧补充逗号,避免 1 匹配到 11、21 等相邻 ID。
  429 + *
  430 + * @param dialect 已识别的数据库方言
  431 + * @param valueColumn 代码中固定的数值 ID 列名,SQL Server 按 varchar(20) 转换,可容纳 bigint
  432 + * @param listColumn 代码中固定的逗号分隔列表列名
  433 + * @return SQL 条件片段
  434 + */
  435 + static String findInSetColumn(Dialect dialect, String valueColumn, String listColumn) {
  436 + checkIdColumn(valueColumn);
  437 + checkColumn(listColumn);
  438 + switch (dialect) {
  439 + case POSTGRESQL:
  440 + return "POSITION(',' || CAST(" + valueColumn + " AS VARCHAR) || ',' IN ',' || " + listColumn + " || ',') > 0";
  441 + case ORACLE:
  442 + // Oracle 拼接时 NULL 按空串处理,需要排除 NULL,否则会匹配到列表中的空元素
  443 + return "(" + valueColumn + " IS NOT NULL AND INSTR(',' || " + listColumn + " || ',', ',' || "
  444 + + valueColumn + " || ',') > 0)";
  445 + case SQLSERVER:
  446 + // SQL Server 2012–2016 可关闭 CONCAT_NULL_YIELDS_NULL,显式排除 NULL ID,避免匹配列表中的空元素
  447 + return "(" + valueColumn + " IS NOT NULL AND CHARINDEX(',' + CAST(" + valueColumn
  448 + + " AS varchar(20)) + ',', ',' + " + listColumn + " + ',') > 0)";
  449 + default:
  450 + return "FIND_IN_SET(" + valueColumn + ", " + listColumn + ")";
  451 + }
  452 + }
  453 +
408 // ========== 行数限制 ========== 454 // ========== 行数限制 ==========
409 455
410 /** 456 /**
@@ -660,6 +706,19 @@ public class SqlDialectUtils { @@ -660,6 +706,19 @@ public class SqlDialectUtils {
660 } 706 }
661 707
662 /** 708 /**
  709 + * 校验代码中固定的 ID 列名:在列名校验的基础上,最后一段需要为 id 或以 _id 结尾,避免用于任意业务字符串列。
  710 + *
  711 + * @param column 待校验的列名,可带表别名前缀
  712 + */
  713 + private static void checkIdColumn(String column) {
  714 + checkColumn(column);
  715 + String name = column.substring(column.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
  716 + if (!name.equals("id") && !name.endsWith("_id")) {
  717 + throw new IllegalArgumentException("Invalid id column name: " + column);
  718 + }
  719 + }
  720 +
  721 + /**
663 * 校验 MyBatis 参数名或属性路径,拒绝占位符逃逸与 SQL 片段。 722 * 校验 MyBatis 参数名或属性路径,拒绝占位符逃逸与 SQL 片段。
664 * 723 *
665 * @param param 待校验的参数名 724 * @param param 待校验的参数名
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; @@ -29,6 +29,7 @@ import java.sql.SQLException;
29 import java.time.LocalDateTime; 29 import java.time.LocalDateTime;
30 import java.util.Collections; 30 import java.util.Collections;
31 import java.util.HashMap; 31 import java.util.HashMap;
  32 +import java.util.LinkedHashMap;
32 import java.util.List; 33 import java.util.List;
33 import java.util.Map; 34 import java.util.Map;
34 import java.util.function.BiFunction; 35 import java.util.function.BiFunction;
@@ -213,6 +214,118 @@ public class SqlDialectUtilsTest { @@ -213,6 +214,118 @@ public class SqlDialectUtilsTest {
213 } 214 }
214 } 215 }
215 216
  217 + // ========== 逗号分隔列表 ==========
  218 +
  219 + @Test
  220 + public void testFindInSetColumn() {
  221 + // 原有片段保持不变,不追加 <> 0
  222 + assertEquals("FIND_IN_SET(cc.id, cfur.contact_ids)",
  223 + SqlDialectUtils.findInSetColumn(Dialect.ORIGINAL, "cc.id", "cfur.contact_ids"));
  224 + assertEquals("POSITION(',' || CAST(cc.id AS VARCHAR) || ',' IN ',' || cfur.contact_ids || ',') > 0",
  225 + SqlDialectUtils.findInSetColumn(Dialect.POSTGRESQL, "cc.id", "cfur.contact_ids"));
  226 + assertEquals("(cc.id IS NOT NULL AND INSTR(',' || cfur.contact_ids || ',', ',' || cc.id || ',') > 0)",
  227 + SqlDialectUtils.findInSetColumn(Dialect.ORACLE, "cc.id", "cfur.contact_ids"));
  228 + assertEquals("(cc.id IS NOT NULL AND CHARINDEX(',' + CAST(cc.id AS varchar(20)) + ',', ',' + cfur.contact_ids + ',') > 0)",
  229 + SqlDialectUtils.findInSetColumn(Dialect.SQLSERVER, "cc.id", "cfur.contact_ids"));
  230 + // 不带表别名、以 _id 结尾的 ID 列
  231 + assertEquals("FIND_IN_SET(contact_id, contact_ids)",
  232 + SqlDialectUtils.findInSetColumn(Dialect.ORIGINAL, "contact_id", "contact_ids"));
  233 + assertEquals("(t.CONTACT_ID IS NOT NULL AND CHARINDEX(',' + CAST(t.CONTACT_ID AS varchar(20)) + ',', ',' + t.contact_ids + ',') > 0)",
  234 + SqlDialectUtils.findInSetColumn(Dialect.SQLSERVER, "t.CONTACT_ID", "t.contact_ids"));
  235 + }
  236 +
  237 + @Test
  238 + public void testFindInSetColumn_invalidColumn() {
  239 + for (Dialect dialect : Dialect.values()) {
  240 + // 值列:只能是 ID 列名,不能是表达式、字面量或业务字符串列
  241 + assertThrows(IllegalArgumentException.class,
  242 + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.id) OR (1=1", "cfur.contact_ids"));
  243 + assertThrows(IllegalArgumentException.class,
  244 + () -> SqlDialectUtils.findInSetColumn(dialect, "'1'", "cfur.contact_ids"));
  245 + assertThrows(IllegalArgumentException.class,
  246 + () -> SqlDialectUtils.findInSetColumn(dialect, "1", "cfur.contact_ids"));
  247 + assertThrows(IllegalArgumentException.class,
  248 + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.name", "cfur.contact_ids"));
  249 + assertThrows(IllegalArgumentException.class,
  250 + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.idx", "cfur.contact_ids"));
  251 + assertThrows(IllegalArgumentException.class,
  252 + () -> SqlDialectUtils.findInSetColumn(dialect, "id.name", "cfur.contact_ids"));
  253 + assertThrows(IllegalArgumentException.class,
  254 + () -> SqlDialectUtils.findInSetColumn(dialect, "", "cfur.contact_ids"));
  255 + assertThrows(IllegalArgumentException.class,
  256 + () -> SqlDialectUtils.findInSetColumn(dialect, null, "cfur.contact_ids"));
  257 + // 列表列
  258 + assertThrows(IllegalArgumentException.class,
  259 + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.id", "cfur.contact_ids) OR (1=1"));
  260 + assertThrows(IllegalArgumentException.class,
  261 + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.id", "'1,2,3'"));
  262 + assertThrows(IllegalArgumentException.class,
  263 + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.id", "cfur.contact_ids;drop table t"));
  264 + assertThrows(IllegalArgumentException.class,
  265 + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.id", ""));
  266 + assertThrows(IllegalArgumentException.class,
  267 + () -> SqlDialectUtils.findInSetColumn(dialect, "cc.id", null));
  268 + }
  269 + }
  270 +
  271 + /**
  272 + * 与 CRM 跟进次数排行相同的 LEFT JOIN 条件,所有方言均需可被 JSqlParser 解析
  273 + */
  274 + @Test
  275 + public void testFindInSetColumn_parsedByJSqlParser() throws Exception {
  276 + for (Dialect dialect : Dialect.values()) {
  277 + String sql = "SELECT COUNT(DISTINCT cc.id) AS count, cc.owner_user_id FROM crm_follow_up_record cfur"
  278 + + " LEFT JOIN crm_contact cc ON " + SqlDialectUtils.findInSetColumn(dialect, "cc.id", "cfur.contact_ids")
  279 + + " WHERE cfur.deleted = 0 AND cc.deleted = 0 AND cc.owner_user_id IN (?, ?)"
  280 + + " AND cfur.create_time BETWEEN ? AND ? GROUP BY cc.owner_user_id";
  281 + assertNotNull(CCJSqlParserUtil.parse(sql), dialect + " " + sql);
  282 + }
  283 + }
  284 +
  285 + @Test
  286 + public void testBoundSql_findInSetColumn() throws Exception {
  287 + FakeDataSource postgresql = new FakeDataSource("PostgreSQL", "jdbc:postgresql://127.0.0.1:5432/a", 17);
  288 + FakeDataSource oracle = new FakeDataSource("Oracle", "jdbc:oracle:thin:@127.0.0.1:1521:xe", 21);
  289 + FakeDataSource sqlServer = new FakeDataSource("Microsoft SQL Server", "jdbc:sqlserver://127.0.0.1:1433", 16);
  290 + FakeDataSource mysql = new FakeDataSource("MySQL", "jdbc:mysql://127.0.0.1:3306/a", 8);
  291 + mockDynamicRoutingDataSource(Map.of("master", postgresql, "oracle", oracle, "sqlserver", sqlServer, "mysql", mysql));
  292 + // 与 CrmStatisticsRankMapper.xml 的 selectFollowCustomerCountRank 相同
  293 + SqlSource sqlSource = new XMLLanguageDriver().createSqlSource(new Configuration(),
  294 + "<script>SELECT COUNT(DISTINCT cc.id) AS count, cc.owner_user_id FROM crm_follow_up_record cfur"
  295 + + " LEFT JOIN crm_contact cc ON " + P + "findInSetColumn('cc.id', 'cfur.contact_ids')}"
  296 + + " WHERE cfur.deleted = 0 AND cc.deleted = 0 AND cc.owner_user_id in"
  297 + + " <foreach collection=\"userIds\" item=\"userId\" open=\"(\" close=\")\" separator=\",\">#{userId}</foreach>"
  298 + + " AND cfur.create_time between #{times[0],javaType=java.time.LocalDateTime}"
  299 + + " and #{times[1],javaType=java.time.LocalDateTime}"
  300 + + " GROUP BY cc.owner_user_id</script>", Map.class);
  301 + Map<String, Object> params = new HashMap<>();
  302 + params.put("userIds", List.of(1L, 2L));
  303 + params.put("times", new LocalDateTime[]{LocalDateTime.of(2026, 1, 1, 0, 0),
  304 + LocalDateTime.of(2026, 1, 31, 23, 59, 59)});
  305 + // 依次切换:主库 PostgreSQL、Oracle、SQL Server、MySQL
  306 + Map<String, Dialect> routes = new LinkedHashMap<>();
  307 + routes.put("master", Dialect.POSTGRESQL);
  308 + routes.put("oracle", Dialect.ORACLE);
  309 + routes.put("sqlserver", Dialect.SQLSERVER);
  310 + routes.put("mysql", Dialect.ORIGINAL);
  311 +
  312 + for (Map.Entry<String, Dialect> route : routes.entrySet()) {
  313 + DynamicDataSourceContextHolder.push(route.getKey());
  314 + BoundSql boundSql = sqlSource.getBoundSql(params);
  315 + String sql = normalize(boundSql.getSql());
  316 + String fragment = SqlDialectUtils.findInSetColumn(route.getValue(), "cc.id", "cfur.contact_ids");
  317 + assertTrue(sql.startsWith("SELECT COUNT(DISTINCT cc.id) AS count, cc.owner_user_id FROM crm_follow_up_record cfur"
  318 + + " LEFT JOIN crm_contact cc ON " + fragment + " WHERE cfur.deleted = 0 AND cc.deleted = 0"), sql);
  319 + assertTrue(sql.endsWith("AND cfur.create_time between ? and ? GROUP BY cc.owner_user_id"), sql);
  320 + // 列对列的片段不引入参数:只有 2 个用户编号与 2 个时间
  321 + List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
  322 + assertEquals(4, parameterMappings.size(), sql);
  323 + assertEquals("times[0]", parameterMappings.get(2).getProperty());
  324 + assertEquals("times[1]", parameterMappings.get(3).getProperty());
  325 + assertNotNull(CCJSqlParserUtil.parse(sql), route.getValue() + " " + sql);
  326 + }
  327 + }
  328 +
216 // ========== 行数限制 ========== 329 // ========== 行数限制 ==========
217 330
218 @Test 331 @Test
yudao-module-crm/src/main/resources/mapper/statistics/CrmStatisticsRankMapper.xml
@@ -89,7 +89,7 @@ @@ -89,7 +89,7 @@
89 resultType="cn.iocoder.yudao.module.crm.controller.admin.statistics.vo.rank.CrmStatisticsRankRespVO"> 89 resultType="cn.iocoder.yudao.module.crm.controller.admin.statistics.vo.rank.CrmStatisticsRankRespVO">
90 SELECT COUNT(1) AS count, cc.owner_user_id 90 SELECT COUNT(1) AS count, cc.owner_user_id
91 FROM crm_follow_up_record cfur 91 FROM crm_follow_up_record cfur
92 - LEFT JOIN crm_contact cc ON FIND_IN_SET(cc.id, cfur.contact_ids) 92 + LEFT JOIN crm_contact cc ON ${@cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils@findInSetColumn('cc.id', 'cfur.contact_ids')}
93 WHERE cfur.deleted = 0 93 WHERE cfur.deleted = 0
94 AND cc.deleted = 0 94 AND cc.deleted = 0
95 AND cc.owner_user_id in 95 AND cc.owner_user_id in
@@ -104,7 +104,7 @@ @@ -104,7 +104,7 @@
104 resultType="cn.iocoder.yudao.module.crm.controller.admin.statistics.vo.rank.CrmStatisticsRankRespVO"> 104 resultType="cn.iocoder.yudao.module.crm.controller.admin.statistics.vo.rank.CrmStatisticsRankRespVO">
105 SELECT COUNT(DISTINCT cc.id) AS count, cc.owner_user_id 105 SELECT COUNT(DISTINCT cc.id) AS count, cc.owner_user_id
106 FROM crm_follow_up_record cfur 106 FROM crm_follow_up_record cfur
107 - LEFT JOIN crm_contact cc ON FIND_IN_SET(cc.id, cfur.contact_ids) 107 + LEFT JOIN crm_contact cc ON ${@cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils@findInSetColumn('cc.id', 'cfur.contact_ids')}
108 WHERE cfur.deleted = 0 108 WHERE cfur.deleted = 0
109 AND cc.deleted = 0 109 AND cc.deleted = 0
110 AND cc.owner_user_id in 110 AND cc.owner_user_id in
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; @@ -23,6 +23,7 @@ import java.util.Map;
23 23
24 import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; 24 import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
25 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; 25 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
  26 +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen;
26 27
27 @Tag(name = "管理后台 - MES 计划班组关联") 28 @Tag(name = "管理后台 - MES 计划班组关联")
28 @RestController 29 @RestController
@@ -58,19 +59,14 @@ public class MesCalPlanTeamController { @@ -58,19 +59,14 @@ public class MesCalPlanTeamController {
58 public CommonResult<List<MesCalPlanTeamRespVO>> getPlanTeamListByPlan(@RequestParam("planId") Long planId) { 59 public CommonResult<List<MesCalPlanTeamRespVO>> getPlanTeamListByPlan(@RequestParam("planId") Long planId) {
59 List<MesCalPlanTeamDO> list = planTeamService.getPlanTeamListByPlanId(planId); 60 List<MesCalPlanTeamDO> list = planTeamService.getPlanTeamListByPlanId(planId);
60 List<MesCalPlanTeamRespVO> respList = BeanUtils.toBean(list, MesCalPlanTeamRespVO.class); 61 List<MesCalPlanTeamRespVO> respList = BeanUtils.toBean(list, MesCalPlanTeamRespVO.class);
61 - // 拼装班组编码/名称  
62 - // TODO @AI:if return  
63 - if (CollUtil.isNotEmpty(respList)) {  
64 - Map<Long, MesCalTeamDO> teamMap = teamService.getTeamMap(  
65 - convertList(respList, MesCalPlanTeamRespVO::getTeamId));  
66 - respList.forEach(resp -> {  
67 - // TODO @AI:findand then  
68 - MesCalTeamDO team = teamMap.get(resp.getTeamId());  
69 - if (team != null) {  
70 - resp.setTeamCode(team.getCode()).setTeamName(team.getName());  
71 - }  
72 - }); 62 + if (CollUtil.isEmpty(respList)) {
  63 + return success(respList);
73 } 64 }
  65 + // 拼装班组编码/名称
  66 + Map<Long, MesCalTeamDO> teamMap = teamService.getTeamMap(
  67 + convertList(respList, MesCalPlanTeamRespVO::getTeamId));
  68 + respList.forEach(resp -> findAndThen(teamMap, resp.getTeamId(),
  69 + team -> resp.setTeamCode(team.getCode()).setTeamName(team.getName())));
74 return success(respList); 70 return success(respList);
75 } 71 }
76 72
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/controller/admin/cal/plan/MesCalPlanTeamControllerTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.mes.controller.admin.cal.plan;
  2 +
  3 +import cn.iocoder.yudao.framework.common.pojo.CommonResult;
  4 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  5 +import cn.iocoder.yudao.module.mes.controller.admin.cal.plan.vo.team.MesCalPlanTeamRespVO;
  6 +import cn.iocoder.yudao.module.mes.dal.dataobject.cal.plan.MesCalPlanTeamDO;
  7 +import cn.iocoder.yudao.module.mes.dal.dataobject.cal.team.MesCalTeamDO;
  8 +import cn.iocoder.yudao.module.mes.service.cal.plan.MesCalPlanTeamService;
  9 +import cn.iocoder.yudao.module.mes.service.cal.team.MesCalTeamService;
  10 +import org.junit.jupiter.api.Test;
  11 +import org.mockito.InjectMocks;
  12 +import org.mockito.Mock;
  13 +
  14 +import java.util.Collections;
  15 +import java.util.List;
  16 +import java.util.Map;
  17 +
  18 +import static org.junit.jupiter.api.Assertions.*;
  19 +import static org.mockito.Mockito.*;
  20 +
  21 +/**
  22 + * {@link MesCalPlanTeamController} 的单元测试类
  23 + *
  24 + * @author 芋道源码
  25 + */
  26 +public class MesCalPlanTeamControllerTest extends BaseMockitoUnitTest {
  27 +
  28 + @InjectMocks
  29 + private MesCalPlanTeamController planTeamController;
  30 +
  31 + @Mock
  32 + private MesCalPlanTeamService planTeamService;
  33 + @Mock
  34 + private MesCalTeamService teamService;
  35 +
  36 + @Test
  37 + public void testGetPlanTeamListByPlan_empty() {
  38 + // mock 方法
  39 + when(planTeamService.getPlanTeamListByPlanId(1L)).thenReturn(Collections.emptyList());
  40 +
  41 + // 调用
  42 + CommonResult<List<MesCalPlanTeamRespVO>> result = planTeamController.getPlanTeamListByPlan(1L);
  43 + // 断言:空列表直接返回,不查询班组
  44 + assertTrue(result.isSuccess());
  45 + assertTrue(result.getData().isEmpty());
  46 + verifyNoInteractions(teamService);
  47 + }
  48 +
  49 + @Test
  50 + public void testGetPlanTeamListByPlan_fillTeam() {
  51 + // mock 方法:班组 20 不存在
  52 + when(planTeamService.getPlanTeamListByPlanId(1L)).thenReturn(List.of(
  53 + MesCalPlanTeamDO.builder().id(3L).planId(1L).teamId(20L).remark("缺失").build(),
  54 + MesCalPlanTeamDO.builder().id(2L).planId(1L).teamId(10L).remark("存在").build()));
  55 + when(teamService.getTeamMap(List.of(20L, 10L))).thenReturn(Map.of(
  56 + 10L, MesCalTeamDO.builder().id(10L).code("T001").name("A组").build()));
  57 +
  58 + // 调用
  59 + CommonResult<List<MesCalPlanTeamRespVO>> result = planTeamController.getPlanTeamListByPlan(1L);
  60 + // 断言:保持原列表顺序;存在的班组回填编码、名称,缺失的班组保持为空
  61 + assertTrue(result.isSuccess());
  62 + List<MesCalPlanTeamRespVO> list = result.getData();
  63 + assertEquals(2, list.size());
  64 + MesCalPlanTeamRespVO missing = list.get(0);
  65 + assertEquals(3L, missing.getId());
  66 + assertEquals(20L, missing.getTeamId());
  67 + assertEquals("缺失", missing.getRemark());
  68 + assertNull(missing.getTeamCode());
  69 + assertNull(missing.getTeamName());
  70 + MesCalPlanTeamRespVO exists = list.get(1);
  71 + assertEquals(2L, exists.getId());
  72 + assertEquals(10L, exists.getTeamId());
  73 + assertEquals("T001", exists.getTeamCode());
  74 + assertEquals("A组", exists.getTeamName());
  75 + verify(teamService).getTeamMap(List.of(20L, 10L));
  76 + }
  77 +
  78 +}
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 { @@ -179,6 +179,37 @@ public class MesCalPlanServiceImplTest extends BaseDbUnitTest {
179 } 179 }
180 180
181 @Test 181 @Test
  182 + public void testGetPlanListByStatusAndDay_boundary() {
  183 + Integer status = MesCalPlanStatusEnum.CONFIRMED.getStatus();
  184 + // mock 数据:开始时间为当天午夜前的最后时刻(H2 timestamp 精度为微秒)
  185 + MesCalPlanDO lastMicro = randomPlan(LocalDateTime.of(2026, 10, 19, 23, 59, 59, 999_999_000),
  186 + LocalDateTime.of(2026, 10, 31, 0, 0), status);
  187 + planMapper.insert(lastMicro);
  188 + MesCalPlanDO lastSecond = randomPlan(LocalDateTime.of(2026, 10, 19, 23, 59, 59),
  189 + LocalDateTime.of(2026, 10, 31, 0, 0), status);
  190 + planMapper.insert(lastSecond);
  191 + // mock 数据:开始时间为次日零点,不包含当天
  192 + planMapper.insert(randomPlan(LocalDateTime.of(2026, 10, 20, 0, 0),
  193 + LocalDateTime.of(2026, 10, 31, 0, 0), status));
  194 + // mock 数据:结束时间为当天零点,包含当天
  195 + MesCalPlanDO endAtStart = randomPlan(LocalDateTime.of(2026, 10, 1, 0, 0),
  196 + LocalDateTime.of(2026, 10, 19, 0, 0), status);
  197 + planMapper.insert(endAtStart);
  198 + // mock 数据:结束时间为当天零点之前,不包含当天
  199 + planMapper.insert(randomPlan(LocalDateTime.of(2026, 10, 1, 0, 0),
  200 + LocalDateTime.of(2026, 10, 18, 23, 59, 59, 999_999_000), status));
  201 +
  202 + // 调用
  203 + List<MesCalPlanDO> list = planService.getPlanListByStatusAndDay(status, LocalDate.of(2026, 10, 19));
  204 +
  205 + // 断言:午夜前的时刻、结束时间为当天零点均包含;次日零点、前一天均不包含;按 id 升序返回
  206 + assertEquals(SetUtils.asSet(lastMicro.getId(), lastSecond.getId(), endAtStart.getId()),
  207 + convertSet(list, MesCalPlanDO::getId));
  208 + List<Long> ids = list.stream().map(MesCalPlanDO::getId).toList();
  209 + assertEquals(ids.stream().sorted().toList(), ids);
  210 + }
  211 +
  212 + @Test
182 public void testDeletePlan_notCascadeDeleteTeamShift() { 213 public void testDeletePlan_notCascadeDeleteTeamShift() {
183 // mock 数据:插入一条草稿状态的排班计划 214 // mock 数据:插入一条草稿状态的排班计划
184 MesCalPlanDO plan = randomPojo(MesCalPlanDO.class, o -> { 215 MesCalPlanDO plan = randomPojo(MesCalPlanDO.class, o -> {
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&lt;OaFileNodeDO&gt; { @@ -37,9 +37,13 @@ public interface OaFileNodeMapper extends BaseMapperX&lt;OaFileNodeDO&gt; {
37 } 37 }
38 38
39 default Long selectSumSizeByCreatorAndType(String creator, Integer type) { 39 default Long selectSumSizeByCreatorAndType(String creator, Integer type) {
40 - // TODO @AI:不要写在一条里;分成多个;看看能不能去掉 COALESCE  
41 - return selectOne(new QueryWrapperX<OaFileNodeDO>().select("COALESCE(SUM(size), 0) AS size")  
42 - .eq("creator", creator).eq("type", type)).getSize(); 40 + QueryWrapperX<OaFileNodeDO> query = new QueryWrapperX<>();
  41 + query.select("SUM(size) AS size").eq("creator", creator).eq("type", type);
  42 + OaFileNodeDO sum = selectOne(query);
  43 + if (sum == null || sum.getSize() == null) {
  44 + return 0L;
  45 + }
  46 + return sum.getSize();
43 } 47 }
44 48
45 default PageResult<OaFileNodeDO> selectPageByParentIdAndCreatorAndStatus( 49 default PageResult<OaFileNodeDO> selectPageByParentIdAndCreatorAndStatus(
yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingCancelExpiredJob.java 0 → 100644
  1 +package cn.iocoder.yudao.module.oa.job.meetingroom;
  2 +
  3 +import cn.hutool.core.util.StrUtil;
  4 +import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler;
  5 +import cn.iocoder.yudao.framework.tenant.core.job.TenantJob;
  6 +import cn.iocoder.yudao.module.oa.service.meetingroom.OaMeetingRoomBookingService;
  7 +import jakarta.annotation.Resource;
  8 +import org.springframework.stereotype.Component;
  9 +
  10 +/**
  11 + * OA 会议室过期预定取消 Job
  12 + *
  13 + * 取消已过结束时间、审批通过但尚未开始的会议预定
  14 + *
  15 + * @author 芋道源码
  16 + */
  17 +@Component
  18 +public class OaMeetingRoomBookingCancelExpiredJob implements JobHandler {
  19 +
  20 + @Resource
  21 + private OaMeetingRoomBookingService meetingRoomBookingService;
  22 +
  23 + @Override
  24 + @TenantJob
  25 + public String execute(String param) {
  26 + int count = meetingRoomBookingService.cancelExpiredMeetingRoomBookings();
  27 + return StrUtil.format("取消过期未开始会议 {} 条", count);
  28 + }
  29 +
  30 +}
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 @@ -7,9 +7,11 @@ import cn.iocoder.yudao.module.oa.service.meetingroom.OaMeetingRoomBookingServic
7 import jakarta.annotation.Resource; 7 import jakarta.annotation.Resource;
8 import org.springframework.stereotype.Component; 8 import org.springframework.stereotype.Component;
9 9
10 -// TODO @AI:是不是分开 job 噢?因为毕竟是 remind;  
11 /** 10 /**
12 - * OA 会议开始提醒与过期预定处理 Job 11 + * OA 会议开始提醒与过期预定处理 Job(兼容入口)
  12 + *
  13 + * 按原顺序先取消过期预定,再发送会议提醒。新配置请使用 {@link OaMeetingRoomBookingCancelExpiredJob}、
  14 + * {@link OaMeetingRoomBookingSendReminderJob};迁移时先停用本 Job 再启用两个新 Job,新旧 Job 不能同时启用
13 * 15 *
14 * @author 芋道源码 16 * @author 芋道源码
15 */ 17 */
yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingSendReminderJob.java 0 → 100644
  1 +package cn.iocoder.yudao.module.oa.job.meetingroom;
  2 +
  3 +import cn.hutool.core.util.StrUtil;
  4 +import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler;
  5 +import cn.iocoder.yudao.framework.tenant.core.job.TenantJob;
  6 +import cn.iocoder.yudao.module.oa.service.meetingroom.OaMeetingRoomBookingService;
  7 +import jakarta.annotation.Resource;
  8 +import org.springframework.stereotype.Component;
  9 +
  10 +/**
  11 + * OA 会议开始提醒 Job
  12 + *
  13 + * 发送已通过且待使用会议的开始提醒
  14 + *
  15 + * @author 芋道源码
  16 + */
  17 +@Component
  18 +public class OaMeetingRoomBookingSendReminderJob implements JobHandler {
  19 +
  20 + @Resource
  21 + private OaMeetingRoomBookingService meetingRoomBookingService;
  22 +
  23 + @Override
  24 + @TenantJob
  25 + public String execute(String param) {
  26 + int count = meetingRoomBookingService.sendMeetingRoomBookingReminders();
  27 + return StrUtil.format("发送会议提醒 {} 条", count);
  28 + }
  29 +
  30 +}
yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocReceiveServiceImpl.java
@@ -213,8 +213,7 @@ public class OaOfficialDocReceiveServiceImpl implements OaOfficialDocReceiveServ @@ -213,8 +213,7 @@ public class OaOfficialDocReceiveServiceImpl implements OaOfficialDocReceiveServ
213 // 1.2 校验收文归属 213 // 1.2 校验收文归属
214 validateOfficialDocReceiveOwner(receive, userId); 214 validateOfficialDocReceiveOwner(receive, userId);
215 // 1.3 未提交、审批不通过和已取消的单据允许删除 215 // 1.3 未提交、审批不通过和已取消的单据允许删除
216 - // TODO @AI:notEqualsAny  
217 - if (!ObjectUtils.equalsAny(receive.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus(), 216 + if (ObjectUtils.notEqualsAny(receive.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus(),
218 BpmProcessInstanceStatusEnum.REJECT.getStatus(), BpmProcessInstanceStatusEnum.CANCEL.getStatus())) { 217 BpmProcessInstanceStatusEnum.REJECT.getStatus(), BpmProcessInstanceStatusEnum.CANCEL.getStatus())) {
219 throw exception(OFFICIAL_DOC_STATUS_INVALID); 218 throw exception(OFFICIAL_DOC_STATUS_INVALID);
220 } 219 }
yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/service/officialdoc/OaOfficialDocSendServiceImpl.java
@@ -96,8 +96,7 @@ public class OaOfficialDocSendServiceImpl implements OaOfficialDocSendService { @@ -96,8 +96,7 @@ public class OaOfficialDocSendServiceImpl implements OaOfficialDocSendService {
96 // 1.2 校验发文归属 96 // 1.2 校验发文归属
97 validateOfficialDocSendOwner(send, userId); 97 validateOfficialDocSendOwner(send, userId);
98 // 1.3 未提交、审批不通过和已取消的单据允许删除 98 // 1.3 未提交、审批不通过和已取消的单据允许删除
99 - // TODO @AI:notEqualsAny  
100 - if (!ObjectUtils.equalsAny(send.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus(), 99 + if (ObjectUtils.notEqualsAny(send.getStatus(), BpmProcessInstanceStatusEnum.NOT_START.getStatus(),
101 BpmProcessInstanceStatusEnum.REJECT.getStatus(), BpmProcessInstanceStatusEnum.CANCEL.getStatus())) { 100 BpmProcessInstanceStatusEnum.REJECT.getStatus(), BpmProcessInstanceStatusEnum.CANCEL.getStatus())) {
102 throw exception(OFFICIAL_DOC_STATUS_INVALID); 101 throw exception(OFFICIAL_DOC_STATUS_INVALID);
103 } 102 }
yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingCancelExpiredJobTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.oa.job.meetingroom;
  2 +
  3 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  4 +import cn.iocoder.yudao.module.oa.service.meetingroom.OaMeetingRoomBookingService;
  5 +import org.junit.jupiter.api.Test;
  6 +import org.mockito.InjectMocks;
  7 +import org.mockito.Mock;
  8 +
  9 +import static org.junit.jupiter.api.Assertions.assertEquals;
  10 +import static org.mockito.Mockito.*;
  11 +
  12 +/**
  13 + * {@link OaMeetingRoomBookingCancelExpiredJob} 的单元测试类
  14 + *
  15 + * @author 芋道源码
  16 + */
  17 +public class OaMeetingRoomBookingCancelExpiredJobTest extends BaseMockitoUnitTest {
  18 +
  19 + @InjectMocks
  20 + private OaMeetingRoomBookingCancelExpiredJob job;
  21 +
  22 + @Mock
  23 + private OaMeetingRoomBookingService meetingRoomBookingService;
  24 +
  25 + @Test
  26 + public void testExecute() {
  27 + // mock 方法
  28 + when(meetingRoomBookingService.cancelExpiredMeetingRoomBookings()).thenReturn(2);
  29 +
  30 + // 调用
  31 + String result = job.execute(null);
  32 + // 断言:只取消过期预定,不发送会议提醒
  33 + assertEquals("取消过期未开始会议 2 条", result);
  34 + verify(meetingRoomBookingService).cancelExpiredMeetingRoomBookings();
  35 + verifyNoMoreInteractions(meetingRoomBookingService);
  36 + }
  37 +
  38 +}
yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingReminderJobTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.oa.job.meetingroom;
  2 +
  3 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  4 +import cn.iocoder.yudao.module.oa.service.meetingroom.OaMeetingRoomBookingService;
  5 +import org.junit.jupiter.api.Test;
  6 +import org.mockito.InOrder;
  7 +import org.mockito.InjectMocks;
  8 +import org.mockito.Mock;
  9 +
  10 +import static org.junit.jupiter.api.Assertions.assertEquals;
  11 +import static org.mockito.Mockito.*;
  12 +
  13 +/**
  14 + * {@link OaMeetingRoomBookingReminderJob} 的单元测试类
  15 + *
  16 + * @author 芋道源码
  17 + */
  18 +public class OaMeetingRoomBookingReminderJobTest extends BaseMockitoUnitTest {
  19 +
  20 + @InjectMocks
  21 + private OaMeetingRoomBookingReminderJob job;
  22 +
  23 + @Mock
  24 + private OaMeetingRoomBookingService meetingRoomBookingService;
  25 +
  26 + @Test
  27 + public void testExecute() {
  28 + // mock 方法
  29 + when(meetingRoomBookingService.cancelExpiredMeetingRoomBookings()).thenReturn(1);
  30 + when(meetingRoomBookingService.sendMeetingRoomBookingReminders()).thenReturn(4);
  31 +
  32 + // 调用
  33 + String result = job.execute(null);
  34 + // 断言:兼容入口仍按原顺序,先取消过期预定,再发送会议提醒
  35 + assertEquals("取消过期未开始会议 1 条,发送会议提醒 4 条", result);
  36 + InOrder inOrder = inOrder(meetingRoomBookingService);
  37 + inOrder.verify(meetingRoomBookingService).cancelExpiredMeetingRoomBookings();
  38 + inOrder.verify(meetingRoomBookingService).sendMeetingRoomBookingReminders();
  39 + verifyNoMoreInteractions(meetingRoomBookingService);
  40 + }
  41 +
  42 +}
yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/job/meetingroom/OaMeetingRoomBookingSendReminderJobTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.oa.job.meetingroom;
  2 +
  3 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  4 +import cn.iocoder.yudao.module.oa.service.meetingroom.OaMeetingRoomBookingService;
  5 +import org.junit.jupiter.api.Test;
  6 +import org.mockito.InjectMocks;
  7 +import org.mockito.Mock;
  8 +
  9 +import static org.junit.jupiter.api.Assertions.assertEquals;
  10 +import static org.mockito.Mockito.*;
  11 +
  12 +/**
  13 + * {@link OaMeetingRoomBookingSendReminderJob} 的单元测试类
  14 + *
  15 + * @author 芋道源码
  16 + */
  17 +public class OaMeetingRoomBookingSendReminderJobTest extends BaseMockitoUnitTest {
  18 +
  19 + @InjectMocks
  20 + private OaMeetingRoomBookingSendReminderJob job;
  21 +
  22 + @Mock
  23 + private OaMeetingRoomBookingService meetingRoomBookingService;
  24 +
  25 + @Test
  26 + public void testExecute() {
  27 + // mock 方法
  28 + when(meetingRoomBookingService.sendMeetingRoomBookingReminders()).thenReturn(3);
  29 +
  30 + // 调用
  31 + String result = job.execute(null);
  32 + // 断言:只发送会议提醒,不取消过期预定
  33 + assertEquals("发送会议提醒 3 条", result);
  34 + verify(meetingRoomBookingService).sendMeetingRoomBookingReminders();
  35 + verifyNoMoreInteractions(meetingRoomBookingService);
  36 + }
  37 +
  38 +}
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; @@ -13,6 +13,7 @@ import cn.iocoder.yudao.module.oa.dal.mysql.file.OaFileNodeMapper;
13 import cn.iocoder.yudao.module.oa.framework.config.OaProperties; 13 import cn.iocoder.yudao.module.oa.framework.config.OaProperties;
14 import cn.iocoder.yudao.module.system.api.user.AdminUserApi; 14 import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
15 import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; 15 import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
  16 +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
16 import jakarta.annotation.Resource; 17 import jakarta.annotation.Resource;
17 import org.junit.jupiter.api.BeforeEach; 18 import org.junit.jupiter.api.BeforeEach;
18 import org.junit.jupiter.api.Test; 19 import org.junit.jupiter.api.Test;
@@ -510,6 +511,32 @@ public class OaFileNodeServiceImplTest extends BaseDbUnitTest { @@ -510,6 +511,32 @@ public class OaFileNodeServiceImplTest extends BaseDbUnitTest {
510 } 511 }
511 512
512 @Test 513 @Test
  514 + public void testGetFileStorage_usedSizeNullAndDeleted() {
  515 + // mock 数据:本人文件的 size 为 NULL,已删除的文件不计入
  516 + OaFileNodeDO nullSize = randomFileNodeDO(1L, 0L, 0).setType(1);
  517 + nullSize.setCreator("1");
  518 + fileNodeMapper.insert(nullSize);
  519 + fileNodeMapper.update(null, new LambdaUpdateWrapper<OaFileNodeDO>().setSql("size = NULL")
  520 + .eq(OaFileNodeDO::getId, 1L));
  521 + OaFileNodeDO deleted = randomFileNodeDO(2L, 0L, 0).setType(1).setSize(500L);
  522 + deleted.setCreator("1");
  523 + fileNodeMapper.insert(deleted);
  524 + fileNodeMapper.deleteById(2L);
  525 + assertNull(fileNodeMapper.selectById(1L).getSize());
  526 + assertNull(fileNodeMapper.selectById(2L));
  527 +
  528 + // 调用,并断言:size 均为 NULL 时为 0
  529 + assertEquals(0L, fileNodeService.getFileStorage(1L).getUsedSize());
  530 +
  531 + // mock 数据:NULL 与有值的文件并存
  532 + OaFileNodeDO normal = randomFileNodeDO(3L, 0L, 0).setType(1).setSize(100L);
  533 + normal.setCreator("1");
  534 + fileNodeMapper.insert(normal);
  535 + // 调用,并断言:只累加有值的文件
  536 + assertEquals(100L, fileNodeService.getFileStorage(1L).getUsedSize());
  537 + }
  538 +
  539 + @Test
513 public void testGetFileStorage_sharedCounts() { 540 public void testGetFileStorage_sharedCounts() {
514 // mock 数据:本人目录多个接收人只计一次,共享给我的子文件不重复计数 541 // mock 数据:本人目录多个接收人只计一次,共享给我的子文件不重复计数
515 OaFileNodeDO own = randomFileNodeDO(1L, 0L, 0); 542 OaFileNodeDO own = randomFileNodeDO(1L, 0L, 0);