Browse Source

refactor(opportunity): 直接新建 service 改为 OpportunityIntake 薄 adapter(Candidate #1 步4)

- CreateOpportunityRequest -> OpportunityIntakeSpec 翻译(source=DIRECT,
  oppSource 用户所选,ownerDeptId=null 走默认模板兜底,与收敛前一致)
- 保留入口特有校验:oppSource=线索转入时 sourceLeadId 必填(常量取自 IntakeSource)
- 保留 listConvertibleLeads(属 Candidate #2 范围,维持原状)
- OpportunityIntakeException -> BusinessErrorException seam 翻译
- 移除建档五步机制与自带 @Transactional(收敛于 OpportunityIntakeImpl)
- 测试瘦身为翻译层断言:删 leadTaken/directCreate_ok/appliesStageOplogTeam
  机制用例,新增 spec 全字段映射 + 异常翻译用例
master
luoweijian 2 weeks ago
parent
commit
13ebebd23f
  1. 177
      crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImpl.java
  2. 125
      crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImplTest.java

177
crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImpl.java

@ -1,97 +1,81 @@
package com.crm.opportunity.service.impl; package com.crm.opportunity.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.crm.base.domain.exception.BusinessErrorException; import com.crm.base.domain.exception.BusinessErrorException;
import com.crm.lead.domain.dto.ConvertibleLeadView; import com.crm.lead.domain.dto.ConvertibleLeadView;
import com.crm.lead.service.ILeadService; import com.crm.lead.service.ILeadService;
import com.crm.opportunity.domain.dto.CreateOpportunityRequest; import com.crm.opportunity.domain.dto.CreateOpportunityRequest;
import com.crm.opportunity.domain.entity.Opportunity; import com.crm.opportunity.domain.entity.Opportunity;
import com.crm.opportunity.domain.entity.OpportunityCustomer; import com.crm.opportunity.intake.IntakeSource;
import com.crm.opportunity.domain.entity.OpportunityOplog; import com.crm.opportunity.intake.OpportunityIntake;
import com.crm.opportunity.domain.entity.OpportunityTeam; import com.crm.opportunity.intake.OpportunityIntakeException;
import com.crm.opportunity.domain.enums.OplogKind; import com.crm.opportunity.intake.OpportunityIntakeSpec;
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.OpportunityMapper;
import com.crm.opportunity.mapper.OpportunityOplogMapper;
import com.crm.opportunity.mapper.OpportunityTeamMapper;
import com.crm.opportunity.service.IOpportunityCreateService; import com.crm.opportunity.service.IOpportunityCreateService;
import com.crm.rule.domain.entity.OpportunityStageNode;
import com.crm.rule.domain.entity.OpportunityStageTemplate;
import com.crm.rule.service.IOpportunityStageTemplateService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* 商机侧直接新建商机实现 12新建入口 A3-1-1-2-1 * 商机侧直接新建商机实现 adapterCandidate #1建档机制收敛进深模块 OpportunityIntake
* *
* <p>与线索侧转商机 port{@code OpportunityCreationPortImpl}并列的第二条建商机入口 * <p>与线索侧转商机 port{@code OpportunityCreationPortImpl}并列的第二条建档入口本层职责
* 建商机核心逻辑主表 + 阶段落位 + 初始日志 + 团队成员 02/16两条入口对称 * {@link CreateOpportunityRequest} 翻译成 {@link OpportunityIntakeSpec}source=DIRECT
* 本入口额外负责 {@code oppSource=线索转入} 时的关联线索必填校验 + source_lead_id 唯一性预检</p> * oppSource 为用户所选来源调用 {@link OpportunityIntake#open}并把深模块的
* {@link OpportunityIntakeException} seam 处译成 {@link BusinessErrorException}HTTP 友好</p>
*
* <p>本入口特有的 {@code oppSource=线索转入}{@link IntakeSource#defaultOppSource()}
* sourceLeadId 必填校验留在 adaptersourceLeadId 唯一性预检与建档五步机制主表 + 阶段落位 +
* 主要意向客户子表 + 初始操作日志 + 团队成员均由 {@code OpportunityIntakeImpl} 承载并测试</p>
*
* <p>事务本层不再标 @Transactional{@code open} 自带 REQUIRED 事务保证原子性
* ownerDeptId null直接新建不带部门快照阶段模板走默认兜底 15与收敛前行为一致</p>
* *
* <p><b>候选线索</b> 12 架构甲准入status IN 3/4 crm-lead * <p><b>候选线索</b> 12 架构甲准入status IN 3/4 crm-lead
* {@link ILeadService#listConvertibleLeads} 在线索域判定未被商机占用由本侧补 * {@link ILeadService#listConvertibleLeads} 在线索域判定未被商机占用由本侧补
* opportunity.source_lead_id职责边界干净</p> * opportunity.source_lead_id职责边界干净此能力属于 Candidate #2 范围维持原状</p>
*/ */
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class OpportunityCreateServiceImpl implements IOpportunityCreateService { public class OpportunityCreateServiceImpl implements IOpportunityCreateService {
/** 商机来源:线索转入(crm-dict opp_source 分组)——触发关联线索必填。 */ private final OpportunityIntake intake;
private static final String OPP_SOURCE_LEAD_CONVERT = "opp_source_01";
/** 领取人项目角色:商机负责人(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 OpportunityMapper oppMapper;
private final OpportunityCustomerMapper customerMapper;
private final OpportunityOplogMapper oplogMapper;
private final OpportunityTeamMapper teamMapper;
private final IOpportunityStageTemplateService stageTemplateService;
private final ILeadService leadService; private final ILeadService leadService;
@Override @Override
@Transactional(rollbackFor = Exception.class)
public Long createOpportunity(CreateOpportunityRequest request, Long ownerUserId) { public Long createOpportunity(CreateOpportunityRequest request, Long ownerUserId) {
boolean isLeadConvert = OPP_SOURCE_LEAD_CONVERT.equals(request.getOppSource()); // 票 12 校验:仅线索转入入口(oppSource=opp_source_01,常量由 IntakeSource 承载)→ sourceLeadId 必填
// 票 12 校验:仅线索转入入口 → sourceLeadId 必填 if (IntakeSource.LEAD_CONVERT.defaultOppSource().equals(request.getOppSource())
if (isLeadConvert && request.getSourceLeadId() == null) { && request.getSourceLeadId() == null) {
throw new BusinessErrorException("商机来源为「线索转入」时必须选择关联线索"); throw new BusinessErrorException("商机来源为「线索转入」时必须选择关联线索");
} }
// 票 12 唯一性预检:sourceLeadId 非空时查是否已被占用(DB uk 作并发最终防线)
if (request.getSourceLeadId() != null) {
Long taken = oppMapper.selectCount(new LambdaQueryWrapper<Opportunity>()
.eq(Opportunity::getSourceLeadId, request.getSourceLeadId()));
if (taken != null && taken > 0) {
throw new BusinessErrorException("该线索已关联其他商机");
}
}
Opportunity opp = buildOpportunity(request, ownerUserId); OpportunityIntakeSpec spec = new OpportunityIntakeSpec(
IntakeSource.DIRECT,
request.getOppSource(),
request.getOpportunityName(),
request.getIndustryCode(),
request.getPartyA(),
request.getProvinceCode(),
request.getCityCode(),
request.getRemark(),
ownerUserId,
null,
request.getCustomerId(),
request.getCustomerName(),
request.getSourceLeadId(),
null,
null,
null);
try { try {
oppMapper.insert(opp); return intake.open(spec);
} catch (org.springframework.dao.DuplicateKeyException e) { } catch (OpportunityIntakeException e) {
throw new BusinessErrorException("该线索已关联其他商机"); throw new BusinessErrorException(e.getMessage());
} }
if (request.getCustomerId() != null) {
linkPrimaryCustomer(opp, request);
}
writeInitialOplog(opp.getId(), ownerUserId);
insertOwnerTeamMember(opp.getId(), ownerUserId);
return opp.getId();
} }
@Override @Override
@ -112,83 +96,4 @@ public class OpportunityCreateServiceImpl implements IOpportunityCreateService {
.filter(v -> !takenLeadIds.contains(v.id())) .filter(v -> !takenLeadIds.contains(v.id()))
.toList(); .toList();
} }
private Opportunity buildOpportunity(CreateOpportunityRequest request, Long ownerUserId) {
Opportunity opp = new Opportunity();
opp.setOppName(request.getOpportunityName());
opp.setOppSource(request.getOppSource());
opp.setIndustryCode(request.getIndustryCode());
opp.setPartyA(request.getPartyA());
opp.setRemark(request.getRemark());
opp.setProvinceCode(request.getProvinceCode());
opp.setCityCode(request.getCityCode());
opp.setOwnerUserId(ownerUserId);
opp.setCreatorUserId(ownerUserId);
// 线索转入入口带 source_lead_id;直接创建 NULL(不参与 UNIQUE)
opp.setSourceLeadId(request.getSourceLeadId());
// 新建即领取,进入推进中
opp.setOppStatus(OpportunityStatus.STATUS_ADVANCING.getValue());
opp.setClaimTime(LocalDateTime.now());
opp.setPrimaryCustomerNameSnapshot(request.getCustomerName());
// 票 02 步 2:阶段落位——按负责人部门解析发布中模板(deptId 未知走默认兜底,票 15)
applyInitialStage(opp);
return opp;
}
/** 票 02 步 2 / 票 06:解析发布中阶段模板版本,current_stage_id 落首节点;无模板容忍空(走法 A)。 */
private void applyInitialStage(Opportunity opp) {
OpportunityStageTemplate version = stageTemplateService.resolveBindingVersion(opp.getOwnerDeptId());
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, Long ownerUserId) {
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("USER");
log.setOpTime(LocalDateTime.now());
log.setOpUserId(ownerUserId);
oplogMapper.insert(log);
}
/** 票 02 步 4:领取人=商机负责人,插一条团队成员(project_role_01 / READ_WRITE)。 */
private void insertOwnerTeamMember(Long oppId, Long ownerUserId) {
OpportunityTeam member = new OpportunityTeam();
member.setOpportunityId(oppId);
member.setUserId(ownerUserId);
member.setProjectRole(PROJECT_ROLE_OWNER);
member.setPermission(TeamMemberPermission.READ_WRITE.getValue());
member.setDeleteKey(0L);
teamMapper.insert(member);
}
/** 票 13:建 is_primary_intended=1 主要意向客户子表 + 刷主表冗余。 */
private void linkPrimaryCustomer(Opportunity opp, CreateOpportunityRequest request) {
OpportunityCustomer link = new OpportunityCustomer();
link.setOpportunityId(opp.getId());
link.setCustomerId(request.getCustomerId());
link.setCustomerNameSnapshot(request.getCustomerName());
link.setCustomerRole(ROLE_INTENDED);
link.setIsPrimaryIntended(1);
customerMapper.insert(link);
opp.setPrimaryCustomerId(request.getCustomerId());
if (StrUtil.isNotBlank(request.getCustomerName())) {
opp.setPrimaryCustomerNameSnapshot(request.getCustomerName());
}
oppMapper.updateById(opp);
}
} }

125
crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImplTest.java

@ -7,16 +7,11 @@ import com.crm.lead.domain.dto.ConvertibleLeadView;
import com.crm.lead.service.ILeadService; import com.crm.lead.service.ILeadService;
import com.crm.opportunity.domain.dto.CreateOpportunityRequest; import com.crm.opportunity.domain.dto.CreateOpportunityRequest;
import com.crm.opportunity.domain.entity.Opportunity; import com.crm.opportunity.domain.entity.Opportunity;
import com.crm.opportunity.domain.enums.OpportunityStatus; import com.crm.opportunity.intake.IntakeSource;
import com.crm.opportunity.mapper.OpportunityCustomerMapper; import com.crm.opportunity.intake.OpportunityIntake;
import com.crm.opportunity.intake.OpportunityIntakeException;
import com.crm.opportunity.intake.OpportunityIntakeSpec;
import com.crm.opportunity.mapper.OpportunityMapper; import com.crm.opportunity.mapper.OpportunityMapper;
import com.crm.opportunity.mapper.OpportunityOplogMapper;
import com.crm.opportunity.mapper.OpportunityTeamMapper;
import com.crm.opportunity.domain.entity.OpportunityOplog;
import com.crm.opportunity.domain.entity.OpportunityTeam;
import com.crm.rule.domain.entity.OpportunityStageNode;
import com.crm.rule.domain.entity.OpportunityStageTemplate;
import com.crm.rule.service.IOpportunityStageTemplateService;
import org.apache.ibatis.builder.MapperBuilderAssistant; import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
@ -32,30 +27,30 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
/** /**
* 12商机侧直接新建商机 + 候选线索查询规格验证 * 12 商机侧直接新建 adapter 单测Candidate #1 后只验翻译层 + 入口特有校验
*
* <p>覆盖oppSource=线索转入时 sourceLeadId 必填校验不进深模块
* {@code CreateOpportunityRequest} {@code OpportunityIntakeSpec} 全字段映射
* {@code OpportunityIntakeException} {@code BusinessErrorException} seam 翻译
* 候选线索剔除已占用建档五步机制与唯一性防护的断言已上移
* {@code OpportunityIntakeImplTest}本类不再重复</p>
*/ */
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@DisplayName("票12 商机侧新建入口") @DisplayName("票12 商机侧新建入口 adapter(翻译层)")
class OpportunityCreateServiceImplTest { class OpportunityCreateServiceImplTest {
private static final String LEAD_CONVERT = "opp_source_01"; private static final String LEAD_CONVERT = "opp_source_01";
private static final Long OWNER = 100L; private static final Long OWNER = 100L;
private static final Long DEPT = 200L;
private static final Long LEAD_ID = 9001L; private static final Long LEAD_ID = 9001L;
private static final Long NEW_OPP_ID = 7001L; private static final Long NEW_OPP_ID = 7001L;
@Mock private OpportunityIntake intake;
@Mock private OpportunityMapper oppMapper; @Mock private OpportunityMapper oppMapper;
@Mock private OpportunityCustomerMapper customerMapper;
@Mock private OpportunityOplogMapper oplogMapper;
@Mock private OpportunityTeamMapper teamMapper;
@Mock private IOpportunityStageTemplateService stageTemplateService;
@Mock private ILeadService leadService; @Mock private ILeadService leadService;
@InjectMocks private OpportunityCreateServiceImpl service; @InjectMocks private OpportunityCreateServiceImpl service;
@ -75,84 +70,62 @@ class OpportunityCreateServiceImplTest {
r.setOpportunityName("商机A"); r.setOpportunityName("商机A");
r.setIndustryCode("ind01"); r.setIndustryCode("ind01");
r.setPartyA("甲方X"); r.setPartyA("甲方X");
r.setCustomerId(5001L);
r.setCustomerName("意向客户名");
r.setProvinceCode("110000"); r.setProvinceCode("110000");
r.setCityCode("110100"); r.setCityCode("110100");
r.setRemark("备注");
return r; return r;
} }
private void stubInsertAssignsId() {
doAnswer(inv -> {
((Opportunity) inv.getArgument(0)).setId(NEW_OPP_ID);
return 1;
}).when(oppMapper).insert(any(Opportunity.class));
}
@Test @Test
@DisplayName("oppSource=线索转入 但未选关联线索 → 抛业务错误(sourceLeadId 必填)") @DisplayName("oppSource=线索转入 但未选关联线索 → 抛业务错误,不进深模块(sourceLeadId 必填)")
void leadConvert_withoutLead_rejected() { void leadConvert_withoutLead_rejected() {
assertThatThrownBy(() -> service.createOpportunity(req(LEAD_CONVERT, null), OWNER)) assertThatThrownBy(() -> service.createOpportunity(req(LEAD_CONVERT, null), OWNER))
.isInstanceOf(BusinessErrorException.class) .isInstanceOf(BusinessErrorException.class)
.hasMessageContaining("关联线索"); .hasMessageContaining("关联线索");
verify(oppMapper, never()).insert(any(Opportunity.class)); verify(intake, never()).open(any(OpportunityIntakeSpec.class));
} }
@Test @Test
@DisplayName("oppSource=线索转入 + 线索已被占用 → 抛业务错误(唯一性预检)") @DisplayName("翻译:request → spec 全字段映射(source=DIRECT,oppSource 用户所选,无来源线索)")
void leadConvert_leadTaken_rejected() { void create_translatesRequestToSpec() {
when(oppMapper.selectCount(any())).thenReturn(1L); when(intake.open(any(OpportunityIntakeSpec.class))).thenReturn(NEW_OPP_ID);
assertThatThrownBy(() -> service.createOpportunity(req(LEAD_CONVERT, LEAD_ID), OWNER))
.isInstanceOf(BusinessErrorException.class)
.hasMessageContaining("已关联");
verify(oppMapper, never()).insert(any(Opportunity.class));
}
@Test
@DisplayName("直接创建(非线索转入)→ sourceLeadId 可空,正常建商机,不做唯一性预检")
void directCreate_noLead_ok() {
stubInsertAssignsId();
Long id = service.createOpportunity(req("opp_source_02", null), OWNER); Long id = service.createOpportunity(req("opp_source_02", null), OWNER);
assertThat(id).isEqualTo(NEW_OPP_ID); assertThat(id).isEqualTo(NEW_OPP_ID);
ArgumentCaptor<Opportunity> cap = ArgumentCaptor.forClass(Opportunity.class); ArgumentCaptor<OpportunityIntakeSpec> cap = ArgumentCaptor.forClass(OpportunityIntakeSpec.class);
verify(oppMapper).insert(cap.capture()); verify(intake).open(cap.capture());
Opportunity saved = cap.getValue(); OpportunityIntakeSpec spec = cap.getValue();
assertThat(saved.getSourceLeadId()).isNull(); assertThat(spec.source()).isEqualTo(IntakeSource.DIRECT);
assertThat(saved.getOppStatus()).isEqualTo(OpportunityStatus.STATUS_ADVANCING.getValue()); assertThat(spec.oppSource()).isEqualTo("opp_source_02");
assertThat(saved.getOwnerUserId()).isEqualTo(OWNER); assertThat(spec.opportunityName()).isEqualTo("商机A");
verify(oppMapper, never()).selectCount(any()); 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);
// 直接新建不带部门快照:阶段模板走默认兜底(票 15),与收敛前行为一致
assertThat(spec.ownerDeptId()).isNull();
assertThat(spec.customerId()).isEqualTo(5001L);
assertThat(spec.intendedCustomer()).isEqualTo("意向客户名");
assertThat(spec.sourceLeadId()).isNull();
assertThat(spec.sourceLeadName()).isNull();
assertThat(spec.sourcePhone()).isNull();
assertThat(spec.sourceProductCode()).isNull();
} }
@Test @Test
@DisplayName("建商机核心:阶段落位(默认模板首节点)+ 初始日志「直接创建」+ 团队成员领取人") @DisplayName("seam 翻译:OpportunityIntakeException → BusinessErrorException(保留消息)")
void create_appliesStageOplogTeam() { void create_translatesIntakeException() {
stubInsertAssignsId(); when(intake.open(any(OpportunityIntakeSpec.class)))
OpportunityStageTemplate ver = new OpportunityStageTemplate(); .thenThrow(new OpportunityIntakeException("该线索已关联其他商机:leadId=" + LEAD_ID));
ver.setId(6601L);
ver.setVersionNo("V1.0"); assertThatThrownBy(() -> service.createOpportunity(req(LEAD_CONVERT, LEAD_ID), OWNER))
when(stageTemplateService.resolveBindingVersion(any())).thenReturn(ver); .isInstanceOf(BusinessErrorException.class)
OpportunityStageNode first = new OpportunityStageNode(); .hasMessageContaining("已关联");
first.setId(6611L);
first.setSeqNo(1);
when(stageTemplateService.listNodesOfVersion(6601L)).thenReturn(List.of(first));
service.createOpportunity(req("opp_source_02", null), OWNER);
ArgumentCaptor<Opportunity> cap = ArgumentCaptor.forClass(Opportunity.class);
verify(oppMapper).insert(cap.capture());
assertThat(cap.getValue().getCurrentStageId()).isEqualTo(6611L);
assertThat(cap.getValue().getStageTemplateVersion()).isEqualTo("V1.0");
ArgumentCaptor<OpportunityOplog> logCap = ArgumentCaptor.forClass(OpportunityOplog.class);
verify(oplogMapper).insert(logCap.capture());
assertThat(logCap.getValue().getOpDesc()).contains("直接创建");
assertThat(logCap.getValue().getOpUserId()).isEqualTo(OWNER);
ArgumentCaptor<OpportunityTeam> teamCap = ArgumentCaptor.forClass(OpportunityTeam.class);
verify(teamMapper).insert(teamCap.capture());
assertThat(teamCap.getValue().getUserId()).isEqualTo(OWNER);
assertThat(teamCap.getValue().getProjectRole()).isEqualTo("project_role_01");
} }
@Test @Test

Loading…
Cancel
Save