Commit 925806e673c61fd2cb25efd8f738141326ebfd66
1 parent
09426f8f
fix: 适配 PostgreSQL JSON 筛选并完善明细账父链处理
Showing
9 changed files
with
297 additions
and
17 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 | 18 | // TODO DONE @AI:方法注释;已补齐方言重载与内部辅助方法的参数、返回值及边界。 |
| 19 | 19 | // TODO DONE @AI:多余的方法,可以考虑去掉;当然,保留也可以。。。已核对生产 Java/XML 调用,现有方法均有用途,保留。 |
| 20 | 20 | /** |
| 21 | - * 日期统计、逗号分隔列表关联、行数限制、分批删除 SQL 片段的方言工具类 | |
| 21 | + * 日期统计、逗号分隔列表关联、JSON 数组匹配、行数限制、分批删除 SQL 片段的方言工具类 | |
| 22 | 22 | * |
| 23 | 23 | * 1. 每次生成 SQL 时,按当前执行时路由到的数据源选择方言(按数据源实例缓存),不使用启动时固定的 databaseId 或主库 |
| 24 | 24 | * 2. 只有明确识别的 PostgreSQL、Oracle、SQL Server 2012+ 输出新片段;其它已识别的数据库(MySQL、MariaDB、OceanBase、H2、 |
| ... | ... | @@ -451,6 +451,41 @@ public class SqlDialectUtils { |
| 451 | 451 | } |
| 452 | 452 | } |
| 453 | 453 | |
| 454 | + // ========== JSON 数组 ========== | |
| 455 | + | |
| 456 | + /** | |
| 457 | + * JSON 数组列是否包含 {0} 绑定的数值 ID,供 Wrapper 的 apply 使用,ID 必须作为 apply 的参数绑定 | |
| 458 | + * | |
| 459 | + * 1. 原有片段:wrapArray 为 false 时为 JSON_CONTAINS(column, {0}),参数为 ID 的数字文本; | |
| 460 | + * 为 true 时为 JSON_CONTAINS(column, JSON_ARRAY({0})),参数为 Long | |
| 461 | + * 2. PostgreSQL:CAST(column AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT)),兼容 jsonb、json 与 JSON 文本列; | |
| 462 | + * 按数字匹配,数组中的字符串 "1" 不匹配 1;列为 NULL 时不匹配 | |
| 463 | + * 3. Oracle、SQL Server 及其它数据库保持原有片段,是否可用未经验收 | |
| 464 | + * | |
| 465 | + * @param column JSON 数组列名,元素为数值 ID | |
| 466 | + * @param wrapArray 原有片段是否使用 JSON_ARRAY 包装参数 | |
| 467 | + * @return 包含 {0} 占位符的 SQL 条件片段 | |
| 468 | + */ | |
| 469 | + public static String jsonArrayContains(String column, boolean wrapArray) { | |
| 470 | + return jsonArrayContains(getDialect(), column, wrapArray); | |
| 471 | + } | |
| 472 | + | |
| 473 | + /** | |
| 474 | + * 按指定方言生成 JSON 数组包含数值 ID 的条件;仅 PostgreSQL 输出新片段。 | |
| 475 | + * | |
| 476 | + * @param dialect 已识别的数据库方言 | |
| 477 | + * @param column 代码中固定的 JSON 数组列名 | |
| 478 | + * @param wrapArray 原有片段是否使用 JSON_ARRAY 包装参数 | |
| 479 | + * @return 包含 {0} 占位符的 SQL 条件片段 | |
| 480 | + */ | |
| 481 | + static String jsonArrayContains(Dialect dialect, String column, boolean wrapArray) { | |
| 482 | + checkColumn(column); | |
| 483 | + if (dialect == Dialect.POSTGRESQL) { | |
| 484 | + return "CAST(" + column + " AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT))"; | |
| 485 | + } | |
| 486 | + return wrapArray ? "JSON_CONTAINS(" + column + ", JSON_ARRAY({0}))" : "JSON_CONTAINS(" + column + ", {0})"; | |
| 487 | + } | |
| 488 | + | |
| 454 | 489 | // ========== 行数限制 ========== |
| 455 | 490 | |
| 456 | 491 | /** | ... | ... |
yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtilsTest.java
| ... | ... | @@ -5,6 +5,7 @@ import cn.hutool.extra.spring.SpringUtil; |
| 5 | 5 | import cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils.Dialect; |
| 6 | 6 | import com.baomidou.dynamic.datasource.DynamicRoutingDataSource; |
| 7 | 7 | import com.baomidou.dynamic.datasource.toolkit.DynamicDataSourceContextHolder; |
| 8 | +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; | |
| 8 | 9 | import net.sf.jsqlparser.parser.CCJSqlParserUtil; |
| 9 | 10 | import org.apache.ibatis.mapping.BoundSql; |
| 10 | 11 | import org.apache.ibatis.mapping.ParameterMapping; |
| ... | ... | @@ -27,6 +28,7 @@ import java.sql.Connection; |
| 27 | 28 | import java.sql.DatabaseMetaData; |
| 28 | 29 | import java.sql.SQLException; |
| 29 | 30 | import java.time.LocalDateTime; |
| 31 | +import java.util.ArrayList; | |
| 30 | 32 | import java.util.Collections; |
| 31 | 33 | import java.util.HashMap; |
| 32 | 34 | import java.util.LinkedHashMap; |
| ... | ... | @@ -326,6 +328,104 @@ public class SqlDialectUtilsTest { |
| 326 | 328 | } |
| 327 | 329 | } |
| 328 | 330 | |
| 331 | + // ========== JSON 数组 ========== | |
| 332 | + | |
| 333 | + @Test | |
| 334 | + public void testJsonArrayContains() { | |
| 335 | + // 原有片段、Oracle、SQL Server:保持原有 JSON_CONTAINS | |
| 336 | + for (Dialect dialect : List.of(Dialect.ORIGINAL, Dialect.ORACLE, Dialect.SQLSERVER)) { | |
| 337 | + assertEquals("JSON_CONTAINS(booking_user_ids, {0})", | |
| 338 | + SqlDialectUtils.jsonArrayContains(dialect, "booking_user_ids", false), dialect.name()); | |
| 339 | + assertEquals("JSON_CONTAINS(t.label_ids, JSON_ARRAY({0}))", | |
| 340 | + SqlDialectUtils.jsonArrayContains(dialect, "t.label_ids", true), dialect.name()); | |
| 341 | + } | |
| 342 | + // PostgreSQL:两种原有片段统一为 jsonb 包含,参数按 BIGINT 匹配数字元素 | |
| 343 | + assertEquals("CAST(booking_user_ids AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT))", | |
| 344 | + SqlDialectUtils.jsonArrayContains(Dialect.POSTGRESQL, "booking_user_ids", false)); | |
| 345 | + assertEquals("CAST(t.label_ids AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT))", | |
| 346 | + SqlDialectUtils.jsonArrayContains(Dialect.POSTGRESQL, "t.label_ids", true)); | |
| 347 | + } | |
| 348 | + | |
| 349 | + @Test | |
| 350 | + public void testJsonArrayContains_invalidColumn() { | |
| 351 | + for (Dialect dialect : Dialect.values()) { | |
| 352 | + for (boolean wrapArray : new boolean[]{true, false}) { | |
| 353 | + assertThrows(IllegalArgumentException.class, | |
| 354 | + () -> SqlDialectUtils.jsonArrayContains(dialect, "label_ids, '[1]') OR (1=1", wrapArray)); | |
| 355 | + assertThrows(IllegalArgumentException.class, | |
| 356 | + () -> SqlDialectUtils.jsonArrayContains(dialect, "'[1, 2]'", wrapArray)); | |
| 357 | + assertThrows(IllegalArgumentException.class, | |
| 358 | + () -> SqlDialectUtils.jsonArrayContains(dialect, "label_ids;drop table t", wrapArray)); | |
| 359 | + assertThrows(IllegalArgumentException.class, | |
| 360 | + () -> SqlDialectUtils.jsonArrayContains(dialect, "", wrapArray)); | |
| 361 | + assertThrows(IllegalArgumentException.class, | |
| 362 | + () -> SqlDialectUtils.jsonArrayContains(dialect, null, wrapArray)); | |
| 363 | + } | |
| 364 | + } | |
| 365 | + } | |
| 366 | + | |
| 367 | + /** | |
| 368 | + * 与 PMS 工作项任一标签筛选相同的 OR 条件,所有方言均需可被 JSqlParser 解析 | |
| 369 | + */ | |
| 370 | + @Test | |
| 371 | + public void testJsonArrayContains_parsedByJSqlParser() throws Exception { | |
| 372 | + for (Dialect dialect : Dialect.values()) { | |
| 373 | + for (boolean wrapArray : new boolean[]{true, false}) { | |
| 374 | + String condition = SqlDialectUtils.jsonArrayContains(dialect, "label_ids", wrapArray).replace("{0}", "?"); | |
| 375 | + String sql = "SELECT id FROM pms_work_item WHERE project_id = ? AND (" + condition + " OR " + condition + ")"; | |
| 376 | + assertNotNull(CCJSqlParserUtil.parse(sql), dialect + " " + sql); | |
| 377 | + } | |
| 378 | + } | |
| 379 | + } | |
| 380 | + | |
| 381 | + /** | |
| 382 | + * 与 Mapper 相同:通过 MyBatis Plus Wrapper 的 apply 绑定参数,再由 MyBatis 生成 BoundSql | |
| 383 | + */ | |
| 384 | + @Test | |
| 385 | + public void testBoundSql_jsonArrayContains() throws Exception { | |
| 386 | + FakeDataSource postgresql = new FakeDataSource("PostgreSQL", "jdbc:postgresql://127.0.0.1:5432/a", 17); | |
| 387 | + FakeDataSource oracle = new FakeDataSource("Oracle", "jdbc:oracle:thin:@127.0.0.1:1521:xe", 21); | |
| 388 | + FakeDataSource sqlServer = new FakeDataSource("Microsoft SQL Server", "jdbc:sqlserver://127.0.0.1:1433", 16); | |
| 389 | + FakeDataSource mysql = new FakeDataSource("MySQL", "jdbc:mysql://127.0.0.1:3306/a", 8); | |
| 390 | + mockDynamicRoutingDataSource(Map.of("master", postgresql, "oracle", oracle, "sqlserver", sqlServer, "mysql", mysql)); | |
| 391 | + SqlSource sqlSource = new XMLLanguageDriver().createSqlSource(new Configuration(), | |
| 392 | + "<script>SELECT id FROM pms_work_item ${ew.customSqlSegment}</script>", Map.class); | |
| 393 | + // 依次切换:主库 PostgreSQL、Oracle、SQL Server、MySQL | |
| 394 | + Map<String, Dialect> routes = new LinkedHashMap<>(); | |
| 395 | + routes.put("master", Dialect.POSTGRESQL); | |
| 396 | + routes.put("oracle", Dialect.ORACLE); | |
| 397 | + routes.put("sqlserver", Dialect.SQLSERVER); | |
| 398 | + routes.put("mysql", Dialect.ORIGINAL); | |
| 399 | + | |
| 400 | + for (Map.Entry<String, Dialect> route : routes.entrySet()) { | |
| 401 | + DynamicDataSourceContextHolder.push(route.getKey()); | |
| 402 | + // PMS:多个标签按 OR 匹配任一标签,参数为 Long | |
| 403 | + String labelCondition = SqlDialectUtils.jsonArrayContains("label_ids", true); | |
| 404 | + QueryWrapper<Object> labelWrapper = new QueryWrapper<>(); | |
| 405 | + labelWrapper.eq("project_id", 100L).and(w -> w.or().apply(labelCondition, 1L).or().apply(labelCondition, 11L)); | |
| 406 | + BoundSql boundSql = sqlSource.getBoundSql(Map.of("ew", labelWrapper)); | |
| 407 | + String sql = normalize(boundSql.getSql()); | |
| 408 | + String expected = SqlDialectUtils.jsonArrayContains(route.getValue(), "label_ids", true).replace("{0}", "?"); | |
| 409 | + assertTrue(sql.startsWith("SELECT id FROM pms_work_item WHERE"), sql); | |
| 410 | + assertTrue(sql.contains(expected + " OR " + expected), sql); | |
| 411 | + assertFalse(sql.contains("11"), sql); // 值只通过参数绑定,不拼接到 SQL 中 | |
| 412 | + assertEquals(List.of(100L, 1L, 11L), getParameterValues(boundSql, labelWrapper)); | |
| 413 | + assertNotNull(CCJSqlParserUtil.parse(sql), route.getValue() + " " + sql); | |
| 414 | + | |
| 415 | + // OA:参数仍为 ID 的数字文本 | |
| 416 | + String bookingCondition = SqlDialectUtils.jsonArrayContains("booking_user_ids", false); | |
| 417 | + QueryWrapper<Object> bookingWrapper = new QueryWrapper<>(); | |
| 418 | + bookingWrapper.eq("booking_scope", 1).apply(bookingCondition, "1024"); | |
| 419 | + boundSql = sqlSource.getBoundSql(Map.of("ew", bookingWrapper)); | |
| 420 | + sql = normalize(boundSql.getSql()); | |
| 421 | + expected = SqlDialectUtils.jsonArrayContains(route.getValue(), "booking_user_ids", false).replace("{0}", "?"); | |
| 422 | + assertTrue(sql.contains(expected), sql); | |
| 423 | + assertFalse(sql.contains("1024"), sql); | |
| 424 | + assertEquals(List.of(1, "1024"), getParameterValues(boundSql, bookingWrapper)); | |
| 425 | + assertNotNull(CCJSqlParserUtil.parse(sql), route.getValue() + " " + sql); | |
| 426 | + } | |
| 427 | + } | |
| 428 | + | |
| 329 | 429 | // ========== 行数限制 ========== |
| 330 | 430 | |
| 331 | 431 | @Test |
| ... | ... | @@ -740,6 +840,20 @@ public class SqlDialectUtilsTest { |
| 740 | 840 | return sql.replaceAll("#\\{[^}]+}", "?"); |
| 741 | 841 | } |
| 742 | 842 | |
| 843 | + /** | |
| 844 | + * 按 BoundSql 的参数顺序,取得 Wrapper 中绑定的参数值 | |
| 845 | + */ | |
| 846 | + private static List<Object> getParameterValues(BoundSql boundSql, QueryWrapper<?> wrapper) { | |
| 847 | + String prefix = "ew.paramNameValuePairs."; | |
| 848 | + List<Object> values = new ArrayList<>(); | |
| 849 | + for (ParameterMapping parameterMapping : boundSql.getParameterMappings()) { | |
| 850 | + String property = parameterMapping.getProperty(); | |
| 851 | + assertTrue(property.startsWith(prefix), property); | |
| 852 | + values.add(wrapper.getParamNameValuePairs().get(property.substring(prefix.length()))); | |
| 853 | + } | |
| 854 | + return values; | |
| 855 | + } | |
| 856 | + | |
| 743 | 857 | private static void assertParameters(BoundSql boundSql, String... properties) { |
| 744 | 858 | List<ParameterMapping> parameterMappings = boundSql.getParameterMappings(); |
| 745 | 859 | assertEquals(properties.length, parameterMappings.size()); | ... | ... |
yudao-module-fms/src/main/java/cn/iocoder/yudao/module/fms/controller/admin/ledger/FmsLedgerController.java
| ... | ... | @@ -18,6 +18,7 @@ import cn.iocoder.yudao.module.fms.controller.admin.ledger.vo.multicolumn.FmsLed |
| 18 | 18 | import cn.iocoder.yudao.module.fms.controller.admin.ledger.vo.quantitydetail.FmsLedgerQuantityDetailRespVO; |
| 19 | 19 | import cn.iocoder.yudao.module.fms.controller.admin.ledger.vo.quantitygeneral.FmsLedgerQuantityGeneralRespVO; |
| 20 | 20 | import cn.iocoder.yudao.module.fms.controller.admin.ledger.vo.subjectbalance.FmsLedgerSubjectBalanceRespVO; |
| 21 | +import cn.iocoder.yudao.module.fms.dal.dataobject.config.FmsSubjectDO; | |
| 21 | 22 | import cn.iocoder.yudao.module.fms.service.ledger.FmsLedgerService; |
| 22 | 23 | import io.swagger.v3.oas.annotations.Operation; |
| 23 | 24 | import io.swagger.v3.oas.annotations.tags.Tag; |
| ... | ... | @@ -59,13 +60,13 @@ public class FmsLedgerController { |
| 59 | 60 | @GetMapping("/detail/subject-list") |
| 60 | 61 | @Operation(summary = "获得指定期间有发生额的明细账科目") |
| 61 | 62 | public CommonResult<List<FmsSubjectRespVO>> getDetailSubjectList(@Valid FmsLedgerListReqVO listReqVO) { |
| 62 | - // TODO @AI:先查询出来,list 变量;然后在去 convert;字段不多,倒是可以考虑 beanutils 去 copy 下? | |
| 63 | - return success(convertList(ledgerService.getDetailSubjectList(listReqVO, getLoginUserId()), subject -> | |
| 64 | - new FmsSubjectRespVO().setId(subject.getId()).setParentId(subject.getParentId()) | |
| 65 | - .setCode(subject.getCode()).setName(subject.getName()).setType(subject.getType()) | |
| 66 | - .setBalanceDirection(subject.getBalanceDirection()).setStatus(subject.getStatus()) | |
| 67 | - .setLevel(subject.getLevel()).setAuxiliaryTypeIds(subject.getAuxiliaryTypeIds()) | |
| 68 | - .setQuantityAccounting(subject.getQuantityAccounting()).setQuantityUnit(subject.getQuantityUnit()))); | |
| 63 | + List<FmsSubjectDO> list = ledgerService.getDetailSubjectList(listReqVO, getLoginUserId()); | |
| 64 | + return success(convertList(list, subject -> new FmsSubjectRespVO().setId(subject.getId()) | |
| 65 | + .setParentId(subject.getParentId()).setCode(subject.getCode()).setName(subject.getName()) | |
| 66 | + .setType(subject.getType()).setBalanceDirection(subject.getBalanceDirection()) | |
| 67 | + .setStatus(subject.getStatus()).setLevel(subject.getLevel()) | |
| 68 | + .setAuxiliaryTypeIds(subject.getAuxiliaryTypeIds()).setQuantityAccounting(subject.getQuantityAccounting()) | |
| 69 | + .setQuantityUnit(subject.getQuantityUnit()))); | |
| 69 | 70 | } |
| 70 | 71 | |
| 71 | 72 | @GetMapping("/detail/export-excel") | ... | ... |
yudao-module-fms/src/main/java/cn/iocoder/yudao/module/fms/service/ledger/FmsLedgerServiceImpl.java
| ... | ... | @@ -183,12 +183,17 @@ public class FmsLedgerServiceImpl implements FmsLedgerService { |
| 183 | 183 | if (!subjectIds.contains(subject.getId())) { |
| 184 | 184 | continue; |
| 185 | 185 | } |
| 186 | - // TODO @AI:尽量不要 while true 死循环,而是有一个 for i 《 short 的上限》 | |
| 186 | + // 逐级向上补齐父级节点,最多 Short.MAX_VALUE 层;父级已补齐(含环形数据)或缺失时结束 | |
| 187 | 187 | Long parentId = subject.getParentId(); |
| 188 | - while (parentId != null && visibleSubjectIds.add(parentId)) { | |
| 188 | + for (int i = 0; i < Short.MAX_VALUE && parentId != null && visibleSubjectIds.add(parentId); i++) { | |
| 189 | 189 | FmsSubjectDO parent = context.subjectMap.get(parentId); |
| 190 | 190 | parentId = parent == null ? null : parent.getParentId(); |
| 191 | 191 | } |
| 192 | + // 达到上限后仍有未补齐的真实父级,不能静默丢弃;根哨兵或缺失父级沿用原退出语义 | |
| 193 | + if (parentId != null && !visibleSubjectIds.contains(parentId) | |
| 194 | + && context.subjectMap.containsKey(parentId)) { | |
| 195 | + throw new IllegalStateException("科目(" + subject.getId() + ") 的父级科目超过 " + Short.MAX_VALUE + " 层"); | |
| 196 | + } | |
| 192 | 197 | } |
| 193 | 198 | return filterList(context.subjects, subject -> visibleSubjectIds.contains(subject.getId())); |
| 194 | 199 | } | ... | ... |
yudao-module-fms/src/test/java/cn/iocoder/yudao/module/fms/service/ledger/FmsLedgerServiceImplTest.java
| ... | ... | @@ -38,14 +38,17 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; |
| 38 | 38 | |
| 39 | 39 | import java.math.BigDecimal; |
| 40 | 40 | import java.time.LocalDateTime; |
| 41 | +import java.util.ArrayList; | |
| 41 | 42 | import java.util.Arrays; |
| 42 | 43 | import java.util.Collections; |
| 43 | 44 | import java.util.List; |
| 44 | 45 | |
| 46 | +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; | |
| 45 | 47 | import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; |
| 46 | 48 | import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; |
| 47 | 49 | import static cn.iocoder.yudao.module.fms.enums.ErrorCodeConstants.LEDGER_PERIOD_BEFORE_ACCOUNT_START; |
| 48 | 50 | import static org.junit.jupiter.api.Assertions.assertEquals; |
| 51 | +import static org.junit.jupiter.api.Assertions.assertThrows; | |
| 49 | 52 | import static org.mockito.Mockito.when; |
| 50 | 53 | |
| 51 | 54 | @Import(FmsLedgerServiceImpl.class) |
| ... | ... | @@ -175,6 +178,69 @@ public class FmsLedgerServiceImplTest extends BaseDbUnitTest { |
| 175 | 178 | } |
| 176 | 179 | |
| 177 | 180 | @Test |
| 181 | + public void testGetDetailSubjectList_parentChain() { | |
| 182 | + // mock 数据:有发生额的末级科目补齐全部父级,无发生额的兄弟、其它科目不返回,且保持科目顺序 | |
| 183 | + FmsSubjectDO root = buildSubject(201L, "2001", "一级科目", null, 1); | |
| 184 | + FmsSubjectDO other = buildSubject(204L, "2002", "其它科目", null, 1); | |
| 185 | + FmsSubjectDO middle = buildSubject(202L, "200101", "二级科目", 201L, 2); | |
| 186 | + FmsSubjectDO sibling = buildSubject(205L, "200102", "兄弟科目", 201L, 2); | |
| 187 | + FmsSubjectDO leaf = buildSubject(203L, "20010101", "三级科目", 202L, 3); | |
| 188 | + when(subjectService.getSubjectList(1L, null, 10L)) | |
| 189 | + .thenReturn(Arrays.asList(root, other, middle, sibling, leaf)); | |
| 190 | + insertVoucherEntry(leaf.getId()); | |
| 191 | + | |
| 192 | + // 调用 | |
| 193 | + List<FmsSubjectDO> result = ledgerService.getDetailSubjectList(buildListReqVO(), 10L); | |
| 194 | + | |
| 195 | + // 断言 | |
| 196 | + assertEquals(Arrays.asList(201L, 202L, 203L), convertList(result, FmsSubjectDO::getId)); | |
| 197 | + } | |
| 198 | + | |
| 199 | + @Test | |
| 200 | + public void testGetDetailSubjectList_parentMissingOrCycle() { | |
| 201 | + // mock 数据:父科目不存在时停止补齐;父子成环时不重复处理 | |
| 202 | + FmsSubjectDO orphan = buildSubject(301L, "3001", "缺父科目", 999L, 2); | |
| 203 | + FmsSubjectDO cycle01 = buildSubject(401L, "4001", "环形科目一", 402L, 1); | |
| 204 | + FmsSubjectDO cycle02 = buildSubject(402L, "4002", "环形科目二", 401L, 1); | |
| 205 | + when(subjectService.getSubjectList(1L, null, 10L)).thenReturn(Arrays.asList(orphan, cycle01, cycle02)); | |
| 206 | + insertVoucherEntry(orphan.getId()); | |
| 207 | + insertVoucherEntry(cycle01.getId()); | |
| 208 | + | |
| 209 | + // 调用 | |
| 210 | + List<FmsSubjectDO> result = ledgerService.getDetailSubjectList(buildListReqVO(), 10L); | |
| 211 | + | |
| 212 | + // 断言 | |
| 213 | + assertEquals(Arrays.asList(301L, 401L, 402L), convertList(result, FmsSubjectDO::getId)); | |
| 214 | + } | |
| 215 | + | |
| 216 | + @Test | |
| 217 | + public void testGetDetailSubjectList_parentDepthLimit() { | |
| 218 | + // mock 数据:恰好 Short.MAX_VALUE 层父级,全部补齐 | |
| 219 | + List<FmsSubjectDO> subjects = buildSubjectChain(100_000L, Short.MAX_VALUE); | |
| 220 | + subjects.get(0).setParentId(FmsSubjectDO.PARENT_ID_ROOT); | |
| 221 | + when(subjectService.getSubjectList(1L, null, 10L)).thenReturn(subjects); | |
| 222 | + insertVoucherEntry(100_000L); | |
| 223 | + | |
| 224 | + // 调用,并断言 | |
| 225 | + List<FmsSubjectDO> result = ledgerService.getDetailSubjectList(buildListReqVO(), 10L); | |
| 226 | + assertEquals(Short.MAX_VALUE + 1, result.size()); | |
| 227 | + assertEquals(subjects.get(0).getId(), result.get(0).getId()); | |
| 228 | + | |
| 229 | + // 调用,并断言:空父级或缺失的根父级也按原语义退出,不把根哨兵当作额外一层 | |
| 230 | + subjects.get(0).setParentId(null); | |
| 231 | + assertEquals(Short.MAX_VALUE + 1, ledgerService.getDetailSubjectList(buildListReqVO(), 10L).size()); | |
| 232 | + subjects.get(0).setParentId(-1L); | |
| 233 | + assertEquals(Short.MAX_VALUE + 1, ledgerService.getDetailSubjectList(buildListReqVO(), 10L).size()); | |
| 234 | + | |
| 235 | + // mock 数据:超过 Short.MAX_VALUE 层父级时,明确失败而不是丢弃祖先科目 | |
| 236 | + when(subjectService.getSubjectList(1L, null, 10L)) | |
| 237 | + .thenReturn(buildSubjectChain(100_000L, Short.MAX_VALUE + 1)); | |
| 238 | + | |
| 239 | + // 调用,并断言 | |
| 240 | + assertThrows(IllegalStateException.class, () -> ledgerService.getDetailSubjectList(buildListReqVO(), 10L)); | |
| 241 | + } | |
| 242 | + | |
| 243 | + @Test | |
| 178 | 244 | public void testGetQuantityDetailList_filterNonQuantityDescendant() { |
| 179 | 245 | // mock 数据:父科目启用数量核算,非数量子科目存在金额分录 |
| 180 | 246 | cashSubject.setQuantityAccounting(true); |
| ... | ... | @@ -721,6 +787,32 @@ public class FmsLedgerServiceImplTest extends BaseDbUnitTest { |
| 721 | 787 | .name(customerItem.getName()).build()))); |
| 722 | 788 | } |
| 723 | 789 | |
| 790 | + private void insertVoucherEntry(Long subjectId) { | |
| 791 | + FmsVoucherWordDO word = buildVoucherWord(); | |
| 792 | + voucherWordMapper.insert(word); | |
| 793 | + FmsVoucherDO voucher = buildVoucher(word.getId(), "10.00"); | |
| 794 | + voucherMapper.insert(voucher); | |
| 795 | + voucherEntryMapper.insert(new FmsVoucherEntryDO().setVoucherId(voucher.getId()) | |
| 796 | + .setAccountSetId(1L).setSubjectId(subjectId).setSubjectCode("TEST") | |
| 797 | + .setSubjectName("测试科目").setDigest("测试分录").setSort(1) | |
| 798 | + .setDebitAmount(new BigDecimal("10.00")).setCreditAmount(BigDecimal.ZERO) | |
| 799 | + .setQuantity(BigDecimal.ZERO).setUnitPrice(BigDecimal.ZERO) | |
| 800 | + .setAuxiliaries(Collections.emptyList())); | |
| 801 | + } | |
| 802 | + | |
| 803 | + /** | |
| 804 | + * 构建从根到末级的单链科目,末级科目编号为 leafId,共 parentCount 层父级 | |
| 805 | + */ | |
| 806 | + private static List<FmsSubjectDO> buildSubjectChain(long leafId, int parentCount) { | |
| 807 | + List<FmsSubjectDO> subjects = new ArrayList<>(parentCount + 1); | |
| 808 | + Long parentId = null; | |
| 809 | + for (long id = leafId - parentCount; id <= leafId; id++) { | |
| 810 | + subjects.add(new FmsSubjectDO().setId(id).setParentId(parentId)); | |
| 811 | + parentId = id; | |
| 812 | + } | |
| 813 | + return subjects; | |
| 814 | + } | |
| 815 | + | |
| 724 | 816 | private FmsSubjectDO buildSubject(Long id, String code, String name, Long parentId, Integer level) { |
| 725 | 817 | return randomPojo(FmsSubjectDO.class, subject -> subject.setId(id) |
| 726 | 818 | .setCode(code).setName(name).setParentId(parentId) | ... | ... |
yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/meetingroom/OaMeetingRoomMapper.java
| ... | ... | @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.oa.dal.mysql.meetingroom; |
| 3 | 3 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| 4 | 4 | import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; |
| 5 | 5 | import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; |
| 6 | +import cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils; | |
| 6 | 7 | import cn.iocoder.yudao.module.oa.controller.admin.meetingroom.vo.room.OaMeetingRoomPageReqVO; |
| 7 | 8 | import cn.iocoder.yudao.module.oa.dal.dataobject.meetingroom.OaMeetingRoomDO; |
| 8 | 9 | import cn.iocoder.yudao.module.oa.enums.meetingroom.OaMeetingRoomBookingScopeEnum; |
| ... | ... | @@ -34,7 +35,7 @@ public interface OaMeetingRoomMapper extends BaseMapperX<OaMeetingRoomDO> { |
| 34 | 35 | .eq(OaMeetingRoomDO::getStatus, OaMeetingRoomStatusEnum.NORMAL.getStatus()) |
| 35 | 36 | .and(scope -> scope.eq(OaMeetingRoomDO::getBookingScope, OaMeetingRoomBookingScopeEnum.ALL.getScope()) |
| 36 | 37 | .or(specified -> specified.eq(OaMeetingRoomDO::getBookingScope, OaMeetingRoomBookingScopeEnum.SPECIFIED.getScope()) |
| 37 | - .apply("JSON_CONTAINS(booking_user_ids, {0})", bookingUserId.toString()))); | |
| 38 | + .apply(SqlDialectUtils.jsonArrayContains("booking_user_ids", false), bookingUserId.toString()))); | |
| 38 | 39 | } |
| 39 | 40 | return selectPage(reqVO, query); |
| 40 | 41 | } | ... | ... |
yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomServiceImplTest.java
| ... | ... | @@ -173,6 +173,26 @@ public class OaMeetingRoomServiceImplTest extends BaseDbUnitTest { |
| 173 | 173 | } |
| 174 | 174 | |
| 175 | 175 | @Test |
| 176 | + public void testGetMeetingRoomPageByBookingUserId_largeIdAndNull() { | |
| 177 | + // mock 数据:雪花编号只匹配完全相同的编号 | |
| 178 | + Long userId = 1872345678901234567L; | |
| 179 | + OaMeetingRoomDO matched = randomMeetingRoomDO().setBookingScope(1).setBookingUserIds(Arrays.asList(-1L, userId)); | |
| 180 | + meetingRoomMapper.insert(matched); | |
| 181 | + meetingRoomMapper.insert(randomMeetingRoomDO().setBookingScope(1) | |
| 182 | + .setBookingUserIds(Collections.singletonList(userId + 1))); | |
| 183 | + // mock 数据:指定范围的可预定人员为 NULL 时,不可预定 | |
| 184 | + meetingRoomMapper.insert(randomMeetingRoomDO().setBookingScope(1).setBookingUserIds(null)); | |
| 185 | + | |
| 186 | + // 调用 | |
| 187 | + PageResult<OaMeetingRoomDO> page = meetingRoomService.getMeetingRoomPageByBookingUserId( | |
| 188 | + new OaMeetingRoomPageReqVO(), userId); | |
| 189 | + | |
| 190 | + // 断言 | |
| 191 | + assertEquals(1L, page.getTotal()); | |
| 192 | + assertEquals(Collections.singletonList(matched.getId()), convertList(page.getList(), OaMeetingRoomDO::getId)); | |
| 193 | + } | |
| 194 | + | |
| 195 | + @Test | |
| 176 | 196 | public void testGetMeetingRoomPageByBookingUserId_filter() { |
| 177 | 197 | // mock 数据 |
| 178 | 198 | OaMeetingRoomDO room = randomMeetingRoomDO().setName("项目会议室").setLocation("三楼"); | ... | ... |
yudao-module-pms/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemMapper.java
| ... | ... | @@ -7,6 +7,7 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| 7 | 7 | import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; |
| 8 | 8 | import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; |
| 9 | 9 | import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; |
| 10 | +import cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils; | |
| 10 | 11 | import cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo.PmsWorkbenchPageReqVO; |
| 11 | 12 | import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemPageReqVO; |
| 12 | 13 | import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; |
| ... | ... | @@ -333,11 +334,12 @@ public interface PmsWorkItemMapper extends BaseMapperX<PmsWorkItemDO> { |
| 333 | 334 | queryWrapper.and(wrapper -> wrapper.notIn(PmsWorkItemDO::getIterationId, |
| 334 | 335 | pageReqVO.getExcludedIterationIds()).or().isNull(PmsWorkItemDO::getIterationId)); |
| 335 | 336 | } |
| 336 | - // label_ids 为 JSON 数组列,使用 MySQL JSON_CONTAINS 匹配元素,H2 单测不覆盖该分支 | |
| 337 | + // label_ids 为 JSON 数组列,匹配任一标签;PostgreSQL 使用 jsonb 包含,其它数据库保持 JSON_CONTAINS | |
| 337 | 338 | if (CollUtil.isNotEmpty(pageReqVO.getLabelIds())) { |
| 339 | + String labelCondition = SqlDialectUtils.jsonArrayContains("label_ids", true); | |
| 338 | 340 | queryWrapper.and(labelWrapper -> { |
| 339 | 341 | for (Long labelId : pageReqVO.getLabelIds()) { |
| 340 | - labelWrapper.or().apply("JSON_CONTAINS(label_ids, JSON_ARRAY({0}))", labelId); | |
| 342 | + labelWrapper.or().apply(labelCondition, labelId); | |
| 341 | 343 | } |
| 342 | 344 | }); |
| 343 | 345 | } | ... | ... |
yudao-module-pms/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemServiceImplTest.java
| 1 | 1 | package cn.iocoder.yudao.module.pms.service.pm.workitem; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.collection.CollUtil; |
| 4 | +import cn.hutool.core.util.StrUtil; | |
| 4 | 5 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| 6 | +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; | |
| 5 | 7 | import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; |
| 6 | 8 | import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusUpdateReqVO; |
| 7 | 9 | import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemBoardReqVO; |
| ... | ... | @@ -76,6 +78,7 @@ import static cn.iocoder.yudao.module.pms.enums.MessageTemplateConstants.WORK_IT |
| 76 | 78 | import static cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemActivityContentEnum.WORK_ITEM_CREATED; |
| 77 | 79 | import static cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemActivityContentEnum.WORK_ITEM_NAME_UPDATED; |
| 78 | 80 | import static org.junit.jupiter.api.Assertions.assertEquals; |
| 81 | +import static org.junit.jupiter.api.Assertions.assertFalse; | |
| 79 | 82 | import static org.junit.jupiter.api.Assertions.assertNotNull; |
| 80 | 83 | import static org.junit.jupiter.api.Assertions.assertNull; |
| 81 | 84 | import static org.junit.jupiter.api.Assertions.assertTrue; |
| ... | ... | @@ -943,15 +946,22 @@ public class PmsWorkItemServiceImplTest extends BaseDbUnitTest { |
| 943 | 946 | @Test |
| 944 | 947 | public void testBuildQueryWrapper_filterAnyLabel() { |
| 945 | 948 | // 准备参数 |
| 949 | + Long labelId01 = 1872345678901234567L; | |
| 950 | + Long labelId02 = 1872345678901234568L; | |
| 946 | 951 | PmsWorkItemPageReqVO reqVO = new PmsWorkItemPageReqVO().setProjectId(randomLongId()) |
| 947 | - .setLabelIds(Arrays.asList(randomLongId(), randomLongId())); | |
| 952 | + .setLabelIds(Arrays.asList(labelId01, labelId02)); | |
| 948 | 953 | |
| 949 | 954 | // 调用 |
| 950 | - String sqlSegment = PmsWorkItemMapper.buildQueryWrapper(reqVO).getSqlSegment(); | |
| 955 | + LambdaQueryWrapperX<PmsWorkItemDO> queryWrapper = PmsWorkItemMapper.buildQueryWrapper(reqVO); | |
| 956 | + String sqlSegment = queryWrapper.getSqlSegment(); | |
| 951 | 957 | |
| 952 | - // 断言 | |
| 953 | - assertTrue(sqlSegment.contains("JSON_CONTAINS(label_ids, JSON_ARRAY(")); | |
| 958 | + // 断言:H2 走原有片段,每个标签一个条件并按 OR 连接;标签编号仍以 Long 绑定,不拼接到 SQL 中 | |
| 959 | + assertEquals(2, StrUtil.count(sqlSegment, "JSON_CONTAINS(label_ids, JSON_ARRAY(")); | |
| 954 | 960 | assertTrue(sqlSegment.contains(" OR ")); |
| 961 | + assertFalse(sqlSegment.contains(labelId01.toString())); | |
| 962 | + assertFalse(sqlSegment.contains(labelId02.toString())); | |
| 963 | + assertTrue(queryWrapper.getParamNameValuePairs().containsValue(labelId01)); | |
| 964 | + assertTrue(queryWrapper.getParamNameValuePairs().containsValue(labelId02)); | |
| 955 | 965 | } |
| 956 | 966 | |
| 957 | 967 | @Test | ... | ... |