diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityCreationPortImpl.java b/crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityCreationPortImpl.java index e74f5ad..91f9f6b 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityCreationPortImpl.java +++ b/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 关联客户子表)。 + * 线索转商机新建端口实现——薄 adapter(Candidate #1:建档机制收敛进深模块 OpportunityIntake)。 * *

crm-lead 声明的出站契约 {@link OpportunityCreationPort} 的商机侧实现(同库同事务,ADR-0020)。 - * crm-lead 事务内调用,本方法任何失败抛 {@link OpportunityCreationException},crm-lead 捕获后整体回滚。

+ * 本层只做翻译:把 {@link CreateOpportunityCmd} 组装成 {@link OpportunityIntakeSpec} + * (source=LEAD_CONVERT,oppSource 恒 {@link IntakeSource#defaultOppSource()}=opp_source_01, + * 修正原实现漏填 opp_source 的缺陷),调 {@link OpportunityIntake#open};深模块的 + * {@link OpportunityIntakeException} 在 seam 处译回 {@link OpportunityCreationException}。

* - *

票 12 唯一性预检:新建前查 {@code source_lead_id} 是否已被占用,命中即抛业务错误 - * (避免依赖 DB 唯一键冲突走错误路径);DB {@code uk(source_lead_id)} 仍作并发最终防线。

+ *

建档五步机制(主表落库 + 阶段落位 + 主要意向客户子表 + 初始操作日志 + 团队成员)与 + * source_lead_id 唯一性防护均不再出现在本层(由 OpportunityIntakeImpl 承载并测试)。

* - *

沨 A4 走法甲:{@code cmd.customerId()} 非空时建一条 {@code is_primary_intended=1} - * 主要意向客户子表 + 刷主表冗余 {@code primary_customer_id/name}(票 03 C3)。 - * A4 客户下拉未就绪前 customerId 可空,此时跳过建子表 / 不刷冗余(不倒退现有自由文本转商机); - * A4 就绪后收紧为必填。

+ *

事务:本层不再标 @Transactional——crm-lead 外层事务 + {@code open} 的 REQUIRED 传播 + * 保证原子性(ADR-0020 语义不变)。cmd.poolId 为线索侧信息,商机建档不消费。

*/ @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() - .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 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); } } diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/port/inbound/OpportunityCreationPortImplTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/port/inbound/OpportunityCreationPortImplTest.java index 16de216..16a748f 100644 --- a/crm-opportunity/src/test/java/com/crm/opportunity/port/inbound/OpportunityCreationPortImplTest.java +++ b/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 关联客户)。 - *

契约:source_lead_id 已占用 → OpportunityCreationException;customerId 非空 → 建 - * is_primary_intended=1 子表 + 刷主表冗余;customerId 为空(走法甲)→ 跳过子表不刷冗余。

+ * 线索转商机新建端口薄 adapter 单测(Candidate #1 后只验翻译层)。 + * + *

覆盖两件事:{@code CreateOpportunityCmd} → {@code OpportunityIntakeSpec} 全字段映射 + * (source=LEAD_CONVERT,oppSource 恒 opp_source_01);{@code OpportunityIntakeException} → + * {@code OpportunityCreationException} 的 seam 异常翻译。建档五步机制与唯一性防护的 + * 断言已上移 {@code OpportunityIntakeImplTest},本类不再重复。

*/ -@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 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 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 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 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 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 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 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 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)); } }