Browse Source

feat(opportunity): 票12 商机侧直接新建入口(A3-1-1-2-1)后端补齐

前误判纠正:此前 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 客户数据源仍在本仓库外。
master
luoweijian 2 weeks ago
parent
commit
70e2e35f68
  1. 15
      crm-lead/src/main/java/com/crm/lead/domain/dto/ConvertibleLeadView.java
  2. 10
      crm-lead/src/main/java/com/crm/lead/service/ILeadService.java
  3. 11
      crm-lead/src/main/java/com/crm/lead/service/impl/LeadServiceImpl.java
  4. 78
      crm-lead/src/test/java/com/crm/lead/service/impl/LeadConvertibleQueryTest.java
  5. 46
      crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCreateController.java
  6. 44
      crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/CreateOpportunityRequest.java
  7. 31
      crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityCreateService.java
  8. 194
      crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImpl.java
  9. 177
      crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImplTest.java

15
crm-lead/src/main/java/com/crm/lead/domain/dto/ConvertibleLeadView.java

@ -0,0 +1,15 @@
package com.crm.lead.domain.dto;
/**
* 可转商机候选线索视图 12商机侧新建入口关联线索下拉数据源
*
* <p>轻量投影仅够下拉展示 + 回填线索 id + 名称 + 电话候选准入
* status IN 已领取/跟进中 {@code ILeadService.listConvertibleLeads} 在线索域判定
* 未被商机占用过滤由商机侧补 opportunity.source_lead_id</p>
*
* @param id 线索 id回填 source_lead_id
* @param leadName 线索名称下拉展示
* @param phone 联系电话下拉辅助展示
*/
public record ConvertibleLeadView(Long id, String leadName, String phone) {
}

10
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商机侧新建入口关联线索下拉数据源
* <p>准入 = 该用户名下 {@code status IN (已领取 3 / 跟进中 4)} 的线索对齐转商机准入
* 未被商机占用过滤由商机侧补本方法不知 opportunity </p>
*
* @param ownerUserId 归属用户 id商机侧传当前登录人
*/
List<ConvertibleLeadView> listConvertibleLeads(Long ownerUserId);
// ==================== 创建/编辑/删除 ====================
/**

11
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<LeadMapper, Lead>
return leadViewQuery.countStats(param);
}
@Override
public List<ConvertibleLeadView> listConvertibleLeads(Long ownerUserId) {
List<Lead> leads = getBaseMapper().selectList(new LambdaQueryWrapper<Lead>()
.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

78
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}规格验证
* <p>准入 = 该用户名下 status IN (已领取 3 / 跟进中 4)投影为轻量 view</p>
*/
@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<ConvertibleLeadView> 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();
}
}

46
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;
/**
* 商机侧新建入口接口 12A3-1-1-2-1薄适配层只调 {@link IOpportunityCreateService}
*
* <p>与线索侧转商机 port 并列的第二条建商机入口oppSource=线索转入 时需在表单选关联线索
* 下拉数据源见 {@code /convertible-leads}当前登录人 = 负责人/创建人</p>
*/
@Tag(name = "商机管理/商机新建")
@RestController
@RequestMapping("/api/opportunity")
@RequiredArgsConstructor
public class OpportunityCreateController {
private final IOpportunityCreateService createService;
@Operation(summary = "新建商机(oppSource=线索转入 时须选关联线索)")
@PostMapping
public Result<Long> create(@RequestBody CreateOpportunityRequest request) {
Long ownerUserId = Long.valueOf(SecurityUtils.getRequiredUserId());
return Result.success(createService.createOpportunity(request, ownerUserId));
}
@Operation(summary = "可关联线索下拉候选(已领取/跟进中 且 未被商机占用)")
@GetMapping("/convertible-leads")
public Result<List<ConvertibleLeadView>> convertibleLeads() {
Long ownerUserId = Long.valueOf(SecurityUtils.getRequiredUserId());
return Result.success(createService.listConvertibleLeads(ownerUserId));
}
}

44
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
*
* <p>两条入口之一另一条为线索侧转商机 port {@code oppSource == opp_source_01}
* 线索转入 {@code sourceLeadId} 必填并复用主表 {@code source_lead_id}其余来源
* {@code sourceLeadId} 可空直接创建NULL 不参与 UNIQUE可多条</p>
*/
@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;
}

31
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
*
* <p>与线索侧转商机 port 并列的第二条建商机入口核心建商机逻辑主表 + 阶段落位 +
* 初始日志 + 团队成员 02/16两条入口对称本入口额外负责 oppSource 驱动的
* 关联线索必填校验 + source_lead_id 唯一性预检</p>
*/
public interface IOpportunityCreateService {
/**
* 直接新建商机
*
* @param request 表单入参
* @param ownerUserId 当前登录人负责人/创建人
* @return 新建商机 id
*/
Long createOpportunity(CreateOpportunityRequest request, Long ownerUserId);
/**
* 可关联线索下拉候选 12该用户 status IN(3,4) 且未被任何商机占用的线索
*
* @param ownerUserId 当前登录人
*/
List<com.crm.lead.domain.dto.ConvertibleLeadView> listConvertibleLeads(Long ownerUserId);
}

194
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
*
* <p>与线索侧转商机 port{@code OpportunityCreationPortImpl}并列的第二条建商机入口
* 建商机核心逻辑主表 + 阶段落位 + 初始日志 + 团队成员 02/16两条入口对称
* 本入口额外负责 {@code oppSource=线索转入} 时的关联线索必填校验 + source_lead_id 唯一性预检</p>
*
* <p><b>候选线索</b> 12 架构甲准入status IN 3/4 crm-lead
* {@link ILeadService#listConvertibleLeads} 在线索域判定未被商机占用由本侧补
* opportunity.source_lead_id职责边界干净</p>
*/
@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<Opportunity>()
.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<ConvertibleLeadView> listConvertibleLeads(Long ownerUserId) {
List<ConvertibleLeadView> candidates = leadService.listConvertibleLeads(ownerUserId);
if (candidates.isEmpty()) {
return candidates;
}
// 商机侧补「未被占用」过滤:剔除 source_lead_id 已在 opportunity 表占用的
Set<Long> takenLeadIds = oppMapper.selectList(new LambdaQueryWrapper<Opportunity>()
.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<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);
}
}

177
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<Opportunity> 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<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
@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<ConvertibleLeadView> 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;
}
}
Loading…
Cancel
Save