From 13ebebd23fd590c9baefec8c5f7b2b9afd8b73b2 Mon Sep 17 00:00:00 2001 From: luoweijian <1329394916@qq.com> Date: Tue, 25 Aug 2026 21:31:09 +0800 Subject: [PATCH] =?UTF-8?q?refactor(opportunity):=20=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E6=96=B0=E5=BB=BA=20service=20=E6=94=B9=E4=B8=BA=20Opportunity?= =?UTF-8?q?Intake=20=E8=96=84=20adapter=EF=BC=88Candidate=20#1=20=E6=AD=A5?= =?UTF-8?q?4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CreateOpportunityRequest -> OpportunityIntakeSpec 翻译(source=DIRECT, oppSource 用户所选,ownerDeptId=null 走默认模板兜底,与收敛前一致) - 保留入口特有校验:oppSource=线索转入时 sourceLeadId 必填(常量取自 IntakeSource) - 保留 listConvertibleLeads(属 Candidate #2 范围,维持原状) - OpportunityIntakeException -> BusinessErrorException seam 翻译 - 移除建档五步机制与自带 @Transactional(收敛于 OpportunityIntakeImpl) - 测试瘦身为翻译层断言:删 leadTaken/directCreate_ok/appliesStageOplogTeam 机制用例,新增 spec 全字段映射 + 异常翻译用例 --- .../impl/OpportunityCreateServiceImpl.java | 177 ++++-------------- .../OpportunityCreateServiceImplTest.java | 125 +++++-------- 2 files changed, 90 insertions(+), 212 deletions(-) diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImpl.java b/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImpl.java index 8a3dc05..29cfa65 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImpl.java +++ b/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImpl.java @@ -1,97 +1,81 @@ package com.crm.opportunity.service.impl; -import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.crm.base.domain.exception.BusinessErrorException; import com.crm.lead.domain.dto.ConvertibleLeadView; import com.crm.lead.service.ILeadService; import com.crm.opportunity.domain.dto.CreateOpportunityRequest; 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.intake.IntakeSource; +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.OpportunityOplogMapper; -import com.crm.opportunity.mapper.OpportunityTeamMapper; 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 org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; import java.util.List; import java.util.Set; import java.util.stream.Collectors; /** - * 商机侧直接新建商机实现(票 12:新建入口 A3-1-1-2-1)。 + * 商机侧直接新建商机实现——薄 adapter(Candidate #1:建档机制收敛进深模块 OpportunityIntake)。 * - *

与线索侧转商机 port({@code OpportunityCreationPortImpl})并列的第二条建商机入口, - * 建商机核心逻辑(主表 + 阶段落位 + 初始日志 + 团队成员,票 02/16)两条入口对称。 - * 本入口额外负责 {@code oppSource=线索转入} 时的关联线索必填校验 + source_lead_id 唯一性预检。

+ *

与线索侧转商机 port({@code OpportunityCreationPortImpl})并列的第二条建档入口。本层职责: + * 把 {@link CreateOpportunityRequest} 翻译成 {@link OpportunityIntakeSpec}(source=DIRECT, + * oppSource 为用户所选来源),调用 {@link OpportunityIntake#open},并把深模块的 + * {@link OpportunityIntakeException} 在 seam 处译成 {@link BusinessErrorException}(HTTP 友好)。

+ * + *

本入口特有的 {@code oppSource=线索转入}({@link IntakeSource#defaultOppSource()})时 + * sourceLeadId 必填校验留在 adapter;sourceLeadId 唯一性预检与建档五步机制(主表 + 阶段落位 + + * 主要意向客户子表 + 初始操作日志 + 团队成员)均由 {@code OpportunityIntakeImpl} 承载并测试。

+ * + *

事务:本层不再标 @Transactional——{@code open} 自带 REQUIRED 事务保证原子性。 + * ownerDeptId 传 null:直接新建不带部门快照,阶段模板走默认兜底(票 15),与收敛前行为一致。

* *

候选线索(票 12 架构甲):准入(status IN 3/4)由 crm-lead * {@link ILeadService#listConvertibleLeads} 在线索域判定;「未被商机占用」由本侧补 - * (查 opportunity.source_lead_id),职责边界干净。

+ * (查 opportunity.source_lead_id),职责边界干净。此能力属于 Candidate #2 范围,维持原状。

*/ @Service @RequiredArgsConstructor public class OpportunityCreateServiceImpl implements IOpportunityCreateService { - /** 商机来源:线索转入(crm-dict opp_source 分组)——触发关联线索必填。 */ - 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 OpportunityIntake intake; private final OpportunityMapper oppMapper; - private final OpportunityCustomerMapper customerMapper; - private final OpportunityOplogMapper oplogMapper; - private final OpportunityTeamMapper teamMapper; - private final IOpportunityStageTemplateService stageTemplateService; private final ILeadService leadService; @Override - @Transactional(rollbackFor = Exception.class) public Long createOpportunity(CreateOpportunityRequest request, Long ownerUserId) { - boolean isLeadConvert = OPP_SOURCE_LEAD_CONVERT.equals(request.getOppSource()); - // 票 12 校验:仅线索转入入口 → sourceLeadId 必填 - if (isLeadConvert && request.getSourceLeadId() == null) { + // 票 12 校验:仅线索转入入口(oppSource=opp_source_01,常量由 IntakeSource 承载)→ sourceLeadId 必填 + if (IntakeSource.LEAD_CONVERT.defaultOppSource().equals(request.getOppSource()) + && request.getSourceLeadId() == null) { throw new BusinessErrorException("商机来源为「线索转入」时必须选择关联线索"); } - // 票 12 唯一性预检:sourceLeadId 非空时查是否已被占用(DB uk 作并发最终防线) - if (request.getSourceLeadId() != null) { - Long taken = oppMapper.selectCount(new LambdaQueryWrapper() - .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 { - oppMapper.insert(opp); - } catch (org.springframework.dao.DuplicateKeyException e) { - throw new BusinessErrorException("该线索已关联其他商机"); + return intake.open(spec); + } catch (OpportunityIntakeException e) { + throw new BusinessErrorException(e.getMessage()); } - - if (request.getCustomerId() != null) { - linkPrimaryCustomer(opp, request); - } - writeInitialOplog(opp.getId(), ownerUserId); - insertOwnerTeamMember(opp.getId(), ownerUserId); - return opp.getId(); } @Override @@ -112,83 +96,4 @@ public class OpportunityCreateServiceImpl implements IOpportunityCreateService { .filter(v -> !takenLeadIds.contains(v.id())) .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 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); - } } diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImplTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImplTest.java index a56a255..aa77d2d 100644 --- a/crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImplTest.java +++ b/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.opportunity.domain.dto.CreateOpportunityRequest; import com.crm.opportunity.domain.entity.Opportunity; -import com.crm.opportunity.domain.enums.OpportunityStatus; -import com.crm.opportunity.mapper.OpportunityCustomerMapper; +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.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.junit.jupiter.api.BeforeAll; 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.assertThatThrownBy; 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.verify; import static org.mockito.Mockito.when; /** - * 票 12:商机侧直接新建商机 + 候选线索查询规格验证。 + * 票 12 商机侧直接新建 adapter 单测(Candidate #1 后只验翻译层 + 入口特有校验)。 + * + *

覆盖:oppSource=线索转入时 sourceLeadId 必填校验(不进深模块)、 + * {@code CreateOpportunityRequest} → {@code OpportunityIntakeSpec} 全字段映射、 + * {@code OpportunityIntakeException} → {@code BusinessErrorException} seam 翻译、 + * 候选线索剔除已占用。建档五步机制与唯一性防护的断言已上移 + * {@code OpportunityIntakeImplTest},本类不再重复。

*/ @ExtendWith(MockitoExtension.class) -@DisplayName("票12 商机侧新建入口") +@DisplayName("票12 商机侧新建入口 adapter(翻译层)") class OpportunityCreateServiceImplTest { private static final String LEAD_CONVERT = "opp_source_01"; private static final Long OWNER = 100L; - private static final Long DEPT = 200L; private static final Long LEAD_ID = 9001L; private static final Long NEW_OPP_ID = 7001L; + @Mock private OpportunityIntake intake; @Mock private OpportunityMapper oppMapper; - @Mock private OpportunityCustomerMapper customerMapper; - @Mock private OpportunityOplogMapper oplogMapper; - @Mock private OpportunityTeamMapper teamMapper; - @Mock private IOpportunityStageTemplateService stageTemplateService; @Mock private ILeadService leadService; @InjectMocks private OpportunityCreateServiceImpl service; @@ -75,84 +70,62 @@ class OpportunityCreateServiceImplTest { r.setOpportunityName("商机A"); r.setIndustryCode("ind01"); r.setPartyA("甲方X"); + r.setCustomerId(5001L); + r.setCustomerName("意向客户名"); r.setProvinceCode("110000"); r.setCityCode("110100"); + r.setRemark("备注"); return r; } - private void stubInsertAssignsId() { - doAnswer(inv -> { - ((Opportunity) inv.getArgument(0)).setId(NEW_OPP_ID); - return 1; - }).when(oppMapper).insert(any(Opportunity.class)); - } - @Test - @DisplayName("oppSource=线索转入 但未选关联线索 → 抛业务错误(sourceLeadId 必填)") + @DisplayName("oppSource=线索转入 但未选关联线索 → 抛业务错误,不进深模块(sourceLeadId 必填)") void leadConvert_withoutLead_rejected() { assertThatThrownBy(() -> service.createOpportunity(req(LEAD_CONVERT, null), OWNER)) .isInstanceOf(BusinessErrorException.class) .hasMessageContaining("关联线索"); - verify(oppMapper, never()).insert(any(Opportunity.class)); + verify(intake, never()).open(any(OpportunityIntakeSpec.class)); } @Test - @DisplayName("oppSource=线索转入 + 线索已被占用 → 抛业务错误(唯一性预检)") - void leadConvert_leadTaken_rejected() { - when(oppMapper.selectCount(any())).thenReturn(1L); - - 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(); + @DisplayName("翻译:request → spec 全字段映射(source=DIRECT,oppSource 用户所选,无来源线索)") + void create_translatesRequestToSpec() { + when(intake.open(any(OpportunityIntakeSpec.class))).thenReturn(NEW_OPP_ID); Long id = service.createOpportunity(req("opp_source_02", null), OWNER); assertThat(id).isEqualTo(NEW_OPP_ID); - ArgumentCaptor cap = ArgumentCaptor.forClass(Opportunity.class); - verify(oppMapper).insert(cap.capture()); - Opportunity saved = cap.getValue(); - assertThat(saved.getSourceLeadId()).isNull(); - assertThat(saved.getOppStatus()).isEqualTo(OpportunityStatus.STATUS_ADVANCING.getValue()); - assertThat(saved.getOwnerUserId()).isEqualTo(OWNER); - verify(oppMapper, never()).selectCount(any()); + ArgumentCaptor cap = ArgumentCaptor.forClass(OpportunityIntakeSpec.class); + verify(intake).open(cap.capture()); + OpportunityIntakeSpec spec = cap.getValue(); + assertThat(spec.source()).isEqualTo(IntakeSource.DIRECT); + assertThat(spec.oppSource()).isEqualTo("opp_source_02"); + 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); + // 直接新建不带部门快照:阶段模板走默认兜底(票 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 - @DisplayName("建商机核心:阶段落位(默认模板首节点)+ 初始日志「直接创建」+ 团队成员领取人") - void create_appliesStageOplogTeam() { - stubInsertAssignsId(); - OpportunityStageTemplate ver = new OpportunityStageTemplate(); - ver.setId(6601L); - ver.setVersionNo("V1.0"); - when(stageTemplateService.resolveBindingVersion(any())).thenReturn(ver); - OpportunityStageNode first = new OpportunityStageNode(); - first.setId(6611L); - first.setSeqNo(1); - when(stageTemplateService.listNodesOfVersion(6601L)).thenReturn(List.of(first)); - - service.createOpportunity(req("opp_source_02", null), OWNER); - - ArgumentCaptor cap = ArgumentCaptor.forClass(Opportunity.class); - verify(oppMapper).insert(cap.capture()); - assertThat(cap.getValue().getCurrentStageId()).isEqualTo(6611L); - assertThat(cap.getValue().getStageTemplateVersion()).isEqualTo("V1.0"); - - ArgumentCaptor logCap = ArgumentCaptor.forClass(OpportunityOplog.class); - verify(oplogMapper).insert(logCap.capture()); - assertThat(logCap.getValue().getOpDesc()).contains("直接创建"); - assertThat(logCap.getValue().getOpUserId()).isEqualTo(OWNER); - - ArgumentCaptor teamCap = ArgumentCaptor.forClass(OpportunityTeam.class); - verify(teamMapper).insert(teamCap.capture()); - assertThat(teamCap.getValue().getUserId()).isEqualTo(OWNER); - assertThat(teamCap.getValue().getProjectRole()).isEqualTo("project_role_01"); + @DisplayName("seam 翻译:OpportunityIntakeException → BusinessErrorException(保留消息)") + void create_translatesIntakeException() { + when(intake.open(any(OpportunityIntakeSpec.class))) + .thenThrow(new OpportunityIntakeException("该线索已关联其他商机:leadId=" + LEAD_ID)); + + assertThatThrownBy(() -> service.createOpportunity(req(LEAD_CONVERT, LEAD_ID), OWNER)) + .isInstanceOf(BusinessErrorException.class) + .hasMessageContaining("已关联"); } @Test