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"); + } }