Commit 925806e673c61fd2cb25efd8f738141326ebfd66

Authored by YunaiV
1 parent 09426f8f

fix: 适配 PostgreSQL JSON 筛选并完善明细账父链处理

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 + * 日期统计、逗号分隔列表关联、JSON 数组匹配、行数限制、分批删除 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、
@@ -451,6 +451,41 @@ public class SqlDialectUtils { @@ -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,6 +5,7 @@ import cn.hutool.extra.spring.SpringUtil;
5 import cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils.Dialect; 5 import cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils.Dialect;
6 import com.baomidou.dynamic.datasource.DynamicRoutingDataSource; 6 import com.baomidou.dynamic.datasource.DynamicRoutingDataSource;
7 import com.baomidou.dynamic.datasource.toolkit.DynamicDataSourceContextHolder; 7 import com.baomidou.dynamic.datasource.toolkit.DynamicDataSourceContextHolder;
  8 +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
8 import net.sf.jsqlparser.parser.CCJSqlParserUtil; 9 import net.sf.jsqlparser.parser.CCJSqlParserUtil;
9 import org.apache.ibatis.mapping.BoundSql; 10 import org.apache.ibatis.mapping.BoundSql;
10 import org.apache.ibatis.mapping.ParameterMapping; 11 import org.apache.ibatis.mapping.ParameterMapping;
@@ -27,6 +28,7 @@ import java.sql.Connection; @@ -27,6 +28,7 @@ import java.sql.Connection;
27 import java.sql.DatabaseMetaData; 28 import java.sql.DatabaseMetaData;
28 import java.sql.SQLException; 29 import java.sql.SQLException;
29 import java.time.LocalDateTime; 30 import java.time.LocalDateTime;
  31 +import java.util.ArrayList;
30 import java.util.Collections; 32 import java.util.Collections;
31 import java.util.HashMap; 33 import java.util.HashMap;
32 import java.util.LinkedHashMap; 34 import java.util.LinkedHashMap;
@@ -326,6 +328,104 @@ public class SqlDialectUtilsTest { @@ -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 @Test 431 @Test
@@ -740,6 +840,20 @@ public class SqlDialectUtilsTest { @@ -740,6 +840,20 @@ public class SqlDialectUtilsTest {
740 return sql.replaceAll("#\\{[^}]+}", "?"); 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 private static void assertParameters(BoundSql boundSql, String... properties) { 857 private static void assertParameters(BoundSql boundSql, String... properties) {
744 List<ParameterMapping> parameterMappings = boundSql.getParameterMappings(); 858 List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
745 assertEquals(properties.length, parameterMappings.size()); 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,6 +18,7 @@ import cn.iocoder.yudao.module.fms.controller.admin.ledger.vo.multicolumn.FmsLed
18 import cn.iocoder.yudao.module.fms.controller.admin.ledger.vo.quantitydetail.FmsLedgerQuantityDetailRespVO; 18 import cn.iocoder.yudao.module.fms.controller.admin.ledger.vo.quantitydetail.FmsLedgerQuantityDetailRespVO;
19 import cn.iocoder.yudao.module.fms.controller.admin.ledger.vo.quantitygeneral.FmsLedgerQuantityGeneralRespVO; 19 import cn.iocoder.yudao.module.fms.controller.admin.ledger.vo.quantitygeneral.FmsLedgerQuantityGeneralRespVO;
20 import cn.iocoder.yudao.module.fms.controller.admin.ledger.vo.subjectbalance.FmsLedgerSubjectBalanceRespVO; 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 import cn.iocoder.yudao.module.fms.service.ledger.FmsLedgerService; 22 import cn.iocoder.yudao.module.fms.service.ledger.FmsLedgerService;
22 import io.swagger.v3.oas.annotations.Operation; 23 import io.swagger.v3.oas.annotations.Operation;
23 import io.swagger.v3.oas.annotations.tags.Tag; 24 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -59,13 +60,13 @@ public class FmsLedgerController { @@ -59,13 +60,13 @@ public class FmsLedgerController {
59 @GetMapping("/detail/subject-list") 60 @GetMapping("/detail/subject-list")
60 @Operation(summary = "获得指定期间有发生额的明细账科目") 61 @Operation(summary = "获得指定期间有发生额的明细账科目")
61 public CommonResult<List<FmsSubjectRespVO>> getDetailSubjectList(@Valid FmsLedgerListReqVO listReqVO) { 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 @GetMapping("/detail/export-excel") 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,12 +183,17 @@ public class FmsLedgerServiceImpl implements FmsLedgerService {
183 if (!subjectIds.contains(subject.getId())) { 183 if (!subjectIds.contains(subject.getId())) {
184 continue; 184 continue;
185 } 185 }
186 - // TODO @AI:尽量不要 while true 死循环,而是有一个 for i 《 short 的上限》 186 + // 逐级向上补齐父级节点,最多 Short.MAX_VALUE 层;父级已补齐(含环形数据)或缺失时结束
187 Long parentId = subject.getParentId(); 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 FmsSubjectDO parent = context.subjectMap.get(parentId); 189 FmsSubjectDO parent = context.subjectMap.get(parentId);
190 parentId = parent == null ? null : parent.getParentId(); 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 return filterList(context.subjects, subject -> visibleSubjectIds.contains(subject.getId())); 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,14 +38,17 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
38 38
39 import java.math.BigDecimal; 39 import java.math.BigDecimal;
40 import java.time.LocalDateTime; 40 import java.time.LocalDateTime;
  41 +import java.util.ArrayList;
41 import java.util.Arrays; 42 import java.util.Arrays;
42 import java.util.Collections; 43 import java.util.Collections;
43 import java.util.List; 44 import java.util.List;
44 45
  46 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
45 import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; 47 import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
46 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; 48 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
47 import static cn.iocoder.yudao.module.fms.enums.ErrorCodeConstants.LEDGER_PERIOD_BEFORE_ACCOUNT_START; 49 import static cn.iocoder.yudao.module.fms.enums.ErrorCodeConstants.LEDGER_PERIOD_BEFORE_ACCOUNT_START;
48 import static org.junit.jupiter.api.Assertions.assertEquals; 50 import static org.junit.jupiter.api.Assertions.assertEquals;
  51 +import static org.junit.jupiter.api.Assertions.assertThrows;
49 import static org.mockito.Mockito.when; 52 import static org.mockito.Mockito.when;
50 53
51 @Import(FmsLedgerServiceImpl.class) 54 @Import(FmsLedgerServiceImpl.class)
@@ -175,6 +178,69 @@ public class FmsLedgerServiceImplTest extends BaseDbUnitTest { @@ -175,6 +178,69 @@ public class FmsLedgerServiceImplTest extends BaseDbUnitTest {
175 } 178 }
176 179
177 @Test 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 public void testGetQuantityDetailList_filterNonQuantityDescendant() { 244 public void testGetQuantityDetailList_filterNonQuantityDescendant() {
179 // mock 数据:父科目启用数量核算,非数量子科目存在金额分录 245 // mock 数据:父科目启用数量核算,非数量子科目存在金额分录
180 cashSubject.setQuantityAccounting(true); 246 cashSubject.setQuantityAccounting(true);
@@ -721,6 +787,32 @@ public class FmsLedgerServiceImplTest extends BaseDbUnitTest { @@ -721,6 +787,32 @@ public class FmsLedgerServiceImplTest extends BaseDbUnitTest {
721 .name(customerItem.getName()).build()))); 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 private FmsSubjectDO buildSubject(Long id, String code, String name, Long parentId, Integer level) { 816 private FmsSubjectDO buildSubject(Long id, String code, String name, Long parentId, Integer level) {
725 return randomPojo(FmsSubjectDO.class, subject -> subject.setId(id) 817 return randomPojo(FmsSubjectDO.class, subject -> subject.setId(id)
726 .setCode(code).setName(name).setParentId(parentId) 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,6 +3,7 @@ package cn.iocoder.yudao.module.oa.dal.mysql.meetingroom;
3 import cn.iocoder.yudao.framework.common.pojo.PageResult; 3 import cn.iocoder.yudao.framework.common.pojo.PageResult;
4 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; 4 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
5 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; 5 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
  6 +import cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils;
6 import cn.iocoder.yudao.module.oa.controller.admin.meetingroom.vo.room.OaMeetingRoomPageReqVO; 7 import cn.iocoder.yudao.module.oa.controller.admin.meetingroom.vo.room.OaMeetingRoomPageReqVO;
7 import cn.iocoder.yudao.module.oa.dal.dataobject.meetingroom.OaMeetingRoomDO; 8 import cn.iocoder.yudao.module.oa.dal.dataobject.meetingroom.OaMeetingRoomDO;
8 import cn.iocoder.yudao.module.oa.enums.meetingroom.OaMeetingRoomBookingScopeEnum; 9 import cn.iocoder.yudao.module.oa.enums.meetingroom.OaMeetingRoomBookingScopeEnum;
@@ -34,7 +35,7 @@ public interface OaMeetingRoomMapper extends BaseMapperX&lt;OaMeetingRoomDO&gt; { @@ -34,7 +35,7 @@ public interface OaMeetingRoomMapper extends BaseMapperX&lt;OaMeetingRoomDO&gt; {
34 .eq(OaMeetingRoomDO::getStatus, OaMeetingRoomStatusEnum.NORMAL.getStatus()) 35 .eq(OaMeetingRoomDO::getStatus, OaMeetingRoomStatusEnum.NORMAL.getStatus())
35 .and(scope -> scope.eq(OaMeetingRoomDO::getBookingScope, OaMeetingRoomBookingScopeEnum.ALL.getScope()) 36 .and(scope -> scope.eq(OaMeetingRoomDO::getBookingScope, OaMeetingRoomBookingScopeEnum.ALL.getScope())
36 .or(specified -> specified.eq(OaMeetingRoomDO::getBookingScope, OaMeetingRoomBookingScopeEnum.SPECIFIED.getScope()) 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 return selectPage(reqVO, query); 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,6 +173,26 @@ public class OaMeetingRoomServiceImplTest extends BaseDbUnitTest {
173 } 173 }
174 174
175 @Test 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 public void testGetMeetingRoomPageByBookingUserId_filter() { 196 public void testGetMeetingRoomPageByBookingUserId_filter() {
177 // mock 数据 197 // mock 数据
178 OaMeetingRoomDO room = randomMeetingRoomDO().setName("项目会议室").setLocation("三楼"); 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,6 +7,7 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult;
7 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; 7 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
8 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; 8 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
9 import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; 9 import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX;
  10 +import cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils;
10 import cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo.PmsWorkbenchPageReqVO; 11 import cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo.PmsWorkbenchPageReqVO;
11 import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemPageReqVO; 12 import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemPageReqVO;
12 import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; 13 import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO;
@@ -333,11 +334,12 @@ public interface PmsWorkItemMapper extends BaseMapperX&lt;PmsWorkItemDO&gt; { @@ -333,11 +334,12 @@ public interface PmsWorkItemMapper extends BaseMapperX&lt;PmsWorkItemDO&gt; {
333 queryWrapper.and(wrapper -> wrapper.notIn(PmsWorkItemDO::getIterationId, 334 queryWrapper.and(wrapper -> wrapper.notIn(PmsWorkItemDO::getIterationId,
334 pageReqVO.getExcludedIterationIds()).or().isNull(PmsWorkItemDO::getIterationId)); 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 if (CollUtil.isNotEmpty(pageReqVO.getLabelIds())) { 338 if (CollUtil.isNotEmpty(pageReqVO.getLabelIds())) {
  339 + String labelCondition = SqlDialectUtils.jsonArrayContains("label_ids", true);
338 queryWrapper.and(labelWrapper -> { 340 queryWrapper.and(labelWrapper -> {
339 for (Long labelId : pageReqVO.getLabelIds()) { 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 package cn.iocoder.yudao.module.pms.service.pm.workitem; 1 package cn.iocoder.yudao.module.pms.service.pm.workitem;
2 2
3 import cn.hutool.core.collection.CollUtil; 3 import cn.hutool.core.collection.CollUtil;
  4 +import cn.hutool.core.util.StrUtil;
4 import cn.iocoder.yudao.framework.common.pojo.PageResult; 5 import cn.iocoder.yudao.framework.common.pojo.PageResult;
  6 +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
5 import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; 7 import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
6 import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusUpdateReqVO; 8 import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusUpdateReqVO;
7 import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemBoardReqVO; 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,6 +78,7 @@ import static cn.iocoder.yudao.module.pms.enums.MessageTemplateConstants.WORK_IT
76 import static cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemActivityContentEnum.WORK_ITEM_CREATED; 78 import static cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemActivityContentEnum.WORK_ITEM_CREATED;
77 import static cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemActivityContentEnum.WORK_ITEM_NAME_UPDATED; 79 import static cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemActivityContentEnum.WORK_ITEM_NAME_UPDATED;
78 import static org.junit.jupiter.api.Assertions.assertEquals; 80 import static org.junit.jupiter.api.Assertions.assertEquals;
  81 +import static org.junit.jupiter.api.Assertions.assertFalse;
79 import static org.junit.jupiter.api.Assertions.assertNotNull; 82 import static org.junit.jupiter.api.Assertions.assertNotNull;
80 import static org.junit.jupiter.api.Assertions.assertNull; 83 import static org.junit.jupiter.api.Assertions.assertNull;
81 import static org.junit.jupiter.api.Assertions.assertTrue; 84 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -943,15 +946,22 @@ public class PmsWorkItemServiceImplTest extends BaseDbUnitTest { @@ -943,15 +946,22 @@ public class PmsWorkItemServiceImplTest extends BaseDbUnitTest {
943 @Test 946 @Test
944 public void testBuildQueryWrapper_filterAnyLabel() { 947 public void testBuildQueryWrapper_filterAnyLabel() {
945 // 准备参数 948 // 准备参数
  949 + Long labelId01 = 1872345678901234567L;
  950 + Long labelId02 = 1872345678901234568L;
946 PmsWorkItemPageReqVO reqVO = new PmsWorkItemPageReqVO().setProjectId(randomLongId()) 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 assertTrue(sqlSegment.contains(" OR ")); 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 @Test 967 @Test