Browse Source

refactor(opportunity): 转商机 port 改为 OpportunityIntake 薄 adapter(Candidate #1 步3)

- CreateOpportunityCmd -> OpportunityIntakeSpec 翻译(source=LEAD_CONVERT,
  oppSource 恒 opp_source_01,修正原实现漏填 opp_source 的缺陷)
- OpportunityIntakeException -> OpportunityCreationException seam 翻译
- 建档五步机制与唯一性防护移除(收敛于 OpportunityIntakeImpl)
- adapter 不再自带 @Transactional(crm-lead 外层事务 + open REQUIRED)
- 测试瘦身为翻译层断言,机制断言由 OpportunityIntakeImplTest 覆盖
master
luoweijian 2 weeks ago
parent
commit
29db6a8ce4
  1. 188
      crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityCreationPortImpl.java
  2. 245
      crm-opportunity/src/test/java/com/crm/opportunity/port/inbound/OpportunityCreationPortImplTest.java

188
crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityCreationPortImpl.java

@ -1,179 +1,59 @@
package com.crm.opportunity.port.inbound.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.crm.lead.port.outbound.CreateOpportunityCmd;
import com.crm.lead.port.outbound.OpportunityCreationException;
import com.crm.lead.port.outbound.OpportunityCreationPort;
import com.crm.opportunity.domain.entity.Opportunity;
import com.crm.opportunity.domain.entity.OpportunityCustomer;
import com.crm.opportunity.domain.entity.OpportunityOplog;
import com.crm.opportunity.domain.entity.OpportunityTeam;
import com.crm.opportunity.domain.enums.OplogKind;
import com.crm.opportunity.domain.enums.OplogLogType;
import com.crm.opportunity.domain.enums.OpportunityStatus;
import com.crm.opportunity.domain.enums.TeamMemberPermission;
import com.crm.opportunity.mapper.OpportunityCustomerMapper;
import com.crm.opportunity.mapper.OpportunityMapper;
import com.crm.opportunity.mapper.OpportunityOplogMapper;
import com.crm.opportunity.mapper.OpportunityTeamMapper;
import com.crm.rule.domain.entity.OpportunityStageNode;
import com.crm.rule.domain.entity.OpportunityStageTemplate;
import com.crm.rule.service.IOpportunityStageTemplateService;
import com.crm.opportunity.intake.IntakeSource;
import com.crm.opportunity.intake.OpportunityIntake;
import com.crm.opportunity.intake.OpportunityIntakeException;
import com.crm.opportunity.intake.OpportunityIntakeSpec;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* 线索转商机新建端口实现 11 消费 + 12 唯一性预检 + 13 关联客户子表
* 线索转商机新建端口实现 adapterCandidate #1建档机制收敛进深模块 OpportunityIntake
*
* <p>crm-lead 声明的出站契约 {@link OpportunityCreationPort} 的商机侧实现同库同事务ADR-0020
* crm-lead 事务内调用本方法任何失败抛 {@link OpportunityCreationException}crm-lead 捕获后整体回滚</p>
* 本层只做翻译 {@link CreateOpportunityCmd} 组装成 {@link OpportunityIntakeSpec}
* source=LEAD_CONVERToppSource {@link IntakeSource#defaultOppSource()}=opp_source_01
* 修正原实现漏填 opp_source 的缺陷 {@link OpportunityIntake#open}深模块的
* {@link OpportunityIntakeException} seam 处译回 {@link OpportunityCreationException}</p>
*
* <p><b> 12 唯一性预检</b>新建前查 {@code source_lead_id} 是否已被占用命中即抛业务错误
* 避免依赖 DB 唯一键冲突走错误路径DB {@code uk(source_lead_id)} 仍作并发最终防线</p>
* <p>建档五步机制主表落库 + 阶段落位 + 主要意向客户子表 + 初始操作日志 + 团队成员
* source_lead_id 唯一性防护均不再出现在本层 OpportunityIntakeImpl 承载并测试</p>
*
* <p><b> A4 走法甲</b>{@code cmd.customerId()} 非空时建一条 {@code is_primary_intended=1}
* 主要意向客户子表 + 刷主表冗余 {@code primary_customer_id/name} 03 C3
* A4 客户下拉未就绪前 customerId 可空此时跳过建子表 / 不刷冗余不倒退现有自由文本转商机
* A4 就绪后收紧为必填</p>
* <p>事务本层不再标 @Transactionalcrm-lead 外层事务 + {@code open} REQUIRED 传播
* 保证原子性ADR-0020 语义不变cmd.poolId 为线索侧信息商机建档不消费</p>
*/
@Service
@RequiredArgsConstructor
public class OpportunityCreationPortImpl implements OpportunityCreationPort {
/** 转商机来源标记:线索转入的意向客户角色(票 13,customer_role 字典)。 */
private static final String ROLE_INTENDED = "intended";
/** 领取人项目角色:商机负责人(crm-dict project_role 分组,票 02)。 */
private static final String PROJECT_ROLE_OWNER = "project_role_01";
private final OpportunityMapper oppMapper;
private final OpportunityCustomerMapper customerMapper;
private final OpportunityOplogMapper oplogMapper;
private final OpportunityTeamMapper teamMapper;
private final IOpportunityStageTemplateService stageTemplateService;
private final OpportunityIntake intake;
@Override
@Transactional(rollbackFor = Exception.class)
public Long createOpportunity(CreateOpportunityCmd cmd) {
// 票 12:source_lead_id 唯一性预检(应用层友好报错,DB 唯一键作并发最终防线)
Long existing = oppMapper.selectCount(new LambdaQueryWrapper<Opportunity>()
.eq(Opportunity::getSourceLeadId, cmd.sourceLeadId()));
if (existing != null && existing > 0) {
throw new OpportunityCreationException("该线索已关联其他商机:leadId=" + cmd.sourceLeadId());
}
Opportunity opp = buildOpportunity(cmd);
OpportunityIntakeSpec spec = new OpportunityIntakeSpec(
IntakeSource.LEAD_CONVERT,
IntakeSource.LEAD_CONVERT.defaultOppSource(),
cmd.opportunityName(),
cmd.industryCode(),
cmd.partyA(),
cmd.provinceCode(),
cmd.cityCode(),
cmd.remark(),
cmd.ownerUserId(),
cmd.ownerDeptId(),
cmd.customerId(),
cmd.intendedCustomer(),
cmd.sourceLeadId(),
cmd.sourceLeadName(),
cmd.sourcePhone(),
cmd.sourceProductCode());
try {
oppMapper.insert(opp);
} catch (org.springframework.dao.DuplicateKeyException e) {
// 并发窗口:预检通过后另一事务先插入 → DB uk(source_lead_id) 命中,转译为同一契约异常
throw new OpportunityCreationException("该线索已关联其他商机:leadId=" + cmd.sourceLeadId(), e);
}
// 票 13:customerId 非空 → 建主要意向客户子表 + 刷主表冗余(走法甲:可空则跳过)
if (cmd.customerId() != null) {
linkPrimaryCustomer(opp, cmd);
}
// 票 02 步 3:初始操作日志(由线索转入创建)
writeInitialOplog(opp.getId(), cmd);
// 票 02 步 4:团队成员(领取人=商机负责人)
insertOwnerTeamMember(opp.getId(), cmd);
return opp.getId();
}
private Opportunity buildOpportunity(CreateOpportunityCmd cmd) {
Opportunity opp = new Opportunity();
opp.setOppName(cmd.opportunityName());
opp.setIndustryCode(cmd.industryCode());
opp.setPartyA(cmd.partyA());
opp.setRemark(cmd.remark());
// 省/市国标 code(与线索同构两级,由转商机弹窗地区选择器带出)
opp.setProvinceCode(cmd.provinceCode());
opp.setCityCode(cmd.cityCode());
// 归属 / 来源快照(从线索带出)
opp.setOwnerUserId(cmd.ownerUserId());
opp.setOwnerDeptId(cmd.ownerDeptId());
opp.setCreatorUserId(cmd.ownerUserId());
opp.setSourceLeadId(cmd.sourceLeadId());
opp.setSourceLeadName(cmd.sourceLeadName());
opp.setSourcePhone(cmd.sourcePhone());
opp.setSourceProductCode(cmd.sourceProductCode());
// 转商机即领取,进入推进中;领取时间落库
opp.setOppStatus(OpportunityStatus.STATUS_ADVANCING.getValue());
opp.setClaimTime(LocalDateTime.now());
// 意向客户名快照(走法 B:引用 + 快照并存);customerId 非空时下方刷主表冗余
opp.setPrimaryCustomerNameSnapshot(cmd.intendedCustomer());
// 票 02 步 2:阶段落位——按部门解析发布中模板版本 + 首节点(无模板容忍空,走法 A)
applyInitialStage(opp, cmd);
return opp;
}
/**
* 02 2 / 06按商机负责人部门解析应绑定的发布中阶段模板版本
* current_stage_id 落首节点listNodesOfVersion 已按 seq_no 升序并锁模板版本 id/
* 无任何发布中模板resolveBindingVersion null时三字段留空容忍空走法 A
* 15 默认模板兜底使其常态不触发
*/
private void applyInitialStage(Opportunity opp, CreateOpportunityCmd cmd) {
OpportunityStageTemplate version = stageTemplateService.resolveBindingVersion(cmd.ownerDeptId());
if (version == null) {
return;
}
List<OpportunityStageNode> nodes = stageTemplateService.listNodesOfVersion(version.getId());
if (nodes == null || nodes.isEmpty()) {
return;
}
opp.setStageTemplateId(version.getId());
opp.setStageTemplateVersion(version.getVersionNo());
opp.setCurrentStageId(nodes.get(0).getId());
}
/** 票 02 步 3:写一条初始操作日志(整行新增 / 子表增删 / 系统自动,“由线索转入创建”)。 */
private void writeInitialOplog(Long oppId, CreateOpportunityCmd cmd) {
OpportunityOplog log = new OpportunityOplog();
log.setOppId(oppId);
log.setOpKind(OplogKind.ROW_ADD.getValue());
log.setLogType(OplogLogType.ROW_CHANGE.getValue());
log.setEntityName("商机");
log.setOpDesc("由线索转入创建");
log.setOpSource("SYSTEM");
log.setOpTime(LocalDateTime.now());
log.setOpUserId(cmd.ownerUserId());
oplogMapper.insert(log);
}
/** 票 02 步 4:领取人=商机负责人,插一条团队成员(project_role_01 / READ_WRITE)。 */
private void insertOwnerTeamMember(Long oppId, CreateOpportunityCmd cmd) {
OpportunityTeam member = new OpportunityTeam();
member.setOpportunityId(oppId);
member.setUserId(cmd.ownerUserId());
member.setProjectRole(PROJECT_ROLE_OWNER);
member.setPermission(TeamMemberPermission.READ_WRITE.getValue());
member.setDeleteKey(0L);
teamMapper.insert(member);
}
/** 票 13:建 is_primary_intended=1 主要意向客户子表 + 刷主表冗余 primary_customer_id/name。 */
private void linkPrimaryCustomer(Opportunity opp, CreateOpportunityCmd cmd) {
OpportunityCustomer link = new OpportunityCustomer();
link.setOpportunityId(opp.getId());
link.setCustomerId(cmd.customerId());
link.setCustomerNameSnapshot(cmd.intendedCustomer());
link.setCustomerRole(ROLE_INTENDED);
link.setIsPrimaryIntended(1);
customerMapper.insert(link);
// 主表冗余(票 03 C3):主要意向客户 id/名,高频展示免 join
opp.setPrimaryCustomerId(cmd.customerId());
if (StrUtil.isNotBlank(cmd.intendedCustomer())) {
opp.setPrimaryCustomerNameSnapshot(cmd.intendedCustomer());
return intake.open(spec);
} catch (OpportunityIntakeException e) {
throw new OpportunityCreationException(e.getMessage(), e);
}
oppMapper.updateById(opp);
}
}

245
crm-opportunity/src/test/java/com/crm/opportunity/port/inbound/OpportunityCreationPortImplTest.java

@ -2,20 +2,11 @@ package com.crm.opportunity.port.inbound;
import com.crm.lead.port.outbound.CreateOpportunityCmd;
import com.crm.lead.port.outbound.OpportunityCreationException;
import com.crm.opportunity.domain.entity.Opportunity;
import com.crm.opportunity.domain.entity.OpportunityCustomer;
import com.crm.opportunity.domain.entity.OpportunityOplog;
import com.crm.opportunity.domain.entity.OpportunityTeam;
import com.crm.opportunity.domain.enums.OpportunityStatus;
import com.crm.opportunity.domain.enums.TeamMemberPermission;
import com.crm.opportunity.mapper.OpportunityCustomerMapper;
import com.crm.opportunity.mapper.OpportunityMapper;
import com.crm.opportunity.mapper.OpportunityOplogMapper;
import com.crm.opportunity.mapper.OpportunityTeamMapper;
import com.crm.opportunity.intake.IntakeSource;
import com.crm.opportunity.intake.OpportunityIntake;
import com.crm.opportunity.intake.OpportunityIntakeException;
import com.crm.opportunity.intake.OpportunityIntakeSpec;
import com.crm.opportunity.port.inbound.impl.OpportunityCreationPortImpl;
import com.crm.rule.domain.entity.OpportunityStageNode;
import com.crm.rule.domain.entity.OpportunityStageTemplate;
import com.crm.rule.service.IOpportunityStageTemplateService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ -23,41 +14,33 @@ import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DuplicateKeyException;
import java.util.List;
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.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 线索转商机新建端口单测 11 消费 + 12 唯一性预检 + 13 关联客户
* <p>契约source_lead_id 已占用 OpportunityCreationExceptioncustomerId 非空
* is_primary_intended=1 子表 + 刷主表冗余customerId 为空走法甲 跳过子表不刷冗余</p>
* 线索转商机新建端口薄 adapter 单测Candidate #1 后只验翻译层
*
* <p>覆盖两件事{@code CreateOpportunityCmd} {@code OpportunityIntakeSpec} 全字段映射
* source=LEAD_CONVERToppSource opp_source_01{@code OpportunityIntakeException}
* {@code OpportunityCreationException} seam 异常翻译建档五步机制与唯一性防护的
* 断言已上移 {@code OpportunityIntakeImplTest}本类不再重复</p>
*/
@DisplayName("线索转商机新建端口 OpportunityCreationPort(票 11/12/13)")
@DisplayName("线索转商机新建端口 adapter(翻译层)")
@ExtendWith(MockitoExtension.class)
class OpportunityCreationPortImplTest {
private static final Long LEAD_ID = 9001L;
private static final Long CUSTOMER_ID = 5001L;
private static final Long NEW_OPP_ID = 7001L;
private static final Long STAGE_TPL_VER_ID = 6601L;
private static final Long FIRST_NODE_ID = 6611L;
private static final Long OWNER_USER_ID = 100L;
private static final Long OWNER_DEPT_ID = 200L;
@Mock private OpportunityMapper oppMapper;
@Mock private OpportunityCustomerMapper customerMapper;
@Mock private OpportunityOplogMapper oplogMapper;
@Mock private OpportunityTeamMapper teamMapper;
@Mock private IOpportunityStageTemplateService stageTemplateService;
@Mock
private OpportunityIntake intake;
@InjectMocks
private OpportunityCreationPortImpl port;
@ -69,190 +52,44 @@ class OpportunityCreationPortImplTest {
OWNER_USER_ID, OWNER_DEPT_ID, 300L);
}
/** 桂定阶段模板命中:部门解析到发布中版本 + 首节点。 */
private void stubStageTemplateHit() {
OpportunityStageTemplate ver = new OpportunityStageTemplate();
ver.setId(STAGE_TPL_VER_ID);
ver.setVersionNo("V1.0");
when(stageTemplateService.resolveBindingVersion(OWNER_DEPT_ID)).thenReturn(ver);
OpportunityStageNode first = new OpportunityStageNode();
first.setId(FIRST_NODE_ID);
first.setSeqNo(1);
OpportunityStageNode second = new OpportunityStageNode();
second.setId(FIRST_NODE_ID + 1);
second.setSeqNo(2);
when(stageTemplateService.listNodesOfVersion(STAGE_TPL_VER_ID))
.thenReturn(List.of(first, second));
}
/** insert 时给实体回填自增 id(模拟 MyBatis-Plus useGeneratedKeys)。 */
private void stubInsertAssignsId() {
doAnswer(inv -> {
((Opportunity) inv.getArgument(0)).setId(NEW_OPP_ID);
return 1;
}).when(oppMapper).insert(any(Opportunity.class));
}
@Test
@DisplayName("source_lead_id 已占用 → 抛 OpportunityCreationException,不新建")
void create_leadAlreadyLinked_throws() {
when(oppMapper.selectCount(any())).thenReturn(1L);
assertThatThrownBy(() -> port.createOpportunity(cmd(CUSTOMER_ID)))
.isInstanceOf(OpportunityCreationException.class)
.hasMessageContaining("已关联");
verify(oppMapper, never()).insert(any(Opportunity.class));
verify(customerMapper, never()).insert(any(OpportunityCustomer.class));
}
@Test
@DisplayName("customerId 非空 → 建 is_primary_intended=1 子表 + 刷主表冗余,返回新 id")
void create_withCustomer_linksPrimary() {
when(oppMapper.selectCount(any())).thenReturn(0L);
stubInsertAssignsId();
@DisplayName("翻译:cmd → spec 全字段映射(source=LEAD_CONVERT,oppSource 恒 opp_source_01)")
void create_translatesCmdToSpec() {
when(intake.open(any(OpportunityIntakeSpec.class))).thenReturn(NEW_OPP_ID);
Long id = port.createOpportunity(cmd(CUSTOMER_ID));
assertThat(id).isEqualTo(NEW_OPP_ID);
ArgumentCaptor<OpportunityCustomer> linkCap = ArgumentCaptor.forClass(OpportunityCustomer.class);
verify(customerMapper).insert(linkCap.capture());
OpportunityCustomer link = linkCap.getValue();
assertThat(link.getOpportunityId()).isEqualTo(NEW_OPP_ID);
assertThat(link.getCustomerId()).isEqualTo(CUSTOMER_ID);
assertThat(link.getIsPrimaryIntended()).isEqualTo(1);
assertThat(link.getCustomerNameSnapshot()).isEqualTo("意向客户名");
// 刷主表冗余走 updateById
ArgumentCaptor<Opportunity> updCap = ArgumentCaptor.forClass(Opportunity.class);
verify(oppMapper).updateById(updCap.capture());
assertThat(updCap.getValue().getPrimaryCustomerId()).isEqualTo(CUSTOMER_ID);
assertThat(updCap.getValue().getPrimaryCustomerNameSnapshot()).isEqualTo("意向客户名");
}
@Test
@DisplayName("customerId 为空(走法甲)→ 跳过建子表、不刷冗余,仍建商机")
void create_noCustomer_skipsLink() {
when(oppMapper.selectCount(any())).thenReturn(0L);
stubInsertAssignsId();
Long id = port.createOpportunity(cmd(null));
assertThat(id).isEqualTo(NEW_OPP_ID);
verify(customerMapper, never()).insert(any(OpportunityCustomer.class));
verify(oppMapper, never()).updateById(any(Opportunity.class));
ArgumentCaptor<OpportunityIntakeSpec> cap = ArgumentCaptor.forClass(OpportunityIntakeSpec.class);
verify(intake).open(cap.capture());
OpportunityIntakeSpec spec = cap.getValue();
assertThat(spec.source()).isEqualTo(IntakeSource.LEAD_CONVERT);
// BUG 修正落点:转商机 oppSource 恒 opp_source_01(原实现漏填,现由 IntakeSource 承载)
assertThat(spec.oppSource()).isEqualTo(IntakeSource.LEAD_CONVERT.defaultOppSource());
assertThat(spec.opportunityName()).isEqualTo("商机A");
assertThat(spec.industryCode()).isEqualTo("ind01");
assertThat(spec.partyA()).isEqualTo("甲方X");
assertThat(spec.provinceCode()).isEqualTo("110000");
assertThat(spec.cityCode()).isEqualTo("110100");
assertThat(spec.remark()).isEqualTo("备注");
assertThat(spec.ownerUserId()).isEqualTo(OWNER_USER_ID);
assertThat(spec.ownerDeptId()).isEqualTo(OWNER_DEPT_ID);
assertThat(spec.customerId()).isEqualTo(CUSTOMER_ID);
assertThat(spec.intendedCustomer()).isEqualTo("意向客户名");
assertThat(spec.sourceLeadId()).isEqualTo(LEAD_ID);
assertThat(spec.sourceLeadName()).isEqualTo("线索A");
assertThat(spec.sourcePhone()).isEqualTo("13800000000");
assertThat(spec.sourceProductCode()).isEqualTo("prod01");
}
@Test
@DisplayName("新建商机初始状态=推进中(2),落来源快照 + 意向客户名快照")
void create_setsAdvancingAndSnapshots() {
when(oppMapper.selectCount(any())).thenReturn(0L);
ArgumentCaptor<Opportunity> insCap = ArgumentCaptor.forClass(Opportunity.class);
doAnswer(inv -> {
((Opportunity) inv.getArgument(0)).setId(NEW_OPP_ID);
return 1;
}).when(oppMapper).insert(insCap.capture());
port.createOpportunity(cmd(null));
@DisplayName("seam 翻译:OpportunityIntakeException → OpportunityCreationException(保留消息)")
void create_translatesIntakeException() {
when(intake.open(any(OpportunityIntakeSpec.class)))
.thenThrow(new OpportunityIntakeException("该线索已关联其他商机:leadId=" + LEAD_ID));
Opportunity saved = insCap.getValue();
assertThat(saved.getOppStatus()).isEqualTo(OpportunityStatus.STATUS_ADVANCING.getValue());
assertThat(saved.getSourceLeadId()).isEqualTo(LEAD_ID);
assertThat(saved.getClaimTime()).isNotNull();
assertThat(saved.getPrimaryCustomerNameSnapshot()).isEqualTo("意向客户名");
// 省/市两级分别落库(不再同填同一 code)
assertThat(saved.getProvinceCode()).isEqualTo("110000");
assertThat(saved.getCityCode()).isEqualTo("110100");
}
@Test
@DisplayName("阶段落位:命中默认模板 → current_stage_id=首节点 + 锁模板版本 id/号(票 02 步 2)")
void create_stagePlacement_bindsFirstNode() {
when(oppMapper.selectCount(any())).thenReturn(0L);
stubStageTemplateHit();
ArgumentCaptor<Opportunity> insCap = ArgumentCaptor.forClass(Opportunity.class);
doAnswer(inv -> {
((Opportunity) inv.getArgument(0)).setId(NEW_OPP_ID);
return 1;
}).when(oppMapper).insert(insCap.capture());
port.createOpportunity(cmd(null));
Opportunity saved = insCap.getValue();
assertThat(saved.getCurrentStageId()).isEqualTo(FIRST_NODE_ID);
assertThat(saved.getStageTemplateId()).isEqualTo(STAGE_TPL_VER_ID);
assertThat(saved.getStageTemplateVersion()).isEqualTo("V1.0");
}
@Test
@DisplayName("阶段落位:无发布中模板(resolveBindingVersion 返 null)→ 三字段空,商机仍建成(容忍空走法 A)")
void create_noStageTemplate_tolerated() {
when(oppMapper.selectCount(any())).thenReturn(0L);
when(stageTemplateService.resolveBindingVersion(OWNER_DEPT_ID)).thenReturn(null);
ArgumentCaptor<Opportunity> insCap = ArgumentCaptor.forClass(Opportunity.class);
doAnswer(inv -> {
((Opportunity) inv.getArgument(0)).setId(NEW_OPP_ID);
return 1;
}).when(oppMapper).insert(insCap.capture());
Long id = port.createOpportunity(cmd(null));
assertThat(id).isEqualTo(NEW_OPP_ID);
Opportunity saved = insCap.getValue();
assertThat(saved.getCurrentStageId()).isNull();
assertThat(saved.getStageTemplateId()).isNull();
assertThat(saved.getStageTemplateVersion()).isNull();
}
@Test
@DisplayName("初始操作日志:写一条 ROW_ADD/ROW_CHANGE/SYSTEM「由线索转入创建」(票 02 步 3)")
void create_writesInitialOplog() {
when(oppMapper.selectCount(any())).thenReturn(0L);
stubInsertAssignsId();
port.createOpportunity(cmd(null));
ArgumentCaptor<OpportunityOplog> logCap = ArgumentCaptor.forClass(OpportunityOplog.class);
verify(oplogMapper).insert(logCap.capture());
OpportunityOplog log = logCap.getValue();
assertThat(log.getOppId()).isEqualTo(NEW_OPP_ID);
assertThat(log.getOpKind()).isEqualTo("ROW_ADD");
assertThat(log.getLogType()).isEqualTo("ROW_CHANGE");
assertThat(log.getOpSource()).isEqualTo("SYSTEM");
assertThat(log.getOpDesc()).contains("由线索转入创建");
assertThat(log.getOpUserId()).isEqualTo(OWNER_USER_ID);
assertThat(log.getOpTime()).isNotNull();
}
@Test
@DisplayName("团队成员:插一条领取人=商机负责人 project_role_01/READ_WRITE(票 02 步 4)")
void create_insertsOwnerTeamMember() {
when(oppMapper.selectCount(any())).thenReturn(0L);
stubInsertAssignsId();
port.createOpportunity(cmd(null));
ArgumentCaptor<OpportunityTeam> teamCap = ArgumentCaptor.forClass(OpportunityTeam.class);
verify(teamMapper).insert(teamCap.capture());
OpportunityTeam member = teamCap.getValue();
assertThat(member.getOpportunityId()).isEqualTo(NEW_OPP_ID);
assertThat(member.getUserId()).isEqualTo(OWNER_USER_ID);
assertThat(member.getProjectRole()).isEqualTo("project_role_01");
assertThat(member.getPermission()).isEqualTo(TeamMemberPermission.READ_WRITE.getValue());
assertThat(member.getDeleteKey()).isEqualTo(0L);
}
@Test
@DisplayName("并发窗口:预检通过后 insert 撞 DB 唯一键 → 转译为 OpportunityCreationException")
void create_concurrentDuplicateKey_translated() {
when(oppMapper.selectCount(any())).thenReturn(0L);
doThrow(new DuplicateKeyException("uk_source_lead")).when(oppMapper).insert(any(Opportunity.class));
assertThatThrownBy(() -> port.createOpportunity(cmd(CUSTOMER_ID)))
assertThatThrownBy(() -> port.createOpportunity(cmd(null)))
.isInstanceOf(OpportunityCreationException.class)
.hasMessageContaining("已关联");
verify(customerMapper, never()).insert(any(OpportunityCustomer.class));
}
}

Loading…
Cancel
Save