From 925806e673c61fd2cb25efd8f738141326ebfd66 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Thu, 29 Oct 2026 21:54:00 +0800 Subject: [PATCH] fix: 适配 PostgreSQL JSON 筛选并完善明细账父链处理 --- yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtils.java | 37 ++++++++++++++++++++++++++++++++++++- yudao-framework/yudao-spring-boot-starter-mybatis/src/test/java/cn/iocoder/yudao/framework/mybatis/core/util/SqlDialectUtilsTest.java | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-fms/src/main/java/cn/iocoder/yudao/module/fms/controller/admin/ledger/FmsLedgerController.java | 15 ++++++++------- yudao-module-fms/src/main/java/cn/iocoder/yudao/module/fms/service/ledger/FmsLedgerServiceImpl.java | 9 +++++++-- yudao-module-fms/src/test/java/cn/iocoder/yudao/module/fms/service/ledger/FmsLedgerServiceImplTest.java | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/meetingroom/OaMeetingRoomMapper.java | 3 ++- yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomServiceImplTest.java | 20 ++++++++++++++++++++ yudao-module-pms/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemMapper.java | 6 ++++-- yudao-module-pms/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemServiceImplTest.java | 18 ++++++++++++++---- 9 files changed, 297 insertions(+), 17 deletions(-) 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 d9f2d83..bf0dfd5 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 片段的方言工具类 + * 日期统计、逗号分隔列表关联、JSON 数组匹配、行数限制、分批删除 SQL 片段的方言工具类 * * 1. 每次生成 SQL 时,按当前执行时路由到的数据源选择方言(按数据源实例缓存),不使用启动时固定的 databaseId 或主库 * 2. 只有明确识别的 PostgreSQL、Oracle、SQL Server 2012+ 输出新片段;其它已识别的数据库(MySQL、MariaDB、OceanBase、H2、 @@ -451,6 +451,41 @@ public class SqlDialectUtils { } } + // ========== JSON 数组 ========== + + /** + * JSON 数组列是否包含 {0} 绑定的数值 ID,供 Wrapper 的 apply 使用,ID 必须作为 apply 的参数绑定 + * + * 1. 原有片段:wrapArray 为 false 时为 JSON_CONTAINS(column, {0}),参数为 ID 的数字文本; + * 为 true 时为 JSON_CONTAINS(column, JSON_ARRAY({0})),参数为 Long + * 2. PostgreSQL:CAST(column AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT)),兼容 jsonb、json 与 JSON 文本列; + * 按数字匹配,数组中的字符串 "1" 不匹配 1;列为 NULL 时不匹配 + * 3. Oracle、SQL Server 及其它数据库保持原有片段,是否可用未经验收 + * + * @param column JSON 数组列名,元素为数值 ID + * @param wrapArray 原有片段是否使用 JSON_ARRAY 包装参数 + * @return 包含 {0} 占位符的 SQL 条件片段 + */ + public static String jsonArrayContains(String column, boolean wrapArray) { + return jsonArrayContains(getDialect(), column, wrapArray); + } + + /** + * 按指定方言生成 JSON 数组包含数值 ID 的条件;仅 PostgreSQL 输出新片段。 + * + * @param dialect 已识别的数据库方言 + * @param column 代码中固定的 JSON 数组列名 + * @param wrapArray 原有片段是否使用 JSON_ARRAY 包装参数 + * @return 包含 {0} 占位符的 SQL 条件片段 + */ + static String jsonArrayContains(Dialect dialect, String column, boolean wrapArray) { + checkColumn(column); + if (dialect == Dialect.POSTGRESQL) { + return "CAST(" + column + " AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT))"; + } + return wrapArray ? "JSON_CONTAINS(" + column + ", JSON_ARRAY({0}))" : "JSON_CONTAINS(" + column + ", {0})"; + } + // ========== 行数限制 ========== /** 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 8453303..7f4a4ab 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 @@ -5,6 +5,7 @@ import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils.Dialect; import com.baomidou.dynamic.datasource.DynamicRoutingDataSource; import com.baomidou.dynamic.datasource.toolkit.DynamicDataSourceContextHolder; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import net.sf.jsqlparser.parser.CCJSqlParserUtil; import org.apache.ibatis.mapping.BoundSql; import org.apache.ibatis.mapping.ParameterMapping; @@ -27,6 +28,7 @@ import java.sql.Connection; import java.sql.DatabaseMetaData; import java.sql.SQLException; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; @@ -326,6 +328,104 @@ public class SqlDialectUtilsTest { } } + // ========== JSON 数组 ========== + + @Test + public void testJsonArrayContains() { + // 原有片段、Oracle、SQL Server:保持原有 JSON_CONTAINS + for (Dialect dialect : List.of(Dialect.ORIGINAL, Dialect.ORACLE, Dialect.SQLSERVER)) { + assertEquals("JSON_CONTAINS(booking_user_ids, {0})", + SqlDialectUtils.jsonArrayContains(dialect, "booking_user_ids", false), dialect.name()); + assertEquals("JSON_CONTAINS(t.label_ids, JSON_ARRAY({0}))", + SqlDialectUtils.jsonArrayContains(dialect, "t.label_ids", true), dialect.name()); + } + // PostgreSQL:两种原有片段统一为 jsonb 包含,参数按 BIGINT 匹配数字元素 + assertEquals("CAST(booking_user_ids AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT))", + SqlDialectUtils.jsonArrayContains(Dialect.POSTGRESQL, "booking_user_ids", false)); + assertEquals("CAST(t.label_ids AS jsonb) @> jsonb_build_array(CAST({0} AS BIGINT))", + SqlDialectUtils.jsonArrayContains(Dialect.POSTGRESQL, "t.label_ids", true)); + } + + @Test + public void testJsonArrayContains_invalidColumn() { + for (Dialect dialect : Dialect.values()) { + for (boolean wrapArray : new boolean[]{true, false}) { + assertThrows(IllegalArgumentException.class, + () -> SqlDialectUtils.jsonArrayContains(dialect, "label_ids, '[1]') OR (1=1", wrapArray)); + assertThrows(IllegalArgumentException.class, + () -> SqlDialectUtils.jsonArrayContains(dialect, "'[1, 2]'", wrapArray)); + assertThrows(IllegalArgumentException.class, + () -> SqlDialectUtils.jsonArrayContains(dialect, "label_ids;drop table t", wrapArray)); + assertThrows(IllegalArgumentException.class, + () -> SqlDialectUtils.jsonArrayContains(dialect, "", wrapArray)); + assertThrows(IllegalArgumentException.class, + () -> SqlDialectUtils.jsonArrayContains(dialect, null, wrapArray)); + } + } + } + + /** + * 与 PMS 工作项任一标签筛选相同的 OR 条件,所有方言均需可被 JSqlParser 解析 + */ + @Test + public void testJsonArrayContains_parsedByJSqlParser() throws Exception { + for (Dialect dialect : Dialect.values()) { + for (boolean wrapArray : new boolean[]{true, false}) { + String condition = SqlDialectUtils.jsonArrayContains(dialect, "label_ids", wrapArray).replace("{0}", "?"); + String sql = "SELECT id FROM pms_work_item WHERE project_id = ? AND (" + condition + " OR " + condition + ")"; + assertNotNull(CCJSqlParserUtil.parse(sql), dialect + " " + sql); + } + } + } + + /** + * 与 Mapper 相同:通过 MyBatis Plus Wrapper 的 apply 绑定参数,再由 MyBatis 生成 BoundSql + */ + @Test + public void testBoundSql_jsonArrayContains() 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)); + SqlSource sqlSource = new XMLLanguageDriver().createSqlSource(new Configuration(), + "", Map.class); + // 依次切换:主库 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()); + // PMS:多个标签按 OR 匹配任一标签,参数为 Long + String labelCondition = SqlDialectUtils.jsonArrayContains("label_ids", true); + QueryWrapper labelWrapper = new QueryWrapper<>(); + labelWrapper.eq("project_id", 100L).and(w -> w.or().apply(labelCondition, 1L).or().apply(labelCondition, 11L)); + BoundSql boundSql = sqlSource.getBoundSql(Map.of("ew", labelWrapper)); + String sql = normalize(boundSql.getSql()); + String expected = SqlDialectUtils.jsonArrayContains(route.getValue(), "label_ids", true).replace("{0}", "?"); + assertTrue(sql.startsWith("SELECT id FROM pms_work_item WHERE"), sql); + assertTrue(sql.contains(expected + " OR " + expected), sql); + assertFalse(sql.contains("11"), sql); // 值只通过参数绑定,不拼接到 SQL 中 + assertEquals(List.of(100L, 1L, 11L), getParameterValues(boundSql, labelWrapper)); + assertNotNull(CCJSqlParserUtil.parse(sql), route.getValue() + " " + sql); + + // OA:参数仍为 ID 的数字文本 + String bookingCondition = SqlDialectUtils.jsonArrayContains("booking_user_ids", false); + QueryWrapper bookingWrapper = new QueryWrapper<>(); + bookingWrapper.eq("booking_scope", 1).apply(bookingCondition, "1024"); + boundSql = sqlSource.getBoundSql(Map.of("ew", bookingWrapper)); + sql = normalize(boundSql.getSql()); + expected = SqlDialectUtils.jsonArrayContains(route.getValue(), "booking_user_ids", false).replace("{0}", "?"); + assertTrue(sql.contains(expected), sql); + assertFalse(sql.contains("1024"), sql); + assertEquals(List.of(1, "1024"), getParameterValues(boundSql, bookingWrapper)); + assertNotNull(CCJSqlParserUtil.parse(sql), route.getValue() + " " + sql); + } + } + // ========== 行数限制 ========== @Test @@ -740,6 +840,20 @@ public class SqlDialectUtilsTest { return sql.replaceAll("#\\{[^}]+}", "?"); } + /** + * 按 BoundSql 的参数顺序,取得 Wrapper 中绑定的参数值 + */ + private static List getParameterValues(BoundSql boundSql, QueryWrapper wrapper) { + String prefix = "ew.paramNameValuePairs."; + List values = new ArrayList<>(); + for (ParameterMapping parameterMapping : boundSql.getParameterMappings()) { + String property = parameterMapping.getProperty(); + assertTrue(property.startsWith(prefix), property); + values.add(wrapper.getParamNameValuePairs().get(property.substring(prefix.length()))); + } + return values; + } + private static void assertParameters(BoundSql boundSql, String... properties) { List parameterMappings = boundSql.getParameterMappings(); assertEquals(properties.length, parameterMappings.size()); diff --git a/yudao-module-fms/src/main/java/cn/iocoder/yudao/module/fms/controller/admin/ledger/FmsLedgerController.java b/yudao-module-fms/src/main/java/cn/iocoder/yudao/module/fms/controller/admin/ledger/FmsLedgerController.java index 5b606e4..ba2a133 100644 --- a/yudao-module-fms/src/main/java/cn/iocoder/yudao/module/fms/controller/admin/ledger/FmsLedgerController.java +++ b/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 import cn.iocoder.yudao.module.fms.controller.admin.ledger.vo.quantitydetail.FmsLedgerQuantityDetailRespVO; import cn.iocoder.yudao.module.fms.controller.admin.ledger.vo.quantitygeneral.FmsLedgerQuantityGeneralRespVO; import cn.iocoder.yudao.module.fms.controller.admin.ledger.vo.subjectbalance.FmsLedgerSubjectBalanceRespVO; +import cn.iocoder.yudao.module.fms.dal.dataobject.config.FmsSubjectDO; import cn.iocoder.yudao.module.fms.service.ledger.FmsLedgerService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -59,13 +60,13 @@ public class FmsLedgerController { @GetMapping("/detail/subject-list") @Operation(summary = "获得指定期间有发生额的明细账科目") public CommonResult> getDetailSubjectList(@Valid FmsLedgerListReqVO listReqVO) { - // TODO @AI:先查询出来,list 变量;然后在去 convert;字段不多,倒是可以考虑 beanutils 去 copy 下? - return success(convertList(ledgerService.getDetailSubjectList(listReqVO, getLoginUserId()), subject -> - new FmsSubjectRespVO().setId(subject.getId()).setParentId(subject.getParentId()) - .setCode(subject.getCode()).setName(subject.getName()).setType(subject.getType()) - .setBalanceDirection(subject.getBalanceDirection()).setStatus(subject.getStatus()) - .setLevel(subject.getLevel()).setAuxiliaryTypeIds(subject.getAuxiliaryTypeIds()) - .setQuantityAccounting(subject.getQuantityAccounting()).setQuantityUnit(subject.getQuantityUnit()))); + List list = ledgerService.getDetailSubjectList(listReqVO, getLoginUserId()); + return success(convertList(list, subject -> new FmsSubjectRespVO().setId(subject.getId()) + .setParentId(subject.getParentId()).setCode(subject.getCode()).setName(subject.getName()) + .setType(subject.getType()).setBalanceDirection(subject.getBalanceDirection()) + .setStatus(subject.getStatus()).setLevel(subject.getLevel()) + .setAuxiliaryTypeIds(subject.getAuxiliaryTypeIds()).setQuantityAccounting(subject.getQuantityAccounting()) + .setQuantityUnit(subject.getQuantityUnit()))); } @GetMapping("/detail/export-excel") diff --git a/yudao-module-fms/src/main/java/cn/iocoder/yudao/module/fms/service/ledger/FmsLedgerServiceImpl.java b/yudao-module-fms/src/main/java/cn/iocoder/yudao/module/fms/service/ledger/FmsLedgerServiceImpl.java index 15feee0..d3dbbee 100644 --- a/yudao-module-fms/src/main/java/cn/iocoder/yudao/module/fms/service/ledger/FmsLedgerServiceImpl.java +++ b/yudao-module-fms/src/main/java/cn/iocoder/yudao/module/fms/service/ledger/FmsLedgerServiceImpl.java @@ -183,12 +183,17 @@ public class FmsLedgerServiceImpl implements FmsLedgerService { if (!subjectIds.contains(subject.getId())) { continue; } - // TODO @AI:尽量不要 while true 死循环,而是有一个 for i 《 short 的上限》 + // 逐级向上补齐父级节点,最多 Short.MAX_VALUE 层;父级已补齐(含环形数据)或缺失时结束 Long parentId = subject.getParentId(); - while (parentId != null && visibleSubjectIds.add(parentId)) { + for (int i = 0; i < Short.MAX_VALUE && parentId != null && visibleSubjectIds.add(parentId); i++) { FmsSubjectDO parent = context.subjectMap.get(parentId); parentId = parent == null ? null : parent.getParentId(); } + // 达到上限后仍有未补齐的真实父级,不能静默丢弃;根哨兵或缺失父级沿用原退出语义 + if (parentId != null && !visibleSubjectIds.contains(parentId) + && context.subjectMap.containsKey(parentId)) { + throw new IllegalStateException("科目(" + subject.getId() + ") 的父级科目超过 " + Short.MAX_VALUE + " 层"); + } } return filterList(context.subjects, subject -> visibleSubjectIds.contains(subject.getId())); } diff --git a/yudao-module-fms/src/test/java/cn/iocoder/yudao/module/fms/service/ledger/FmsLedgerServiceImplTest.java b/yudao-module-fms/src/test/java/cn/iocoder/yudao/module/fms/service/ledger/FmsLedgerServiceImplTest.java index 7572a13..3b79842 100644 --- a/yudao-module-fms/src/test/java/cn/iocoder/yudao/module/fms/service/ledger/FmsLedgerServiceImplTest.java +++ b/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; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; import static cn.iocoder.yudao.module.fms.enums.ErrorCodeConstants.LEDGER_PERIOD_BEFORE_ACCOUNT_START; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.when; @Import(FmsLedgerServiceImpl.class) @@ -175,6 +178,69 @@ public class FmsLedgerServiceImplTest extends BaseDbUnitTest { } @Test + public void testGetDetailSubjectList_parentChain() { + // mock 数据:有发生额的末级科目补齐全部父级,无发生额的兄弟、其它科目不返回,且保持科目顺序 + FmsSubjectDO root = buildSubject(201L, "2001", "一级科目", null, 1); + FmsSubjectDO other = buildSubject(204L, "2002", "其它科目", null, 1); + FmsSubjectDO middle = buildSubject(202L, "200101", "二级科目", 201L, 2); + FmsSubjectDO sibling = buildSubject(205L, "200102", "兄弟科目", 201L, 2); + FmsSubjectDO leaf = buildSubject(203L, "20010101", "三级科目", 202L, 3); + when(subjectService.getSubjectList(1L, null, 10L)) + .thenReturn(Arrays.asList(root, other, middle, sibling, leaf)); + insertVoucherEntry(leaf.getId()); + + // 调用 + List result = ledgerService.getDetailSubjectList(buildListReqVO(), 10L); + + // 断言 + assertEquals(Arrays.asList(201L, 202L, 203L), convertList(result, FmsSubjectDO::getId)); + } + + @Test + public void testGetDetailSubjectList_parentMissingOrCycle() { + // mock 数据:父科目不存在时停止补齐;父子成环时不重复处理 + FmsSubjectDO orphan = buildSubject(301L, "3001", "缺父科目", 999L, 2); + FmsSubjectDO cycle01 = buildSubject(401L, "4001", "环形科目一", 402L, 1); + FmsSubjectDO cycle02 = buildSubject(402L, "4002", "环形科目二", 401L, 1); + when(subjectService.getSubjectList(1L, null, 10L)).thenReturn(Arrays.asList(orphan, cycle01, cycle02)); + insertVoucherEntry(orphan.getId()); + insertVoucherEntry(cycle01.getId()); + + // 调用 + List result = ledgerService.getDetailSubjectList(buildListReqVO(), 10L); + + // 断言 + assertEquals(Arrays.asList(301L, 401L, 402L), convertList(result, FmsSubjectDO::getId)); + } + + @Test + public void testGetDetailSubjectList_parentDepthLimit() { + // mock 数据:恰好 Short.MAX_VALUE 层父级,全部补齐 + List subjects = buildSubjectChain(100_000L, Short.MAX_VALUE); + subjects.get(0).setParentId(FmsSubjectDO.PARENT_ID_ROOT); + when(subjectService.getSubjectList(1L, null, 10L)).thenReturn(subjects); + insertVoucherEntry(100_000L); + + // 调用,并断言 + List result = ledgerService.getDetailSubjectList(buildListReqVO(), 10L); + assertEquals(Short.MAX_VALUE + 1, result.size()); + assertEquals(subjects.get(0).getId(), result.get(0).getId()); + + // 调用,并断言:空父级或缺失的根父级也按原语义退出,不把根哨兵当作额外一层 + subjects.get(0).setParentId(null); + assertEquals(Short.MAX_VALUE + 1, ledgerService.getDetailSubjectList(buildListReqVO(), 10L).size()); + subjects.get(0).setParentId(-1L); + assertEquals(Short.MAX_VALUE + 1, ledgerService.getDetailSubjectList(buildListReqVO(), 10L).size()); + + // mock 数据:超过 Short.MAX_VALUE 层父级时,明确失败而不是丢弃祖先科目 + when(subjectService.getSubjectList(1L, null, 10L)) + .thenReturn(buildSubjectChain(100_000L, Short.MAX_VALUE + 1)); + + // 调用,并断言 + assertThrows(IllegalStateException.class, () -> ledgerService.getDetailSubjectList(buildListReqVO(), 10L)); + } + + @Test public void testGetQuantityDetailList_filterNonQuantityDescendant() { // mock 数据:父科目启用数量核算,非数量子科目存在金额分录 cashSubject.setQuantityAccounting(true); @@ -721,6 +787,32 @@ public class FmsLedgerServiceImplTest extends BaseDbUnitTest { .name(customerItem.getName()).build()))); } + private void insertVoucherEntry(Long subjectId) { + FmsVoucherWordDO word = buildVoucherWord(); + voucherWordMapper.insert(word); + FmsVoucherDO voucher = buildVoucher(word.getId(), "10.00"); + voucherMapper.insert(voucher); + voucherEntryMapper.insert(new FmsVoucherEntryDO().setVoucherId(voucher.getId()) + .setAccountSetId(1L).setSubjectId(subjectId).setSubjectCode("TEST") + .setSubjectName("测试科目").setDigest("测试分录").setSort(1) + .setDebitAmount(new BigDecimal("10.00")).setCreditAmount(BigDecimal.ZERO) + .setQuantity(BigDecimal.ZERO).setUnitPrice(BigDecimal.ZERO) + .setAuxiliaries(Collections.emptyList())); + } + + /** + * 构建从根到末级的单链科目,末级科目编号为 leafId,共 parentCount 层父级 + */ + private static List buildSubjectChain(long leafId, int parentCount) { + List subjects = new ArrayList<>(parentCount + 1); + Long parentId = null; + for (long id = leafId - parentCount; id <= leafId; id++) { + subjects.add(new FmsSubjectDO().setId(id).setParentId(parentId)); + parentId = id; + } + return subjects; + } + private FmsSubjectDO buildSubject(Long id, String code, String name, Long parentId, Integer level) { return randomPojo(FmsSubjectDO.class, subject -> subject.setId(id) .setCode(code).setName(name).setParentId(parentId) diff --git a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/meetingroom/OaMeetingRoomMapper.java b/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/meetingroom/OaMeetingRoomMapper.java index 1c9b50f..83fdd82 100644 --- a/yudao-module-oa/src/main/java/cn/iocoder/yudao/module/oa/dal/mysql/meetingroom/OaMeetingRoomMapper.java +++ b/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; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils; import cn.iocoder.yudao.module.oa.controller.admin.meetingroom.vo.room.OaMeetingRoomPageReqVO; import cn.iocoder.yudao.module.oa.dal.dataobject.meetingroom.OaMeetingRoomDO; import cn.iocoder.yudao.module.oa.enums.meetingroom.OaMeetingRoomBookingScopeEnum; @@ -34,7 +35,7 @@ public interface OaMeetingRoomMapper extends BaseMapperX { .eq(OaMeetingRoomDO::getStatus, OaMeetingRoomStatusEnum.NORMAL.getStatus()) .and(scope -> scope.eq(OaMeetingRoomDO::getBookingScope, OaMeetingRoomBookingScopeEnum.ALL.getScope()) .or(specified -> specified.eq(OaMeetingRoomDO::getBookingScope, OaMeetingRoomBookingScopeEnum.SPECIFIED.getScope()) - .apply("JSON_CONTAINS(booking_user_ids, {0})", bookingUserId.toString()))); + .apply(SqlDialectUtils.jsonArrayContains("booking_user_ids", false), bookingUserId.toString()))); } return selectPage(reqVO, query); } diff --git a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomServiceImplTest.java b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomServiceImplTest.java index a63139e..fd0574b 100644 --- a/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomServiceImplTest.java +++ b/yudao-module-oa/src/test/java/cn/iocoder/yudao/module/oa/service/meetingroom/OaMeetingRoomServiceImplTest.java @@ -173,6 +173,26 @@ public class OaMeetingRoomServiceImplTest extends BaseDbUnitTest { } @Test + public void testGetMeetingRoomPageByBookingUserId_largeIdAndNull() { + // mock 数据:雪花编号只匹配完全相同的编号 + Long userId = 1872345678901234567L; + OaMeetingRoomDO matched = randomMeetingRoomDO().setBookingScope(1).setBookingUserIds(Arrays.asList(-1L, userId)); + meetingRoomMapper.insert(matched); + meetingRoomMapper.insert(randomMeetingRoomDO().setBookingScope(1) + .setBookingUserIds(Collections.singletonList(userId + 1))); + // mock 数据:指定范围的可预定人员为 NULL 时,不可预定 + meetingRoomMapper.insert(randomMeetingRoomDO().setBookingScope(1).setBookingUserIds(null)); + + // 调用 + PageResult page = meetingRoomService.getMeetingRoomPageByBookingUserId( + new OaMeetingRoomPageReqVO(), userId); + + // 断言 + assertEquals(1L, page.getTotal()); + assertEquals(Collections.singletonList(matched.getId()), convertList(page.getList(), OaMeetingRoomDO::getId)); + } + + @Test public void testGetMeetingRoomPageByBookingUserId_filter() { // mock 数据 OaMeetingRoomDO room = randomMeetingRoomDO().setName("项目会议室").setLocation("三楼"); diff --git a/yudao-module-pms/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemMapper.java b/yudao-module-pms/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemMapper.java index ad99272..68d422e 100644 --- a/yudao-module-pms/src/main/java/cn/iocoder/yudao/module/pms/dal/mysql/pm/workitem/PmsWorkItemMapper.java +++ b/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; import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.util.SqlDialectUtils; import cn.iocoder.yudao.module.pms.controller.admin.pm.workbench.vo.PmsWorkbenchPageReqVO; import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.workitem.PmsWorkItemPageReqVO; import cn.iocoder.yudao.module.pms.dal.dataobject.pm.workitem.PmsWorkItemDO; @@ -333,11 +334,12 @@ public interface PmsWorkItemMapper extends BaseMapperX { queryWrapper.and(wrapper -> wrapper.notIn(PmsWorkItemDO::getIterationId, pageReqVO.getExcludedIterationIds()).or().isNull(PmsWorkItemDO::getIterationId)); } - // label_ids 为 JSON 数组列,使用 MySQL JSON_CONTAINS 匹配元素,H2 单测不覆盖该分支 + // label_ids 为 JSON 数组列,匹配任一标签;PostgreSQL 使用 jsonb 包含,其它数据库保持 JSON_CONTAINS if (CollUtil.isNotEmpty(pageReqVO.getLabelIds())) { + String labelCondition = SqlDialectUtils.jsonArrayContains("label_ids", true); queryWrapper.and(labelWrapper -> { for (Long labelId : pageReqVO.getLabelIds()) { - labelWrapper.or().apply("JSON_CONTAINS(label_ids, JSON_ARRAY({0}))", labelId); + labelWrapper.or().apply(labelCondition, labelId); } }); } diff --git a/yudao-module-pms/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemServiceImplTest.java b/yudao-module-pms/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemServiceImplTest.java index ccb16ae..934fd31 100644 --- a/yudao-module-pms/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemServiceImplTest.java +++ b/yudao-module-pms/src/test/java/cn/iocoder/yudao/module/pms/service/pm/workitem/PmsWorkItemServiceImplTest.java @@ -1,7 +1,9 @@ package cn.iocoder.yudao.module.pms.service.pm.workitem; import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; import cn.iocoder.yudao.module.pms.controller.admin.pm.workitem.vo.status.PmsWorkItemStatusUpdateReqVO; 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 import static cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemActivityContentEnum.WORK_ITEM_CREATED; import static cn.iocoder.yudao.module.pms.enums.pm.workitem.PmsWorkItemActivityContentEnum.WORK_ITEM_NAME_UPDATED; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -943,15 +946,22 @@ public class PmsWorkItemServiceImplTest extends BaseDbUnitTest { @Test public void testBuildQueryWrapper_filterAnyLabel() { // 准备参数 + Long labelId01 = 1872345678901234567L; + Long labelId02 = 1872345678901234568L; PmsWorkItemPageReqVO reqVO = new PmsWorkItemPageReqVO().setProjectId(randomLongId()) - .setLabelIds(Arrays.asList(randomLongId(), randomLongId())); + .setLabelIds(Arrays.asList(labelId01, labelId02)); // 调用 - String sqlSegment = PmsWorkItemMapper.buildQueryWrapper(reqVO).getSqlSegment(); + LambdaQueryWrapperX queryWrapper = PmsWorkItemMapper.buildQueryWrapper(reqVO); + String sqlSegment = queryWrapper.getSqlSegment(); - // 断言 - assertTrue(sqlSegment.contains("JSON_CONTAINS(label_ids, JSON_ARRAY(")); + // 断言:H2 走原有片段,每个标签一个条件并按 OR 连接;标签编号仍以 Long 绑定,不拼接到 SQL 中 + assertEquals(2, StrUtil.count(sqlSegment, "JSON_CONTAINS(label_ids, JSON_ARRAY(")); assertTrue(sqlSegment.contains(" OR ")); + assertFalse(sqlSegment.contains(labelId01.toString())); + assertFalse(sqlSegment.contains(labelId02.toString())); + assertTrue(queryWrapper.getParamNameValuePairs().containsValue(labelId01)); + assertTrue(queryWrapper.getParamNameValuePairs().containsValue(labelId02)); } @Test -- libgit2 0.21.4