diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/query/OpportunityViewQuery.java b/crm-opportunity/src/main/java/com/crm/opportunity/query/OpportunityViewQuery.java new file mode 100644 index 0000000..b419315 --- /dev/null +++ b/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 视图族)。 + * + *

与写侧(状态机/命令编排)分离。视图数据集见 {@link com.crm.opportunity.domain.enums.OpportunityViewType}; + * {@code @DataScope}({@code Opportunity} 实体注解)经拦截器自动追加部门天花板。个人四视图叠加团队成员 + * 白名单可见性,MANAGE/PUBLIC_POOL 严格按 DataScope(PRD §1.3)。

+ */ +public interface OpportunityViewQuery { + + /** 六视图分页(RECENT/MINE/PARTICIPATED/FOLLOWED/MANAGE/PUBLIC_POOL),回填展示字段 + 计算列。 */ + PageResult pageOpportunities(OpportunityPageParam param); +} 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 new file mode 100644 index 0000000..e802ca1 --- /dev/null +++ b/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 视图族)。 + * + *

读侧独占 {@link OpportunityMapper} + {@link OpportunityFocusMapper}(关注读)。六视图数据集见 + * {@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 团队白名单可见性叠加详情链路。

+ */ +@Component +@RequiredArgsConstructor +public class OpportunityViewQueryImpl implements OpportunityViewQuery { + + private final OpportunityMapper opportunityMapper; + private final OpportunityFocusMapper opportunityFocusMapper; + + @Override + public PageResult pageOpportunities(OpportunityPageParam param) { + Long currentUserId = getCurrentUserId(); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + applyViewFilters(wrapper, param, currentUserId); + wrapper.orderByDesc(Opportunity::getCreateTime); + + Page page = opportunityMapper.selectPage(PageConverter.toMpPage(param), wrapper); + PageResult result = new PageResult<>(page).convert(OpportunityDTO::fromEntity); + fillDisplayFields(result.getContent(), currentUserId); + return result; + } + + /** + * 六视图共享查询条件(viewType 数据集 + 公共筛选),不含排序/分页。 + *

{@code @DataScope} 经拦截器对本 wrapper 自动追加部门天花板(MANAGE/PUBLIC_POOL 严格生效)。

+ */ + private void applyViewFilters(LambdaQueryWrapper 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(当前用户是否关注)。 + *

TODO 后续:字典/部门/区划名 + 票 10 §二计算列(节点停留/最近跟进/方案卡状态/方案预算/入池前节点)。

+ */ + private void fillDisplayFields(List dtos, Long currentUserId) { + if (CollUtil.isEmpty(dtos)) { + return; + } + List ids = dtos.stream().map(OpportunityDTO::getId).collect(Collectors.toList()); + + // 当前用户是否关注 + Set followedIds = opportunityFocusMapper.selectList( + new LambdaQueryWrapper() + .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()); + } +} 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 new file mode 100644 index 0000000..2801319 --- /dev/null +++ b/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 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 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"); + } +}