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);