Browse Source

feat(opportunity): 票 10 §二数值计算列(节点停留/最近跟进/方案卡状态/预算)

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 全绿。
master
luoweijian 2 weeks ago
parent
commit
3d4834c26c
  1. 116
      crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunityViewQueryImpl.java
  2. 89
      crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunityViewQueryImplTest.java

116
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.base.utils.PageConverter;
import com.crm.opportunity.domain.dto.OpportunityDTO; import com.crm.opportunity.domain.dto.OpportunityDTO;
import com.crm.opportunity.domain.entity.Opportunity; 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.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.OpportunityStatus;
import com.crm.opportunity.domain.enums.OpportunityViewType; import com.crm.opportunity.domain.enums.OpportunityViewType;
import com.crm.opportunity.domain.param.OpportunityPageParam; import com.crm.opportunity.domain.param.OpportunityPageParam;
import com.crm.opportunity.mapper.OpportunityCustomerMapper;
import com.crm.opportunity.mapper.OpportunityFocusMapper; import com.crm.opportunity.mapper.OpportunityFocusMapper;
import com.crm.opportunity.mapper.OpportunityFollowMapper;
import com.crm.opportunity.mapper.OpportunityMapper; 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 com.crm.opportunity.query.OpportunityViewQuery;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component; 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.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -30,10 +43,12 @@ import java.util.stream.Collectors;
* {@link #applyViewFilters}{@code @DataScope}{@code Opportunity} 实体注解经拦截器对 wrapper 自动 * {@link #applyViewFilters}{@code @DataScope}{@code Opportunity} 实体注解经拦截器对 wrapper 自动
* 追加部门天花板当前步已落视图 filter + 公共筛选 + 分页 + followed 回显</p> * 追加部门天花板当前步已落视图 filter + 公共筛选 + 分页 + followed 回显</p>
* *
* <p><b>TODO后续步骤</b> 字典/部门/区划名回显DictQueryService/SysDeptService/SysRegionService * <p> 10 §二数值计算列节点停留/最近跟进/方案卡状态/方案预算已批量 join 填充 N+1</p>
* 对称 crm-lead fillDisplayFields 10 §二计算列 join节点停留/最近跟进/方案卡状态/方案预算/入池前节点 *
* RECENT 视图按 {@code opportunity_view_log.last_view_time} 倒序取上限当前 RECENT 先按 create_time * <p><b>TODO后续步骤</b> 字典/部门/区划名回显DictQueryService/SysDeptService/SysRegionService,
* 兜底排序待接 view_log join PARTICIPATED 团队白名单可见性叠加详情链路</p> * 对称 crm-lead fillDisplayFields 当前/下一节点名依赖 crm-rule 阶段模板服务 RECENT 视图按
* {@code opportunity_view_log.last_view_time} 倒序取上限当前 RECENT 先按 create_time 兜底排序待接
* view_log join PARTICIPATED 团队白名单可见性叠加详情链路</p>
*/ */
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
@ -41,6 +56,10 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
private final OpportunityMapper opportunityMapper; private final OpportunityMapper opportunityMapper;
private final OpportunityFocusMapper opportunityFocusMapper; private final OpportunityFocusMapper opportunityFocusMapper;
private final OpportunityStageHistoryMapper stageHistoryMapper;
private final OpportunityFollowMapper followMapper;
private final OpportunityCustomerMapper customerMapper;
private final OpportunitySchemeCardMapper schemeCardMapper;
@Override @Override
public PageResult<OpportunityDTO> pageOpportunities(OpportunityPageParam param) { public PageResult<OpportunityDTO> pageOpportunities(OpportunityPageParam param) {
@ -107,8 +126,8 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
} }
/** /**
* 出参回显字段填充当前步followed当前用户是否关注 * 出参回显字段填充followed + 10 §二数值计算列批量 N+1
* <p>TODO 后续字典/部门/区划名 + 10 §二计算列节点停留/最近跟进/方案卡状态/方案预算/入池前节点</p> * <p>TODO 后续字典/部门/区划名 + 当前/下一节点名依赖 crm-rule 模板服务</p>
*/ */
private void fillDisplayFields(List<OpportunityDTO> dtos, Long currentUserId) { private void fillDisplayFields(List<OpportunityDTO> dtos, Long currentUserId) {
if (CollUtil.isEmpty(dtos)) { if (CollUtil.isEmpty(dtos)) {
@ -123,6 +142,91 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
.eq(OpportunityFocus::getUserId, currentUserId)) .eq(OpportunityFocus::getUserId, currentUserId))
.stream().map(OpportunityFocus::getOpportunityId).collect(Collectors.toSet()); .stream().map(OpportunityFocus::getOpportunityId).collect(Collectors.toSet());
dtos.forEach(d -> d.setFollowed(followedIds.contains(d.getId()))); dtos.forEach(d -> d.setFollowed(followedIds.contains(d.getId())));
fillStageStayDays(dtos, ids);
fillLastFollowTime(dtos, ids);
fillSchemeCardColumns(dtos, ids);
}
/**
* 节点停留时间= 进入当前节点日期
* <p>进入日期 = {@code opportunity_stage_history} {@code to_stage_id = current_stage_id} 的最新
* {@code operate_time}最近一次迁入当前节点无迁移记录如新建即在初始节点未记史 </p>
*/
private void fillStageStayDays(List<OpportunityDTO> dtos, List<Long> ids) {
List<OpportunityStageHistory> histories = stageHistoryMapper.selectList(
new LambdaQueryWrapper<OpportunityStageHistory>()
.in(OpportunityStageHistory::getOppId, ids));
// (oppId, toStageId) -> 最新 operateTime
Map<Long, Map<Long, LocalDateTime>> 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<Long, LocalDateTime> 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<OpportunityDTO> dtos, List<Long> ids) {
List<OpportunityFollow> follows = followMapper.selectList(
new LambdaQueryWrapper<OpportunityFollow>()
.in(OpportunityFollow::getOppId, ids)
.isNotNull(OpportunityFollow::getFollowTime));
Map<Long, LocalDateTime> 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一客户一卡故取值唯一
* <p>无主客户或主客户无卡 两列均空前端渲染未创建</p>
*/
private void fillSchemeCardColumns(List<OpportunityDTO> dtos, List<Long> ids) {
// 1. 每个 opp 的主要意向客户 customerId
Map<Long, Long> primaryCustomer = customerMapper.selectList(
new LambdaQueryWrapper<OpportunityCustomer>()
.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<OpportunitySchemeCard> cards = schemeCardMapper.selectList(
new LambdaQueryWrapper<OpportunitySchemeCard>()
.in(OpportunitySchemeCard::getOppId, primaryCustomer.keySet()));
Map<Long, OpportunitySchemeCard> 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() { private Long getCurrentUserId() {

89
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.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.crm.base.security.SecurityUtils; 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.domain.param.OpportunityPageParam;
import com.crm.opportunity.mapper.OpportunityCustomerMapper;
import com.crm.opportunity.mapper.OpportunityFocusMapper; import com.crm.opportunity.mapper.OpportunityFocusMapper;
import com.crm.opportunity.mapper.OpportunityFollowMapper;
import com.crm.opportunity.mapper.OpportunityMapper; 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.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
@ -18,6 +28,9 @@ import org.mockito.MockedStatic;
import org.mockito.Mockito; import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension; 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.Collections;
import java.util.List; import java.util.List;
@ -39,6 +52,14 @@ class OpportunityViewQueryImplTest {
private OpportunityMapper opportunityMapper; private OpportunityMapper opportunityMapper;
@Mock @Mock
private OpportunityFocusMapper opportunityFocusMapper; private OpportunityFocusMapper opportunityFocusMapper;
@Mock
private OpportunityStageHistoryMapper stageHistoryMapper;
@Mock
private OpportunityFollowMapper followMapper;
@Mock
private OpportunityCustomerMapper customerMapper;
@Mock
private OpportunitySchemeCardMapper schemeCardMapper;
@InjectMocks @InjectMocks
private OpportunityViewQueryImpl query; private OpportunityViewQueryImpl query;
@ -50,6 +71,10 @@ class OpportunityViewQueryImplTest {
securityMock.when(SecurityUtils::getRequiredUserId).thenReturn(String.valueOf(CURRENT_USER)); securityMock.when(SecurityUtils::getRequiredUserId).thenReturn(String.valueOf(CURRENT_USER));
when(opportunityMapper.selectPage(any(), any())).thenReturn(new Page<>()); when(opportunityMapper.selectPage(any(), any())).thenReturn(new Page<>());
lenient().when(opportunityFocusMapper.selectList(any())).thenReturn(Collections.emptyList()); 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 @AfterEach
@ -110,4 +135,68 @@ class OpportunityViewQueryImplTest {
.doesNotContain("opportunity_focus") .doesNotContain("opportunity_focus")
.doesNotContain("opportunity_view_log"); .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<Opportunity> 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<OpportunityDTO> 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");
}
} }

Loading…
Cancel
Save