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; + } +}