diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunityViewQueryImpl.java b/crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunityViewQueryImpl.java
index 7e0e41a..8325dd8 100644
--- a/crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunityViewQueryImpl.java
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunityViewQueryImpl.java
@@ -31,6 +31,8 @@ import com.crm.opportunity.mapper.OpportunityStageHistoryMapper;
import com.crm.opportunity.mapper.OpportunityViewLogMapper;
import com.crm.opportunity.query.OpportunityViewQuery;
import com.crm.rule.domain.dto.SysRegionDTO;
+import com.crm.rule.domain.entity.OpportunityStageNode;
+import com.crm.rule.service.IOpportunityStageTemplateService;
import com.crm.rule.service.ISysRegionService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@@ -55,11 +57,10 @@ import java.util.stream.Collectors;
*
*
票 10 §二数值计算列(节点停留/最近跟进/方案卡状态/方案预算)已批量 join 填充(无 N+1);
* 字典/部门/区划名回显(DictQueryService/SysDeptService/SysRegionService,对称 crm-lead fillDisplayFields)
- * 已落。
+ * 及当前/下一节点名(IOpportunityStageTemplateService)均已落。
*
- * TODO(后续步骤):① 当前/下一节点名(依赖 crm-rule 阶段模板服务);② PARTICIPATED
- * 团队白名单可见性叠加详情链路。RECENT 视图已按 {@code opportunity_view_log.last_view_time} 倒序取上限
- * (见 {@link #pageRecent})。
+ * TODO(后续步骤):PARTICIPATED 团队白名单可见性叠加详情链路。RECENT 视图已按
+ * {@code opportunity_view_log.last_view_time} 倒序取上限(见 {@link #pageRecent})。
*/
@Component
@RequiredArgsConstructor
@@ -75,6 +76,7 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
private final DictQueryService dictQueryService;
private final ISysDeptService sysDeptService;
private final ISysRegionService sysRegionService;
+ private final IOpportunityStageTemplateService stageTemplateService;
/** RECENT 视图上限(票 10 P2-2 默认 50;view_log 已被后台 Job 裁剪,此处再兜底截断)。 */
private static final int RECENT_LIMIT = 50;
@@ -208,6 +210,7 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
fillDictNames(dtos);
fillDeptNames(dtos);
fillRegionNames(dtos);
+ fillStageNodeNames(dtos);
}
/**
@@ -269,6 +272,90 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
});
}
+ /**
+ * 当前/下一节点名回显(票 10 §二口径,依 crm-rule
+ * {@link IOpportunityStageTemplateService})。
+ *
+ * - 当前节点 = {@code current_stage_id}(本身即节点 id)对应节点;
+ * 展示名 = {@code customNodeName} 非空则取之,否则带入 {@code opp_stage}
+ * 字典中 {@code stageDictCode} 的名(票 06 “空则带入字典名”)。
+ * - 下一节点 = 当前节点所属模板版本({@code node.templateId})下按 {@code seqNo}
+ * 升序的下一节点;已处末节点(如已转项目)→ 空。
+ *
+ * 批量:按 distinct {@code currentStageId} 取节点(同页去重),按 distinct
+ * {@code templateId} 取版本节点列表,stageDictCode 字典名也去重后查,避免 N+1。
+ */
+ private void fillStageNodeNames(List dtos) {
+ Set stageIds = dtos.stream().map(OpportunityDTO::getCurrentStageId)
+ .filter(java.util.Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
+ if (stageIds.isEmpty()) {
+ return;
+ }
+ // 1. 当前节点:distinct id → node
+ Map currentNodes = new HashMap<>();
+ for (Long stageId : stageIds) {
+ OpportunityStageNode node = stageTemplateService.getNodeById(stageId);
+ if (node != null) {
+ currentNodes.put(stageId, node);
+ }
+ }
+ // 2. 下一节点:按 distinct templateId 取整版节点列表,缓存。
+ Map> versionNodes = new HashMap<>();
+ for (OpportunityStageNode node : currentNodes.values()) {
+ versionNodes.computeIfAbsent(node.getTemplateId(),
+ tplId -> stageTemplateService.listNodesOfVersion(tplId));
+ }
+ // 3. 字典名带入(distinct stageDictCode)
+ Set dictCodes = new HashSet<>();
+ for (OpportunityStageNode node : currentNodes.values()) {
+ collectDictCode(dictCodes, node);
+ List siblings = versionNodes.get(node.getTemplateId());
+ OpportunityStageNode next = nextNode(siblings, node);
+ if (next != null) {
+ collectDictCode(dictCodes, next);
+ }
+ }
+ Map stageDictNames = dictNames(
+ com.crm.rule.constant.OpportunityRuleConstants.STAGE_DICT_GROUP_CODE, dictCodes);
+
+ dtos.forEach(d -> {
+ OpportunityStageNode node = currentNodes.get(d.getCurrentStageId());
+ if (node == null) {
+ return;
+ }
+ d.setCurrentStageName(nodeDisplayName(node, stageDictNames));
+ OpportunityStageNode next = nextNode(versionNodes.get(node.getTemplateId()), node);
+ if (next != null) {
+ d.setNextStageName(nodeDisplayName(next, stageDictNames));
+ }
+ });
+ }
+
+ private void collectDictCode(Set codes, OpportunityStageNode node) {
+ if (StrUtil.isBlank(node.getCustomNodeName()) && StrUtil.isNotBlank(node.getStageDictCode())) {
+ codes.add(node.getStageDictCode());
+ }
+ }
+
+ /** 节点展示名:customNodeName 优先,空则带入 opp_stage 字典名(票 06)。 */
+ private String nodeDisplayName(OpportunityStageNode node, Map stageDictNames) {
+ if (StrUtil.isNotBlank(node.getCustomNodeName())) {
+ return node.getCustomNodeName();
+ }
+ return stageDictNames.get(node.getStageDictCode());
+ }
+
+ /** 同版本内按 seqNo 升序取 current 的下一节点;无则 null。 */
+ private OpportunityStageNode nextNode(List siblings, OpportunityStageNode current) {
+ if (CollUtil.isEmpty(siblings) || current.getSeqNo() == null) {
+ return null;
+ }
+ return siblings.stream()
+ .filter(n -> n.getSeqNo() != null && n.getSeqNo() > current.getSeqNo())
+ .min(java.util.Comparator.comparingInt(OpportunityStageNode::getSeqNo))
+ .orElse(null);
+ }
+
/**
* 节点停留时间(天)= 今 − 进入当前节点日期。
* 进入日期 = {@code opportunity_stage_history} 中 {@code to_stage_id = current_stage_id} 的最新
diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunityViewQueryImplTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunityViewQueryImplTest.java
index 2b45513..1bda46c 100644
--- a/crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunityViewQueryImplTest.java
+++ b/crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunityViewQueryImplTest.java
@@ -70,6 +70,8 @@ class OpportunityViewQueryImplTest {
private com.crm.auth.service.ISysDeptService sysDeptService;
@Mock
private com.crm.rule.service.ISysRegionService sysRegionService;
+ @Mock
+ private com.crm.rule.service.IOpportunityStageTemplateService stageTemplateService;
@InjectMocks
private OpportunityViewQueryImpl query;
@@ -88,6 +90,8 @@ class OpportunityViewQueryImplTest {
lenient().when(viewLogMapper.selectList(any())).thenReturn(Collections.emptyList());
lenient().when(sysDeptService.listByIds(any())).thenReturn(Collections.emptyList());
lenient().when(sysRegionService.listByCodes(any())).thenReturn(Collections.emptyList());
+ lenient().when(stageTemplateService.getNodeById(any())).thenReturn(null);
+ lenient().when(stageTemplateService.listNodesOfVersion(any())).thenReturn(Collections.emptyList());
}
@AfterEach
@@ -287,4 +291,78 @@ class OpportunityViewQueryImplTest {
assertThat(dto.getProvinceName()).isEqualTo("广东省");
assertThat(dto.getCityName()).isEqualTo("深圳市");
}
+
+ @Test
+ @DisplayName("节点名:当前节点名(customNodeName 优先/字典名兵底)+ 下一节点(seq 升序)")
+ @SuppressWarnings("unchecked")
+ void stageNodeNames_currentAndNext() {
+ // opp 当前在节点 200(模板版本 8001)
+ Opportunity opp = new Opportunity();
+ opp.setId(7001L);
+ opp.setCurrentStageId(200L);
+ Page page = new Page<>();
+ page.setRecords(List.of(opp));
+ when(opportunityMapper.selectPage(any(), any())).thenReturn(page);
+
+ // 当前节点 200:seq=2, 无 customNodeName → 带入 opp_stage 字典名
+ com.crm.rule.domain.entity.OpportunityStageNode cur = new com.crm.rule.domain.entity.OpportunityStageNode();
+ cur.setId(200L);
+ cur.setTemplateId(8001L);
+ cur.setSeqNo(2);
+ cur.setStageDictCode("OPP_STAGE_02");
+ when(stageTemplateService.getNodeById(200L)).thenReturn(cur);
+
+ // 版本 8001 节点列:seq1 / seq2(当前) / seq3(下一,有 customNodeName)
+ com.crm.rule.domain.entity.OpportunityStageNode n1 = new com.crm.rule.domain.entity.OpportunityStageNode();
+ n1.setId(100L);
+ n1.setTemplateId(8001L);
+ n1.setSeqNo(1);
+ n1.setStageDictCode("OPP_STAGE_01");
+ com.crm.rule.domain.entity.OpportunityStageNode n3 = new com.crm.rule.domain.entity.OpportunityStageNode();
+ n3.setId(300L);
+ n3.setTemplateId(8001L);
+ n3.setSeqNo(3);
+ n3.setStageDictCode("OPP_STAGE_03");
+ n3.setCustomNodeName("自定义节点名");
+ when(stageTemplateService.listNodesOfVersion(8001L)).thenReturn(List.of(cur, n1, n3));
+
+ // 字典:OPP_STAGE_02 → 关系摸排(当前节点带入)
+ com.crm.dict.domain.entity.DictItem stageItem = new com.crm.dict.domain.entity.DictItem();
+ stageItem.setName("关系摸排");
+ when(dictQueryService.getItem("opp_stage", "OPP_STAGE_02")).thenReturn(stageItem);
+
+ OpportunityPageParam param = new OpportunityPageParam();
+ param.setViewType("MINE");
+ OpportunityDTO dto = query.pageOpportunities(param).getContent().get(0);
+
+ assertThat(dto.getCurrentStageName()).isEqualTo("关系摸排");
+ assertThat(dto.getNextStageName()).isEqualTo("自定义节点名");
+ }
+
+ @Test
+ @DisplayName("节点名:已处末节点 → 下一节点名为空")
+ @SuppressWarnings("unchecked")
+ void stageNodeNames_lastNode_noNext() {
+ Opportunity opp = new Opportunity();
+ opp.setId(7001L);
+ opp.setCurrentStageId(500L);
+ Page page = new Page<>();
+ page.setRecords(List.of(opp));
+ when(opportunityMapper.selectPage(any(), any())).thenReturn(page);
+
+ com.crm.rule.domain.entity.OpportunityStageNode last = new com.crm.rule.domain.entity.OpportunityStageNode();
+ last.setId(500L);
+ last.setTemplateId(8001L);
+ last.setSeqNo(5);
+ last.setCustomNodeName("已转项目");
+ when(stageTemplateService.getNodeById(500L)).thenReturn(last);
+ when(stageTemplateService.listNodesOfVersion(8001L)).thenReturn(List.of(last));
+
+ OpportunityPageParam param = new OpportunityPageParam();
+ param.setViewType("MINE");
+ OpportunityDTO dto = query.pageOpportunities(param).getContent().get(0);
+
+ assertThat(dto.getCurrentStageName()).isEqualTo("已转项目");
+ assertThat(dto.getNextStageName()).isNull();
+ }
}