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