From 915edfdb9af8a91f735bd0dcc97f4a4f77066e86 Mon Sep 17 00:00:00 2001 From: luoweijian <1329394916@qq.com> Date: Tue, 1 Sep 2026 14:57:40 +0800 Subject: [PATCH] feat(opportunity-board): group board by stage display name, drop status filter - rule: validate display-name uniqueness within a template version on publish (64017) - rule: add describeStageNames / resolveStageIdsByDisplayName batch contracts - opportunity: board stops filtering by advancing(2), rows with current_stage_id go on board - opportunity: boardStageSummary groups by display name, ordered full/empty viewport columns - opportunity: boardCards takes stageName and returns oppStatus - tests: rule unit tests + opportunity service tests + H2 board integration test - docs: sync board Bruno collection (stageName param, oppStatus field, drop stageId) --- crm-opportunity/pom.xml | 6 + .../OpportunityCollabController.java | 6 +- .../query/OpportunityViewFilter.java | 13 +- .../service/IOpportunityCollabService.java | 15 +- .../impl/OpportunityCollabServiceImpl.java | 153 +++++-- .../impl/OpportunityBoardIntegrationTest.java | 385 ++++++++++++++++++ .../OpportunityCollabServiceImplTest.java | 282 +++++++++++++ .../IOpportunityStageTemplateService.java | 16 + .../OpportunityStageTemplateServiceImpl.java | 121 +++++- ...portunityStageTemplateServiceImplTest.java | 89 ++++ 10 files changed, 1041 insertions(+), 45 deletions(-) create mode 100644 crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityBoardIntegrationTest.java create mode 100644 crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCollabServiceImplTest.java diff --git a/crm-opportunity/pom.xml b/crm-opportunity/pom.xml index bf43010..48b4007 100644 --- a/crm-opportunity/pom.xml +++ b/crm-opportunity/pom.xml @@ -61,5 +61,11 @@ spring-boot-starter-test test + + + com.h2database + h2 + test + diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCollabController.java b/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCollabController.java index a920bd8..3f8a474 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCollabController.java +++ b/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCollabController.java @@ -72,15 +72,15 @@ public class OpportunityCollabController { return Result.success(collabService.boardStageSummary(viewType, stageTemplateId, userId)); } - @Operation(summary = "看板单阶段卡片列表(viewType 与列表视图同口径,票 06 D-15)", + @Operation(summary = "看板单阶段卡片列表(viewType 与列表视图同口径,票 06 D-15;入参 stageName 按展示名取卡)", tags = {"A3 商机管理/商机公海/看板列表", "A3 商机管理/商机管理/看板列表", "A3 商机管理/销售机会/看板列表"}) @PostMapping("/board/cards") public Result boardCards( @RequestParam(value = "viewType", required = false) String viewType, - @RequestParam("stageId") Long stageId, + @RequestParam("stageName") String stageName, @RequestParam(value = "offset", defaultValue = "0") int offset, @RequestParam(value = "limit", defaultValue = "20") int limit) { Long userId = Long.valueOf(SecurityUtils.getRequiredUserId()); - return Result.success(collabService.boardCards(viewType, stageId, offset, limit, userId)); + return Result.success(collabService.boardCards(viewType, stageName, offset, limit, userId)); } } diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/query/OpportunityViewFilter.java b/crm-opportunity/src/main/java/com/crm/opportunity/query/OpportunityViewFilter.java index 14470a7..4ae2476 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/query/OpportunityViewFilter.java +++ b/crm-opportunity/src/main/java/com/crm/opportunity/query/OpportunityViewFilter.java @@ -19,8 +19,7 @@ import org.springframework.stereotype.Component; *
  • PARTICIPATED — 团队成员且非负责人(与 MINE 互斥)
  • *
  • FOLLOWED — opportunity_focus 命中当前用户
  • *
  • RECENT — 无额外条件(列表侧最近访问走 pageRecent 独立路径取上限;看板侧退化为同基础集)
  • - *
  • PUBLIC_POOL — opp_status=待领取 且 owner 空(逻辑视图,票 05;status 条件由本分支给出, - * 调用方不再叠加基础状态条件)
  • + *
  • PUBLIC_POOL — opp_status=待领取 且 owner 空(逻辑视图,票 05;status 条件由本分支给出)
  • *
  • MANAGE — 无额外条件,@DataScope 兜底部门天花板
  • * */ @@ -28,8 +27,9 @@ import org.springframework.stereotype.Component; public class OpportunityViewFilter { /** - * 叠加视图数据集条件。调用方先按场景落基础条件(如看板的推进中状态、阶段锚点), - * 再调本方法;PUBLIC_POOL 视图下基础状态条件由本方法给出(调用方须跳过自己的状态叠加)。 + * 叠加视图数据集条件。调用方先按场景落基础条件(如看板的阶段锚点:current_stage_id 非空), + * 再调本方法。看板不再按 opp_status=推进中 过滤(ADR-0032:有阶段值即上板), + * 公海视图的 status 条件由本方法在 PUBLIC_POOL 分支给出。 */ public void apply(LambdaQueryWrapper wrapper, OpportunityViewType viewType, Long currentUserId) { @@ -55,9 +55,4 @@ public class OpportunityViewFilter { case MANAGE -> { /* @DataScope handles dept ceiling */ } } } - - /** PUBLIC_POOL 视图的基础状态由视图分支给出(调用方跳过自己的状态叠加),供调用方判断。 */ - public boolean providesStatusCondition(OpportunityViewType viewType) { - return viewType == OpportunityViewType.PUBLIC_POOL; - } } diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityCollabService.java b/crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityCollabService.java index 9a93306..73e8321 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityCollabService.java +++ b/crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityCollabService.java @@ -17,9 +17,18 @@ public interface IOpportunityCollabService { /** 访问上报(upsert last_view_time) */ void touch(Long oppId, Long userId); - /** 看板阶段汇总:List<{stageId, stageName, count, totalAmount}>;viewType 与列表视图同口径(票 06 D-15),空→MINE。 */ + /** + * 看板阶段汇总(票 04):List<{stageName, count, totalAmount}>,按当前节点展示名分组; + * 空列 count=0 也返回,列顺序由视口阶段模板决定(stageTemplateId 仅视口选择器,null → + * 当前用户部门绑定模板 → 全公司默认模板),视口外按各自模板 seq_no 追加列末,跨模板同名合并。 + * viewType 与列表视图同口径(票 06 D-15),空→MINE。 + */ List> boardStageSummary(String viewType, Long stageTemplateId, Long userId); - /** 看板单阶段卡片列表(分页 offset/limit);viewType 与列表视图同口径(票 06 D-15),空→MINE。 */ - List> boardCards(String viewType, Long stageId, int offset, int limit, Long userId); + /** + * 看板单阶段卡片列表(票 04,分页 offset/limit):入参 stageName 为展示名, + * 后端解析为「展示名相等的节点 id 集合」过滤(跨模板同名一次取全),卡片带 oppStatus。 + * viewType 与列表视图同口径(票 06 D-15),空→MINE。 + */ + List> boardCards(String viewType, String stageName, int offset, int limit, Long userId); } diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCollabServiceImpl.java b/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCollabServiceImpl.java index a465538..6f03e3b 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCollabServiceImpl.java +++ b/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCollabServiceImpl.java @@ -5,22 +5,33 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.crm.opportunity.domain.entity.Opportunity; import com.crm.opportunity.domain.entity.OpportunityFocus; import com.crm.opportunity.domain.entity.OpportunityViewLog; +import com.crm.opportunity.domain.enums.OpportunityViewType; import com.crm.opportunity.mapper.OpportunityFocusMapper; import com.crm.opportunity.mapper.OpportunityMapper; import com.crm.opportunity.mapper.OpportunityViewLogMapper; -import com.crm.opportunity.domain.enums.OpportunityStatus; -import com.crm.opportunity.domain.enums.OpportunityViewType; +import com.crm.opportunity.owner.OwnerSnapshot; +import com.crm.opportunity.owner.OwnerSnapshotResolver; import com.crm.opportunity.query.OpportunityViewFilter; import com.crm.opportunity.service.IOpportunityCollabService; +import com.crm.rule.domain.entity.OpportunityStageNode; +import com.crm.rule.domain.entity.OpportunityStageTemplate; +import com.crm.rule.service.IOpportunityStageTemplateService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; /** * 商机协作服务实现(票 04 整改)。 @@ -33,6 +44,8 @@ public class OpportunityCollabServiceImpl implements IOpportunityCollabService { private final OpportunityViewLogMapper viewLogMapper; private final OpportunityMapper oppMapper; private final OpportunityViewFilter viewFilter; + private final IOpportunityStageTemplateService stageTemplateService; + private final OwnerSnapshotResolver ownerSnapshotResolver; // ==================== 关注 ==================== @@ -84,40 +97,111 @@ public class OpportunityCollabServiceImpl implements IOpportunityCollabService { @Override public List> boardStageSummary(String viewType, Long stageTemplateId, Long userId) { - // 按 current_stage_id 分组统计:数量 + 金额汇总(D-15:数据集与 cards 同一视图过滤,同一口径) + // 票 03:看板基础数据集只锚定「current_stage_id 非空」+ 视图条件,不再按推进中(2)过滤。 LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); applyBoardViewFilter(wrapper, viewType, userId); - if (stageTemplateId != null) { - wrapper.eq(Opportunity::getStageTemplateId, stageTemplateId); - } List opps = oppMapper.selectList(wrapper - .select(Opportunity::getCurrentStageId, Opportunity::getProjectAmount)); + .select(Opportunity::getCurrentStageId, Opportunity::getStageTemplateId, + Opportunity::getProjectAmount)); + + // 票 04:stageTemplateId 纯视口选择器,只决定列顺序,不过滤数据集。 + Long viewportTemplateId = resolveViewportTemplateId(stageTemplateId, userId); + + // 数据集模板 + 视口模板统一批量取节点列表(避免逐商机 N+1) + Set templateIds = opps.stream() + .map(Opportunity::getStageTemplateId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (viewportTemplateId != null) { + templateIds.add(viewportTemplateId); + } + Map> nodesByTemplate = new LinkedHashMap<>(); + Set allNodeIds = new LinkedHashSet<>(); + for (Long templateId : templateIds) { + List nodes = stageTemplateService.listNodesOfVersion(templateId); + nodesByTemplate.put(templateId, nodes); + nodes.stream() + .map(OpportunityStageNode::getId) + .filter(Objects::nonNull) + .forEach(allNodeIds::add); + } - // 聚合 - Map> grouped = new java.util.LinkedHashMap<>(); + // 节点 id → 展示名(crm-rule 契约,票 02:customNodeName 优先、空则字典名) + Map nodeNames = stageTemplateService.describeStageNames(allNodeIds); + + // 展示名 → 最小 seq_no(视口外列排序键:撞名取最靠前位置) + Map minSeqByName = new HashMap<>(); + for (List nodes : nodesByTemplate.values()) { + for (OpportunityStageNode node : nodes) { + String name = nodeNames.get(node.getId()); + if (name != null && node.getSeqNo() != null) { + minSeqByName.merge(name, node.getSeqNo(), Math::min); + } + } + } + + // 视口列:按视口模板节点 seq_no 升序,展示名去重保序 + LinkedHashSet viewportColumns = new LinkedHashSet<>(); + if (viewportTemplateId != null) { + nodesByTemplate.getOrDefault(viewportTemplateId, List.of()).stream() + .sorted(Comparator.comparingInt(OpportunityStageNode::getSeqNo)) + .map(OpportunityStageNode::getId) + .map(nodeNames::get) + .filter(Objects::nonNull) + .forEach(viewportColumns::add); + } + + // 聚合:current_stage_id 非空即上板;展示名解析不到的脏数据退回节点 id 字符串,不丢商机 + Map countByName = new HashMap<>(); + Map totalAmountByName = new HashMap<>(); for (Opportunity opp : opps) { Long stageId = opp.getCurrentStageId(); - grouped.computeIfAbsent(stageId, k -> { - Map m = new HashMap<>(); - m.put("stageId", k); - m.put("count", 0); - m.put("totalAmount", java.math.BigDecimal.ZERO); - return m; - }); - Map m = grouped.get(stageId); - m.put("count", (int) m.get("count") + 1); + String stageName = nodeNames.get(stageId); + if (stageName == null) { + stageName = String.valueOf(stageId); + } + countByName.merge(stageName, 1, Integer::sum); if (opp.getProjectAmount() != null) { - m.put("totalAmount", ((java.math.BigDecimal) m.get("totalAmount")).add(opp.getProjectAmount())); + totalAmountByName.merge(stageName, opp.getProjectAmount(), BigDecimal::add); } } - return new ArrayList<>(grouped.values()); + + // 有序列:视口列(空列 count=0)→ 视口外按 min(seq_no) 升序、展示名字典序兜底 + List orderedColumns = new ArrayList<>(viewportColumns); + Set viewportSet = new LinkedHashSet<>(viewportColumns); + List outsideColumns = countByName.keySet().stream() + .filter(name -> !viewportSet.contains(name)) + .sorted(Comparator + .comparingInt((String name) -> minSeqByName.getOrDefault(name, Integer.MAX_VALUE)) + .thenComparing(Comparator.naturalOrder())) + .collect(Collectors.toList()); + orderedColumns.addAll(outsideColumns); + + List> result = new ArrayList<>(); + for (String stageName : orderedColumns) { + Map row = new LinkedHashMap<>(); + row.put("stageName", stageName); + row.put("count", countByName.getOrDefault(stageName, 0)); + row.put("totalAmount", totalAmountByName.getOrDefault(stageName, BigDecimal.ZERO)); + result.add(row); + } + return result; } @Override - public List> boardCards(String viewType, Long stageId, int offset, int limit, Long userId) { + public List> boardCards(String viewType, String stageName, int offset, int limit, Long userId) { + if (stageName == null || stageName.isBlank()) { + return List.of(); + } + // 票 04:stageName → 展示名相等的全部节点 id 集合(跨模板同名一次取全) + Set stageIds = stageTemplateService.resolveStageIdsByDisplayName(Set.of(stageName)) + .getOrDefault(stageName, Set.of()); + if (stageIds.isEmpty()) { + return List.of(); + } LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); applyBoardViewFilter(wrapper, viewType, userId); - wrapper.eq(Opportunity::getCurrentStageId, stageId) + wrapper.in(Opportunity::getCurrentStageId, stageIds) .last("LIMIT " + offset + "," + limit); List opps = oppMapper.selectList(wrapper); List> result = new ArrayList<>(); @@ -129,21 +213,34 @@ public class OpportunityCollabServiceImpl implements IOpportunityCollabService { card.put("ownerNameSnapshot", opp.getOwnerNameSnapshot()); card.put("projectAmount", opp.getProjectAmount()); card.put("currentStageId", opp.getCurrentStageId()); + card.put("oppStatus", opp.getOppStatus()); result.add(card); } return result; } /** - * D-15:看板基础数据集——视图条件统一走 OpportunityViewFilter(列表同一事实源); - * 非 PUBLIC_POOL 视图叠「推进中」基础状态(看板列 = 在办商机);PUBLIC_POOL 由视图分支 - * 给出公海数据集(status=待领取 + owner 空),调用方跳过状态叠加避免恒假与条件。 + * 票 03:看板基础数据集——视图条件统一走 {@link OpportunityViewFilter}(列表同一事实源); + * 阶段有值(current_stage_id 非空)即上板,不再叠加「推进中」状态过滤; + * PUBLIC_POOL 的 status=待领取 + owner 空 条件由视图分支给出。 */ private void applyBoardViewFilter(LambdaQueryWrapper wrapper, String viewType, Long userId) { OpportunityViewType view = OpportunityViewType.fromValue(viewType); - if (!viewFilter.providesStatusCondition(view)) { - wrapper.eq(Opportunity::getOppStatus, OpportunityStatus.STATUS_ADVANCING.getValue()); - } + wrapper.isNotNull(Opportunity::getCurrentStageId); viewFilter.apply(wrapper, view, userId); } + + /** + * 视口模板版本解析(票 04):显式 stageTemplateId 优先;null → 当前用户所属部门绑定模板 + * → 全公司通用默认模板。仅用于列排序,不用于数据集过滤。 + */ + private Long resolveViewportTemplateId(Long stageTemplateId, Long userId) { + if (stageTemplateId != null) { + return stageTemplateId; + } + OwnerSnapshot snapshot = ownerSnapshotResolver.of(userId); + OpportunityStageTemplate template = stageTemplateService.resolveBindingVersion( + snapshot == null ? null : snapshot.deptId()); + return template == null ? null : template.getId(); + } } diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityBoardIntegrationTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityBoardIntegrationTest.java new file mode 100644 index 0000000..e449a76 --- /dev/null +++ b/crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityBoardIntegrationTest.java @@ -0,0 +1,385 @@ +package com.crm.opportunity.service.impl; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator; +import com.baomidou.mybatisplus.core.toolkit.GlobalConfigUtils; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import com.crm.auth.service.ISysDeptService; +import com.crm.base.config.MetaObjectFillHandler; +import com.crm.base.domain.exception.BusinessErrorException; +import com.crm.dict.domain.dto.DictItemDTO; +import com.crm.dict.service.DictQueryService; +import com.crm.opportunity.domain.entity.Opportunity; +import com.crm.opportunity.mapper.OpportunityFocusMapper; +import com.crm.opportunity.mapper.OpportunityMapper; +import com.crm.opportunity.mapper.OpportunityViewLogMapper; +import com.crm.opportunity.owner.OwnerSnapshot; +import com.crm.opportunity.owner.OwnerSnapshotResolver; +import com.crm.opportunity.query.OpportunityViewFilter; +import com.crm.rule.constant.OpportunityRuleConstants; +import com.crm.rule.domain.dto.OpportunityStageTemplateDTO; +import com.crm.rule.domain.entity.OpportunityStageNode; +import com.crm.rule.domain.entity.OpportunityStageTemplate; +import com.crm.rule.domain.entity.OpportunityStageTemplateDept; +import com.crm.rule.mapper.OpportunityStageNodeMapper; +import com.crm.rule.mapper.OpportunityStageTemplateDeptMapper; +import com.crm.rule.mapper.OpportunityStageTemplateMapper; +import com.crm.rule.service.impl.OpportunityStageTemplateServiceImpl; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.LocalCacheScope; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactory; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.Statement; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.Mockito.when; + +/** + * 商机看板按展示名分列 H2 集成测试(票 05)。 + * + *

    真实 SQL 链路:{@link OpportunityMapper} + 真实 {@link OpportunityViewFilter} + 真实 + * {@link OpportunityStageTemplateServiceImpl}(节点/模板/部门子表均走 H2),仅字典与部门名查询、 + * 领取人快照解析用 mock。锁定 03/04 组合语义:

    + *
      + *
    • summary 按展示名分组,视口列按视口模板 seq_no 排序
    • + *
    • 跨模板同展示名合并为一列
    • + *
    • 视口空列以 count=0 返回
    • + *
    • 暂缓中/已关闭/已转项目上板,无阶段值(current_stage_id 为空)不上板
    • + *
    • cards 按 stageName 取卡且卡片带 oppStatus
    • + *
    + * + *

    固定数据集:

    + *
    + * 模板 T1(视口,发布中):N1 客户圈定(seq1) / N2 方案引导(seq2) / N3 已转项目(seq3,固定)
    + * 模板 T2(发布中):     N4 客户圈定(seq1,与 N1 同名) / N5 商务谈判(seq2)
    + * 商机:O1→N1 推进中 ¥100;O2→N2 暂缓中 ¥200;O3→N4 已关闭 ¥300;O4→N5 已转项目 ¥400;
    + *       O5→无阶段值 推进中 ¥500(不上板)
    + * 
    + */ +@DisplayName("商机看板:按展示名分列(H2 集成,票 05)") +class OpportunityBoardIntegrationTest { + + private static final Long USER_ID = 5001L; + private static final Long DEPT_ID = 1001L; + + private static final Long T1 = 9001L; + private static final Long T2 = 9002L; + private static final Long N1 = 1001L; + private static final Long N2 = 1002L; + private static final Long N3 = 1003L; + private static final Long N4 = 2001L; + private static final Long N5 = 2002L; + + private static final AtomicBoolean BOOTSTRAPPED = new AtomicBoolean(false); + private static SqlSessionFactory sqlSessionFactory; + + private SqlSession session; + private OpportunityCollabServiceImpl boardService; + private OpportunityStageTemplateServiceImpl ruleService; + + private final DictQueryService dictQueryService = Mockito.mock(DictQueryService.class); + private final ISysDeptService sysDeptService = Mockito.mock(ISysDeptService.class); + private final OwnerSnapshotResolver ownerSnapshotResolver = Mockito.mock(OwnerSnapshotResolver.class); + + @BeforeAll + static void bootstrap() throws Exception { + if (!BOOTSTRAPPED.compareAndSet(false, true)) { + return; + } + JdbcDataSource ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:oppboard;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;" + + "DB_CLOSE_DELAY=-1"); + + MybatisConfiguration configuration = new MybatisConfiguration(); + configuration.setEnvironment(new Environment("oppboard-test", new JdbcTransactionFactory(), ds)); + configuration.setMapUnderscoreToCamelCase(true); + configuration.setLocalCacheScope(LocalCacheScope.STATEMENT); + + MybatisPlusInterceptor mpInterceptor = new MybatisPlusInterceptor(); + mpInterceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); + configuration.addInterceptor(mpInterceptor); + + GlobalConfigUtils.getGlobalConfig(configuration).setMetaObjectHandler(new MetaObjectFillHandler()); + GlobalConfigUtils.getGlobalConfig(configuration).setIdentifierGenerator(DefaultIdentifierGenerator.getInstance()); + + configuration.addMapper(OpportunityMapper.class); + configuration.addMapper(OpportunityFocusMapper.class); + configuration.addMapper(OpportunityViewLogMapper.class); + configuration.addMapper(OpportunityStageTemplateMapper.class); + configuration.addMapper(OpportunityStageNodeMapper.class); + configuration.addMapper(OpportunityStageTemplateDeptMapper.class); + sqlSessionFactory = new SqlSessionFactoryBuilder().build(configuration); + + try (SqlSession s = sqlSessionFactory.openSession(true)) { + Connection conn = s.getConnection(); + try (Statement st = conn.createStatement()) { + for (String sql : SCHEMA) { + st.execute(sql); + } + } + } + } + + @BeforeEach + void openSession() throws Exception { + session = sqlSessionFactory.openSession(true); + try (Statement st = session.getConnection().createStatement()) { + for (String sql : RESET) { + st.execute(sql); + } + for (String sql : SEED) { + st.execute(sql); + } + } + + OpportunityStageTemplateMapper templateMapper = session.getMapper(OpportunityStageTemplateMapper.class); + OpportunityStageNodeMapper nodeMapper = session.getMapper(OpportunityStageNodeMapper.class); + OpportunityStageTemplateDeptMapper deptMapper = session.getMapper(OpportunityStageTemplateDeptMapper.class); + + ruleService = new OpportunityStageTemplateServiceImpl(deptMapper, nodeMapper, sysDeptService, dictQueryService); + ReflectionTestUtils.setField(ruleService, "baseMapper", templateMapper); + ReflectionTestUtils.setField(ruleService, "entityClass", OpportunityStageTemplate.class); + + // 看板测试节点均带 custom_node_name,describeStageNames 不触达字典名回退;空集返回空 Map。 + when(dictQueryService.getNames(any(), anySet())).thenReturn(Map.of()); + when(ownerSnapshotResolver.of(USER_ID)).thenReturn(new OwnerSnapshot(USER_ID, "测试用户", DEPT_ID)); + + OpportunityMapper oppMapper = session.getMapper(OpportunityMapper.class); + boardService = new OpportunityCollabServiceImpl( + session.getMapper(OpportunityFocusMapper.class), + session.getMapper(OpportunityViewLogMapper.class), + oppMapper, + new OpportunityViewFilter(), + ruleService, + ownerSnapshotResolver); + } + + @AfterEach + void closeSession() { + if (session != null) { + session.close(); + } + } + + @Test + @DisplayName("summary:按展示名分组、视口列按序、视口外追加、跨状态上板、无阶段值不上板") + void summary_groupsByDisplayName_ordersByViewport() { + List> rows = boardService.boardStageSummary("MANAGE", T1, USER_ID); + + assertThat(rows).hasSize(4); + + // 视口列顺序 = T1 节点 seq:客户圈定 → 方案引导 → 已转项目(空列 count=0) + Map col1 = rows.get(0); + assertThat(col1.get("stageName")).isEqualTo("客户圈定"); + // 跨模板同名合并:O1(推进中 ¥100) + O3(已关闭 ¥300) + assertThat(col1.get("count")).isEqualTo(2); + assertThat((BigDecimal) col1.get("totalAmount")).isEqualByComparingTo(new BigDecimal("400")); + + Map col2 = rows.get(1); + assertThat(col2.get("stageName")).isEqualTo("方案引导"); + // O2 暂缓中 ¥200:暂缓中也上板 + assertThat(col2.get("count")).isEqualTo(1); + assertThat((BigDecimal) col2.get("totalAmount")).isEqualByComparingTo(new BigDecimal("200")); + + Map col3 = rows.get(2); + assertThat(col3.get("stageName")).isEqualTo("已转项目"); + assertThat(col3.get("count")).isEqualTo(0); + assertThat((BigDecimal) col3.get("totalAmount")).isEqualByComparingTo(new BigDecimal("0")); + + // 视口外列追加:商务谈判(O4 已转项目 ¥400) + Map col4 = rows.get(3); + assertThat(col4.get("stageName")).isEqualTo("商务谈判"); + assertThat(col4.get("count")).isEqualTo(1); + assertThat((BigDecimal) col4.get("totalAmount")).isEqualByComparingTo(new BigDecimal("400")); + } + + @Test + @DisplayName("summary:O5 无阶段值不上板(列计数不含 null current_stage_id 行)") + void summary_nullStage_notOnBoard() { + List> rows = boardService.boardStageSummary("MANAGE", T1, USER_ID); + + int totalCount = rows.stream() + .mapToInt(row -> ((Number) row.get("count")).intValue()) + .sum(); + // 仅 O1..O4 四行上板;O5(current_stage_id=null) 被排除 + assertThat(totalCount).isEqualTo(4); + } + + @Test + @DisplayName("summary:无显式视口时回退当前用户部门绑定模板(resolveBindingVersion → 部门绑定优先)") + void summary_nullViewport_resolvesDeptBindingTemplate() { + // T1 未绑定部门(apply_scope=1 全公司默认);T2 绑定 DEPT_ID(apply_scope=2 部门专用) + session.getMapper(OpportunityStageTemplateDeptMapper.class) + .insert(deptRow(T2, DEPT_ID)); + + List> rows = boardService.boardStageSummary("MANAGE", null, USER_ID); + + // 视口 = T2:客户圈定(seq1) → 商务谈判(seq2);视口外 = T1 的方案引导(已转项目无商机命中,不出现) + assertThat(rows).extracting(row -> row.get("stageName")) + .containsExactly("客户圈定", "商务谈判", "方案引导"); + } + + @Test + @DisplayName("cards:按 stageName 取卡(跨模板同名一次取全),卡片带 oppStatus") + void cards_byStageName_withOppStatus() { + List> cards = boardService.boardCards("MANAGE", "客户圈定", 0, 10, USER_ID); + + assertThat(cards).hasSize(2); + assertThat(cards).extracting(card -> card.get("id")).containsExactlyInAnyOrder(1L, 3L); + // 每张卡携带 oppStatus:O1=2 推进中、O3=4 已关闭 + assertThat(cards).extracting(card -> card.get("oppStatus")) + .containsExactlyInAnyOrder(2, 4); + assertThat(cards).allSatisfy(card -> { + assertThat(card).containsKeys("id", "oppName", "ownerUserId", "projectAmount", + "currentStageId", "oppStatus"); + }); + } + + @Test + @DisplayName("cards:空白 stageName 或未解析到的展示名 → 空列表") + void cards_blankOrUnknownStageName_empty() { + assertThat(boardService.boardCards("MANAGE", " ", 0, 10, USER_ID)).isEmpty(); + assertThat(boardService.boardCards("MANAGE", "不存在的阶段", 0, 10, USER_ID)).isEmpty(); + } + + @Test + @DisplayName("阶段模板发布:同版本内展示名重复(custom 撞 custom)→ 拒绝 64017") + void publish_duplicateDisplayName_rejected() { + // 启用字典项桩:校验先过字典状态关,才能触达展示名唯一性校验 + DictItemDTO enabled01 = new DictItemDTO(); + enabled01.setCode("OPP_STAGE_01"); + DictItemDTO enabled02 = new DictItemDTO(); + enabled02.setCode("OPP_STAGE_02"); + when(dictQueryService.listEnabledItems(OpportunityRuleConstants.STAGE_DICT_GROUP_CODE)) + .thenReturn(List.of(enabled01, enabled02)); + + OpportunityStageTemplateDTO dto = new OpportunityStageTemplateDTO(); + dto.setTemplateName("重复展示名模板"); + dto.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_ALL); + dto.setIsDefault(OpportunityRuleConstants.FLAG_NO); + dto.setNodes(List.of(nodeItem("OPP_STAGE_01", "客户圈定"), + nodeItem("OPP_STAGE_02", "客户圈定"))); + + assertThatThrownBy(() -> ruleService.saveAndPublish(dto)) + .isInstanceOf(BusinessErrorException.class) + .hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_NODE_INVALID) + .hasMessageContaining("展示名重复"); + } + + // ==================== 数据工厂 ==================== + + private OpportunityStageTemplateDTO.NodeItem nodeItem(String dictCode, String customName) { + OpportunityStageTemplateDTO.NodeItem item = new OpportunityStageTemplateDTO.NodeItem(); + item.setStageDictCode(dictCode); + item.setCustomNodeName(customName); + return item; + } + + private OpportunityStageTemplateDept deptRow(Long templateVersionId, Long deptId) { + OpportunityStageTemplateDept d = new OpportunityStageTemplateDept(); + d.setTemplateVersionId(templateVersionId); + d.setDeptId(deptId); + return d; + } + + // ==================== 建表 DDL ==================== + + private static final String[] SCHEMA = { + """ + CREATE TABLE opportunity ( + id BIGINT PRIMARY KEY, creator_id VARCHAR(50), create_time DATETIME, + updater_id VARCHAR(50), update_time DATETIME, deleted TINYINT NOT NULL DEFAULT 0, + opp_name VARCHAR(200), opp_source VARCHAR(64), opp_type VARCHAR(64), + industry_code VARCHAR(64), bid_form VARCHAR(64), locality_type VARCHAR(64), + party_a_clear TINYINT, party_a VARCHAR(200), province_code VARCHAR(12), + city_code VARCHAR(12), remark TEXT, project_amount DECIMAL(14,2), address VARCHAR(255), + owner_user_id BIGINT, owner_name_snapshot VARCHAR(64), owner_dept_id BIGINT, + origin_dept_id BIGINT, pool_reason VARCHAR(32), creator_user_id BIGINT, + opp_status TINYINT, current_stage_id BIGINT, stage_template_id BIGINT, + stage_template_version VARCHAR(20), source_lead_id BIGINT, source_lead_name VARCHAR(200), + source_phone VARCHAR(32), source_product_code VARCHAR(64), claim_time DATETIME, + last_valid_follow_time DATETIME, primary_customer_id BIGINT, + primary_customer_name_snapshot VARCHAR(200), key_contact_id BIGINT, + key_contact_name_snapshot VARCHAR(200), key_contact_company_snapshot VARCHAR(200), + scheme_budget DECIMAL(18,2), scheme_card_status INT, pause_time DATETIME, + pause_reason VARCHAR(32), pause_expected_restart_date DATE, pause_remark VARCHAR(500), + version INT NOT NULL DEFAULT 0) + """, + """ + CREATE TABLE opportunity_stage_template ( + id BIGINT PRIMARY KEY, creator_id VARCHAR(50), create_time DATETIME, + updater_id VARCHAR(50), update_time DATETIME, deleted TINYINT NOT NULL DEFAULT 0, + template_code VARCHAR(64), template_name VARCHAR(100), version_no VARCHAR(20), + status TINYINT, apply_scope TINYINT, is_default TINYINT DEFAULT 0, + template_desc VARCHAR(500)) + """, + """ + CREATE TABLE opportunity_stage_node ( + id BIGINT PRIMARY KEY, template_id BIGINT, seq_no INT, stage_dict_code VARCHAR(64), + custom_node_name VARCHAR(100), work_goal VARCHAR(500), is_fixed TINYINT DEFAULT 0) + """, + """ + CREATE TABLE opportunity_stage_template_dept ( + id BIGINT PRIMARY KEY, template_version_id BIGINT, dept_id BIGINT, + CONSTRAINT uk_ost_version_dept UNIQUE (template_version_id, dept_id)) + """, + }; + + private static final String[] RESET = { + "DELETE FROM opportunity", + "DELETE FROM opportunity_stage_node", + "DELETE FROM opportunity_stage_template", + "DELETE FROM opportunity_stage_template_dept", + }; + + private static final String[] SEED = { + // 模板:T1 全公司默认(视口),T2 部门专用 + "INSERT INTO opportunity_stage_template (id, deleted, template_code, template_name, version_no, status, apply_scope, is_default) " + + "VALUES (" + T1 + ", 0, 'OPP_STAGE_TPL_01', '通用阶段模板', 'V1.0', 2, 1, 1)", + "INSERT INTO opportunity_stage_template (id, deleted, template_code, template_name, version_no, status, apply_scope, is_default) " + + "VALUES (" + T2 + ", 0, 'OPP_STAGE_TPL_02', '华东阶段模板', 'V1.0', 2, 2, 0)", + // 节点:T1 = 客户圈定/方案引导/已转项目(固定);T2 = 客户圈定(同名)/商务谈判 + "INSERT INTO opportunity_stage_node (id, template_id, seq_no, stage_dict_code, custom_node_name, is_fixed) " + + "VALUES (" + N1 + ", " + T1 + ", 1, 'OPP_STAGE_01', '客户圈定', 0)", + "INSERT INTO opportunity_stage_node (id, template_id, seq_no, stage_dict_code, custom_node_name, is_fixed) " + + "VALUES (" + N2 + ", " + T1 + ", 2, 'OPP_STAGE_02', '方案引导', 0)", + "INSERT INTO opportunity_stage_node (id, template_id, seq_no, stage_dict_code, custom_node_name, is_fixed) " + + "VALUES (" + N3 + ", " + T1 + ", 3, 'OPP_STAGE_05', '已转项目', 1)", + "INSERT INTO opportunity_stage_node (id, template_id, seq_no, stage_dict_code, custom_node_name, is_fixed) " + + "VALUES (" + N4 + ", " + T2 + ", 1, 'OPP_STAGE_01', '客户圈定', 0)", + "INSERT INTO opportunity_stage_node (id, template_id, seq_no, stage_dict_code, custom_node_name, is_fixed) " + + "VALUES (" + N5 + ", " + T2 + ", 2, 'OPP_STAGE_03', '商务谈判', 0)", + // 商机:O1 推进中→N1;O2 暂缓中→N2;O3 已关闭→N4;O4 已转项目→N5;O5 无阶段值(不上板) + "INSERT INTO opportunity (id, deleted, opp_name, project_amount, opp_status, current_stage_id, stage_template_id, stage_template_version, version) " + + "VALUES (1, 0, '商机A', 100, 2, " + N1 + ", " + T1 + ", 'V1.0', 0)", + "INSERT INTO opportunity (id, deleted, opp_name, project_amount, opp_status, current_stage_id, stage_template_id, stage_template_version, version) " + + "VALUES (2, 0, '商机B', 200, 3, " + N2 + ", " + T1 + ", 'V1.0', 0)", + "INSERT INTO opportunity (id, deleted, opp_name, project_amount, opp_status, current_stage_id, stage_template_id, stage_template_version, version) " + + "VALUES (3, 0, '商机C', 300, 4, " + N4 + ", " + T2 + ", 'V1.0', 0)", + "INSERT INTO opportunity (id, deleted, opp_name, project_amount, opp_status, current_stage_id, stage_template_id, stage_template_version, version) " + + "VALUES (4, 0, '商机D', 400, 5, " + N5 + ", " + T2 + ", 'V1.0', 0)", + "INSERT INTO opportunity (id, deleted, opp_name, project_amount, opp_status, current_stage_id, stage_template_id, stage_template_version, version) " + + "VALUES (5, 0, '商机E', 500, 2, NULL, NULL, NULL, 0)", + }; +} diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCollabServiceImplTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCollabServiceImplTest.java new file mode 100644 index 0000000..01b089e --- /dev/null +++ b/crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCollabServiceImplTest.java @@ -0,0 +1,282 @@ +package com.crm.opportunity.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.crm.opportunity.domain.entity.Opportunity; +import com.crm.opportunity.mapper.OpportunityFocusMapper; +import com.crm.opportunity.mapper.OpportunityMapper; +import com.crm.opportunity.mapper.OpportunityViewLogMapper; +import com.crm.opportunity.owner.OwnerSnapshot; +import com.crm.opportunity.owner.OwnerSnapshotResolver; +import com.crm.opportunity.query.OpportunityViewFilter; +import com.crm.rule.domain.entity.OpportunityStageNode; +import com.crm.rule.domain.entity.OpportunityStageTemplate; +import com.crm.rule.service.IOpportunityStageTemplateService; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 看板两端点规格验证(票 03 / 票 04)。 + * + *

    断言点:

    + *
      + *
    • 基础数据集不再按 opp_status=推进中 过滤,只锚定 current_stage_id 非空;公海视图的 + * 待领取 + owner 空条件不变
    • + *
    • summary 按展示名分组、跨模板同名合并、视口列顺序 + 空列 count=0、视口外按 seq_no 追加
    • + *
    • stageTemplateId 仅决定列顺序,不过滤数据集
    • + *
    • cards 入参 stageName 解析成节点 id 集合过滤,卡片带 oppStatus
    • + *
    + */ +@DisplayName("商机看板(票 03/04)") +@ExtendWith(MockitoExtension.class) +class OpportunityCollabServiceImplTest { + + private static final Long USER_ID = 1001L; + private static final Long DEPT_ID = 5001L; + private static final Long VIEWPORT_TPL_ID = 9001L; + private static final Long OTHER_TPL_ID = 9002L; + + @Mock private OpportunityFocusMapper focusMapper; + @Mock private OpportunityViewLogMapper viewLogMapper; + @Mock private OpportunityMapper oppMapper; + @Spy private OpportunityViewFilter viewFilter = new OpportunityViewFilter(); + @Mock private IOpportunityStageTemplateService stageTemplateService; + @Mock private OwnerSnapshotResolver ownerSnapshotResolver; + + @InjectMocks + private OpportunityCollabServiceImpl service; + + @BeforeAll + static void initLambdaCache() { + MapperBuilderAssistant assistant = + new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, Opportunity.class); + } + + @BeforeEach + void setUp() { + lenient().when(stageTemplateService.listNodesOfVersion(any())).thenReturn(List.of()); + lenient().when(stageTemplateService.describeStageNames(any())).thenReturn(Map.of()); + lenient().when(stageTemplateService.resolveStageIdsByDisplayName(any())).thenReturn(Map.of()); + lenient().when(ownerSnapshotResolver.of(any())).thenReturn(new OwnerSnapshot(USER_ID, "u", DEPT_ID)); + } + + // ==================== 工厂 ==================== + + private Opportunity opp(Long id, Long stageId, Long tplId, Integer status, String amount) { + Opportunity o = new Opportunity(); + o.setId(id); + o.setOppName("商机" + id); + o.setCurrentStageId(stageId); + o.setStageTemplateId(tplId); + o.setOppStatus(status); + o.setProjectAmount(amount == null ? null : new BigDecimal(amount)); + return o; + } + + private OpportunityStageNode node(Long id, Long tplId, int seqNo, String displayName) { + OpportunityStageNode n = new OpportunityStageNode(); + n.setId(id); + n.setTemplateId(tplId); + n.setSeqNo(seqNo); + n.setCustomNodeName(displayName); + return n; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private String captureSummarySql(String viewType, Long stageTemplateId) { + when(oppMapper.selectList(any())).thenReturn(List.of()); + service.boardStageSummary(viewType, stageTemplateId, USER_ID); + ArgumentCaptor captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(oppMapper).selectList(captor.capture()); + return captor.getValue().getSqlSegment(); + } + + // ==================== 票 03:基础数据集口径 ==================== + + @Test + @DisplayName("MINE 看板:不再叠加 opp_status=推进中,只锚定 current_stage_id 非空 + 视图条件") + void summary_mineDropsAdvancingStatusFilter() { + String sql = captureSummarySql("MINE", VIEWPORT_TPL_ID); + + assertThat(sql).contains("current_stage_id IS NOT NULL"); + assertThat(sql).doesNotContain("opp_status"); + assertThat(sql).contains("owner_user_id"); + } + + @Test + @DisplayName("PUBLIC_POOL 看板:待领取 + owner 空条件不变,且同样锚定阶段非空") + void summary_publicPoolKeepsPoolDefinition() { + String sql = captureSummarySql("PUBLIC_POOL", VIEWPORT_TPL_ID); + + assertThat(sql).contains("current_stage_id IS NOT NULL"); + assertThat(sql).contains("opp_status"); + assertThat(sql).contains("owner_user_id IS NULL"); + } + + // ==================== 票 04:summary 按展示名分列 ==================== + + @Test + @DisplayName("summary:跨模板同名合并为一列,视口列顺序由视口模板 seq_no 决定") + void summary_groupsByDisplayNameAndMergesCrossTemplate() { + when(stageTemplateService.listNodesOfVersion(VIEWPORT_TPL_ID)) + .thenReturn(List.of(node(11L, VIEWPORT_TPL_ID, 1, "关系摸排"), + node(12L, VIEWPORT_TPL_ID, 2, "方案引导"))); + when(stageTemplateService.listNodesOfVersion(OTHER_TPL_ID)) + .thenReturn(List.of(node(21L, OTHER_TPL_ID, 1, "关系摸排"), + node(22L, OTHER_TPL_ID, 2, "方案引导"))); + when(stageTemplateService.describeStageNames(any())).thenReturn(Map.of( + 11L, "关系摸排", 12L, "方案引导", 21L, "关系摸排", 22L, "方案引导")); + when(oppMapper.selectList(any())).thenReturn(List.of( + opp(1L, 11L, VIEWPORT_TPL_ID, 2, "10"), + opp(2L, 21L, OTHER_TPL_ID, 3, "20"), + opp(3L, 22L, OTHER_TPL_ID, 4, "5"))); + + List> result = service.boardStageSummary("MINE", VIEWPORT_TPL_ID, USER_ID); + + assertThat(result).hasSize(2); + assertThat(result.get(0)) + .containsEntry("stageName", "关系摸排") + .containsEntry("count", 2) + .containsEntry("totalAmount", new BigDecimal("30")); + assertThat(result.get(1)) + .containsEntry("stageName", "方案引导") + .containsEntry("count", 1) + .containsEntry("totalAmount", new BigDecimal("5")); + } + + @Test + @DisplayName("summary:空列 count=0 返回;视口外展示名按各自模板 seq_no 追加列末") + void summary_emptyColumnsReturnedAndOutsideAppendedBySeq() { + when(stageTemplateService.listNodesOfVersion(VIEWPORT_TPL_ID)) + .thenReturn(List.of(node(11L, VIEWPORT_TPL_ID, 1, "关系摸排"), + node(12L, VIEWPORT_TPL_ID, 2, "已转项目"))); + when(stageTemplateService.listNodesOfVersion(OTHER_TPL_ID)) + .thenReturn(List.of(node(31L, OTHER_TPL_ID, 3, "方案引导"))); + when(stageTemplateService.describeStageNames(any())).thenReturn(Map.of( + 11L, "关系摸排", 12L, "已转项目", 31L, "方案引导")); + when(oppMapper.selectList(any())).thenReturn(List.of( + opp(1L, 31L, OTHER_TPL_ID, 5, "7"))); + + List> result = service.boardStageSummary("MINE", VIEWPORT_TPL_ID, USER_ID); + + assertThat(result).hasSize(3); + assertThat(result.get(0)).containsEntry("stageName", "关系摸排").containsEntry("count", 0); + assertThat(result.get(1)).containsEntry("stageName", "已转项目").containsEntry("count", 0); + assertThat(result.get(2)).containsEntry("stageName", "方案引导").containsEntry("count", 1); + } + + @Test + @DisplayName("summary:stageTemplateId 只决定列顺序,不过滤其他模板商机上板") + void summary_viewportTemplateDoesNotFilterDataset() { + when(stageTemplateService.listNodesOfVersion(VIEWPORT_TPL_ID)) + .thenReturn(List.of(node(11L, VIEWPORT_TPL_ID, 1, "关系摸排"))); + when(stageTemplateService.listNodesOfVersion(OTHER_TPL_ID)) + .thenReturn(List.of(node(31L, OTHER_TPL_ID, 5, "方案引导"))); + when(stageTemplateService.describeStageNames(any())).thenReturn(Map.of( + 11L, "关系摸排", 31L, "方案引导")); + // 商机在 OTHER_TPL_ID(非视口模板),仍应上板 + when(oppMapper.selectList(any())).thenReturn(List.of( + opp(1L, 31L, OTHER_TPL_ID, 3, "9"))); + + List> result = service.boardStageSummary("MINE", VIEWPORT_TPL_ID, USER_ID); + + assertThat(result).extracting(row -> row.get("stageName")) + .containsExactly("关系摸排", "方案引导"); + assertThat(result.get(1)).containsEntry("count", 1); + } + + @Test + @DisplayName("summary:stageTemplateId 为空 → 当前用户部门绑定模板 → 全公司默认模板解析视口") + void summary_nullStageTemplateIdResolvesViewportFromUserDept() { + OpportunityStageTemplate bound = new OpportunityStageTemplate(); + bound.setId(VIEWPORT_TPL_ID); + when(ownerSnapshotResolver.of(USER_ID)).thenReturn(new OwnerSnapshot(USER_ID, "u", DEPT_ID)); + when(stageTemplateService.resolveBindingVersion(DEPT_ID)).thenReturn(bound); + when(stageTemplateService.listNodesOfVersion(VIEWPORT_TPL_ID)) + .thenReturn(List.of(node(11L, VIEWPORT_TPL_ID, 1, "关系摸排"), + node(12L, VIEWPORT_TPL_ID, 2, "方案引导"))); + when(stageTemplateService.describeStageNames(any())).thenReturn(Map.of( + 11L, "关系摸排", 12L, "方案引导")); + when(oppMapper.selectList(any())).thenReturn(List.of()); + + List> result = service.boardStageSummary("MINE", null, USER_ID); + + assertThat(result).extracting(row -> row.get("stageName")) + .containsExactly("关系摸排", "方案引导"); + verify(stageTemplateService).resolveBindingVersion(DEPT_ID); + } + + // ==================== 票 04:cards 按展示名取卡 ==================== + + @Test + @DisplayName("cards:stageName 解析成节点 id 集合过滤(跨模板同名一次取全),卡片带 oppStatus") + void cards_resolvesStageNameAndCarriesOppStatus() { + when(stageTemplateService.resolveStageIdsByDisplayName(java.util.Set.of("关系摸排"))) + .thenReturn(Map.of("关系摸排", java.util.Set.of(11L, 21L))); + when(oppMapper.selectList(any())).thenReturn(List.of( + opp(1L, 11L, VIEWPORT_TPL_ID, 3, "10"), + opp(2L, 21L, OTHER_TPL_ID, 5, "20"))); + + List> result = service.boardCards("MINE", "关系摸排", 0, 20, USER_ID); + + assertThat(result).hasSize(2); + assertThat(result.get(0)).containsEntry("oppStatus", 3); + assertThat(result.get(1)).containsEntry("oppStatus", 5); + assertThat(result.get(0)).containsKey("currentStageId"); + } + + @Test + @DisplayName("cards:stageName 无匹配节点 id → 不查商机,直接空列表") + void cards_noMatchingStageIds_returnsEmptyWithoutQuery() { + when(stageTemplateService.resolveStageIdsByDisplayName(java.util.Set.of("不存在"))) + .thenReturn(Map.of()); + + assertThat(service.boardCards("MINE", "不存在", 0, 20, USER_ID)).isEmpty(); + verify(oppMapper, never()).selectList(any()); + } + + @Test + @DisplayName("cards:stageName 空白 → 直接空列表") + void cards_blankStageName_returnsEmpty() { + assertThat(service.boardCards("MINE", " ", 0, 20, USER_ID)).isEmpty(); + verify(oppMapper, never()).selectList(any()); + } + + @Test + @DisplayName("cards:解析出的节点 id 集合应用到 current_stage_id IN 过滤") + void cards_appliesStageIdInFilter() { + when(stageTemplateService.resolveStageIdsByDisplayName(java.util.Set.of("关系摸排"))) + .thenReturn(Map.of("关系摸排", java.util.Set.of(11L, 21L))); + when(oppMapper.selectList(any())).thenReturn(List.of()); + + service.boardCards("MINE", "关系摸排", 0, 20, USER_ID); + + @SuppressWarnings({"rawtypes", "unchecked"}) + ArgumentCaptor captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(oppMapper).selectList(captor.capture()); + assertThat(captor.getValue().getSqlSegment()).contains("current_stage_id IN"); + } +} diff --git a/crm-rule/src/main/java/com/crm/rule/service/IOpportunityStageTemplateService.java b/crm-rule/src/main/java/com/crm/rule/service/IOpportunityStageTemplateService.java index e866c65..05e75db 100644 --- a/crm-rule/src/main/java/com/crm/rule/service/IOpportunityStageTemplateService.java +++ b/crm-rule/src/main/java/com/crm/rule/service/IOpportunityStageTemplateService.java @@ -106,4 +106,20 @@ public interface IOpportunityStageTemplateService { * crm-opportunity 查询层的优化现收回 crm-rule 内部)。未命中/空的 stageId 不进结果 Map。

    */ Map describeStages(Set stageIds); + + /** + * 批量节点展示名解析(票 02):入参一批节点 id({@code current_stage_id}), + * 出参 nodeId → 展示名(customNodeName 优先,空则回落 opp_stage 字典名)。 + *

    与 {@link #describeStages} 同展示名规则;未命中或展示名无法解析(自定义名空且字典名缺失) + * 的 id 不进结果 Map。字典名批量去重后一次查,避免 N+1。

    + */ + Map describeStageNames(Set stageIds); + + /** + * 批量展示名反查(票 02):入参一批展示名,出参 展示名 → 命中的全部节点 id 集合。 + *

    展示名是派生值(customNodeName 或 opp_stage 字典名),无法用名称直接命中 DB 列; + * 阶段节点为版本化配置小表,整表一次取出后按统一展示名规则反查,避免逐名 N+1。 + * 跨模板同名节点全部返回(看板同名合并取卡用);未命中的展示名不进结果 Map。

    + */ + Map> resolveStageIdsByDisplayName(Set stageNames); } diff --git a/crm-rule/src/main/java/com/crm/rule/service/impl/OpportunityStageTemplateServiceImpl.java b/crm-rule/src/main/java/com/crm/rule/service/impl/OpportunityStageTemplateServiceImpl.java index 27ff068..0bea150 100644 --- a/crm-rule/src/main/java/com/crm/rule/service/impl/OpportunityStageTemplateServiceImpl.java +++ b/crm-rule/src/main/java/com/crm/rule/service/impl/OpportunityStageTemplateServiceImpl.java @@ -114,6 +114,67 @@ public class OpportunityStageTemplateServiceImpl .orderByAsc(OpportunityStageNode::getSeqNo)); } + @Override + public Map describeStageNames(Set stageIds) { + Map result = new java.util.HashMap<>(); + if (CollUtil.isEmpty(stageIds)) { + return result; + } + Set distinctIds = stageIds.stream() + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + if (distinctIds.isEmpty()) { + return result; + } + List nodes = nodeMapper.selectList( + new LambdaQueryWrapper() + .in(OpportunityStageNode::getId, distinctIds)); + Map stageDictNames = stageDictNamesForNodes(nodes); + for (OpportunityStageNode node : nodes) { + String displayName = nodeDisplayName(node, stageDictNames); + if (displayName != null) { + result.put(node.getId(), displayName); + } + } + return result; + } + + @Override + public Map> resolveStageIdsByDisplayName(Set stageNames) { + Map> result = new java.util.HashMap<>(); + if (CollUtil.isEmpty(stageNames)) { + return result; + } + Set distinctNames = stageNames.stream() + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + if (distinctNames.isEmpty()) { + return result; + } + // 展示名是派生值(customNodeName 优先,空则回落 opp_stage 字典名),DB 无直接列可 in 反查; + // 阶段节点为版本化配置小表,整表一次取出后按统一展示名规则匹配,跨模板同名全部返回。 + List nodes = nodeMapper.selectList( + new LambdaQueryWrapper()); + Map stageDictNames = stageDictNamesForNodes(nodes); + for (OpportunityStageNode node : nodes) { + String displayName = nodeDisplayName(node, stageDictNames); + if (displayName != null && distinctNames.contains(displayName)) { + result.computeIfAbsent(displayName, k -> new java.util.LinkedHashSet<>()).add(node.getId()); + } + } + return result; + } + + /** 批量解析节点回显展示名所需的字典名:只收集自定义名为空的节点的字典 code。 */ + private Map stageDictNamesForNodes(List nodes) { + Set dictCodes = new java.util.HashSet<>(); + for (OpportunityStageNode node : nodes) { + collectDictCode(dictCodes, node); + } + return dictQueryService.getNames( + OpportunityRuleConstants.STAGE_DICT_GROUP_CODE, dictCodes); + } + @Override public Map describeStages(Set stageIds) { Map result = new java.util.HashMap<>(); @@ -452,12 +513,68 @@ public class OpportunityStageTemplateServiceImpl } } - /** 发布附加校验:除固定节点外至少创建一个阶段节点(原型校验文案) */ + /** + * 发布附加校验: + *
      + *
    1. 除固定节点外至少创建一个阶段节点(原型校验文案)
    2. + *
    3. 同版本内展示名唯一(含系统补的固定节点):customNodeName 优先,空则回落 + * opp_stage 字典名;撞名直接拒绝发布(64017,文案指出相撞的两个节点位置)
    4. + *
    + */ private void validateForPublish(OpportunityStageTemplateDTO dto) { - if (safeNodes(dto).isEmpty()) { + List nodes = safeNodes(dto); + if (nodes.isEmpty()) { throw new BusinessErrorException(OpportunityRuleConstants.CODE_STAGE_TPL_NODE_INVALID, "除固定节点外至少创建一个阶段节点"); } + validateDisplayNameUnique(nodes); + } + + /** + * 展示名唯一性校验(票 01):同一模板版本内所有节点的最终展示名不得重复, + * 固定节点「已转项目」同样参与。展示名规则 = customNodeName 非空优先,空则回落 + * opp_stage 字典名。重复时报 64017,并指明两个撞名节点在列表中的位置。 + */ + private void validateDisplayNameUnique(List nodes) { + Set blankCustomDictCodes = nodes.stream() + .filter(n -> StrUtil.isBlank(n.getCustomNodeName())) + .map(OpportunityStageTemplateDTO.NodeItem::getStageDictCode) + .filter(StrUtil::isNotBlank) + .collect(Collectors.toSet()); + Map dictNames = blankCustomDictCodes.isEmpty() + ? Map.of() + : dictQueryService.getNames(OpportunityRuleConstants.STAGE_DICT_GROUP_CODE, blankCustomDictCodes); + + Map firstSeenAt = new java.util.LinkedHashMap<>(); + int seq = 0; + for (OpportunityStageTemplateDTO.NodeItem node : nodes) { + seq++; + String displayName = nodeItemDisplayName(node, dictNames); + if (displayName == null) { + continue; + } + Integer prev = firstSeenAt.putIfAbsent(displayName, seq); + if (prev != null) { + throw new BusinessErrorException(OpportunityRuleConstants.CODE_STAGE_TPL_NODE_INVALID, + "阶段展示名重复:「" + displayName + "」在第 " + prev + " 个与第 " + seq + " 个节点重复"); + } + } + // 系统固定节点恒为「已转项目」,与普通节点撞名同样拒绝 + if (firstSeenAt.containsKey(OpportunityRuleConstants.FIXED_STAGE_NAME)) { + throw new BusinessErrorException(OpportunityRuleConstants.CODE_STAGE_TPL_NODE_INVALID, + "阶段展示名重复:「" + OpportunityRuleConstants.FIXED_STAGE_NAME + + "」与第 " + firstSeenAt.get(OpportunityRuleConstants.FIXED_STAGE_NAME) + + " 个节点重复"); + } + } + + /** NodeItem 的展示名:与实体 {@link #nodeDisplayName(OpportunityStageNode, Map)} 同规则。 */ + private String nodeItemDisplayName(OpportunityStageTemplateDTO.NodeItem node, + Map dictNames) { + if (StrUtil.isNotBlank(node.getCustomNodeName())) { + return node.getCustomNodeName(); + } + return dictNames.get(node.getStageDictCode()); } private List safeNodes(OpportunityStageTemplateDTO dto) { diff --git a/crm-rule/src/test/java/com/crm/rule/service/impl/OpportunityStageTemplateServiceImplTest.java b/crm-rule/src/test/java/com/crm/rule/service/impl/OpportunityStageTemplateServiceImplTest.java index b6243bc..87a3de3 100644 --- a/crm-rule/src/test/java/com/crm/rule/service/impl/OpportunityStageTemplateServiceImplTest.java +++ b/crm-rule/src/test/java/com/crm/rule/service/impl/OpportunityStageTemplateServiceImplTest.java @@ -826,4 +826,93 @@ class OpportunityStageTemplateServiceImplTest { .isEqualTo(OpportunityRuleConstants.FIXED_STAGE_DICT_CODE); assertThat(dto.getNodes().get(1).getIsFixed()).isEqualTo(OpportunityRuleConstants.FLAG_YES); } + + // ==================== 票 01:发布校验展示名唯一 ==================== + + @Test + @DisplayName("发布校验:同版本两个节点自定义名相同 → 拒绝 64017") + void saveAndPublish_duplicateCustomNames_rejected() { + OpportunityStageTemplateDTO dto = validDto(); + dto.setNodes(List.of(nodeItem("OPP_STAGE_01", "关系摸排"), + nodeItem("OPP_STAGE_02", "关系摸排"))); + + assertThatThrownBy(() -> service.saveAndPublish(dto)) + .isInstanceOf(BusinessErrorException.class) + .hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_NODE_INVALID) + .hasMessageContaining("关系摸排"); + + verify(templateMapper, never()).insert(any(OpportunityStageTemplate.class)); + } + + @Test + @DisplayName("发布校验:自定义名撞回落字典名 → 拒绝 64017,信息指明两个节点位置") + void saveAndPublish_customNameCollidesWithDictFallback_rejected() { + OpportunityStageTemplateDTO dto = validDto(); + dto.setNodes(List.of(nodeItem("OPP_STAGE_01", "关系摸排"), + nodeItem("OPP_STAGE_02", null))); + when(dictQueryService.getNames(OpportunityRuleConstants.STAGE_DICT_GROUP_CODE, + java.util.Set.of("OPP_STAGE_02"))) + .thenReturn(java.util.Map.of("OPP_STAGE_02", "关系摸排")); + + assertThatThrownBy(() -> service.saveAndPublish(dto)) + .isInstanceOf(BusinessErrorException.class) + .hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_NODE_INVALID) + .hasMessageContaining("关系摸排") + .hasMessageContaining("第 1 个") + .hasMessageContaining("第 2 个"); + + verify(templateMapper, never()).insert(any(OpportunityStageTemplate.class)); + } + + // ==================== 票 02:展示名解析契约 ==================== + + @Test + @DisplayName("describeStageNames:空入参直接返回空 Map,不查库") + void describeStageNames_emptyInput() { + assertThat(service.describeStageNames(java.util.Set.of())).isEmpty(); + verify(nodeMapper, never()).selectList(any()); + } + + @Test + @DisplayName("describeStageNames:customNodeName 优先,空则回落 opp_stage 字典名;未命中 id 不进结果") + void describeStageNames_batchResolution() { + OpportunityStageNode n1 = stageNode(100L, 8001L, 1, "OPP_STAGE_01", "客户圈定"); + OpportunityStageNode n2 = stageNode(200L, 8001L, 2, "OPP_STAGE_02", null); + when(nodeMapper.selectList(any())).thenReturn(List.of(n1, n2)); + when(dictQueryService.getNames(OpportunityRuleConstants.STAGE_DICT_GROUP_CODE, + java.util.Set.of("OPP_STAGE_02"))) + .thenReturn(java.util.Map.of("OPP_STAGE_02", "关系摸排")); + + Map result = service.describeStageNames(java.util.Set.of(100L, 200L, 999L)); + + assertThat(result).containsOnlyKeys(100L, 200L); + assertThat(result.get(100L)).isEqualTo("客户圈定"); + assertThat(result.get(200L)).isEqualTo("关系摸排"); + } + + @Test + @DisplayName("resolveStageIdsByDisplayName:空入参直接返回空 Map,不查库") + void resolveStageIdsByDisplayName_emptyInput() { + assertThat(service.resolveStageIdsByDisplayName(java.util.Set.of())).isEmpty(); + verify(nodeMapper, never()).selectList(any()); + } + + @Test + @DisplayName("resolveStageIdsByDisplayName:跨模板同名全部返回;customNodeName 与字典回落同规则") + void resolveStageIdsByDisplayName_crossTemplateAllReturned() { + OpportunityStageNode custom = stageNode(100L, 8001L, 1, "OPP_STAGE_01", "关系摸排"); + OpportunityStageNode fallback = stageNode(200L, 8002L, 1, "OPP_STAGE_02", null); + OpportunityStageNode other = stageNode(300L, 8002L, 2, "OPP_STAGE_03", "方案引导"); + when(nodeMapper.selectList(any())).thenReturn(List.of(custom, fallback, other)); + when(dictQueryService.getNames(OpportunityRuleConstants.STAGE_DICT_GROUP_CODE, + java.util.Set.of("OPP_STAGE_02"))) + .thenReturn(java.util.Map.of("OPP_STAGE_02", "关系摸排")); + + Map> result = + service.resolveStageIdsByDisplayName(java.util.Set.of("关系摸排", "方案引导")); + + assertThat(result).containsOnlyKeys("关系摸排", "方案引导"); + assertThat(result.get("关系摸排")).containsExactlyInAnyOrder(100L, 200L); + assertThat(result.get("方案引导")).containsExactly(300L); + } }