Browse Source

feat(opportunity): 票 10 六视图 filter + 分页查询层

OpportunityViewQuery 接口 + impl:六视图数据集 filter(MINE owner /
PARTICIPATED team 存活行且非负责人 / FOLLOWED focus / RECENT view_log /
PUBLIC_POOL status=1且owner空 / MANAGE @DataScope兜底)+ 公共筛选(字段池)
+ keyword(名称/甲方/主客户名)+ 分页 + followed 回显。
计算列 join(节点停留/最近跟进/方案卡状态/预算/入池前节点)+ 字典/部门/区划名
回显标 TODO 留后续步骤。ArgumentCaptor 验证各 viewType 的 WHERE 片段。
crm-opportunity 121→127 全绿。
master
luoweijian 2 weeks ago
parent
commit
101ee399cc
  1. 18
      crm-opportunity/src/main/java/com/crm/opportunity/query/OpportunityViewQuery.java
  2. 131
      crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunityViewQueryImpl.java
  3. 113
      crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunityViewQueryImplTest.java

18
crm-opportunity/src/main/java/com/crm/opportunity/query/OpportunityViewQuery.java

@ -0,0 +1,18 @@
package com.crm.opportunity.query;
import com.crm.base.domain.result.PageResult;
import com.crm.opportunity.domain.dto.OpportunityDTO;
import com.crm.opportunity.domain.param.OpportunityPageParam;
/**
* 商机读侧六视图分页查询 + 展示字段拼装 10 四视图口径 + 14 视图族
*
* <p>与写侧状态机/命令编排分离视图数据集见 {@link com.crm.opportunity.domain.enums.OpportunityViewType}
* {@code @DataScope}{@code Opportunity} 实体注解经拦截器自动追加部门天花板个人四视图叠加团队成员
* 白名单可见性MANAGE/PUBLIC_POOL 严格按 DataScopePRD §1.3</p>
*/
public interface OpportunityViewQuery {
/** 六视图分页(RECENT/MINE/PARTICIPATED/FOLLOWED/MANAGE/PUBLIC_POOL),回填展示字段 + 计算列。 */
PageResult<OpportunityDTO> pageOpportunities(OpportunityPageParam param);
}

131
crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunityViewQueryImpl.java

@ -0,0 +1,131 @@
package com.crm.opportunity.query.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.crm.base.domain.result.PageResult;
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.OpportunityFocus;
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.OpportunityFocusMapper;
import com.crm.opportunity.mapper.OpportunityMapper;
import com.crm.opportunity.query.OpportunityViewQuery;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* {@link OpportunityViewQuery} 默认实现 10 四视图 + 14 视图族
*
* <p>读侧独占 {@link OpportunityMapper} + {@link OpportunityFocusMapper}关注读六视图数据集见
* {@link #applyViewFilters}{@code @DataScope}{@code Opportunity} 实体注解经拦截器对 wrapper 自动
* 追加部门天花板当前步已落视图 filter + 公共筛选 + 分页 + followed 回显</p>
*
* <p><b>TODO后续步骤</b> 字典/部门/区划名回显DictQueryService/SysDeptService/SysRegionService
* 对称 crm-lead fillDisplayFields 10 §二计算列 join节点停留/最近跟进/方案卡状态/方案预算/入池前节点
* RECENT 视图按 {@code opportunity_view_log.last_view_time} 倒序取上限当前 RECENT 先按 create_time
* 兜底排序待接 view_log join PARTICIPATED 团队白名单可见性叠加详情链路</p>
*/
@Component
@RequiredArgsConstructor
public class OpportunityViewQueryImpl implements OpportunityViewQuery {
private final OpportunityMapper opportunityMapper;
private final OpportunityFocusMapper opportunityFocusMapper;
@Override
public PageResult<OpportunityDTO> pageOpportunities(OpportunityPageParam param) {
Long currentUserId = getCurrentUserId();
LambdaQueryWrapper<Opportunity> wrapper = new LambdaQueryWrapper<>();
applyViewFilters(wrapper, param, currentUserId);
wrapper.orderByDesc(Opportunity::getCreateTime);
Page<Opportunity> page = opportunityMapper.selectPage(PageConverter.toMpPage(param), wrapper);
PageResult<OpportunityDTO> result = new PageResult<>(page).convert(OpportunityDTO::fromEntity);
fillDisplayFields(result.getContent(), currentUserId);
return result;
}
/**
* 六视图共享查询条件viewType 数据集 + 公共筛选不含排序/分页
* <p>{@code @DataScope} 经拦截器对本 wrapper 自动追加部门天花板MANAGE/PUBLIC_POOL 严格生效</p>
*/
private void applyViewFilters(LambdaQueryWrapper<Opportunity> wrapper,
OpportunityPageParam param, Long currentUserId) {
switch (OpportunityViewType.fromValue(param.getViewType())) {
// 我负责的:owner = 当前用户
case MINE -> wrapper.eq(Opportunity::getOwnerUserId, currentUserId);
// 我参与的:团队成员且非负责人(team 存活行命中 AND owner <> 当前用户),票 14
case PARTICIPATED -> wrapper
.ne(Opportunity::getOwnerUserId, currentUserId)
.apply("id IN (SELECT opportunity_id FROM opportunity_team "
+ "WHERE user_id = {0} AND deleted = 0)", currentUserId);
// 我关注的:opportunity_focus 命中当前用户
case FOLLOWED -> wrapper.apply(
"id IN (SELECT opportunity_id FROM opportunity_focus WHERE user_id = {0})", currentUserId);
// 最近访问:view_log 命中当前用户(排序/去重/上限待后续 join,当前先出数据集)
case RECENT -> wrapper.apply(
"id IN (SELECT opportunity_id FROM opportunity_view_log WHERE user_id = {0})", currentUserId);
// 商机公海:opp_status=待领取 且 owner 空(逻辑视图,票 05)
case PUBLIC_POOL -> wrapper
.eq(Opportunity::getOppStatus, OpportunityStatus.STATUS_IN_POOL.getValue())
.isNull(Opportunity::getOwnerUserId);
// 商机管理:无额外过滤,@DataScope 兜底部门天花板
case MANAGE -> { /* @DataScope handles dept ceiling */ }
}
// 公共筛选(列表可筛选列,票 14 字段池)
wrapper.in(CollUtil.isNotEmpty(param.getStatusIn()), Opportunity::getOppStatus, param.getStatusIn())
.eq(StrUtil.isNotBlank(param.getOppSource()), Opportunity::getOppSource, param.getOppSource())
.eq(StrUtil.isNotBlank(param.getOppType()), Opportunity::getOppType, param.getOppType())
.eq(StrUtil.isNotBlank(param.getIndustryCode()), Opportunity::getIndustryCode, param.getIndustryCode())
.eq(StrUtil.isNotBlank(param.getBidForm()), Opportunity::getBidForm, param.getBidForm())
.eq(StrUtil.isNotBlank(param.getLocalityType()), Opportunity::getLocalityType, param.getLocalityType())
.eq(StrUtil.isNotBlank(param.getProvinceCode()), Opportunity::getProvinceCode, param.getProvinceCode())
.eq(StrUtil.isNotBlank(param.getCityCode()), Opportunity::getCityCode, param.getCityCode())
.eq(param.getOwnerDeptId() != null, Opportunity::getOwnerDeptId, param.getOwnerDeptId())
.eq(param.getOwnerUserId() != null, Opportunity::getOwnerUserId, param.getOwnerUserId())
.eq(StrUtil.isNotBlank(param.getPoolReason()), Opportunity::getPoolReason, param.getPoolReason())
.eq(param.getCurrentStageId() != null, Opportunity::getCurrentStageId, param.getCurrentStageId());
// keyword:商机名称/甲方/主客户名快照模糊
if (StrUtil.isNotBlank(param.getKeyword())) {
String kw = param.getKeyword().trim();
wrapper.and(w -> w.like(Opportunity::getOppName, kw)
.or().like(Opportunity::getPartyA, kw)
.or().like(Opportunity::getPrimaryCustomerNameSnapshot, kw));
}
}
/**
* 出参回显字段填充当前步followed当前用户是否关注
* <p>TODO 后续字典/部门/区划名 + 10 §二计算列节点停留/最近跟进/方案卡状态/方案预算/入池前节点</p>
*/
private void fillDisplayFields(List<OpportunityDTO> dtos, Long currentUserId) {
if (CollUtil.isEmpty(dtos)) {
return;
}
List<Long> ids = dtos.stream().map(OpportunityDTO::getId).collect(Collectors.toList());
// 当前用户是否关注
Set<Long> followedIds = opportunityFocusMapper.selectList(
new LambdaQueryWrapper<OpportunityFocus>()
.in(OpportunityFocus::getOpportunityId, ids)
.eq(OpportunityFocus::getUserId, currentUserId))
.stream().map(OpportunityFocus::getOpportunityId).collect(Collectors.toSet());
dtos.forEach(d -> d.setFollowed(followedIds.contains(d.getId())));
}
private Long getCurrentUserId() {
return Long.valueOf(SecurityUtils.getRequiredUserId());
}
}

113
crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunityViewQueryImplTest.java

@ -0,0 +1,113 @@
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.param.OpportunityPageParam;
import com.crm.opportunity.mapper.OpportunityFocusMapper;
import com.crm.opportunity.mapper.OpportunityMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
/**
* 商机六视图 filter 规格验证 10/14断言各 viewType 生成正确的 WHERE 片段
*/
@DisplayName("商机视图查询 OpportunityViewQuery(票 10 六视图 filter)")
@ExtendWith(MockitoExtension.class)
class OpportunityViewQueryImplTest {
private static final Long CURRENT_USER = 1001L;
@Mock
private OpportunityMapper opportunityMapper;
@Mock
private OpportunityFocusMapper opportunityFocusMapper;
@InjectMocks
private OpportunityViewQueryImpl query;
private MockedStatic<SecurityUtils> securityMock;
@BeforeEach
void setUp() {
securityMock = Mockito.mockStatic(SecurityUtils.class);
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());
}
@AfterEach
void tearDown() {
securityMock.close();
}
@SuppressWarnings("unchecked")
private String captureSql(String viewType) {
OpportunityPageParam param = new OpportunityPageParam();
param.setViewType(viewType);
query.pageOpportunities(param);
ArgumentCaptor<LambdaQueryWrapper> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
org.mockito.Mockito.verify(opportunityMapper).selectPage(any(), captor.capture());
LambdaQueryWrapper<?> w = captor.getValue();
String sql = w.getSqlSegment();
// 参数值内联进 paramNameValuePairs,SQL segment 用占位符;apply 片段直接含子查询
return sql;
}
@Test
@DisplayName("MINE:owner_user_id = 当前用户")
void mine_filtersByOwner() {
assertThat(captureSql("MINE")).contains("owner_user_id");
}
@Test
@DisplayName("PARTICIPATED:team 子查询 + owner <> 当前用户")
void participated_teamSubqueryExcludeOwner() {
String sql = captureSql("PARTICIPATED");
assertThat(sql).contains("opportunity_team").contains("deleted = 0").contains("owner_user_id");
}
@Test
@DisplayName("FOLLOWED:focus 子查询")
void followed_focusSubquery() {
assertThat(captureSql("FOLLOWED")).contains("opportunity_focus");
}
@Test
@DisplayName("RECENT:view_log 子查询")
void recent_viewLogSubquery() {
assertThat(captureSql("RECENT")).contains("opportunity_view_log");
}
@Test
@DisplayName("PUBLIC_POOL:opp_status=1 且 owner 空")
void publicPool_statusAndOwnerNull() {
String sql = captureSql("PUBLIC_POOL");
assertThat(sql).contains("opp_status").contains("owner_user_id").containsIgnoringCase("null");
}
@Test
@DisplayName("MANAGE / 空 viewType(回落 MINE):不含公海/子查询片段")
void manage_noExtraFilter() {
String sql = captureSql("MANAGE");
assertThat(sql).doesNotContain("opportunity_team")
.doesNotContain("opportunity_focus")
.doesNotContain("opportunity_view_log");
}
}
Loading…
Cancel
Save