Browse Source
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
3 changed files with 262 additions and 0 deletions
@ -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 严格按 DataScope(PRD §1.3)。</p> |
||||
|
*/ |
||||
|
public interface OpportunityViewQuery { |
||||
|
|
||||
|
/** 六视图分页(RECENT/MINE/PARTICIPATED/FOLLOWED/MANAGE/PUBLIC_POOL),回填展示字段 + 计算列。 */ |
||||
|
PageResult<OpportunityDTO> pageOpportunities(OpportunityPageParam param); |
||||
|
} |
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
@ -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…
Reference in new issue