Browse Source

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)
master
luoweijian 7 days ago
parent
commit
915edfdb9a
  1. 6
      crm-opportunity/pom.xml
  2. 6
      crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCollabController.java
  3. 13
      crm-opportunity/src/main/java/com/crm/opportunity/query/OpportunityViewFilter.java
  4. 15
      crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityCollabService.java
  5. 153
      crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCollabServiceImpl.java
  6. 385
      crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityBoardIntegrationTest.java
  7. 282
      crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCollabServiceImplTest.java
  8. 16
      crm-rule/src/main/java/com/crm/rule/service/IOpportunityStageTemplateService.java
  9. 121
      crm-rule/src/main/java/com/crm/rule/service/impl/OpportunityStageTemplateServiceImpl.java
  10. 89
      crm-rule/src/test/java/com/crm/rule/service/impl/OpportunityStageTemplateServiceImplTest.java

6
crm-opportunity/pom.xml

@ -61,5 +61,11 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- 集成测试:H2 MySQL 模式(仿 crm-auth/crm-dict 服务层 H2 集成测试范式) -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

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

13
crm-opportunity/src/main/java/com/crm/opportunity/query/OpportunityViewFilter.java

@ -19,8 +19,7 @@ import org.springframework.stereotype.Component;
* <li>PARTICIPATED 团队成员且非负责人 MINE 互斥</li>
* <li>FOLLOWED opportunity_focus 命中当前用户</li>
* <li>RECENT 无额外条件列表侧最近访问走 pageRecent 独立路径取上限看板侧退化为同基础集</li>
* <li>PUBLIC_POOL opp_status=待领取 owner 逻辑视图 05status 条件由本分支给出
* 调用方不再叠加基础状态条件</li>
* <li>PUBLIC_POOL opp_status=待领取 owner 逻辑视图 05status 条件由本分支给出</li>
* <li>MANAGE 无额外条件@DataScope 兜底部门天花板</li>
* </ul>
*/
@ -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<Opportunity> 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;
}
}

15
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。 */
/**
* 看板阶段汇总 04List&lt;{stageName, count, totalAmount}&gt;按当前节点展示名分组
* 空列 count=0 也返回列顺序由视口阶段模板决定stageTemplateId 仅视口选择器null
* 当前用户部门绑定模板 全公司默认模板视口外按各自模板 seq_no 追加列末跨模板同名合并
* viewType 与列表视图同口径 06 D-15MINE
*/
List<Map<String, Object>> boardStageSummary(String viewType, Long stageTemplateId, Long userId);
/** 看板单阶段卡片列表(分页 offset/limit);viewType 与列表视图同口径(票 06 D-15),空→MINE。 */
List<Map<String, Object>> boardCards(String viewType, Long stageId, int offset, int limit, Long userId);
/**
* 看板单阶段卡片列表 04分页 offset/limit入参 stageName 为展示名
* 后端解析为展示名相等的节点 id 集合过滤跨模板同名一次取全卡片带 oppStatus
* viewType 与列表视图同口径 06 D-15MINE
*/
List<Map<String, Object>> boardCards(String viewType, String stageName, int offset, int limit, Long userId);
}

153
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<Map<String, Object>> boardStageSummary(String viewType, Long stageTemplateId, Long userId) {
// 按 current_stage_id 分组统计:数量 + 金额汇总(D-15:数据集与 cards 同一视图过滤,同一口径)
// 票 03:看板基础数据集只锚定「current_stage_id 非空」+ 视图条件,不再按推进中(2)过滤。
LambdaQueryWrapper<Opportunity> wrapper = new LambdaQueryWrapper<>();
applyBoardViewFilter(wrapper, viewType, userId);
if (stageTemplateId != null) {
wrapper.eq(Opportunity::getStageTemplateId, stageTemplateId);
}
List<Opportunity> 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<Long> templateIds = opps.stream()
.map(Opportunity::getStageTemplateId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (viewportTemplateId != null) {
templateIds.add(viewportTemplateId);
}
Map<Long, List<OpportunityStageNode>> nodesByTemplate = new LinkedHashMap<>();
Set<Long> allNodeIds = new LinkedHashSet<>();
for (Long templateId : templateIds) {
List<OpportunityStageNode> nodes = stageTemplateService.listNodesOfVersion(templateId);
nodesByTemplate.put(templateId, nodes);
nodes.stream()
.map(OpportunityStageNode::getId)
.filter(Objects::nonNull)
.forEach(allNodeIds::add);
}
// 聚合
Map<Long, Map<String, Object>> grouped = new java.util.LinkedHashMap<>();
// 节点 id → 展示名(crm-rule 契约,票 02:customNodeName 优先、空则字典名)
Map<Long, String> nodeNames = stageTemplateService.describeStageNames(allNodeIds);
// 展示名 → 最小 seq_no(视口外列排序键:撞名取最靠前位置)
Map<String, Integer> minSeqByName = new HashMap<>();
for (List<OpportunityStageNode> 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<String> 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<String, Integer> countByName = new HashMap<>();
Map<String, BigDecimal> totalAmountByName = new HashMap<>();
for (Opportunity opp : opps) {
Long stageId = opp.getCurrentStageId();
grouped.computeIfAbsent(stageId, k -> {
Map<String, Object> m = new HashMap<>();
m.put("stageId", k);
m.put("count", 0);
m.put("totalAmount", java.math.BigDecimal.ZERO);
return m;
});
Map<String, Object> 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<String> orderedColumns = new ArrayList<>(viewportColumns);
Set<String> viewportSet = new LinkedHashSet<>(viewportColumns);
List<String> 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<Map<String, Object>> result = new ArrayList<>();
for (String stageName : orderedColumns) {
Map<String, Object> 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<Map<String, Object>> boardCards(String viewType, Long stageId, int offset, int limit, Long userId) {
public List<Map<String, Object>> boardCards(String viewType, String stageName, int offset, int limit, Long userId) {
if (stageName == null || stageName.isBlank()) {
return List.of();
}
// 票 04:stageName → 展示名相等的全部节点 id 集合(跨模板同名一次取全)
Set<Long> stageIds = stageTemplateService.resolveStageIdsByDisplayName(Set.of(stageName))
.getOrDefault(stageName, Set.of());
if (stageIds.isEmpty()) {
return List.of();
}
LambdaQueryWrapper<Opportunity> wrapper = new LambdaQueryWrapper<>();
applyBoardViewFilter(wrapper, viewType, userId);
wrapper.eq(Opportunity::getCurrentStageId, stageId)
wrapper.in(Opportunity::getCurrentStageId, stageIds)
.last("LIMIT " + offset + "," + limit);
List<Opportunity> opps = oppMapper.selectList(wrapper);
List<Map<String, Object>> 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<Opportunity> 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();
}
}

385
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
*
* <p>真实 SQL 链路{@link OpportunityMapper} + 真实 {@link OpportunityViewFilter} + 真实
* {@link OpportunityStageTemplateServiceImpl}节点/模板/部门子表均走 H2仅字典与部门名查询
* 领取人快照解析用 mock锁定 03/04 组合语义</p>
* <ul>
* <li>summary 按展示名分组视口列按视口模板 seq_no 排序</li>
* <li>跨模板同展示名合并为一列</li>
* <li>视口空列以 count=0 返回</li>
* <li>暂缓中/已关闭/已转项目上板无阶段值current_stage_id 为空不上板</li>
* <li>cards stageName 取卡且卡片带 oppStatus</li>
* </ul>
*
* <p>固定数据集</p>
* <pre>
* 模板 T1视口发布中N1 客户圈定(seq1) / N2 方案引导(seq2) / N3 已转项目(seq3,固定)
* 模板 T2发布中 N4 客户圈定(seq1 N1 同名) / N5 商务谈判(seq2)
* 商机O1N1 推进中 ¥100O2N2 暂缓中 ¥200O3N4 已关闭 ¥300O4N5 已转项目 ¥400
* O5无阶段值 推进中 ¥500不上板
* </pre>
*/
@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<Map<String, Object>> rows = boardService.boardStageSummary("MANAGE", T1, USER_ID);
assertThat(rows).hasSize(4);
// 视口列顺序 = T1 节点 seq:客户圈定 → 方案引导 → 已转项目(空列 count=0)
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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)",
};
}

282
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
*
* <p>断言点</p>
* <ul>
* <li>基础数据集不再按 opp_status=推进中 过滤只锚定 current_stage_id 非空公海视图的
* 待领取 + owner 空条件不变</li>
* <li>summary 按展示名分组跨模板同名合并视口列顺序 + 空列 count=0视口外按 seq_no 追加</li>
* <li>stageTemplateId 仅决定列顺序不过滤数据集</li>
* <li>cards 入参 stageName 解析成节点 id 集合过滤卡片带 oppStatus</li>
* </ul>
*/
@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<LambdaQueryWrapper> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<LambdaQueryWrapper> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(oppMapper).selectList(captor.capture());
assertThat(captor.getValue().getSqlSegment()).contains("current_stage_id IN");
}
}

16
crm-rule/src/main/java/com/crm/rule/service/IOpportunityStageTemplateService.java

@ -106,4 +106,20 @@ public interface IOpportunityStageTemplateService {
* crm-opportunity 查询层的优化现收回 crm-rule 内部未命中/空的 stageId 不进结果 Map</p>
*/
Map<Long, StageDisplay> describeStages(Set<Long> stageIds);
/**
* 批量节点展示名解析 02入参一批节点 id{@code current_stage_id}
* 出参 nodeId 展示名customNodeName 优先空则回落 opp_stage 字典名
* <p> {@link #describeStages} 同展示名规则未命中或展示名无法解析自定义名空且字典名缺失
* id 不进结果 Map字典名批量去重后一次查避免 N+1</p>
*/
Map<Long, String> describeStageNames(Set<Long> stageIds);
/**
* 批量展示名反查 02入参一批展示名出参 展示名 命中的全部节点 id 集合
* <p>展示名是派生值customNodeName opp_stage 字典名无法用名称直接命中 DB
* 阶段节点为版本化配置小表整表一次取出后按统一展示名规则反查避免逐名 N+1
* 跨模板同名节点全部返回看板同名合并取卡用未命中的展示名不进结果 Map</p>
*/
Map<String, Set<Long>> resolveStageIdsByDisplayName(Set<String> stageNames);
}

121
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<Long, String> describeStageNames(Set<Long> stageIds) {
Map<Long, String> result = new java.util.HashMap<>();
if (CollUtil.isEmpty(stageIds)) {
return result;
}
Set<Long> distinctIds = stageIds.stream()
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (distinctIds.isEmpty()) {
return result;
}
List<OpportunityStageNode> nodes = nodeMapper.selectList(
new LambdaQueryWrapper<OpportunityStageNode>()
.in(OpportunityStageNode::getId, distinctIds));
Map<String, String> 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<String, Set<Long>> resolveStageIdsByDisplayName(Set<String> stageNames) {
Map<String, Set<Long>> result = new java.util.HashMap<>();
if (CollUtil.isEmpty(stageNames)) {
return result;
}
Set<String> distinctNames = stageNames.stream()
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (distinctNames.isEmpty()) {
return result;
}
// 展示名是派生值(customNodeName 优先,空则回落 opp_stage 字典名),DB 无直接列可 in 反查;
// 阶段节点为版本化配置小表,整表一次取出后按统一展示名规则匹配,跨模板同名全部返回。
List<OpportunityStageNode> nodes = nodeMapper.selectList(
new LambdaQueryWrapper<OpportunityStageNode>());
Map<String, String> 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<String, String> stageDictNamesForNodes(List<OpportunityStageNode> nodes) {
Set<String> dictCodes = new java.util.HashSet<>();
for (OpportunityStageNode node : nodes) {
collectDictCode(dictCodes, node);
}
return dictQueryService.getNames(
OpportunityRuleConstants.STAGE_DICT_GROUP_CODE, dictCodes);
}
@Override
public Map<Long, StageDisplay> describeStages(Set<Long> stageIds) {
Map<Long, StageDisplay> result = new java.util.HashMap<>();
@ -452,12 +513,68 @@ public class OpportunityStageTemplateServiceImpl
}
}
/** 发布附加校验:除固定节点外至少创建一个阶段节点(原型校验文案) */
/**
* 发布附加校验
* <ol>
* <li>除固定节点外至少创建一个阶段节点原型校验文案</li>
* <li>同版本内展示名唯一含系统补的固定节点customNodeName 优先空则回落
* opp_stage 字典名撞名直接拒绝发布64017文案指出相撞的两个节点位置</li>
* </ol>
*/
private void validateForPublish(OpportunityStageTemplateDTO dto) {
if (safeNodes(dto).isEmpty()) {
List<OpportunityStageTemplateDTO.NodeItem> 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<OpportunityStageTemplateDTO.NodeItem> nodes) {
Set<String> blankCustomDictCodes = nodes.stream()
.filter(n -> StrUtil.isBlank(n.getCustomNodeName()))
.map(OpportunityStageTemplateDTO.NodeItem::getStageDictCode)
.filter(StrUtil::isNotBlank)
.collect(Collectors.toSet());
Map<String, String> dictNames = blankCustomDictCodes.isEmpty()
? Map.of()
: dictQueryService.getNames(OpportunityRuleConstants.STAGE_DICT_GROUP_CODE, blankCustomDictCodes);
Map<String, Integer> 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<String, String> dictNames) {
if (StrUtil.isNotBlank(node.getCustomNodeName())) {
return node.getCustomNodeName();
}
return dictNames.get(node.getStageDictCode());
}
private List<OpportunityStageTemplateDTO.NodeItem> safeNodes(OpportunityStageTemplateDTO dto) {

89
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<Long, String> 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<String, java.util.Set<Long>> result =
service.resolveStageIdsByDisplayName(java.util.Set.of("关系摸排", "方案引导"));
assertThat(result).containsOnlyKeys("关系摸排", "方案引导");
assertThat(result.get("关系摸排")).containsExactlyInAnyOrder(100L, 200L);
assertThat(result.get("方案引导")).containsExactly(300L);
}
}

Loading…
Cancel
Save