From 70e2e35f688799b2490342e251b9479c7dc03fae Mon Sep 17 00:00:00 2001
From: luoweijian <1329394916@qq.com>
Date: Tue, 25 Aug 2026 17:04:56 +0800
Subject: [PATCH] =?UTF-8?q?feat(opportunity):=20=E7=A5=A812=20=E5=95=86?=
=?UTF-8?q?=E6=9C=BA=E4=BE=A7=E7=9B=B4=E6=8E=A5=E6=96=B0=E5=BB=BA=E5=85=A5?=
=?UTF-8?q?=E5=8F=A3=EF=BC=88A3-1-1-2-1=EF=BC=89=E5=90=8E=E7=AB=AF?=
=?UTF-8?q?=E8=A1=A5=E9=BD=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
前误判纠正:此前 map 将票12标已落,实为仅验了转商机 port 路径;
本轮补齐商机侧独立新建入口后端(此前一行未写):
- crm-lead: ILeadService.listConvertibleLeads(ownerUserId) 返 status IN(3,4)
候选线索轻量视图 ConvertibleLeadView(准入规则留线索域,架构甲)
- crm-opportunity: CreateOpportunityRequest + IOpportunityCreateService + Impl
+ OpportunityCreateController(POST /api/opportunity, GET /convertible-leads)
- oppSource=opp_source_01(线索转入) → sourceLeadId 必填校验
- source_lead_id 唯一性预检 + DB uk 并发防线(转译业务异常)
- 建商机核心(主表+阶段落位+初始日志+团队成员)两条入口对称,
日志描述「直接创建商机」/opSource=USER 区别于转商机
- 候选下拉商机侧补「未被占用」过滤(剔除已在 opp 表占用的 lead)
crm-lead 119 + crm-opportunity 161 全绿,无 BOM。
前端【关联线索/客户】下拉交互 + A4 客户数据源仍在本仓库外。
---
.../lead/domain/dto/ConvertibleLeadView.java | 15 ++
.../com/crm/lead/service/ILeadService.java | 10 +
.../lead/service/impl/LeadServiceImpl.java | 11 +
.../impl/LeadConvertibleQueryTest.java | 78 +++++++
.../OpportunityCreateController.java | 46 +++++
.../domain/dto/CreateOpportunityRequest.java | 44 ++++
.../service/IOpportunityCreateService.java | 31 +++
.../impl/OpportunityCreateServiceImpl.java | 194 ++++++++++++++++++
.../OpportunityCreateServiceImplTest.java | 177 ++++++++++++++++
9 files changed, 606 insertions(+)
create mode 100644 crm-lead/src/main/java/com/crm/lead/domain/dto/ConvertibleLeadView.java
create mode 100644 crm-lead/src/test/java/com/crm/lead/service/impl/LeadConvertibleQueryTest.java
create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCreateController.java
create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/CreateOpportunityRequest.java
create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityCreateService.java
create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImpl.java
create mode 100644 crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImplTest.java
diff --git a/crm-lead/src/main/java/com/crm/lead/domain/dto/ConvertibleLeadView.java b/crm-lead/src/main/java/com/crm/lead/domain/dto/ConvertibleLeadView.java
new file mode 100644
index 0000000..c76b197
--- /dev/null
+++ b/crm-lead/src/main/java/com/crm/lead/domain/dto/ConvertibleLeadView.java
@@ -0,0 +1,15 @@
+package com.crm.lead.domain.dto;
+
+/**
+ * 可转商机候选线索视图(票 12:商机侧新建入口「关联线索」下拉数据源)。
+ *
+ *
轻量投影,仅够下拉展示 + 回填:线索 id + 名称 + 电话。候选准入
+ * (status IN 已领取/跟进中)由 {@code ILeadService.listConvertibleLeads} 在线索域判定;
+ * 「未被商机占用」过滤由商机侧补(查 opportunity.source_lead_id)。
+ *
+ * @param id 线索 id(回填 source_lead_id)
+ * @param leadName 线索名称(下拉展示)
+ * @param phone 联系电话(下拉辅助展示)
+ */
+public record ConvertibleLeadView(Long id, String leadName, String phone) {
+}
diff --git a/crm-lead/src/main/java/com/crm/lead/service/ILeadService.java b/crm-lead/src/main/java/com/crm/lead/service/ILeadService.java
index 13b9ff8..0b60cc2 100644
--- a/crm-lead/src/main/java/com/crm/lead/service/ILeadService.java
+++ b/crm-lead/src/main/java/com/crm/lead/service/ILeadService.java
@@ -2,6 +2,7 @@ package com.crm.lead.service;
import com.crm.base.domain.result.PageResult;
import com.crm.base.domain.result.BatchResult;
+import com.crm.lead.domain.dto.ConvertibleLeadView;
import com.crm.lead.domain.dto.LeadDTO;
import com.crm.lead.domain.dto.LeadBatchFailItem;
import com.crm.lead.domain.dto.LeadFeedbackDTO;
@@ -33,6 +34,15 @@ public interface ILeadService {
/** 视图统计卡片(与 {@link #pageLeads} 同一套筛选口径,口径随视图,ADR-0023) */
LeadStatsDTO countStats(LeadPageParam param);
+ /**
+ * 可转商机候选线索(票 12:商机侧新建入口【关联线索】下拉数据源)。
+ * 准入 = 该用户名下 {@code status IN (已领取 3 / 跟进中 4)} 的线索(对齐转商机准入)。
+ * “未被商机占用”过滤由商机侧补(本方法不知 opportunity 表)。
+ *
+ * @param ownerUserId 归属用户 id(商机侧传当前登录人)
+ */
+ List listConvertibleLeads(Long ownerUserId);
+
// ==================== 创建/编辑/删除 ====================
/**
diff --git a/crm-lead/src/main/java/com/crm/lead/service/impl/LeadServiceImpl.java b/crm-lead/src/main/java/com/crm/lead/service/impl/LeadServiceImpl.java
index c80a475..f1f2532 100644
--- a/crm-lead/src/main/java/com/crm/lead/service/impl/LeadServiceImpl.java
+++ b/crm-lead/src/main/java/com/crm/lead/service/impl/LeadServiceImpl.java
@@ -14,6 +14,7 @@ import com.crm.lead.creation.LeadCreateContext;
import com.crm.lead.creation.LeadCreationPlanner;
import com.crm.lead.deadline.DeadlineBudget;
import com.crm.lead.deadline.LeadDeadlines;
+import com.crm.lead.domain.dto.ConvertibleLeadView;
import com.crm.lead.domain.dto.LeadDTO;
import com.crm.lead.domain.dto.LeadBatchFailItem;
import com.crm.lead.domain.dto.LeadFeedbackDTO;
@@ -104,6 +105,16 @@ public class LeadServiceImpl extends BaseServiceImpl
return leadViewQuery.countStats(param);
}
+ @Override
+ public List listConvertibleLeads(Long ownerUserId) {
+ List leads = getBaseMapper().selectList(new LambdaQueryWrapper()
+ .eq(Lead::getOwnerUserId, ownerUserId)
+ .in(Lead::getStatus, LeadConstants.STATUS_CLAIMED, LeadConstants.STATUS_FOLLOWING));
+ return leads.stream()
+ .map(l -> new ConvertibleLeadView(l.getId(), l.getLeadName(), l.getPhone()))
+ .toList();
+ }
+
// ==================== 创建/编辑/删除 ====================
@Override
diff --git a/crm-lead/src/test/java/com/crm/lead/service/impl/LeadConvertibleQueryTest.java b/crm-lead/src/test/java/com/crm/lead/service/impl/LeadConvertibleQueryTest.java
new file mode 100644
index 0000000..961273e
--- /dev/null
+++ b/crm-lead/src/test/java/com/crm/lead/service/impl/LeadConvertibleQueryTest.java
@@ -0,0 +1,78 @@
+package com.crm.lead.service.impl;
+
+import com.crm.lead.constant.LeadConstants;
+import com.crm.lead.domain.dto.ConvertibleLeadView;
+import com.crm.lead.domain.entity.Lead;
+import com.crm.lead.mapper.LeadMapper;
+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.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 票 12:可转商机候选线索查询({@link LeadServiceImpl#listConvertibleLeads})规格验证。
+ * 准入 = 该用户名下 status IN (已领取 3 / 跟进中 4);投影为轻量 view。
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("票12 可转商机候选线索查询")
+class LeadConvertibleQueryTest {
+
+ private static final Long OWNER = 100L;
+
+ @Mock private LeadMapper leadMapper;
+
+ private LeadServiceImpl service() {
+ // 仅测 listConvertibleLeads:其余构造依赖本方法不触及,传 null 即可
+ LeadServiceImpl svc = new LeadServiceImpl(
+ null, null, null, null, null, null, null, null, null, null, null);
+ ReflectionTestUtils.setField(svc, "baseMapper", leadMapper);
+ return svc;
+ }
+
+ private Lead lead(Long id, String name, String phone) {
+ Lead l = new Lead();
+ l.setId(id);
+ l.setLeadName(name);
+ l.setPhone(phone);
+ return l;
+ }
+
+ @Test
+ @DisplayName("投影 id/name/phone,仅查该用户 status IN (3,4)")
+ void listConvertibleLeads_projectsAndFiltersByOwnerAndStatus() {
+ when(leadMapper.selectList(any())).thenReturn(List.of(
+ lead(1L, "线索甲", "13800000001"),
+ lead(2L, "线索乙", "13800000002")));
+
+ List views = service().listConvertibleLeads(OWNER);
+
+ assertThat(views).extracting(ConvertibleLeadView::id).containsExactly(1L, 2L);
+ assertThat(views).extracting(ConvertibleLeadView::leadName).containsExactly("线索甲", "线索乙");
+ assertThat(views).extracting(ConvertibleLeadView::phone).containsExactly("13800000001", "13800000002");
+ }
+
+ @Test
+ @DisplayName("准入状态常量对齐:STATUS_CLAIMED=3 / STATUS_FOLLOWING=4")
+ void statusConstantsAlignWithConvertPrecondition() {
+ assertThat(LeadConstants.STATUS_CLAIMED).isEqualTo(3);
+ assertThat(LeadConstants.STATUS_FOLLOWING).isEqualTo(4);
+ }
+
+ @Test
+ @DisplayName("无候选时返回空列表(不返回 null)")
+ void listConvertibleLeads_emptyWhenNoCandidates() {
+ when(leadMapper.selectList(any())).thenReturn(List.of());
+
+ assertThat(service().listConvertibleLeads(OWNER)).isEmpty();
+ }
+}
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCreateController.java b/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCreateController.java
new file mode 100644
index 0000000..29c9cb3
--- /dev/null
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCreateController.java
@@ -0,0 +1,46 @@
+package com.crm.opportunity.controller;
+
+import com.crm.base.domain.result.Result;
+import com.crm.base.security.SecurityUtils;
+import com.crm.lead.domain.dto.ConvertibleLeadView;
+import com.crm.opportunity.domain.dto.CreateOpportunityRequest;
+import com.crm.opportunity.service.IOpportunityCreateService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 商机侧新建入口接口(票 12,A3-1-1-2-1,薄适配层,只调 {@link IOpportunityCreateService})。
+ *
+ * 与线索侧转商机 port 并列的第二条建商机入口:oppSource=线索转入 时需在表单选【关联线索】
+ * (下拉数据源见 {@code /convertible-leads})。当前登录人 = 负责人/创建人。
+ */
+@Tag(name = "商机管理/商机新建")
+@RestController
+@RequestMapping("/api/opportunity")
+@RequiredArgsConstructor
+public class OpportunityCreateController {
+
+ private final IOpportunityCreateService createService;
+
+ @Operation(summary = "新建商机(oppSource=线索转入 时须选关联线索)")
+ @PostMapping
+ public Result create(@RequestBody CreateOpportunityRequest request) {
+ Long ownerUserId = Long.valueOf(SecurityUtils.getRequiredUserId());
+ return Result.success(createService.createOpportunity(request, ownerUserId));
+ }
+
+ @Operation(summary = "可关联线索下拉候选(已领取/跟进中 且 未被商机占用)")
+ @GetMapping("/convertible-leads")
+ public Result> convertibleLeads() {
+ Long ownerUserId = Long.valueOf(SecurityUtils.getRequiredUserId());
+ return Result.success(createService.listConvertibleLeads(ownerUserId));
+ }
+}
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/CreateOpportunityRequest.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/CreateOpportunityRequest.java
new file mode 100644
index 0000000..966c19d
--- /dev/null
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/CreateOpportunityRequest.java
@@ -0,0 +1,44 @@
+package com.crm.opportunity.domain.dto;
+
+import lombok.Data;
+
+/**
+ * 商机侧直接新建商机请求(票 12:新建入口 A3-1-1-2-1)。
+ *
+ * 两条入口之一(另一条为线索侧转商机 port)。当 {@code oppSource == opp_source_01}
+ * (线索转入)时 {@code sourceLeadId} 必填并复用主表 {@code source_lead_id};其余来源
+ * {@code sourceLeadId} 可空(直接创建,NULL 不参与 UNIQUE,可多条)。
+ */
+@Data
+public class CreateOpportunityRequest {
+
+ /** 商机来源字典 code(必填;opp_source_01=线索转入触发关联线索必填)。 */
+ private String oppSource;
+
+ /** 关联线索 id(oppSource=线索转入时必填,回填 source_lead_id;其余可空)。 */
+ private Long sourceLeadId;
+
+ /** 商机名称(必填)。 */
+ private String opportunityName;
+
+ /** 行业字典 code。 */
+ private String industryCode;
+
+ /** 甲方。 */
+ private String partyA;
+
+ /** 主要意向客户 id(引用 A4,A4 未就绪前可空)。 */
+ private Long customerId;
+
+ /** 主要意向客户名快照。 */
+ private String customerName;
+
+ /** 省国标 code。 */
+ private String provinceCode;
+
+ /** 市国标 code。 */
+ private String cityCode;
+
+ /** 备注。 */
+ private String remark;
+}
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityCreateService.java b/crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityCreateService.java
new file mode 100644
index 0000000..307f576
--- /dev/null
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityCreateService.java
@@ -0,0 +1,31 @@
+package com.crm.opportunity.service;
+
+import com.crm.opportunity.domain.dto.CreateOpportunityRequest;
+
+import java.util.List;
+
+/**
+ * 商机侧直接新建商机服务(票 12:新建入口 A3-1-1-2-1)。
+ *
+ * 与线索侧转商机 port 并列的第二条建商机入口。核心建商机逻辑(主表 + 阶段落位 +
+ * 初始日志 + 团队成员,票 02/16)两条入口对称;本入口额外负责 oppSource 驱动的
+ * 关联线索必填校验 + source_lead_id 唯一性预检。
+ */
+public interface IOpportunityCreateService {
+
+ /**
+ * 直接新建商机。
+ *
+ * @param request 表单入参
+ * @param ownerUserId 当前登录人(负责人/创建人)
+ * @return 新建商机 id
+ */
+ Long createOpportunity(CreateOpportunityRequest request, Long ownerUserId);
+
+ /**
+ * 可关联线索下拉候选(票 12):该用户 status IN(3,4) 且未被任何商机占用的线索。
+ *
+ * @param ownerUserId 当前登录人
+ */
+ List listConvertibleLeads(Long ownerUserId);
+}
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
new file mode 100644
index 0000000..8a3dc05
--- /dev/null
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImpl.java
@@ -0,0 +1,194 @@
+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.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)。
+ *
+ * 与线索侧转商机 port({@code OpportunityCreationPortImpl})并列的第二条建商机入口,
+ * 建商机核心逻辑(主表 + 阶段落位 + 初始日志 + 团队成员,票 02/16)两条入口对称。
+ * 本入口额外负责 {@code oppSource=线索转入} 时的关联线索必填校验 + source_lead_id 唯一性预检。
+ *
+ * 候选线索(票 12 架构甲):准入(status IN 3/4)由 crm-lead
+ * {@link ILeadService#listConvertibleLeads} 在线索域判定;「未被商机占用」由本侧补
+ * (查 opportunity.source_lead_id),职责边界干净。
+ */
+@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 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) {
+ 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);
+ try {
+ oppMapper.insert(opp);
+ } catch (org.springframework.dao.DuplicateKeyException e) {
+ throw new BusinessErrorException("该线索已关联其他商机");
+ }
+
+ if (request.getCustomerId() != null) {
+ linkPrimaryCustomer(opp, request);
+ }
+ writeInitialOplog(opp.getId(), ownerUserId);
+ insertOwnerTeamMember(opp.getId(), ownerUserId);
+ return opp.getId();
+ }
+
+ @Override
+ public List listConvertibleLeads(Long ownerUserId) {
+ List candidates = leadService.listConvertibleLeads(ownerUserId);
+ if (candidates.isEmpty()) {
+ return candidates;
+ }
+ // 商机侧补「未被占用」过滤:剔除 source_lead_id 已在 opportunity 表占用的
+ Set takenLeadIds = oppMapper.selectList(new LambdaQueryWrapper()
+ .select(Opportunity::getSourceLeadId)
+ .in(Opportunity::getSourceLeadId,
+ candidates.stream().map(ConvertibleLeadView::id).toList()))
+ .stream()
+ .map(Opportunity::getSourceLeadId)
+ .collect(Collectors.toSet());
+ return candidates.stream()
+ .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
new file mode 100644
index 0000000..a56a255
--- /dev/null
+++ b/crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImplTest.java
@@ -0,0 +1,177 @@
+package com.crm.opportunity.service.impl;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+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.enums.OpportunityStatus;
+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.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;
+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 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:商机侧直接新建商机 + 候选线索查询规格验证。
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("票12 商机侧新建入口")
+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 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;
+
+ /** 纯 Mockito 无 SqlSessionFactory,LambdaQueryWrapper 的 lambda 解析需 TableInfoHelper 缓存。 */
+ @BeforeAll
+ static void initTableInfo() {
+ MapperBuilderAssistant assistant =
+ new MapperBuilderAssistant(new MybatisConfiguration(), "");
+ TableInfoHelper.initTableInfo(assistant, Opportunity.class);
+ }
+
+ private CreateOpportunityRequest req(String oppSource, Long sourceLeadId) {
+ CreateOpportunityRequest r = new CreateOpportunityRequest();
+ r.setOppSource(oppSource);
+ r.setSourceLeadId(sourceLeadId);
+ r.setOpportunityName("商机A");
+ r.setIndustryCode("ind01");
+ r.setPartyA("甲方X");
+ r.setProvinceCode("110000");
+ r.setCityCode("110100");
+ 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 必填)")
+ void leadConvert_withoutLead_rejected() {
+ assertThatThrownBy(() -> service.createOpportunity(req(LEAD_CONVERT, null), OWNER))
+ .isInstanceOf(BusinessErrorException.class)
+ .hasMessageContaining("关联线索");
+ verify(oppMapper, never()).insert(any(Opportunity.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();
+
+ 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());
+ }
+
+ @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");
+ }
+
+ @Test
+ @DisplayName("候选线索:剔除已被商机占用的 source_lead_id")
+ void listConvertibleLeads_excludesTaken() {
+ when(leadService.listConvertibleLeads(OWNER)).thenReturn(List.of(
+ new ConvertibleLeadView(1L, "线索甲", "138"),
+ new ConvertibleLeadView(2L, "线索乙", "139")));
+ // 商机侧查已占用:lead 2 已被占用
+ when(oppMapper.selectList(any())).thenReturn(List.of(occupied(2L)));
+
+ List result = service.listConvertibleLeads(OWNER);
+
+ assertThat(result).extracting(ConvertibleLeadView::id).containsExactly(1L);
+ }
+
+ private Opportunity occupied(Long leadId) {
+ Opportunity o = new Opportunity();
+ o.setSourceLeadId(leadId);
+ return o;
+ }
+}