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