From 3d4834c26cf75ec2acb30a08cce35cbc60525296 Mon Sep 17 00:00:00 2001
From: luoweijian <1329394916@qq.com>
Date: Tue, 25 Aug 2026 13:35:06 +0800
Subject: [PATCH] =?UTF-8?q?feat(opportunity):=20=E7=A5=A8=2010=20=C2=A7?=
=?UTF-8?q?=E4=BA=8C=E6=95=B0=E5=80=BC=E8=AE=A1=E7=AE=97=E5=88=97=EF=BC=88?=
=?UTF-8?q?=E8=8A=82=E7=82=B9=E5=81=9C=E7=95=99/=E6=9C=80=E8=BF=91?=
=?UTF-8?q?=E8=B7=9F=E8=BF=9B/=E6=96=B9=E6=A1=88=E5=8D=A1=E7=8A=B6?=
=?UTF-8?q?=E6=80=81/=E9=A2=84=E7=AE=97=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
fillDisplayFields 扩展四项计算列(批量 join,无 N+1):
- 节点停留天数 = 今 − stage_history 中 to_stage_id=current_stage_id 的最新
operate_time(进当前节点日期,取最新那条迁入记录)
- 最近跟进 = 该商机最新 opportunity_follow.follow_time
- 方案卡状态/预算 = 主要意向客户(customer.is_primary_intended=1)的 customerId
对应那张 scheme_card 的 card_status/scheme_budget(一客户一卡取值唯一,
非主客户卡不命中);无主客户卡→两列空
测试验证取最新逻辑 + 主客户卡命中 + 非主卡排除。字典/部门/区划名 + 节点名
(依赖 crm-rule 模板)+ Controller 仍 TODO。crm-opportunity 127→128 全绿。
---
.../query/impl/OpportunityViewQueryImpl.java | 116 +++++++++++++++++-
.../impl/OpportunityViewQueryImplTest.java | 89 ++++++++++++++
2 files changed, 199 insertions(+), 6 deletions(-)
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 e802ca1..b77c5ab 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
@@ -9,17 +9,30 @@ import com.crm.base.security.SecurityUtils;
import com.crm.base.utils.PageConverter;
import com.crm.opportunity.domain.dto.OpportunityDTO;
import com.crm.opportunity.domain.entity.Opportunity;
+import com.crm.opportunity.domain.entity.OpportunityCustomer;
import com.crm.opportunity.domain.entity.OpportunityFocus;
+import com.crm.opportunity.domain.entity.OpportunityFollow;
+import com.crm.opportunity.domain.entity.OpportunitySchemeCard;
+import com.crm.opportunity.domain.entity.OpportunityStageHistory;
import com.crm.opportunity.domain.enums.OpportunityStatus;
import com.crm.opportunity.domain.enums.OpportunityViewType;
import com.crm.opportunity.domain.param.OpportunityPageParam;
+import com.crm.opportunity.mapper.OpportunityCustomerMapper;
import com.crm.opportunity.mapper.OpportunityFocusMapper;
+import com.crm.opportunity.mapper.OpportunityFollowMapper;
import com.crm.opportunity.mapper.OpportunityMapper;
+import com.crm.opportunity.mapper.OpportunitySchemeCardMapper;
+import com.crm.opportunity.mapper.OpportunityStageHistoryMapper;
import com.crm.opportunity.query.OpportunityViewQuery;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@@ -30,10 +43,12 @@ import java.util.stream.Collectors;
* {@link #applyViewFilters};{@code @DataScope}({@code Opportunity} 实体注解)经拦截器对 wrapper 自动
* 追加部门天花板。当前步已落:视图 filter + 公共筛选 + 分页 + followed 回显。
*
- * TODO(后续步骤):① 字典/部门/区划名回显(DictQueryService/SysDeptService/SysRegionService,
- * 对称 crm-lead fillDisplayFields);② 票 10 §二计算列 join(节点停留/最近跟进/方案卡状态/方案预算/入池前节点);
- * ③ RECENT 视图按 {@code opportunity_view_log.last_view_time} 倒序取上限(当前 RECENT 先按 create_time
- * 兜底排序,待接 view_log join);④ PARTICIPATED 团队白名单可见性叠加详情链路。
+ * 票 10 §二数值计算列(节点停留/最近跟进/方案卡状态/方案预算)已批量 join 填充(无 N+1)。
+ *
+ * TODO(后续步骤):① 字典/部门/区划名回显(DictQueryService/SysDeptService/SysRegionService,
+ * 对称 crm-lead fillDisplayFields);② 当前/下一节点名(依赖 crm-rule 阶段模板服务);③ RECENT 视图按
+ * {@code opportunity_view_log.last_view_time} 倒序取上限(当前 RECENT 先按 create_time 兜底排序,待接
+ * view_log join);④ PARTICIPATED 团队白名单可见性叠加详情链路。
*/
@Component
@RequiredArgsConstructor
@@ -41,6 +56,10 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
private final OpportunityMapper opportunityMapper;
private final OpportunityFocusMapper opportunityFocusMapper;
+ private final OpportunityStageHistoryMapper stageHistoryMapper;
+ private final OpportunityFollowMapper followMapper;
+ private final OpportunityCustomerMapper customerMapper;
+ private final OpportunitySchemeCardMapper schemeCardMapper;
@Override
public PageResult pageOpportunities(OpportunityPageParam param) {
@@ -107,8 +126,8 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
}
/**
- * 出参回显字段填充。当前步:followed(当前用户是否关注)。
- * TODO 后续:字典/部门/区划名 + 票 10 §二计算列(节点停留/最近跟进/方案卡状态/方案预算/入池前节点)。
+ * 出参回显字段填充:followed + 票 10 §二数值计算列(批量,无 N+1)。
+ * TODO 后续:字典/部门/区划名 + 当前/下一节点名(依赖 crm-rule 模板服务)。
*/
private void fillDisplayFields(List dtos, Long currentUserId) {
if (CollUtil.isEmpty(dtos)) {
@@ -123,6 +142,91 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
.eq(OpportunityFocus::getUserId, currentUserId))
.stream().map(OpportunityFocus::getOpportunityId).collect(Collectors.toSet());
dtos.forEach(d -> d.setFollowed(followedIds.contains(d.getId())));
+
+ fillStageStayDays(dtos, ids);
+ fillLastFollowTime(dtos, ids);
+ fillSchemeCardColumns(dtos, ids);
+ }
+
+ /**
+ * 节点停留时间(天)= 今 − 进入当前节点日期。
+ * 进入日期 = {@code opportunity_stage_history} 中 {@code to_stage_id = current_stage_id} 的最新
+ * {@code operate_time}(最近一次迁入当前节点)。无迁移记录(如新建即在初始节点未记史)→ 空。
+ */
+ private void fillStageStayDays(List dtos, List ids) {
+ List histories = stageHistoryMapper.selectList(
+ new LambdaQueryWrapper()
+ .in(OpportunityStageHistory::getOppId, ids));
+ // (oppId, toStageId) -> 最新 operateTime
+ Map> byOpp = new HashMap<>();
+ for (OpportunityStageHistory h : histories) {
+ if (h.getToStageId() == null || h.getOperateTime() == null) {
+ continue;
+ }
+ byOpp.computeIfAbsent(h.getOppId(), k -> new HashMap<>())
+ .merge(h.getToStageId(), h.getOperateTime(),
+ (a, b) -> a.isAfter(b) ? a : b);
+ }
+ LocalDate today = LocalDate.now();
+ dtos.forEach(d -> {
+ if (d.getCurrentStageId() == null) {
+ return;
+ }
+ Map stageTimes = byOpp.get(d.getId());
+ if (stageTimes == null) {
+ return;
+ }
+ LocalDateTime enterTime = stageTimes.get(d.getCurrentStageId());
+ if (enterTime != null) {
+ d.setStageStayDays(ChronoUnit.DAYS.between(enterTime.toLocalDate(), today));
+ }
+ });
+ }
+
+ /** 最近跟进 = 最新一条 {@code opportunity_follow.follow_time}(票 09);无则空。 */
+ private void fillLastFollowTime(List dtos, List ids) {
+ List follows = followMapper.selectList(
+ new LambdaQueryWrapper()
+ .in(OpportunityFollow::getOppId, ids)
+ .isNotNull(OpportunityFollow::getFollowTime));
+ Map latest = follows.stream().collect(Collectors.toMap(
+ OpportunityFollow::getOppId, OpportunityFollow::getFollowTime,
+ (a, b) -> a.isAfter(b) ? a : b));
+ dtos.forEach(d -> d.setLastFollowTime(latest.get(d.getId())));
+ }
+
+ /**
+ * 方案卡状态 + 方案预算 = 取主要意向客户({@code opportunity_customer.is_primary_intended=1})那张
+ * 方案卡的 {@code card_status} / {@code scheme_budget}(票 07/13,一客户一卡故取值唯一)。
+ * 无主客户或主客户无卡 → 两列均空(前端渲染「未创建」)。
+ */
+ private void fillSchemeCardColumns(List dtos, List ids) {
+ // 1. 每个 opp 的主要意向客户 customerId
+ Map primaryCustomer = customerMapper.selectList(
+ new LambdaQueryWrapper()
+ .in(OpportunityCustomer::getOpportunityId, ids)
+ .eq(OpportunityCustomer::getIsPrimaryIntended, 1))
+ .stream().collect(Collectors.toMap(
+ OpportunityCustomer::getOpportunityId,
+ OpportunityCustomer::getCustomerId, (a, b) -> a));
+ if (primaryCustomer.isEmpty()) {
+ return;
+ }
+ // 2. 主客户那张方案卡(按 oppId + customerId 命中)
+ List cards = schemeCardMapper.selectList(
+ new LambdaQueryWrapper()
+ .in(OpportunitySchemeCard::getOppId, primaryCustomer.keySet()));
+ Map cardByOpp = cards.stream()
+ .filter(c -> c.getCustomerId() != null
+ && c.getCustomerId().equals(primaryCustomer.get(c.getOppId())))
+ .collect(Collectors.toMap(OpportunitySchemeCard::getOppId, c -> c, (a, b) -> a));
+ dtos.forEach(d -> {
+ OpportunitySchemeCard card = cardByOpp.get(d.getId());
+ if (card != null) {
+ d.setSchemeCardStatus(card.getCardStatus());
+ d.setSchemeBudget(card.getSchemeBudget());
+ }
+ });
}
private Long getCurrentUserId() {
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 2801319..cfc870d 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
@@ -3,9 +3,19 @@ package com.crm.opportunity.query.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.crm.base.security.SecurityUtils;
+import com.crm.opportunity.domain.dto.OpportunityDTO;
+import com.crm.opportunity.domain.entity.Opportunity;
+import com.crm.opportunity.domain.entity.OpportunityCustomer;
+import com.crm.opportunity.domain.entity.OpportunityFollow;
+import com.crm.opportunity.domain.entity.OpportunitySchemeCard;
+import com.crm.opportunity.domain.entity.OpportunityStageHistory;
import com.crm.opportunity.domain.param.OpportunityPageParam;
+import com.crm.opportunity.mapper.OpportunityCustomerMapper;
import com.crm.opportunity.mapper.OpportunityFocusMapper;
+import com.crm.opportunity.mapper.OpportunityFollowMapper;
import com.crm.opportunity.mapper.OpportunityMapper;
+import com.crm.opportunity.mapper.OpportunitySchemeCardMapper;
+import com.crm.opportunity.mapper.OpportunityStageHistoryMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
@@ -18,6 +28,9 @@ import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
@@ -39,6 +52,14 @@ class OpportunityViewQueryImplTest {
private OpportunityMapper opportunityMapper;
@Mock
private OpportunityFocusMapper opportunityFocusMapper;
+ @Mock
+ private OpportunityStageHistoryMapper stageHistoryMapper;
+ @Mock
+ private OpportunityFollowMapper followMapper;
+ @Mock
+ private OpportunityCustomerMapper customerMapper;
+ @Mock
+ private OpportunitySchemeCardMapper schemeCardMapper;
@InjectMocks
private OpportunityViewQueryImpl query;
@@ -50,6 +71,10 @@ class OpportunityViewQueryImplTest {
securityMock.when(SecurityUtils::getRequiredUserId).thenReturn(String.valueOf(CURRENT_USER));
when(opportunityMapper.selectPage(any(), any())).thenReturn(new Page<>());
lenient().when(opportunityFocusMapper.selectList(any())).thenReturn(Collections.emptyList());
+ lenient().when(stageHistoryMapper.selectList(any())).thenReturn(Collections.emptyList());
+ lenient().when(followMapper.selectList(any())).thenReturn(Collections.emptyList());
+ lenient().when(customerMapper.selectList(any())).thenReturn(Collections.emptyList());
+ lenient().when(schemeCardMapper.selectList(any())).thenReturn(Collections.emptyList());
}
@AfterEach
@@ -110,4 +135,68 @@ class OpportunityViewQueryImplTest {
.doesNotContain("opportunity_focus")
.doesNotContain("opportunity_view_log");
}
+
+ @Test
+ @DisplayName("计算列:节点停留天数 / 最近跟进 / 主客户方案卡状态与预算")
+ @SuppressWarnings("unchecked")
+ void computedColumns_filled() {
+ // 一页一条:opp=7001, current_stage=200
+ 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);
+
+ // stage_history:进入节点 200 于 5 天前(取最新那条)
+ OpportunityStageHistory older = new OpportunityStageHistory();
+ older.setOppId(7001L);
+ older.setToStageId(200L);
+ older.setOperateTime(LocalDate.now().minusDays(9).atStartOfDay());
+ OpportunityStageHistory latest = new OpportunityStageHistory();
+ latest.setOppId(7001L);
+ latest.setToStageId(200L);
+ latest.setOperateTime(LocalDate.now().minusDays(5).atStartOfDay());
+ when(stageHistoryMapper.selectList(any())).thenReturn(List.of(older, latest));
+
+ // follow:取最新 follow_time
+ OpportunityFollow f1 = new OpportunityFollow();
+ f1.setOppId(7001L);
+ f1.setFollowTime(LocalDateTime.now().minusDays(3));
+ OpportunityFollow f2 = new OpportunityFollow();
+ f2.setOppId(7001L);
+ f2.setFollowTime(LocalDateTime.now().minusDays(1));
+ when(followMapper.selectList(any())).thenReturn(List.of(f1, f2));
+
+ // 主客户 = customer 88
+ OpportunityCustomer pc = new OpportunityCustomer();
+ pc.setOpportunityId(7001L);
+ pc.setCustomerId(88L);
+ pc.setIsPrimaryIntended(1);
+ when(customerMapper.selectList(any())).thenReturn(List.of(pc));
+
+ // 主客户那张卡:customer 88
+ OpportunitySchemeCard card = new OpportunitySchemeCard();
+ card.setOppId(7001L);
+ card.setCustomerId(88L);
+ card.setCardStatus(2);
+ card.setSchemeBudget(new BigDecimal("50000.00"));
+ // 另一张非主客户卡不应命中
+ OpportunitySchemeCard other = new OpportunitySchemeCard();
+ other.setOppId(7001L);
+ other.setCustomerId(99L);
+ other.setCardStatus(1);
+ when(schemeCardMapper.selectList(any())).thenReturn(List.of(card, other));
+
+ OpportunityPageParam param = new OpportunityPageParam();
+ param.setViewType("MINE");
+ List rows = query.pageOpportunities(param).getContent();
+
+ assertThat(rows).hasSize(1);
+ OpportunityDTO dto = rows.get(0);
+ assertThat(dto.getStageStayDays()).isEqualTo(5L);
+ assertThat(dto.getLastFollowTime()).isEqualTo(f2.getFollowTime());
+ assertThat(dto.getSchemeCardStatus()).isEqualTo(2);
+ assertThat(dto.getSchemeBudget()).isEqualByComparingTo("50000.00");
+ }
}