Browse Source
- rule: validate display-name uniqueness within a template version on publish (64017) - rule: add describeStageNames / resolveStageIdsByDisplayName batch contracts - opportunity: board stops filtering by advancing(2), rows with current_stage_id go on board - opportunity: boardStageSummary groups by display name, ordered full/empty viewport columns - opportunity: boardCards takes stageName and returns oppStatus - tests: rule unit tests + opportunity service tests + H2 board integration test - docs: sync board Bruno collection (stageName param, oppStatus field, drop stageId)master
10 changed files with 1041 additions and 45 deletions
@ -0,0 +1,385 @@ |
|||
package com.crm.opportunity.service.impl; |
|||
|
|||
import com.baomidou.mybatisplus.annotation.DbType; |
|||
import com.baomidou.mybatisplus.core.MybatisConfiguration; |
|||
import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator; |
|||
import com.baomidou.mybatisplus.core.toolkit.GlobalConfigUtils; |
|||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; |
|||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; |
|||
import com.crm.auth.service.ISysDeptService; |
|||
import com.crm.base.config.MetaObjectFillHandler; |
|||
import com.crm.base.domain.exception.BusinessErrorException; |
|||
import com.crm.dict.domain.dto.DictItemDTO; |
|||
import com.crm.dict.service.DictQueryService; |
|||
import com.crm.opportunity.domain.entity.Opportunity; |
|||
import com.crm.opportunity.mapper.OpportunityFocusMapper; |
|||
import com.crm.opportunity.mapper.OpportunityMapper; |
|||
import com.crm.opportunity.mapper.OpportunityViewLogMapper; |
|||
import com.crm.opportunity.owner.OwnerSnapshot; |
|||
import com.crm.opportunity.owner.OwnerSnapshotResolver; |
|||
import com.crm.opportunity.query.OpportunityViewFilter; |
|||
import com.crm.rule.constant.OpportunityRuleConstants; |
|||
import com.crm.rule.domain.dto.OpportunityStageTemplateDTO; |
|||
import com.crm.rule.domain.entity.OpportunityStageNode; |
|||
import com.crm.rule.domain.entity.OpportunityStageTemplate; |
|||
import com.crm.rule.domain.entity.OpportunityStageTemplateDept; |
|||
import com.crm.rule.mapper.OpportunityStageNodeMapper; |
|||
import com.crm.rule.mapper.OpportunityStageTemplateDeptMapper; |
|||
import com.crm.rule.mapper.OpportunityStageTemplateMapper; |
|||
import com.crm.rule.service.impl.OpportunityStageTemplateServiceImpl; |
|||
import org.apache.ibatis.mapping.Environment; |
|||
import org.apache.ibatis.session.LocalCacheScope; |
|||
import org.apache.ibatis.session.SqlSession; |
|||
import org.apache.ibatis.session.SqlSessionFactory; |
|||
import org.apache.ibatis.session.SqlSessionFactoryBuilder; |
|||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; |
|||
import org.h2.jdbcx.JdbcDataSource; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeAll; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.DisplayName; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.mockito.Mockito; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.sql.Connection; |
|||
import java.sql.Statement; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.concurrent.atomic.AtomicBoolean; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.anySet; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
/** |
|||
* 商机看板按展示名分列 H2 集成测试(票 05)。 |
|||
* |
|||
* <p>真实 SQL 链路:{@link OpportunityMapper} + 真实 {@link OpportunityViewFilter} + 真实 |
|||
* {@link OpportunityStageTemplateServiceImpl}(节点/模板/部门子表均走 H2),仅字典与部门名查询、 |
|||
* 领取人快照解析用 mock。锁定 03/04 组合语义:</p> |
|||
* <ul> |
|||
* <li>summary 按展示名分组,视口列按视口模板 seq_no 排序</li> |
|||
* <li>跨模板同展示名合并为一列</li> |
|||
* <li>视口空列以 count=0 返回</li> |
|||
* <li>暂缓中/已关闭/已转项目上板,无阶段值(current_stage_id 为空)不上板</li> |
|||
* <li>cards 按 stageName 取卡且卡片带 oppStatus</li> |
|||
* </ul> |
|||
* |
|||
* <p>固定数据集:</p> |
|||
* <pre> |
|||
* 模板 T1(视口,发布中):N1 客户圈定(seq1) / N2 方案引导(seq2) / N3 已转项目(seq3,固定) |
|||
* 模板 T2(发布中): N4 客户圈定(seq1,与 N1 同名) / N5 商务谈判(seq2) |
|||
* 商机:O1→N1 推进中 ¥100;O2→N2 暂缓中 ¥200;O3→N4 已关闭 ¥300;O4→N5 已转项目 ¥400; |
|||
* O5→无阶段值 推进中 ¥500(不上板) |
|||
* </pre> |
|||
*/ |
|||
@DisplayName("商机看板:按展示名分列(H2 集成,票 05)") |
|||
class OpportunityBoardIntegrationTest { |
|||
|
|||
private static final Long USER_ID = 5001L; |
|||
private static final Long DEPT_ID = 1001L; |
|||
|
|||
private static final Long T1 = 9001L; |
|||
private static final Long T2 = 9002L; |
|||
private static final Long N1 = 1001L; |
|||
private static final Long N2 = 1002L; |
|||
private static final Long N3 = 1003L; |
|||
private static final Long N4 = 2001L; |
|||
private static final Long N5 = 2002L; |
|||
|
|||
private static final AtomicBoolean BOOTSTRAPPED = new AtomicBoolean(false); |
|||
private static SqlSessionFactory sqlSessionFactory; |
|||
|
|||
private SqlSession session; |
|||
private OpportunityCollabServiceImpl boardService; |
|||
private OpportunityStageTemplateServiceImpl ruleService; |
|||
|
|||
private final DictQueryService dictQueryService = Mockito.mock(DictQueryService.class); |
|||
private final ISysDeptService sysDeptService = Mockito.mock(ISysDeptService.class); |
|||
private final OwnerSnapshotResolver ownerSnapshotResolver = Mockito.mock(OwnerSnapshotResolver.class); |
|||
|
|||
@BeforeAll |
|||
static void bootstrap() throws Exception { |
|||
if (!BOOTSTRAPPED.compareAndSet(false, true)) { |
|||
return; |
|||
} |
|||
JdbcDataSource ds = new JdbcDataSource(); |
|||
ds.setURL("jdbc:h2:mem:oppboard;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;" |
|||
+ "DB_CLOSE_DELAY=-1"); |
|||
|
|||
MybatisConfiguration configuration = new MybatisConfiguration(); |
|||
configuration.setEnvironment(new Environment("oppboard-test", new JdbcTransactionFactory(), ds)); |
|||
configuration.setMapUnderscoreToCamelCase(true); |
|||
configuration.setLocalCacheScope(LocalCacheScope.STATEMENT); |
|||
|
|||
MybatisPlusInterceptor mpInterceptor = new MybatisPlusInterceptor(); |
|||
mpInterceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); |
|||
configuration.addInterceptor(mpInterceptor); |
|||
|
|||
GlobalConfigUtils.getGlobalConfig(configuration).setMetaObjectHandler(new MetaObjectFillHandler()); |
|||
GlobalConfigUtils.getGlobalConfig(configuration).setIdentifierGenerator(DefaultIdentifierGenerator.getInstance()); |
|||
|
|||
configuration.addMapper(OpportunityMapper.class); |
|||
configuration.addMapper(OpportunityFocusMapper.class); |
|||
configuration.addMapper(OpportunityViewLogMapper.class); |
|||
configuration.addMapper(OpportunityStageTemplateMapper.class); |
|||
configuration.addMapper(OpportunityStageNodeMapper.class); |
|||
configuration.addMapper(OpportunityStageTemplateDeptMapper.class); |
|||
sqlSessionFactory = new SqlSessionFactoryBuilder().build(configuration); |
|||
|
|||
try (SqlSession s = sqlSessionFactory.openSession(true)) { |
|||
Connection conn = s.getConnection(); |
|||
try (Statement st = conn.createStatement()) { |
|||
for (String sql : SCHEMA) { |
|||
st.execute(sql); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
@BeforeEach |
|||
void openSession() throws Exception { |
|||
session = sqlSessionFactory.openSession(true); |
|||
try (Statement st = session.getConnection().createStatement()) { |
|||
for (String sql : RESET) { |
|||
st.execute(sql); |
|||
} |
|||
for (String sql : SEED) { |
|||
st.execute(sql); |
|||
} |
|||
} |
|||
|
|||
OpportunityStageTemplateMapper templateMapper = session.getMapper(OpportunityStageTemplateMapper.class); |
|||
OpportunityStageNodeMapper nodeMapper = session.getMapper(OpportunityStageNodeMapper.class); |
|||
OpportunityStageTemplateDeptMapper deptMapper = session.getMapper(OpportunityStageTemplateDeptMapper.class); |
|||
|
|||
ruleService = new OpportunityStageTemplateServiceImpl(deptMapper, nodeMapper, sysDeptService, dictQueryService); |
|||
ReflectionTestUtils.setField(ruleService, "baseMapper", templateMapper); |
|||
ReflectionTestUtils.setField(ruleService, "entityClass", OpportunityStageTemplate.class); |
|||
|
|||
// 看板测试节点均带 custom_node_name,describeStageNames 不触达字典名回退;空集返回空 Map。
|
|||
when(dictQueryService.getNames(any(), anySet())).thenReturn(Map.of()); |
|||
when(ownerSnapshotResolver.of(USER_ID)).thenReturn(new OwnerSnapshot(USER_ID, "测试用户", DEPT_ID)); |
|||
|
|||
OpportunityMapper oppMapper = session.getMapper(OpportunityMapper.class); |
|||
boardService = new OpportunityCollabServiceImpl( |
|||
session.getMapper(OpportunityFocusMapper.class), |
|||
session.getMapper(OpportunityViewLogMapper.class), |
|||
oppMapper, |
|||
new OpportunityViewFilter(), |
|||
ruleService, |
|||
ownerSnapshotResolver); |
|||
} |
|||
|
|||
@AfterEach |
|||
void closeSession() { |
|||
if (session != null) { |
|||
session.close(); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("summary:按展示名分组、视口列按序、视口外追加、跨状态上板、无阶段值不上板") |
|||
void summary_groupsByDisplayName_ordersByViewport() { |
|||
List<Map<String, Object>> rows = boardService.boardStageSummary("MANAGE", T1, USER_ID); |
|||
|
|||
assertThat(rows).hasSize(4); |
|||
|
|||
// 视口列顺序 = T1 节点 seq:客户圈定 → 方案引导 → 已转项目(空列 count=0)
|
|||
Map<String, Object> col1 = rows.get(0); |
|||
assertThat(col1.get("stageName")).isEqualTo("客户圈定"); |
|||
// 跨模板同名合并:O1(推进中 ¥100) + O3(已关闭 ¥300)
|
|||
assertThat(col1.get("count")).isEqualTo(2); |
|||
assertThat((BigDecimal) col1.get("totalAmount")).isEqualByComparingTo(new BigDecimal("400")); |
|||
|
|||
Map<String, Object> col2 = rows.get(1); |
|||
assertThat(col2.get("stageName")).isEqualTo("方案引导"); |
|||
// O2 暂缓中 ¥200:暂缓中也上板
|
|||
assertThat(col2.get("count")).isEqualTo(1); |
|||
assertThat((BigDecimal) col2.get("totalAmount")).isEqualByComparingTo(new BigDecimal("200")); |
|||
|
|||
Map<String, Object> col3 = rows.get(2); |
|||
assertThat(col3.get("stageName")).isEqualTo("已转项目"); |
|||
assertThat(col3.get("count")).isEqualTo(0); |
|||
assertThat((BigDecimal) col3.get("totalAmount")).isEqualByComparingTo(new BigDecimal("0")); |
|||
|
|||
// 视口外列追加:商务谈判(O4 已转项目 ¥400)
|
|||
Map<String, Object> col4 = rows.get(3); |
|||
assertThat(col4.get("stageName")).isEqualTo("商务谈判"); |
|||
assertThat(col4.get("count")).isEqualTo(1); |
|||
assertThat((BigDecimal) col4.get("totalAmount")).isEqualByComparingTo(new BigDecimal("400")); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("summary:O5 无阶段值不上板(列计数不含 null current_stage_id 行)") |
|||
void summary_nullStage_notOnBoard() { |
|||
List<Map<String, Object>> rows = boardService.boardStageSummary("MANAGE", T1, USER_ID); |
|||
|
|||
int totalCount = rows.stream() |
|||
.mapToInt(row -> ((Number) row.get("count")).intValue()) |
|||
.sum(); |
|||
// 仅 O1..O4 四行上板;O5(current_stage_id=null) 被排除
|
|||
assertThat(totalCount).isEqualTo(4); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("summary:无显式视口时回退当前用户部门绑定模板(resolveBindingVersion → 部门绑定优先)") |
|||
void summary_nullViewport_resolvesDeptBindingTemplate() { |
|||
// T1 未绑定部门(apply_scope=1 全公司默认);T2 绑定 DEPT_ID(apply_scope=2 部门专用)
|
|||
session.getMapper(OpportunityStageTemplateDeptMapper.class) |
|||
.insert(deptRow(T2, DEPT_ID)); |
|||
|
|||
List<Map<String, Object>> rows = boardService.boardStageSummary("MANAGE", null, USER_ID); |
|||
|
|||
// 视口 = T2:客户圈定(seq1) → 商务谈判(seq2);视口外 = T1 的方案引导(已转项目无商机命中,不出现)
|
|||
assertThat(rows).extracting(row -> row.get("stageName")) |
|||
.containsExactly("客户圈定", "商务谈判", "方案引导"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("cards:按 stageName 取卡(跨模板同名一次取全),卡片带 oppStatus") |
|||
void cards_byStageName_withOppStatus() { |
|||
List<Map<String, Object>> cards = boardService.boardCards("MANAGE", "客户圈定", 0, 10, USER_ID); |
|||
|
|||
assertThat(cards).hasSize(2); |
|||
assertThat(cards).extracting(card -> card.get("id")).containsExactlyInAnyOrder(1L, 3L); |
|||
// 每张卡携带 oppStatus:O1=2 推进中、O3=4 已关闭
|
|||
assertThat(cards).extracting(card -> card.get("oppStatus")) |
|||
.containsExactlyInAnyOrder(2, 4); |
|||
assertThat(cards).allSatisfy(card -> { |
|||
assertThat(card).containsKeys("id", "oppName", "ownerUserId", "projectAmount", |
|||
"currentStageId", "oppStatus"); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("cards:空白 stageName 或未解析到的展示名 → 空列表") |
|||
void cards_blankOrUnknownStageName_empty() { |
|||
assertThat(boardService.boardCards("MANAGE", " ", 0, 10, USER_ID)).isEmpty(); |
|||
assertThat(boardService.boardCards("MANAGE", "不存在的阶段", 0, 10, USER_ID)).isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("阶段模板发布:同版本内展示名重复(custom 撞 custom)→ 拒绝 64017") |
|||
void publish_duplicateDisplayName_rejected() { |
|||
// 启用字典项桩:校验先过字典状态关,才能触达展示名唯一性校验
|
|||
DictItemDTO enabled01 = new DictItemDTO(); |
|||
enabled01.setCode("OPP_STAGE_01"); |
|||
DictItemDTO enabled02 = new DictItemDTO(); |
|||
enabled02.setCode("OPP_STAGE_02"); |
|||
when(dictQueryService.listEnabledItems(OpportunityRuleConstants.STAGE_DICT_GROUP_CODE)) |
|||
.thenReturn(List.of(enabled01, enabled02)); |
|||
|
|||
OpportunityStageTemplateDTO dto = new OpportunityStageTemplateDTO(); |
|||
dto.setTemplateName("重复展示名模板"); |
|||
dto.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_ALL); |
|||
dto.setIsDefault(OpportunityRuleConstants.FLAG_NO); |
|||
dto.setNodes(List.of(nodeItem("OPP_STAGE_01", "客户圈定"), |
|||
nodeItem("OPP_STAGE_02", "客户圈定"))); |
|||
|
|||
assertThatThrownBy(() -> ruleService.saveAndPublish(dto)) |
|||
.isInstanceOf(BusinessErrorException.class) |
|||
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_NODE_INVALID) |
|||
.hasMessageContaining("展示名重复"); |
|||
} |
|||
|
|||
// ==================== 数据工厂 ====================
|
|||
|
|||
private OpportunityStageTemplateDTO.NodeItem nodeItem(String dictCode, String customName) { |
|||
OpportunityStageTemplateDTO.NodeItem item = new OpportunityStageTemplateDTO.NodeItem(); |
|||
item.setStageDictCode(dictCode); |
|||
item.setCustomNodeName(customName); |
|||
return item; |
|||
} |
|||
|
|||
private OpportunityStageTemplateDept deptRow(Long templateVersionId, Long deptId) { |
|||
OpportunityStageTemplateDept d = new OpportunityStageTemplateDept(); |
|||
d.setTemplateVersionId(templateVersionId); |
|||
d.setDeptId(deptId); |
|||
return d; |
|||
} |
|||
|
|||
// ==================== 建表 DDL ====================
|
|||
|
|||
private static final String[] SCHEMA = { |
|||
""" |
|||
CREATE TABLE opportunity ( |
|||
id BIGINT PRIMARY KEY, creator_id VARCHAR(50), create_time DATETIME, |
|||
updater_id VARCHAR(50), update_time DATETIME, deleted TINYINT NOT NULL DEFAULT 0, |
|||
opp_name VARCHAR(200), opp_source VARCHAR(64), opp_type VARCHAR(64), |
|||
industry_code VARCHAR(64), bid_form VARCHAR(64), locality_type VARCHAR(64), |
|||
party_a_clear TINYINT, party_a VARCHAR(200), province_code VARCHAR(12), |
|||
city_code VARCHAR(12), remark TEXT, project_amount DECIMAL(14,2), address VARCHAR(255), |
|||
owner_user_id BIGINT, owner_name_snapshot VARCHAR(64), owner_dept_id BIGINT, |
|||
origin_dept_id BIGINT, pool_reason VARCHAR(32), creator_user_id BIGINT, |
|||
opp_status TINYINT, current_stage_id BIGINT, stage_template_id BIGINT, |
|||
stage_template_version VARCHAR(20), source_lead_id BIGINT, source_lead_name VARCHAR(200), |
|||
source_phone VARCHAR(32), source_product_code VARCHAR(64), claim_time DATETIME, |
|||
last_valid_follow_time DATETIME, primary_customer_id BIGINT, |
|||
primary_customer_name_snapshot VARCHAR(200), key_contact_id BIGINT, |
|||
key_contact_name_snapshot VARCHAR(200), key_contact_company_snapshot VARCHAR(200), |
|||
scheme_budget DECIMAL(18,2), scheme_card_status INT, pause_time DATETIME, |
|||
pause_reason VARCHAR(32), pause_expected_restart_date DATE, pause_remark VARCHAR(500), |
|||
version INT NOT NULL DEFAULT 0) |
|||
""", |
|||
""" |
|||
CREATE TABLE opportunity_stage_template ( |
|||
id BIGINT PRIMARY KEY, creator_id VARCHAR(50), create_time DATETIME, |
|||
updater_id VARCHAR(50), update_time DATETIME, deleted TINYINT NOT NULL DEFAULT 0, |
|||
template_code VARCHAR(64), template_name VARCHAR(100), version_no VARCHAR(20), |
|||
status TINYINT, apply_scope TINYINT, is_default TINYINT DEFAULT 0, |
|||
template_desc VARCHAR(500)) |
|||
""", |
|||
""" |
|||
CREATE TABLE opportunity_stage_node ( |
|||
id BIGINT PRIMARY KEY, template_id BIGINT, seq_no INT, stage_dict_code VARCHAR(64), |
|||
custom_node_name VARCHAR(100), work_goal VARCHAR(500), is_fixed TINYINT DEFAULT 0) |
|||
""", |
|||
""" |
|||
CREATE TABLE opportunity_stage_template_dept ( |
|||
id BIGINT PRIMARY KEY, template_version_id BIGINT, dept_id BIGINT, |
|||
CONSTRAINT uk_ost_version_dept UNIQUE (template_version_id, dept_id)) |
|||
""", |
|||
}; |
|||
|
|||
private static final String[] RESET = { |
|||
"DELETE FROM opportunity", |
|||
"DELETE FROM opportunity_stage_node", |
|||
"DELETE FROM opportunity_stage_template", |
|||
"DELETE FROM opportunity_stage_template_dept", |
|||
}; |
|||
|
|||
private static final String[] SEED = { |
|||
// 模板:T1 全公司默认(视口),T2 部门专用
|
|||
"INSERT INTO opportunity_stage_template (id, deleted, template_code, template_name, version_no, status, apply_scope, is_default) " |
|||
+ "VALUES (" + T1 + ", 0, 'OPP_STAGE_TPL_01', '通用阶段模板', 'V1.0', 2, 1, 1)", |
|||
"INSERT INTO opportunity_stage_template (id, deleted, template_code, template_name, version_no, status, apply_scope, is_default) " |
|||
+ "VALUES (" + T2 + ", 0, 'OPP_STAGE_TPL_02', '华东阶段模板', 'V1.0', 2, 2, 0)", |
|||
// 节点:T1 = 客户圈定/方案引导/已转项目(固定);T2 = 客户圈定(同名)/商务谈判
|
|||
"INSERT INTO opportunity_stage_node (id, template_id, seq_no, stage_dict_code, custom_node_name, is_fixed) " |
|||
+ "VALUES (" + N1 + ", " + T1 + ", 1, 'OPP_STAGE_01', '客户圈定', 0)", |
|||
"INSERT INTO opportunity_stage_node (id, template_id, seq_no, stage_dict_code, custom_node_name, is_fixed) " |
|||
+ "VALUES (" + N2 + ", " + T1 + ", 2, 'OPP_STAGE_02', '方案引导', 0)", |
|||
"INSERT INTO opportunity_stage_node (id, template_id, seq_no, stage_dict_code, custom_node_name, is_fixed) " |
|||
+ "VALUES (" + N3 + ", " + T1 + ", 3, 'OPP_STAGE_05', '已转项目', 1)", |
|||
"INSERT INTO opportunity_stage_node (id, template_id, seq_no, stage_dict_code, custom_node_name, is_fixed) " |
|||
+ "VALUES (" + N4 + ", " + T2 + ", 1, 'OPP_STAGE_01', '客户圈定', 0)", |
|||
"INSERT INTO opportunity_stage_node (id, template_id, seq_no, stage_dict_code, custom_node_name, is_fixed) " |
|||
+ "VALUES (" + N5 + ", " + T2 + ", 2, 'OPP_STAGE_03', '商务谈判', 0)", |
|||
// 商机:O1 推进中→N1;O2 暂缓中→N2;O3 已关闭→N4;O4 已转项目→N5;O5 无阶段值(不上板)
|
|||
"INSERT INTO opportunity (id, deleted, opp_name, project_amount, opp_status, current_stage_id, stage_template_id, stage_template_version, version) " |
|||
+ "VALUES (1, 0, '商机A', 100, 2, " + N1 + ", " + T1 + ", 'V1.0', 0)", |
|||
"INSERT INTO opportunity (id, deleted, opp_name, project_amount, opp_status, current_stage_id, stage_template_id, stage_template_version, version) " |
|||
+ "VALUES (2, 0, '商机B', 200, 3, " + N2 + ", " + T1 + ", 'V1.0', 0)", |
|||
"INSERT INTO opportunity (id, deleted, opp_name, project_amount, opp_status, current_stage_id, stage_template_id, stage_template_version, version) " |
|||
+ "VALUES (3, 0, '商机C', 300, 4, " + N4 + ", " + T2 + ", 'V1.0', 0)", |
|||
"INSERT INTO opportunity (id, deleted, opp_name, project_amount, opp_status, current_stage_id, stage_template_id, stage_template_version, version) " |
|||
+ "VALUES (4, 0, '商机D', 400, 5, " + N5 + ", " + T2 + ", 'V1.0', 0)", |
|||
"INSERT INTO opportunity (id, deleted, opp_name, project_amount, opp_status, current_stage_id, stage_template_id, stage_template_version, version) " |
|||
+ "VALUES (5, 0, '商机E', 500, 2, NULL, NULL, NULL, 0)", |
|||
}; |
|||
} |
|||
@ -0,0 +1,282 @@ |
|||
package com.crm.opportunity.service.impl; |
|||
|
|||
import com.baomidou.mybatisplus.core.MybatisConfiguration; |
|||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
|||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; |
|||
import com.crm.opportunity.domain.entity.Opportunity; |
|||
import com.crm.opportunity.mapper.OpportunityFocusMapper; |
|||
import com.crm.opportunity.mapper.OpportunityMapper; |
|||
import com.crm.opportunity.mapper.OpportunityViewLogMapper; |
|||
import com.crm.opportunity.owner.OwnerSnapshot; |
|||
import com.crm.opportunity.owner.OwnerSnapshotResolver; |
|||
import com.crm.opportunity.query.OpportunityViewFilter; |
|||
import com.crm.rule.domain.entity.OpportunityStageNode; |
|||
import com.crm.rule.domain.entity.OpportunityStageTemplate; |
|||
import com.crm.rule.service.IOpportunityStageTemplateService; |
|||
import org.apache.ibatis.builder.MapperBuilderAssistant; |
|||
import org.junit.jupiter.api.BeforeAll; |
|||
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.Spy; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.eq; |
|||
import static org.mockito.Mockito.lenient; |
|||
import static org.mockito.Mockito.never; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
/** |
|||
* 看板两端点规格验证(票 03 / 票 04)。 |
|||
* |
|||
* <p>断言点:</p> |
|||
* <ul> |
|||
* <li>基础数据集不再按 opp_status=推进中 过滤,只锚定 current_stage_id 非空;公海视图的 |
|||
* 待领取 + owner 空条件不变</li> |
|||
* <li>summary 按展示名分组、跨模板同名合并、视口列顺序 + 空列 count=0、视口外按 seq_no 追加</li> |
|||
* <li>stageTemplateId 仅决定列顺序,不过滤数据集</li> |
|||
* <li>cards 入参 stageName 解析成节点 id 集合过滤,卡片带 oppStatus</li> |
|||
* </ul> |
|||
*/ |
|||
@DisplayName("商机看板(票 03/04)") |
|||
@ExtendWith(MockitoExtension.class) |
|||
class OpportunityCollabServiceImplTest { |
|||
|
|||
private static final Long USER_ID = 1001L; |
|||
private static final Long DEPT_ID = 5001L; |
|||
private static final Long VIEWPORT_TPL_ID = 9001L; |
|||
private static final Long OTHER_TPL_ID = 9002L; |
|||
|
|||
@Mock private OpportunityFocusMapper focusMapper; |
|||
@Mock private OpportunityViewLogMapper viewLogMapper; |
|||
@Mock private OpportunityMapper oppMapper; |
|||
@Spy private OpportunityViewFilter viewFilter = new OpportunityViewFilter(); |
|||
@Mock private IOpportunityStageTemplateService stageTemplateService; |
|||
@Mock private OwnerSnapshotResolver ownerSnapshotResolver; |
|||
|
|||
@InjectMocks |
|||
private OpportunityCollabServiceImpl service; |
|||
|
|||
@BeforeAll |
|||
static void initLambdaCache() { |
|||
MapperBuilderAssistant assistant = |
|||
new MapperBuilderAssistant(new MybatisConfiguration(), ""); |
|||
TableInfoHelper.initTableInfo(assistant, Opportunity.class); |
|||
} |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
lenient().when(stageTemplateService.listNodesOfVersion(any())).thenReturn(List.of()); |
|||
lenient().when(stageTemplateService.describeStageNames(any())).thenReturn(Map.of()); |
|||
lenient().when(stageTemplateService.resolveStageIdsByDisplayName(any())).thenReturn(Map.of()); |
|||
lenient().when(ownerSnapshotResolver.of(any())).thenReturn(new OwnerSnapshot(USER_ID, "u", DEPT_ID)); |
|||
} |
|||
|
|||
// ==================== 工厂 ====================
|
|||
|
|||
private Opportunity opp(Long id, Long stageId, Long tplId, Integer status, String amount) { |
|||
Opportunity o = new Opportunity(); |
|||
o.setId(id); |
|||
o.setOppName("商机" + id); |
|||
o.setCurrentStageId(stageId); |
|||
o.setStageTemplateId(tplId); |
|||
o.setOppStatus(status); |
|||
o.setProjectAmount(amount == null ? null : new BigDecimal(amount)); |
|||
return o; |
|||
} |
|||
|
|||
private OpportunityStageNode node(Long id, Long tplId, int seqNo, String displayName) { |
|||
OpportunityStageNode n = new OpportunityStageNode(); |
|||
n.setId(id); |
|||
n.setTemplateId(tplId); |
|||
n.setSeqNo(seqNo); |
|||
n.setCustomNodeName(displayName); |
|||
return n; |
|||
} |
|||
|
|||
@SuppressWarnings({"rawtypes", "unchecked"}) |
|||
private String captureSummarySql(String viewType, Long stageTemplateId) { |
|||
when(oppMapper.selectList(any())).thenReturn(List.of()); |
|||
service.boardStageSummary(viewType, stageTemplateId, USER_ID); |
|||
ArgumentCaptor<LambdaQueryWrapper> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); |
|||
verify(oppMapper).selectList(captor.capture()); |
|||
return captor.getValue().getSqlSegment(); |
|||
} |
|||
|
|||
// ==================== 票 03:基础数据集口径 ====================
|
|||
|
|||
@Test |
|||
@DisplayName("MINE 看板:不再叠加 opp_status=推进中,只锚定 current_stage_id 非空 + 视图条件") |
|||
void summary_mineDropsAdvancingStatusFilter() { |
|||
String sql = captureSummarySql("MINE", VIEWPORT_TPL_ID); |
|||
|
|||
assertThat(sql).contains("current_stage_id IS NOT NULL"); |
|||
assertThat(sql).doesNotContain("opp_status"); |
|||
assertThat(sql).contains("owner_user_id"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("PUBLIC_POOL 看板:待领取 + owner 空条件不变,且同样锚定阶段非空") |
|||
void summary_publicPoolKeepsPoolDefinition() { |
|||
String sql = captureSummarySql("PUBLIC_POOL", VIEWPORT_TPL_ID); |
|||
|
|||
assertThat(sql).contains("current_stage_id IS NOT NULL"); |
|||
assertThat(sql).contains("opp_status"); |
|||
assertThat(sql).contains("owner_user_id IS NULL"); |
|||
} |
|||
|
|||
// ==================== 票 04:summary 按展示名分列 ====================
|
|||
|
|||
@Test |
|||
@DisplayName("summary:跨模板同名合并为一列,视口列顺序由视口模板 seq_no 决定") |
|||
void summary_groupsByDisplayNameAndMergesCrossTemplate() { |
|||
when(stageTemplateService.listNodesOfVersion(VIEWPORT_TPL_ID)) |
|||
.thenReturn(List.of(node(11L, VIEWPORT_TPL_ID, 1, "关系摸排"), |
|||
node(12L, VIEWPORT_TPL_ID, 2, "方案引导"))); |
|||
when(stageTemplateService.listNodesOfVersion(OTHER_TPL_ID)) |
|||
.thenReturn(List.of(node(21L, OTHER_TPL_ID, 1, "关系摸排"), |
|||
node(22L, OTHER_TPL_ID, 2, "方案引导"))); |
|||
when(stageTemplateService.describeStageNames(any())).thenReturn(Map.of( |
|||
11L, "关系摸排", 12L, "方案引导", 21L, "关系摸排", 22L, "方案引导")); |
|||
when(oppMapper.selectList(any())).thenReturn(List.of( |
|||
opp(1L, 11L, VIEWPORT_TPL_ID, 2, "10"), |
|||
opp(2L, 21L, OTHER_TPL_ID, 3, "20"), |
|||
opp(3L, 22L, OTHER_TPL_ID, 4, "5"))); |
|||
|
|||
List<Map<String, Object>> result = service.boardStageSummary("MINE", VIEWPORT_TPL_ID, USER_ID); |
|||
|
|||
assertThat(result).hasSize(2); |
|||
assertThat(result.get(0)) |
|||
.containsEntry("stageName", "关系摸排") |
|||
.containsEntry("count", 2) |
|||
.containsEntry("totalAmount", new BigDecimal("30")); |
|||
assertThat(result.get(1)) |
|||
.containsEntry("stageName", "方案引导") |
|||
.containsEntry("count", 1) |
|||
.containsEntry("totalAmount", new BigDecimal("5")); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("summary:空列 count=0 返回;视口外展示名按各自模板 seq_no 追加列末") |
|||
void summary_emptyColumnsReturnedAndOutsideAppendedBySeq() { |
|||
when(stageTemplateService.listNodesOfVersion(VIEWPORT_TPL_ID)) |
|||
.thenReturn(List.of(node(11L, VIEWPORT_TPL_ID, 1, "关系摸排"), |
|||
node(12L, VIEWPORT_TPL_ID, 2, "已转项目"))); |
|||
when(stageTemplateService.listNodesOfVersion(OTHER_TPL_ID)) |
|||
.thenReturn(List.of(node(31L, OTHER_TPL_ID, 3, "方案引导"))); |
|||
when(stageTemplateService.describeStageNames(any())).thenReturn(Map.of( |
|||
11L, "关系摸排", 12L, "已转项目", 31L, "方案引导")); |
|||
when(oppMapper.selectList(any())).thenReturn(List.of( |
|||
opp(1L, 31L, OTHER_TPL_ID, 5, "7"))); |
|||
|
|||
List<Map<String, Object>> result = service.boardStageSummary("MINE", VIEWPORT_TPL_ID, USER_ID); |
|||
|
|||
assertThat(result).hasSize(3); |
|||
assertThat(result.get(0)).containsEntry("stageName", "关系摸排").containsEntry("count", 0); |
|||
assertThat(result.get(1)).containsEntry("stageName", "已转项目").containsEntry("count", 0); |
|||
assertThat(result.get(2)).containsEntry("stageName", "方案引导").containsEntry("count", 1); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("summary:stageTemplateId 只决定列顺序,不过滤其他模板商机上板") |
|||
void summary_viewportTemplateDoesNotFilterDataset() { |
|||
when(stageTemplateService.listNodesOfVersion(VIEWPORT_TPL_ID)) |
|||
.thenReturn(List.of(node(11L, VIEWPORT_TPL_ID, 1, "关系摸排"))); |
|||
when(stageTemplateService.listNodesOfVersion(OTHER_TPL_ID)) |
|||
.thenReturn(List.of(node(31L, OTHER_TPL_ID, 5, "方案引导"))); |
|||
when(stageTemplateService.describeStageNames(any())).thenReturn(Map.of( |
|||
11L, "关系摸排", 31L, "方案引导")); |
|||
// 商机在 OTHER_TPL_ID(非视口模板),仍应上板
|
|||
when(oppMapper.selectList(any())).thenReturn(List.of( |
|||
opp(1L, 31L, OTHER_TPL_ID, 3, "9"))); |
|||
|
|||
List<Map<String, Object>> result = service.boardStageSummary("MINE", VIEWPORT_TPL_ID, USER_ID); |
|||
|
|||
assertThat(result).extracting(row -> row.get("stageName")) |
|||
.containsExactly("关系摸排", "方案引导"); |
|||
assertThat(result.get(1)).containsEntry("count", 1); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("summary:stageTemplateId 为空 → 当前用户部门绑定模板 → 全公司默认模板解析视口") |
|||
void summary_nullStageTemplateIdResolvesViewportFromUserDept() { |
|||
OpportunityStageTemplate bound = new OpportunityStageTemplate(); |
|||
bound.setId(VIEWPORT_TPL_ID); |
|||
when(ownerSnapshotResolver.of(USER_ID)).thenReturn(new OwnerSnapshot(USER_ID, "u", DEPT_ID)); |
|||
when(stageTemplateService.resolveBindingVersion(DEPT_ID)).thenReturn(bound); |
|||
when(stageTemplateService.listNodesOfVersion(VIEWPORT_TPL_ID)) |
|||
.thenReturn(List.of(node(11L, VIEWPORT_TPL_ID, 1, "关系摸排"), |
|||
node(12L, VIEWPORT_TPL_ID, 2, "方案引导"))); |
|||
when(stageTemplateService.describeStageNames(any())).thenReturn(Map.of( |
|||
11L, "关系摸排", 12L, "方案引导")); |
|||
when(oppMapper.selectList(any())).thenReturn(List.of()); |
|||
|
|||
List<Map<String, Object>> result = service.boardStageSummary("MINE", null, USER_ID); |
|||
|
|||
assertThat(result).extracting(row -> row.get("stageName")) |
|||
.containsExactly("关系摸排", "方案引导"); |
|||
verify(stageTemplateService).resolveBindingVersion(DEPT_ID); |
|||
} |
|||
|
|||
// ==================== 票 04:cards 按展示名取卡 ====================
|
|||
|
|||
@Test |
|||
@DisplayName("cards:stageName 解析成节点 id 集合过滤(跨模板同名一次取全),卡片带 oppStatus") |
|||
void cards_resolvesStageNameAndCarriesOppStatus() { |
|||
when(stageTemplateService.resolveStageIdsByDisplayName(java.util.Set.of("关系摸排"))) |
|||
.thenReturn(Map.of("关系摸排", java.util.Set.of(11L, 21L))); |
|||
when(oppMapper.selectList(any())).thenReturn(List.of( |
|||
opp(1L, 11L, VIEWPORT_TPL_ID, 3, "10"), |
|||
opp(2L, 21L, OTHER_TPL_ID, 5, "20"))); |
|||
|
|||
List<Map<String, Object>> result = service.boardCards("MINE", "关系摸排", 0, 20, USER_ID); |
|||
|
|||
assertThat(result).hasSize(2); |
|||
assertThat(result.get(0)).containsEntry("oppStatus", 3); |
|||
assertThat(result.get(1)).containsEntry("oppStatus", 5); |
|||
assertThat(result.get(0)).containsKey("currentStageId"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("cards:stageName 无匹配节点 id → 不查商机,直接空列表") |
|||
void cards_noMatchingStageIds_returnsEmptyWithoutQuery() { |
|||
when(stageTemplateService.resolveStageIdsByDisplayName(java.util.Set.of("不存在"))) |
|||
.thenReturn(Map.of()); |
|||
|
|||
assertThat(service.boardCards("MINE", "不存在", 0, 20, USER_ID)).isEmpty(); |
|||
verify(oppMapper, never()).selectList(any()); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("cards:stageName 空白 → 直接空列表") |
|||
void cards_blankStageName_returnsEmpty() { |
|||
assertThat(service.boardCards("MINE", " ", 0, 20, USER_ID)).isEmpty(); |
|||
verify(oppMapper, never()).selectList(any()); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("cards:解析出的节点 id 集合应用到 current_stage_id IN 过滤") |
|||
void cards_appliesStageIdInFilter() { |
|||
when(stageTemplateService.resolveStageIdsByDisplayName(java.util.Set.of("关系摸排"))) |
|||
.thenReturn(Map.of("关系摸排", java.util.Set.of(11L, 21L))); |
|||
when(oppMapper.selectList(any())).thenReturn(List.of()); |
|||
|
|||
service.boardCards("MINE", "关系摸排", 0, 20, USER_ID); |
|||
|
|||
@SuppressWarnings({"rawtypes", "unchecked"}) |
|||
ArgumentCaptor<LambdaQueryWrapper> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); |
|||
verify(oppMapper).selectList(captor.capture()); |
|||
assertThat(captor.getValue().getSqlSegment()).contains("current_stage_id IN"); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue