|
|
|
@ -1,97 +1,81 @@ |
|
|
|
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.intake.IntakeSource; |
|
|
|
import com.crm.opportunity.intake.OpportunityIntake; |
|
|
|
import com.crm.opportunity.intake.OpportunityIntakeException; |
|
|
|
import com.crm.opportunity.intake.OpportunityIntakeSpec; |
|
|
|
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)。 |
|
|
|
* 商机侧直接新建商机实现——薄 adapter(Candidate #1:建档机制收敛进深模块 OpportunityIntake)。 |
|
|
|
* |
|
|
|
* <p>与线索侧转商机 port({@code OpportunityCreationPortImpl})并列的第二条建商机入口, |
|
|
|
* 建商机核心逻辑(主表 + 阶段落位 + 初始日志 + 团队成员,票 02/16)两条入口对称。 |
|
|
|
* 本入口额外负责 {@code oppSource=线索转入} 时的关联线索必填校验 + source_lead_id 唯一性预检。</p> |
|
|
|
* <p>与线索侧转商机 port({@code OpportunityCreationPortImpl})并列的第二条建档入口。本层职责: |
|
|
|
* 把 {@link CreateOpportunityRequest} 翻译成 {@link OpportunityIntakeSpec}(source=DIRECT, |
|
|
|
* oppSource 为用户所选来源),调用 {@link OpportunityIntake#open},并把深模块的 |
|
|
|
* {@link OpportunityIntakeException} 在 seam 处译成 {@link BusinessErrorException}(HTTP 友好)。</p> |
|
|
|
* |
|
|
|
* <p>本入口特有的 {@code oppSource=线索转入}({@link IntakeSource#defaultOppSource()})时 |
|
|
|
* sourceLeadId 必填校验留在 adapter;sourceLeadId 唯一性预检与建档五步机制(主表 + 阶段落位 + |
|
|
|
* 主要意向客户子表 + 初始操作日志 + 团队成员)均由 {@code OpportunityIntakeImpl} 承载并测试。</p> |
|
|
|
* |
|
|
|
* <p>事务:本层不再标 @Transactional——{@code open} 自带 REQUIRED 事务保证原子性。 |
|
|
|
* ownerDeptId 传 null:直接新建不带部门快照,阶段模板走默认兜底(票 15),与收敛前行为一致。</p> |
|
|
|
* |
|
|
|
* <p><b>候选线索</b>(票 12 架构甲):准入(status IN 3/4)由 crm-lead |
|
|
|
* {@link ILeadService#listConvertibleLeads} 在线索域判定;「未被商机占用」由本侧补 |
|
|
|
* (查 opportunity.source_lead_id),职责边界干净。</p> |
|
|
|
* (查 opportunity.source_lead_id),职责边界干净。此能力属于 Candidate #2 范围,维持原状。</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 OpportunityIntake intake; |
|
|
|
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) { |
|
|
|
// 票 12 校验:仅线索转入入口(oppSource=opp_source_01,常量由 IntakeSource 承载)→ sourceLeadId 必填
|
|
|
|
if (IntakeSource.LEAD_CONVERT.defaultOppSource().equals(request.getOppSource()) |
|
|
|
&& 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); |
|
|
|
OpportunityIntakeSpec spec = new OpportunityIntakeSpec( |
|
|
|
IntakeSource.DIRECT, |
|
|
|
request.getOppSource(), |
|
|
|
request.getOpportunityName(), |
|
|
|
request.getIndustryCode(), |
|
|
|
request.getPartyA(), |
|
|
|
request.getProvinceCode(), |
|
|
|
request.getCityCode(), |
|
|
|
request.getRemark(), |
|
|
|
ownerUserId, |
|
|
|
null, |
|
|
|
request.getCustomerId(), |
|
|
|
request.getCustomerName(), |
|
|
|
request.getSourceLeadId(), |
|
|
|
null, |
|
|
|
null, |
|
|
|
null); |
|
|
|
try { |
|
|
|
oppMapper.insert(opp); |
|
|
|
} catch (org.springframework.dao.DuplicateKeyException e) { |
|
|
|
throw new BusinessErrorException("该线索已关联其他商机"); |
|
|
|
} |
|
|
|
|
|
|
|
if (request.getCustomerId() != null) { |
|
|
|
linkPrimaryCustomer(opp, request); |
|
|
|
return intake.open(spec); |
|
|
|
} catch (OpportunityIntakeException e) { |
|
|
|
throw new BusinessErrorException(e.getMessage()); |
|
|
|
} |
|
|
|
writeInitialOplog(opp.getId(), ownerUserId); |
|
|
|
insertOwnerTeamMember(opp.getId(), ownerUserId); |
|
|
|
return opp.getId(); |
|
|
|
} |
|
|
|
|
|
|
|
@Override |
|
|
|
@ -112,83 +96,4 @@ public class OpportunityCreateServiceImpl implements IOpportunityCreateService { |
|
|
|
.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); |
|
|
|
} |
|
|
|
} |
|
|
|
|