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));
}
}