Browse Source
前误判纠正:此前 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
9 changed files with 606 additions and 0 deletions
@ -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) { |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -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})。 |
|||
* |
|||
* <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)); |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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…
Reference in new issue