7 changed files with 632 additions and 0 deletions
@ -0,0 +1,45 @@ |
|||||
|
package com.crm.opportunity.intake; |
||||
|
|
||||
|
/** |
||||
|
* 商机进入方式(建档来源)。 |
||||
|
* |
||||
|
* <p>两条建档入口的语义差异收敛于此枚举:线索侧转商机({@link #LEAD_CONVERT})与商机侧直接 |
||||
|
* 新建({@link #DIRECT})。承载两处按来源分叉的细节——初始操作日志的 {@code op_source} 与 |
||||
|
* {@code op_desc},以及线索转入恒定的商机来源字典 code({@code defaultOppSource})。</p> |
||||
|
* |
||||
|
* <p>{@code defaultOppSource}:{@code LEAD_CONVERT} 恒为 {@code opp_source_01}(线索转入); |
||||
|
* {@code DIRECT} 为 null——直接创建的商机来源由用户在弹窗选择,经 spec 显式携带。</p> |
||||
|
*/ |
||||
|
public enum IntakeSource { |
||||
|
|
||||
|
/** 线索侧转商机(crm-lead 出站端口触发):SYSTEM 记「由线索转入创建」,来源恒 opp_source_01。 */ |
||||
|
LEAD_CONVERT("opp_source_01", "SYSTEM", "由线索转入创建"), |
||||
|
|
||||
|
/** 商机侧直接新建:USER 记「直接创建商机」,来源由用户选择(spec 携带)。 */ |
||||
|
DIRECT(null, "USER", "直接创建商机"); |
||||
|
|
||||
|
private final String defaultOppSource; |
||||
|
private final String oplogOpSource; |
||||
|
private final String oplogOpDesc; |
||||
|
|
||||
|
IntakeSource(String defaultOppSource, String oplogOpSource, String oplogOpDesc) { |
||||
|
this.defaultOppSource = defaultOppSource; |
||||
|
this.oplogOpSource = oplogOpSource; |
||||
|
this.oplogOpDesc = oplogOpDesc; |
||||
|
} |
||||
|
|
||||
|
/** 线索转入恒定的商机来源字典 code;DIRECT 为 null(由 spec 显式携带用户所选来源)。 */ |
||||
|
public String defaultOppSource() { |
||||
|
return defaultOppSource; |
||||
|
} |
||||
|
|
||||
|
/** 初始操作日志 op_source(LEAD_CONVERT=SYSTEM,DIRECT=USER)。 */ |
||||
|
public String oplogOpSource() { |
||||
|
return oplogOpSource; |
||||
|
} |
||||
|
|
||||
|
/** 初始操作日志 op_desc(LEAD_CONVERT=由线索转入创建,DIRECT=直接创建商机)。 */ |
||||
|
public String oplogOpDesc() { |
||||
|
return oplogOpDesc; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,27 @@ |
|||||
|
package com.crm.opportunity.intake; |
||||
|
|
||||
|
/** |
||||
|
* 商机建档深模块(票 02/11/12/13/16:新建商机的唯一核心机制)。 |
||||
|
* |
||||
|
* <p>两条建档入口——线索侧转商机 port({@code OpportunityCreationPortImpl})与商机侧直接 |
||||
|
* 新建({@code OpportunityCreateServiceImpl})——的公共机制收敛于此:主表落库 + 阶段落位 |
||||
|
* + 主要意向客户子表 + 初始操作日志 + 领取人团队成员 + source_lead_id 唯一性防护, |
||||
|
* 一个事务内原子完成。</p> |
||||
|
* |
||||
|
* <p>接口窄(单方法),实现厚:调用方只需把各自入参翻译成 {@link OpportunityIntakeSpec}, |
||||
|
* 建档的全部规则与副作用都在实现内,不再两处各写一遍。</p> |
||||
|
*/ |
||||
|
public interface OpportunityIntake { |
||||
|
|
||||
|
/** |
||||
|
* 按规格建档一个商机,返回新商机 id。 |
||||
|
* |
||||
|
* <p>同库同事务(ADR-0020):本方法标 {@code @Transactional},线索转商机路径下并入 |
||||
|
* crm-lead 外层事务(REQUIRED 传播),任一副作用失败整体回滚。</p> |
||||
|
* |
||||
|
* @param spec 建档规格(两条入口翻译后的统一内部契约) |
||||
|
* @return 新建商机 id |
||||
|
* @throws OpportunityIntakeException source_lead_id 已被其他商机占用(预检命中或并发唯一键冲突) |
||||
|
*/ |
||||
|
Long open(OpportunityIntakeSpec spec); |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
package com.crm.opportunity.intake; |
||||
|
|
||||
|
/** |
||||
|
* 商机建档领域异常(crm-opportunity 自有)。 |
||||
|
* |
||||
|
* <p>{@link OpportunityIntake#open} 内部机制失败时抛出,语义为建档本身的领域事实 |
||||
|
* ——当前唯一场景是 {@code source_lead_id} 已被其他商机占用(应用层预检命中,或并发窗口 |
||||
|
* DB 唯一键冲突转译)。</p> |
||||
|
* |
||||
|
* <p>本异常<b>不跨模块边界</b>:深模块只认自己的语言,由各 adapter 在 seam 处翻译—— |
||||
|
* 线索侧 port 译为 {@code com.crm.lead.port.outbound.OpportunityCreationException}, |
||||
|
* 商机侧直接创建译为 {@code BusinessErrorException}(HTTP 友好)。</p> |
||||
|
*/ |
||||
|
public class OpportunityIntakeException extends RuntimeException { |
||||
|
|
||||
|
public OpportunityIntakeException(String message) { |
||||
|
super(message); |
||||
|
} |
||||
|
|
||||
|
public OpportunityIntakeException(String message, Throwable cause) { |
||||
|
super(message, cause); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,49 @@ |
|||||
|
package com.crm.opportunity.intake; |
||||
|
|
||||
|
/** |
||||
|
* 商机建档规格(两条入口翻译后的统一内部契约,票 02/11/12/13/16)。 |
||||
|
* |
||||
|
* <p>{@code OpportunityIntake.open} 的唯一入参。crm-opportunity 内部类型,不导出: |
||||
|
* 线索侧转商机 port({@code OpportunityCreationPortImpl})与商机侧直接新建 |
||||
|
* ({@code OpportunityCreateServiceImpl})各自把入参 DTO 翻译成本 spec,深模块只认本语言。</p> |
||||
|
* |
||||
|
* <p>差异收敛:{@code source} 决定初始日志文案/op_source;{@code oppSource} 由 adapter 按 |
||||
|
* {@code source} 填——{@code LEAD_CONVERT} 取 {@link IntakeSource#defaultOppSource()} |
||||
|
* (opp_source_01),{@code DIRECT} 取用户所选来源。</p> |
||||
|
* |
||||
|
* @param source 进入方式(决定 oplog 文案 + op_source) |
||||
|
* @param oppSource 商机来源字典 code(LEAD_CONVERT 恒 opp_source_01;DIRECT 用户所选) |
||||
|
* @param opportunityName 商机名称(必填) |
||||
|
* @param industryCode 行业字典 code |
||||
|
* @param partyA 甲方(自由文本,可空) |
||||
|
* @param provinceCode 省国标 code |
||||
|
* @param cityCode 市国标 code |
||||
|
* @param remark 备注(可空) |
||||
|
* @param ownerUserId 领取人 = 创建人 = 商机负责人 |
||||
|
* @param ownerDeptId 领取人所属部门快照(阶段模板按部门解析;可空 → 默认模板兜底) |
||||
|
* @param customerId 主要意向客户 id(可空;非空则建 is_primary_intended 子表 + 刷主表冗余) |
||||
|
* @param intendedCustomer 意向客户名快照 |
||||
|
* @param sourceLeadId 来源线索 id(唯一性预检 key;DIRECT 非线索来源时为 null,不参与 UNIQUE) |
||||
|
* @param sourceLeadName 线索名称快照(可空) |
||||
|
* @param sourcePhone 联系电话快照(可空) |
||||
|
* @param sourceProductCode 需求产品 code 快照(可空) |
||||
|
*/ |
||||
|
public record OpportunityIntakeSpec( |
||||
|
IntakeSource source, |
||||
|
String oppSource, |
||||
|
String opportunityName, |
||||
|
String industryCode, |
||||
|
String partyA, |
||||
|
String provinceCode, |
||||
|
String cityCode, |
||||
|
String remark, |
||||
|
Long ownerUserId, |
||||
|
Long ownerDeptId, |
||||
|
Long customerId, |
||||
|
String intendedCustomer, |
||||
|
Long sourceLeadId, |
||||
|
String sourceLeadName, |
||||
|
String sourcePhone, |
||||
|
String sourceProductCode |
||||
|
) { |
||||
|
} |
||||
@ -0,0 +1,180 @@ |
|||||
|
package com.crm.opportunity.intake.impl; |
||||
|
|
||||
|
import cn.hutool.core.util.StrUtil; |
||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
||||
|
import com.crm.opportunity.domain.entity.Opportunity; |
||||
|
import com.crm.opportunity.domain.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.intake.OpportunityIntake; |
||||
|
import com.crm.opportunity.intake.OpportunityIntakeException; |
||||
|
import com.crm.opportunity.intake.OpportunityIntakeSpec; |
||||
|
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 lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.List; |
||||
|
|
||||
|
/** |
||||
|
* 商机建档深模块实现(Candidate #1:两条入口机制收敛一处)。 |
||||
|
* |
||||
|
* <p>原 {@code OpportunityCreationPortImpl}(线索转商机 port)与 {@code OpportunityCreateServiceImpl} |
||||
|
* (商机侧直接新建)逐字复制的五步建档机制(主表 + 阶段落位 + 客户子表 + 初始日志 + 团队成员) |
||||
|
* 在此合并。差异点经 {@link OpportunityIntakeSpec} / {@code IntakeSource} 参数化:oplog 文案/来源、 |
||||
|
* oppSource 取值均由 spec 携带,实现不再分叉。</p> |
||||
|
* |
||||
|
* <p>事务(ADR-0020):{@code open} 标 {@code @Transactional},线索转商机路径并入 crm-lead |
||||
|
* 外层事务(REQUIRED),任一副作用失败整体回滚。source_lead_id 唯一性预检 + 并发 DB 唯一键 |
||||
|
* 冲突转译收在本模块,抛自有 {@link OpportunityIntakeException},由各 adapter 在 seam 翻译。</p> |
||||
|
*/ |
||||
|
@Service |
||||
|
@RequiredArgsConstructor |
||||
|
public class OpportunityIntakeImpl implements OpportunityIntake { |
||||
|
|
||||
|
/** 领取人项目角色:商机负责人(crm-dict project_role 分组,票 02)。 */ |
||||
|
private static final String PROJECT_ROLE_OWNER = "project_role_01"; |
||||
|
|
||||
|
/** 主要意向客户角色(票 13,customer_role 字典)。 */ |
||||
|
private static final String ROLE_INTENDED = "intended"; |
||||
|
|
||||
|
private final OpportunityMapper oppMapper; |
||||
|
private final OpportunityCustomerMapper customerMapper; |
||||
|
private final OpportunityOplogMapper oplogMapper; |
||||
|
private final OpportunityTeamMapper teamMapper; |
||||
|
private final IOpportunityStageTemplateService stageTemplateService; |
||||
|
|
||||
|
@Override |
||||
|
@Transactional(rollbackFor = Exception.class) |
||||
|
public Long open(OpportunityIntakeSpec spec) { |
||||
|
// 票 12:source_lead_id 唯一性预检(应用层友好报错,DB 唯一键作并发最终防线)。
|
||||
|
// sourceLeadId 为空(DIRECT 非线索来源,NULL 不参与 UNIQUE)→ 跳过预检。
|
||||
|
if (spec.sourceLeadId() != null) { |
||||
|
Long existing = oppMapper.selectCount(new LambdaQueryWrapper<Opportunity>() |
||||
|
.eq(Opportunity::getSourceLeadId, spec.sourceLeadId())); |
||||
|
if (existing != null && existing > 0) { |
||||
|
throw new OpportunityIntakeException("该线索已关联其他商机:leadId=" + spec.sourceLeadId()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Opportunity opp = buildOpportunity(spec); |
||||
|
try { |
||||
|
oppMapper.insert(opp); |
||||
|
} catch (org.springframework.dao.DuplicateKeyException e) { |
||||
|
// 并发窗口:预检通过后另一事务先插入 → DB uk(source_lead_id) 命中,转译为领域异常
|
||||
|
throw new OpportunityIntakeException("该线索已关联其他商机:leadId=" + spec.sourceLeadId(), e); |
||||
|
} |
||||
|
|
||||
|
// 票 13:customerId 非空 → 建主要意向客户子表 + 刷主表冗余(走法甲:可空则跳过)
|
||||
|
if (spec.customerId() != null) { |
||||
|
linkPrimaryCustomer(opp, spec); |
||||
|
} |
||||
|
|
||||
|
// 票 02 步 3:初始操作日志(文案/来源按进入方式分叉)
|
||||
|
writeInitialOplog(opp.getId(), spec); |
||||
|
// 票 02 步 4:团队成员(领取人=商机负责人)
|
||||
|
insertOwnerTeamMember(opp.getId(), spec); |
||||
|
return opp.getId(); |
||||
|
} |
||||
|
|
||||
|
private Opportunity buildOpportunity(OpportunityIntakeSpec spec) { |
||||
|
Opportunity opp = new Opportunity(); |
||||
|
opp.setOppName(spec.opportunityName()); |
||||
|
// 商机来源:LEAD_CONVERT 恒 opp_source_01(adapter 已按枚举默认填入 spec),DIRECT 为用户所选。
|
||||
|
opp.setOppSource(spec.oppSource()); |
||||
|
opp.setIndustryCode(spec.industryCode()); |
||||
|
opp.setPartyA(spec.partyA()); |
||||
|
opp.setRemark(spec.remark()); |
||||
|
opp.setProvinceCode(spec.provinceCode()); |
||||
|
opp.setCityCode(spec.cityCode()); |
||||
|
opp.setOwnerUserId(spec.ownerUserId()); |
||||
|
opp.setOwnerDeptId(spec.ownerDeptId()); |
||||
|
opp.setCreatorUserId(spec.ownerUserId()); |
||||
|
// 来源线索快照(DIRECT 非线索来源时为 null)
|
||||
|
opp.setSourceLeadId(spec.sourceLeadId()); |
||||
|
opp.setSourceLeadName(spec.sourceLeadName()); |
||||
|
opp.setSourcePhone(spec.sourcePhone()); |
||||
|
opp.setSourceProductCode(spec.sourceProductCode()); |
||||
|
// 新建即领取,进入推进中;领取时间落库
|
||||
|
opp.setOppStatus(OpportunityStatus.STATUS_ADVANCING.getValue()); |
||||
|
opp.setClaimTime(LocalDateTime.now()); |
||||
|
// 意向客户名快照(引用 + 快照并存);customerId 非空时下方刷主表冗余
|
||||
|
opp.setPrimaryCustomerNameSnapshot(spec.intendedCustomer()); |
||||
|
// 票 02 步 2:阶段落位——按负责人部门解析发布中模板 + 首节点(无模板容忍空,走法 A)
|
||||
|
applyInitialStage(opp, spec); |
||||
|
return opp; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 票 02 步 2 / 票 06:按负责人部门解析应绑定的发布中阶段模板版本,current_stage_id 落首节点 |
||||
|
* (listNodesOfVersion 已按 seq_no 升序),并锁模板版本 id/号。无发布中模板(返 null)时 |
||||
|
* 三字段留空(容忍空,走法 A;ownerDeptId 为空亦然,走默认模板兜底)。 |
||||
|
*/ |
||||
|
private void applyInitialStage(Opportunity opp, OpportunityIntakeSpec spec) { |
||||
|
OpportunityStageTemplate version = stageTemplateService.resolveBindingVersion(spec.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:写一条初始操作日志(整行新增;文案 / op_source 按进入方式分叉,IntakeSource 承载)。 */ |
||||
|
private void writeInitialOplog(Long oppId, OpportunityIntakeSpec spec) { |
||||
|
OpportunityOplog log = new OpportunityOplog(); |
||||
|
log.setOppId(oppId); |
||||
|
log.setOpKind(OplogKind.ROW_ADD.getValue()); |
||||
|
log.setLogType(OplogLogType.ROW_CHANGE.getValue()); |
||||
|
log.setEntityName("商机"); |
||||
|
log.setOpDesc(spec.source().oplogOpDesc()); |
||||
|
log.setOpSource(spec.source().oplogOpSource()); |
||||
|
log.setOpTime(LocalDateTime.now()); |
||||
|
log.setOpUserId(spec.ownerUserId()); |
||||
|
oplogMapper.insert(log); |
||||
|
} |
||||
|
|
||||
|
/** 票 02 步 4:领取人=商机负责人,插一条团队成员(project_role_01 / READ_WRITE)。 */ |
||||
|
private void insertOwnerTeamMember(Long oppId, OpportunityIntakeSpec spec) { |
||||
|
OpportunityTeam member = new OpportunityTeam(); |
||||
|
member.setOpportunityId(oppId); |
||||
|
member.setUserId(spec.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, OpportunityIntakeSpec spec) { |
||||
|
OpportunityCustomer link = new OpportunityCustomer(); |
||||
|
link.setOpportunityId(opp.getId()); |
||||
|
link.setCustomerId(spec.customerId()); |
||||
|
link.setCustomerNameSnapshot(spec.intendedCustomer()); |
||||
|
link.setCustomerRole(ROLE_INTENDED); |
||||
|
link.setIsPrimaryIntended(1); |
||||
|
customerMapper.insert(link); |
||||
|
|
||||
|
// 主表冗余(票 03 C3):主要意向客户 id/名,高频展示免 join
|
||||
|
opp.setPrimaryCustomerId(spec.customerId()); |
||||
|
if (StrUtil.isNotBlank(spec.intendedCustomer())) { |
||||
|
opp.setPrimaryCustomerNameSnapshot(spec.intendedCustomer()); |
||||
|
} |
||||
|
oppMapper.updateById(opp); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,287 @@ |
|||||
|
package com.crm.opportunity.intake.impl; |
||||
|
|
||||
|
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.intake.IntakeSource; |
||||
|
import com.crm.opportunity.intake.OpportunityIntakeException; |
||||
|
import com.crm.opportunity.intake.OpportunityIntakeSpec; |
||||
|
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 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.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; |
||||
|
|
||||
|
/** |
||||
|
* 商机建档深模块单测(Candidate #1:两条入口机制收敛一处)。 |
||||
|
* |
||||
|
* <p>覆盖建档机制:阶段落位(命中/无模板容忍空)、初始日志两来源分支、领取人团队成员、 |
||||
|
* 主要意向客户子表(有/无 customerId)、初始状态推进中 + 快照、source_lead_id 唯一性 |
||||
|
* 预检 + 并发 DuplicateKey 转译。adapter 侧的异常翻译由各自 adapter 测试覆盖。</p> |
||||
|
*/ |
||||
|
@DisplayName("商机建档深模块 OpportunityIntake") |
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
class OpportunityIntakeImplTest { |
||||
|
|
||||
|
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; |
||||
|
|
||||
|
@InjectMocks |
||||
|
private OpportunityIntakeImpl intake; |
||||
|
|
||||
|
/** 线索转入 spec(source=LEAD_CONVERT,oppSource 已由 adapter 填 opp_source_01)。 */ |
||||
|
private OpportunityIntakeSpec leadConvertSpec(Long customerId) { |
||||
|
return new OpportunityIntakeSpec( |
||||
|
IntakeSource.LEAD_CONVERT, "opp_source_01", "商机A", "ind01", "甲方X", |
||||
|
"110000", "110100", "备注", OWNER_USER_ID, OWNER_DEPT_ID, |
||||
|
customerId, "意向客户名", LEAD_ID, "线索A", "13800000000", "prod01"); |
||||
|
} |
||||
|
|
||||
|
/** 直接创建 spec(source=DIRECT,oppSource 用户所选,无来源线索)。 */ |
||||
|
private OpportunityIntakeSpec directSpec(String oppSource, Long customerId) { |
||||
|
return new OpportunityIntakeSpec( |
||||
|
IntakeSource.DIRECT, oppSource, "商机B", "ind02", "甲方Y", |
||||
|
"310000", "310100", "备注2", OWNER_USER_ID, OWNER_DEPT_ID, |
||||
|
customerId, "意向客户名2", null, null, null, null); |
||||
|
} |
||||
|
|
||||
|
/** 桩定阶段模板命中:部门解析到发布中版本 + 首节点。 */ |
||||
|
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 追加区 ====================
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("source_lead_id 已占用 → 抛 OpportunityIntakeException,不新建") |
||||
|
void open_leadAlreadyLinked_throws() { |
||||
|
when(oppMapper.selectCount(any())).thenReturn(1L); |
||||
|
assertThatThrownBy(() -> intake.open(leadConvertSpec(null))) |
||||
|
.isInstanceOf(OpportunityIntakeException.class); |
||||
|
verify(oppMapper, never()).insert(any(Opportunity.class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("并发窗口:预检通过后 insert 撞 DB 唯一键 → 转译为 OpportunityIntakeException") |
||||
|
void open_concurrentDuplicateKey_translated() { |
||||
|
when(oppMapper.selectCount(any())).thenReturn(0L); |
||||
|
doThrow(new DuplicateKeyException("uk(source_lead_id)")) |
||||
|
.when(oppMapper).insert(any(Opportunity.class)); |
||||
|
assertThatThrownBy(() -> intake.open(leadConvertSpec(null))) |
||||
|
.isInstanceOf(OpportunityIntakeException.class); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("sourceLeadId 为空(DIRECT 非线索来源)→ 不做唯一性预检,正常建档") |
||||
|
void open_directNoLead_skipsUniquenessPrecheck() { |
||||
|
stubStageTemplateHit(); |
||||
|
stubInsertAssignsId(); |
||||
|
Long id = intake.open(directSpec("opp_source_02", null)); |
||||
|
assertThat(id).isEqualTo(NEW_OPP_ID); |
||||
|
verify(oppMapper, never()).selectCount(any()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("新建商机初始状态=推进中(2),落来源快照 + 意向客户名快照") |
||||
|
void open_setsAdvancingAndSnapshots() { |
||||
|
when(oppMapper.selectCount(any())).thenReturn(0L); |
||||
|
stubStageTemplateHit(); |
||||
|
stubInsertAssignsId(); |
||||
|
ArgumentCaptor<Opportunity> captor = ArgumentCaptor.forClass(Opportunity.class); |
||||
|
|
||||
|
intake.open(leadConvertSpec(null)); |
||||
|
|
||||
|
verify(oppMapper).insert(captor.capture()); |
||||
|
Opportunity opp = captor.getValue(); |
||||
|
assertThat(opp.getOppStatus()).isEqualTo(OpportunityStatus.STATUS_ADVANCING.getValue()); |
||||
|
assertThat(opp.getClaimTime()).isNotNull(); |
||||
|
assertThat(opp.getSourceLeadName()).isEqualTo("线索A"); |
||||
|
assertThat(opp.getSourcePhone()).isEqualTo("13800000000"); |
||||
|
assertThat(opp.getSourceProductCode()).isEqualTo("prod01"); |
||||
|
assertThat(opp.getPrimaryCustomerNameSnapshot()).isEqualTo("意向客户名"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("BUG 修正:LEAD_CONVERT 建档 opp_source 落 opp_source_01(原转商机 port 漏填)") |
||||
|
void open_leadConvert_setsOppSource() { |
||||
|
when(oppMapper.selectCount(any())).thenReturn(0L); |
||||
|
stubStageTemplateHit(); |
||||
|
stubInsertAssignsId(); |
||||
|
ArgumentCaptor<Opportunity> captor = ArgumentCaptor.forClass(Opportunity.class); |
||||
|
|
||||
|
intake.open(leadConvertSpec(null)); |
||||
|
|
||||
|
verify(oppMapper).insert(captor.capture()); |
||||
|
assertThat(captor.getValue().getOppSource()).isEqualTo("opp_source_01"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("阶段落位:命中默认模板 → current_stage_id=首节点 + 锁模板版本 id/号") |
||||
|
void open_stagePlacement_bindsFirstNode() { |
||||
|
when(oppMapper.selectCount(any())).thenReturn(0L); |
||||
|
stubStageTemplateHit(); |
||||
|
stubInsertAssignsId(); |
||||
|
ArgumentCaptor<Opportunity> captor = ArgumentCaptor.forClass(Opportunity.class); |
||||
|
|
||||
|
intake.open(leadConvertSpec(null)); |
||||
|
|
||||
|
verify(oppMapper).insert(captor.capture()); |
||||
|
Opportunity opp = captor.getValue(); |
||||
|
assertThat(opp.getStageTemplateId()).isEqualTo(STAGE_TPL_VER_ID); |
||||
|
assertThat(opp.getStageTemplateVersion()).isEqualTo("V1.0"); |
||||
|
assertThat(opp.getCurrentStageId()).isEqualTo(FIRST_NODE_ID); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("阶段落位:无发布中模板(resolveBindingVersion 返 null)→ 三字段空,商机仍建成(容忍空)") |
||||
|
void open_noStageTemplate_tolerated() { |
||||
|
when(oppMapper.selectCount(any())).thenReturn(0L); |
||||
|
when(stageTemplateService.resolveBindingVersion(OWNER_DEPT_ID)).thenReturn(null); |
||||
|
stubInsertAssignsId(); |
||||
|
ArgumentCaptor<Opportunity> captor = ArgumentCaptor.forClass(Opportunity.class); |
||||
|
|
||||
|
Long id = intake.open(leadConvertSpec(null)); |
||||
|
|
||||
|
assertThat(id).isEqualTo(NEW_OPP_ID); |
||||
|
verify(oppMapper).insert(captor.capture()); |
||||
|
Opportunity opp = captor.getValue(); |
||||
|
assertThat(opp.getStageTemplateId()).isNull(); |
||||
|
assertThat(opp.getCurrentStageId()).isNull(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("customerId 非空 → 建 is_primary_intended=1 子表 + 刷主表冗余") |
||||
|
void open_withCustomer_linksPrimary() { |
||||
|
when(oppMapper.selectCount(any())).thenReturn(0L); |
||||
|
stubStageTemplateHit(); |
||||
|
stubInsertAssignsId(); |
||||
|
ArgumentCaptor<OpportunityCustomer> captor = ArgumentCaptor.forClass(OpportunityCustomer.class); |
||||
|
|
||||
|
intake.open(leadConvertSpec(CUSTOMER_ID)); |
||||
|
|
||||
|
verify(customerMapper).insert(captor.capture()); |
||||
|
OpportunityCustomer link = captor.getValue(); |
||||
|
assertThat(link.getOpportunityId()).isEqualTo(NEW_OPP_ID); |
||||
|
assertThat(link.getCustomerId()).isEqualTo(CUSTOMER_ID); |
||||
|
assertThat(link.getIsPrimaryIntended()).isEqualTo(1); |
||||
|
verify(oppMapper).updateById(any(Opportunity.class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("customerId 为空(走法甲)→ 跳过建子表、不刷冗余,仍建商机") |
||||
|
void open_noCustomer_skipsLink() { |
||||
|
when(oppMapper.selectCount(any())).thenReturn(0L); |
||||
|
stubStageTemplateHit(); |
||||
|
stubInsertAssignsId(); |
||||
|
|
||||
|
Long id = intake.open(leadConvertSpec(null)); |
||||
|
|
||||
|
assertThat(id).isEqualTo(NEW_OPP_ID); |
||||
|
verify(customerMapper, never()).insert(any(OpportunityCustomer.class)); |
||||
|
verify(oppMapper, never()).updateById(any(Opportunity.class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("初始日志:LEAD_CONVERT → SYSTEM「由线索转入创建」") |
||||
|
void open_leadConvert_writesSystemOplog() { |
||||
|
when(oppMapper.selectCount(any())).thenReturn(0L); |
||||
|
stubStageTemplateHit(); |
||||
|
stubInsertAssignsId(); |
||||
|
ArgumentCaptor<OpportunityOplog> captor = ArgumentCaptor.forClass(OpportunityOplog.class); |
||||
|
|
||||
|
intake.open(leadConvertSpec(null)); |
||||
|
|
||||
|
verify(oplogMapper).insert(captor.capture()); |
||||
|
OpportunityOplog log = captor.getValue(); |
||||
|
assertThat(log.getOpSource()).isEqualTo("SYSTEM"); |
||||
|
assertThat(log.getOpDesc()).isEqualTo("由线索转入创建"); |
||||
|
assertThat(log.getOpUserId()).isEqualTo(OWNER_USER_ID); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("初始日志:DIRECT → USER「直接创建商机」") |
||||
|
void open_direct_writesUserOplog() { |
||||
|
stubStageTemplateHit(); |
||||
|
stubInsertAssignsId(); |
||||
|
ArgumentCaptor<OpportunityOplog> captor = ArgumentCaptor.forClass(OpportunityOplog.class); |
||||
|
|
||||
|
intake.open(directSpec("opp_source_02", null)); |
||||
|
|
||||
|
verify(oplogMapper).insert(captor.capture()); |
||||
|
OpportunityOplog log = captor.getValue(); |
||||
|
assertThat(log.getOpSource()).isEqualTo("USER"); |
||||
|
assertThat(log.getOpDesc()).isEqualTo("直接创建商机"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("团队成员:插一条领取人=商机负责人 project_role_01/READ_WRITE") |
||||
|
void open_insertsOwnerTeamMember() { |
||||
|
when(oppMapper.selectCount(any())).thenReturn(0L); |
||||
|
stubStageTemplateHit(); |
||||
|
stubInsertAssignsId(); |
||||
|
ArgumentCaptor<OpportunityTeam> captor = ArgumentCaptor.forClass(OpportunityTeam.class); |
||||
|
|
||||
|
intake.open(leadConvertSpec(null)); |
||||
|
|
||||
|
verify(teamMapper).insert(captor.capture()); |
||||
|
OpportunityTeam member = captor.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()); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue