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})。 + * + *

批量:按 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(); + } }