diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/SavedViewOperator.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/SavedViewOperator.java
new file mode 100644
index 0000000..b2009d9
--- /dev/null
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/SavedViewOperator.java
@@ -0,0 +1,54 @@
+package com.crm.opportunity.domain.enums;
+
+import cn.hutool.core.util.StrUtil;
+
+/**
+ * 自定义视图检索条件操作符(票 14 定义 2,demo 0803 {@code customMatches} 六档)。
+ *
+ *
{@code crm-preference} 的 {@code SavedViewCondition.operator} 存的是这些 code(平台侧不校验,
+ * 字段池/操作符语义归商机模块)。商机侧翻译器 {@code OpportunitySavedViewFilter} 按此把条件翻译成
+ * {@code LambdaQueryWrapper} 片段。未知 code 视为无效条件(跳过,不报错)。
+ */
+public enum SavedViewOperator {
+
+ /** 等于 */
+ EQ("eq"),
+ /** 不等于 */
+ NE("ne"),
+ /** 包含(like %v%) */
+ CONTAINS("contains"),
+ /** 不包含(not like %v%) */
+ NOT_CONTAINS("notContains"),
+ /** 为空(is null or '') */
+ IS_EMPTY("isEmpty"),
+ /** 不为空(is not null and <> '') */
+ IS_NOT_EMPTY("isNotEmpty");
+
+ private final String code;
+
+ SavedViewOperator(String code) {
+ this.code = code;
+ }
+
+ public String getCode() {
+ return code;
+ }
+
+ /** 是否为不需要值的操作符(为空/不为空)。 */
+ public boolean isValueless() {
+ return this == IS_EMPTY || this == IS_NOT_EMPTY;
+ }
+
+ /** 把 code 解析为操作符;空白或未知返回 null(调用方跳过该条件)。 */
+ public static SavedViewOperator fromCode(String code) {
+ if (StrUtil.isBlank(code)) {
+ return null;
+ }
+ for (SavedViewOperator op : values()) {
+ if (op.code.equals(code)) {
+ return op;
+ }
+ }
+ return null;
+ }
+}
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/param/OpportunityPageParam.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/param/OpportunityPageParam.java
index 47102b5..125103d 100644
--- a/crm-opportunity/src/main/java/com/crm/opportunity/domain/param/OpportunityPageParam.java
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/domain/param/OpportunityPageParam.java
@@ -21,6 +21,9 @@ public class OpportunityPageParam extends BaseParam {
@Schema(description = "视图类型(RECENT/MINE/PARTICIPATED/FOLLOWED/MANAGE/PUBLIC_POOL,空/非法回落 MINE)")
private String viewType;
+ @Schema(description = "自定义视图 id(票 14;非空时读当前用户 opportunity.sales scope 下该视图的检索条件,叠在 viewType 数据集之上)")
+ private String savedViewId;
+
@Schema(description = "商机状态筛选(多值,支撑复合卡片下钻)")
private List statusIn;
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunitySavedViewFilter.java b/crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunitySavedViewFilter.java
new file mode 100644
index 0000000..a522d23
--- /dev/null
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunitySavedViewFilter.java
@@ -0,0 +1,89 @@
+package com.crm.opportunity.query.impl;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.crm.opportunity.domain.entity.Opportunity;
+import com.crm.opportunity.domain.enums.SavedViewOperator;
+import com.crm.preference.domain.dto.SavedViewCondition;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 自定义视图检索条件 → {@code LambdaQueryWrapper} 翻译器(票 14 商机侧接线)。
+ *
+ * 字段合法性归商机模块(crm-preference 不校验,见票 14 确认点 3)。字段池 = 票 10
+ * {@code OpportunityPageParam} 的可筛选列,本类持有「字段 key → 主表列名」映射;未知字段 / 未知操作符
+ * 的条件静默跳过(防止用户误存的条件把查询打爆或注入)。
+ *
+ * 操作符语义({@link SavedViewOperator} 六档,demo 0803 {@code customMatches}):
+ * eq/ne 等值;contains/notContains 走 like;isEmpty/isNotEmpty 判空(null 或空串)。
+ * 条件间为 AND(本期 demo 只 1 组,契约按列表预留复合,多组即 AND 叠加)。
+ *
+ * 翻译只追加过滤,不决定数据集边界——数据集仍由 viewType 走
+ * {@code OpportunityViewQueryImpl#applyViewFilters}(销售机会自定义视图默认叠在 MINE 之上)。
+ */
+@Component
+public class OpportunitySavedViewFilter {
+
+ /**
+ * 字段 key → 主表物理列名白名单(字段池,票 10 OpportunityPageParam 可筛选列)。
+ * 只允许映射内的字段进入 SQL,杜绝任意列名注入;列名为常量拼接,值走 {0} 占位参数化。
+ */
+ private static final Map FIELD_COLUMN = Map.ofEntries(
+ Map.entry("oppSource", "opp_source"),
+ Map.entry("oppType", "opp_type"),
+ Map.entry("industryCode", "industry_code"),
+ Map.entry("bidForm", "bid_form"),
+ Map.entry("localityType", "locality_type"),
+ Map.entry("provinceCode", "province_code"),
+ Map.entry("cityCode", "city_code"),
+ Map.entry("ownerDeptId", "owner_dept_id"),
+ Map.entry("ownerUserId", "owner_user_id"),
+ Map.entry("poolReason", "pool_reason"),
+ Map.entry("currentStageId", "current_stage_id"),
+ Map.entry("oppStatus", "opp_status"),
+ Map.entry("oppName", "opp_name"),
+ Map.entry("partyA", "party_a"));
+
+ /**
+ * 把自定义视图条件列表翻译并追加到 wrapper(AND 叠加)。
+ * 空列表 / 全非法 → 不追加任何条件(等价无自定义过滤)。
+ */
+ public void apply(LambdaQueryWrapper wrapper, List conditions) {
+ if (conditions == null || conditions.isEmpty()) {
+ return;
+ }
+ for (SavedViewCondition c : conditions) {
+ applyOne(wrapper, c);
+ }
+ }
+
+ private void applyOne(LambdaQueryWrapper wrapper, SavedViewCondition c) {
+ if (c == null || StrUtil.isBlank(c.field())) {
+ return;
+ }
+ String column = FIELD_COLUMN.get(c.field());
+ if (column == null) {
+ return; // 字段不在字段池 → 跳过
+ }
+ SavedViewOperator op = SavedViewOperator.fromCode(c.operator());
+ if (op == null) {
+ return; // 未知操作符 → 跳过
+ }
+ // 需值的操作符但值为空 → 跳过(避免 like %null% 之类无意义查询)
+ if (!op.isValueless() && StrUtil.isBlank(c.value())) {
+ return;
+ }
+ String value = c.value();
+ switch (op) {
+ case EQ -> wrapper.apply(column + " = {0}", value);
+ case NE -> wrapper.apply(column + " <> {0}", value);
+ case CONTAINS -> wrapper.apply(column + " LIKE {0}", "%" + value + "%");
+ case NOT_CONTAINS -> wrapper.apply(column + " NOT LIKE {0}", "%" + value + "%");
+ case IS_EMPTY -> wrapper.apply("(" + column + " IS NULL OR " + column + " = '')");
+ case IS_NOT_EMPTY -> wrapper.apply("(" + column + " IS NOT NULL AND " + column + " <> '')");
+ }
+ }
+}
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 8325dd8..a56d4e2 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
@@ -30,6 +30,8 @@ import com.crm.opportunity.mapper.OpportunitySchemeCardMapper;
import com.crm.opportunity.mapper.OpportunityStageHistoryMapper;
import com.crm.opportunity.mapper.OpportunityViewLogMapper;
import com.crm.opportunity.query.OpportunityViewQuery;
+import com.crm.preference.domain.dto.SavedView;
+import com.crm.preference.service.SavedViewService;
import com.crm.rule.domain.dto.SysRegionDTO;
import com.crm.rule.domain.entity.OpportunityStageNode;
import com.crm.rule.service.IOpportunityStageTemplateService;
@@ -77,6 +79,13 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
private final ISysDeptService sysDeptService;
private final ISysRegionService sysRegionService;
private final IOpportunityStageTemplateService stageTemplateService;
+ private final SavedViewService savedViewService;
+
+ /** 自定义视图翻译器(票 14):把 SavedView 条件列表翻译成 wrapper 片段。 */
+ private final OpportunitySavedViewFilter savedViewFilter;
+
+ /** 销售机会工作区自定义视图的 scope(票 14,crm-preference 按此分组)。 */
+ private static final String SAVED_VIEW_SCOPE = "opportunity.sales";
/** RECENT 视图上限(票 10 P2-2 默认 50;view_log 已被后台 Job 裁剪,此处再兜底截断)。 */
private static final int RECENT_LIMIT = 50;
@@ -92,6 +101,7 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
applyViewFilters(wrapper, param, currentUserId);
+ applySavedView(wrapper, param, currentUserId);
wrapper.orderByDesc(Opportunity::getCreateTime);
Page page = opportunityMapper.selectPage(PageConverter.toMpPage(param), wrapper);
@@ -121,6 +131,7 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
wrapper.in(Opportunity::getId, orderedIds);
applyCommonFilters(wrapper, param);
+ applySavedView(wrapper, param, currentUserId);
wrapper.last("order by field(id," + orderedIds.stream()
.map(String::valueOf).collect(Collectors.joining(",")) + ")");
@@ -437,6 +448,24 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
});
}
+ /**
+ * 自定义视图条件叠加(票 14):{@code param.savedViewId} 非空时,读当前用户
+ * {@code opportunity.sales} scope 下该视图的检索条件,翻译后 AND 叠在当前数据集之上。
+ * 视图不存在(已删 / 非本人)→ 无额外条件(等价写回未选自定义视图)。
+ * 排序(sortField/sortDirection)本期不入库级排序(列表默认按 createTime),留后续实现。
+ */
+ private void applySavedView(LambdaQueryWrapper wrapper,
+ OpportunityPageParam param, Long currentUserId) {
+ if (StrUtil.isBlank(param.getSavedViewId())) {
+ return;
+ }
+ savedViewService.list(currentUserId, SAVED_VIEW_SCOPE).stream()
+ .filter(v -> param.getSavedViewId().equals(v.viewId()))
+ .findFirst()
+ .map(SavedView::conditions)
+ .ifPresent(conditions -> savedViewFilter.apply(wrapper, conditions));
+ }
+
private Long getCurrentUserId() {
return Long.valueOf(SecurityUtils.getRequiredUserId());
}
diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunitySavedViewFilterTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunitySavedViewFilterTest.java
new file mode 100644
index 0000000..25123b7
--- /dev/null
+++ b/crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunitySavedViewFilterTest.java
@@ -0,0 +1,140 @@
+package com.crm.opportunity.query.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.crm.opportunity.domain.entity.Opportunity;
+import com.crm.opportunity.domain.enums.SavedViewOperator;
+import com.crm.preference.domain.dto.SavedViewCondition;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * 自定义视图条件翻译器规格验证(票 14 商机侧接线)。
+ *
+ * 用例来源:票 14 定义 2(六操作符 customMatches)+ 确认点 3(字段池 = 票 10 可筛选列,
+ * 未知字段/操作符跳过)。断言查 wrapper 拼出的 SQL 片段 + 参数,覆盖:eq/ne/contains/notContains/
+ * isEmpty/isNotEmpty、未知字段跳过、未知操作符跳过、需值操作符空值跳过、空列表不追加、多条件 AND。
+ */
+@DisplayName("自定义视图条件翻译器(ticket 14 / OpportunitySavedViewFilter)")
+class OpportunitySavedViewFilterTest {
+
+ private final OpportunitySavedViewFilter filter = new OpportunitySavedViewFilter();
+
+ private LambdaQueryWrapper apply(List conditions) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ filter.apply(wrapper, conditions);
+ return wrapper;
+ }
+
+ private SavedViewCondition c(String field, String op, String value) {
+ return new SavedViewCondition(field, op, value);
+ }
+
+ // ==================== 六操作符 ====================
+
+ @Test
+ @DisplayName("eq:字段 = 值(值参数化占位)")
+ void eq_producesEquals() {
+ LambdaQueryWrapper w = apply(List.of(c("industryCode", "eq", "IT")));
+ assertThat(w.getSqlSegment()).contains("industry_code = ");
+ assertThat(w.getParamNameValuePairs().values()).contains("IT");
+ }
+
+ @Test
+ @DisplayName("ne:字段 <> 值")
+ void ne_producesNotEquals() {
+ LambdaQueryWrapper w = apply(List.of(c("oppType", "ne", "A")));
+ assertThat(w.getSqlSegment()).contains("opp_type <> ");
+ assertThat(w.getParamNameValuePairs().values()).contains("A");
+ }
+
+ @Test
+ @DisplayName("contains:字段 LIKE %值%")
+ void contains_producesLike() {
+ LambdaQueryWrapper w = apply(List.of(c("oppName", "contains", "网络")));
+ assertThat(w.getSqlSegment()).contains("opp_name LIKE ");
+ assertThat(w.getParamNameValuePairs().values()).contains("%网络%");
+ }
+
+ @Test
+ @DisplayName("notContains:字段 NOT LIKE %值%")
+ void notContains_producesNotLike() {
+ LambdaQueryWrapper w = apply(List.of(c("partyA", "notContains", "测试")));
+ assertThat(w.getSqlSegment()).contains("party_a NOT LIKE ");
+ assertThat(w.getParamNameValuePairs().values()).contains("%测试%");
+ }
+
+ @Test
+ @DisplayName("isEmpty:字段 IS NULL OR = ''(无值参数)")
+ void isEmpty_producesNullOrEmpty() {
+ LambdaQueryWrapper w = apply(List.of(c("poolReason", "isEmpty", null)));
+ assertThat(w.getSqlSegment()).contains("pool_reason IS NULL OR pool_reason = ''");
+ }
+
+ @Test
+ @DisplayName("isNotEmpty:字段 IS NOT NULL AND <> ''")
+ void isNotEmpty_producesNotNullAndNotEmpty() {
+ LambdaQueryWrapper w = apply(List.of(c("bidForm", "isNotEmpty", null)));
+ assertThat(w.getSqlSegment()).contains("bid_form IS NOT NULL AND bid_form <> ''");
+ }
+
+ // ==================== 跳过规则 ====================
+
+ @Test
+ @DisplayName("未知字段(不在字段池)→ 跳过,不追加条件")
+ void unknownField_skipped() {
+ LambdaQueryWrapper w = apply(List.of(c("secretColumn", "eq", "x")));
+ assertThat(w.getSqlSegment()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("未知操作符 → 跳过")
+ void unknownOperator_skipped() {
+ LambdaQueryWrapper w = apply(List.of(c("industryCode", "weird", "x")));
+ assertThat(w.getSqlSegment()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("需值操作符但值为空 → 跳过(不产生 like %null%)")
+ void valueRequiredButBlank_skipped() {
+ LambdaQueryWrapper w = apply(List.of(c("industryCode", "eq", " ")));
+ assertThat(w.getSqlSegment()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("空/null 条件列表 → 不追加任何条件")
+ void emptyConditions_noop() {
+ assertThat(apply(List.of()).getSqlSegment()).isEmpty();
+ assertThat(apply(null).getSqlSegment()).isEmpty();
+ }
+
+ // ==================== 多条件 AND ====================
+
+ @Test
+ @DisplayName("多条件 → AND 叠加(本期 demo 1 组,契约按列表预留复合)")
+ void multipleConditions_andJoined() {
+ LambdaQueryWrapper w = apply(List.of(
+ c("industryCode", "eq", "IT"),
+ c("oppStatus", "ne", "4")));
+ String sql = w.getSqlSegment();
+ assertThat(sql).contains("industry_code = ");
+ assertThat(sql).contains("opp_status <> ");
+ assertThat(sql).contains("AND");
+ }
+
+ // ==================== 操作符枚举 ====================
+
+ @Test
+ @DisplayName("SavedViewOperator.fromCode:空/未知 → null;isValueless 判空档")
+ void operator_fromCodeAndValueless() {
+ assertThat(SavedViewOperator.fromCode(null)).isNull();
+ assertThat(SavedViewOperator.fromCode("nope")).isNull();
+ assertThat(SavedViewOperator.fromCode("eq")).isEqualTo(SavedViewOperator.EQ);
+ assertThat(SavedViewOperator.IS_EMPTY.isValueless()).isTrue();
+ assertThat(SavedViewOperator.IS_NOT_EMPTY.isValueless()).isTrue();
+ assertThat(SavedViewOperator.EQ.isValueless()).isFalse();
+ }
+}