|
|
@ -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 |
|
|
|