Browse Source
- CreateOpportunityCmd -> OpportunityIntakeSpec 翻译(source=LEAD_CONVERT, oppSource 恒 opp_source_01,修正原实现漏填 opp_source 的缺陷) - OpportunityIntakeException -> OpportunityCreationException seam 翻译 - 建档五步机制与唯一性防护移除(收敛于 OpportunityIntakeImpl) - adapter 不再自带 @Transactional(crm-lead 外层事务 + open REQUIRED) - 测试瘦身为翻译层断言,机制断言由 OpportunityIntakeImplTest 覆盖master
2 changed files with 75 additions and 358 deletions
@ -1,179 +1,59 @@ |
|||
package com.crm.opportunity.port.inbound.impl; |
|||
|
|||
import cn.hutool.core.util.StrUtil; |
|||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
|||
import com.crm.lead.port.outbound.CreateOpportunityCmd; |
|||
import com.crm.lead.port.outbound.OpportunityCreationException; |
|||
import com.crm.lead.port.outbound.OpportunityCreationPort; |
|||
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.rule.domain.entity.OpportunityStageNode; |
|||
import com.crm.rule.domain.entity.OpportunityStageTemplate; |
|||
import com.crm.rule.service.IOpportunityStageTemplateService; |
|||
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 lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 线索转商机新建端口实现(票 11 消费 + 票 12 唯一性预检 + 票 13 关联客户子表)。 |
|||
* 线索转商机新建端口实现——薄 adapter(Candidate #1:建档机制收敛进深模块 OpportunityIntake)。 |
|||
* |
|||
* <p>crm-lead 声明的出站契约 {@link OpportunityCreationPort} 的商机侧实现(同库同事务,ADR-0020)。 |
|||
* crm-lead 事务内调用,本方法任何失败抛 {@link OpportunityCreationException},crm-lead 捕获后整体回滚。</p> |
|||
* 本层只做翻译:把 {@link CreateOpportunityCmd} 组装成 {@link OpportunityIntakeSpec} |
|||
* (source=LEAD_CONVERT,oppSource 恒 {@link IntakeSource#defaultOppSource()}=opp_source_01, |
|||
* 修正原实现漏填 opp_source 的缺陷),调 {@link OpportunityIntake#open};深模块的 |
|||
* {@link OpportunityIntakeException} 在 seam 处译回 {@link OpportunityCreationException}。</p> |
|||
* |
|||
* <p><b>票 12 唯一性预检</b>:新建前查 {@code source_lead_id} 是否已被占用,命中即抛业务错误 |
|||
* (避免依赖 DB 唯一键冲突走错误路径);DB {@code uk(source_lead_id)} 仍作并发最终防线。</p> |
|||
* <p>建档五步机制(主表落库 + 阶段落位 + 主要意向客户子表 + 初始操作日志 + 团队成员)与 |
|||
* source_lead_id 唯一性防护均不再出现在本层(由 OpportunityIntakeImpl 承载并测试)。</p> |
|||
* |
|||
* <p><b>沨 A4 走法甲</b>:{@code cmd.customerId()} 非空时建一条 {@code is_primary_intended=1} |
|||
* 主要意向客户子表 + 刷主表冗余 {@code primary_customer_id/name}(票 03 C3)。 |
|||
* A4 客户下拉未就绪前 customerId 可空,此时跳过建子表 / 不刷冗余(不倒退现有自由文本转商机); |
|||
* A4 就绪后收紧为必填。</p> |
|||
* <p>事务:本层不再标 @Transactional——crm-lead 外层事务 + {@code open} 的 REQUIRED 传播 |
|||
* 保证原子性(ADR-0020 语义不变)。cmd.poolId 为线索侧信息,商机建档不消费。</p> |
|||
*/ |
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class OpportunityCreationPortImpl implements OpportunityCreationPort { |
|||
|
|||
/** 转商机来源标记:线索转入的意向客户角色(票 13,customer_role 字典)。 */ |
|||
private static final String ROLE_INTENDED = "intended"; |
|||
|
|||
/** 领取人项目角色:商机负责人(crm-dict project_role 分组,票 02)。 */ |
|||
private static final String PROJECT_ROLE_OWNER = "project_role_01"; |
|||
|
|||
private final OpportunityMapper oppMapper; |
|||
private final OpportunityCustomerMapper customerMapper; |
|||
private final OpportunityOplogMapper oplogMapper; |
|||
private final OpportunityTeamMapper teamMapper; |
|||
private final IOpportunityStageTemplateService stageTemplateService; |
|||
private final OpportunityIntake intake; |
|||
|
|||
@Override |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public Long createOpportunity(CreateOpportunityCmd cmd) { |
|||
// 票 12:source_lead_id 唯一性预检(应用层友好报错,DB 唯一键作并发最终防线)
|
|||
Long existing = oppMapper.selectCount(new LambdaQueryWrapper<Opportunity>() |
|||
.eq(Opportunity::getSourceLeadId, cmd.sourceLeadId())); |
|||
if (existing != null && existing > 0) { |
|||
throw new OpportunityCreationException("该线索已关联其他商机:leadId=" + cmd.sourceLeadId()); |
|||
} |
|||
|
|||
Opportunity opp = buildOpportunity(cmd); |
|||
OpportunityIntakeSpec spec = new OpportunityIntakeSpec( |
|||
IntakeSource.LEAD_CONVERT, |
|||
IntakeSource.LEAD_CONVERT.defaultOppSource(), |
|||
cmd.opportunityName(), |
|||
cmd.industryCode(), |
|||
cmd.partyA(), |
|||
cmd.provinceCode(), |
|||
cmd.cityCode(), |
|||
cmd.remark(), |
|||
cmd.ownerUserId(), |
|||
cmd.ownerDeptId(), |
|||
cmd.customerId(), |
|||
cmd.intendedCustomer(), |
|||
cmd.sourceLeadId(), |
|||
cmd.sourceLeadName(), |
|||
cmd.sourcePhone(), |
|||
cmd.sourceProductCode()); |
|||
try { |
|||
oppMapper.insert(opp); |
|||
} catch (org.springframework.dao.DuplicateKeyException e) { |
|||
// 并发窗口:预检通过后另一事务先插入 → DB uk(source_lead_id) 命中,转译为同一契约异常
|
|||
throw new OpportunityCreationException("该线索已关联其他商机:leadId=" + cmd.sourceLeadId(), e); |
|||
} |
|||
|
|||
// 票 13:customerId 非空 → 建主要意向客户子表 + 刷主表冗余(走法甲:可空则跳过)
|
|||
if (cmd.customerId() != null) { |
|||
linkPrimaryCustomer(opp, cmd); |
|||
} |
|||
|
|||
// 票 02 步 3:初始操作日志(由线索转入创建)
|
|||
writeInitialOplog(opp.getId(), cmd); |
|||
// 票 02 步 4:团队成员(领取人=商机负责人)
|
|||
insertOwnerTeamMember(opp.getId(), cmd); |
|||
return opp.getId(); |
|||
} |
|||
|
|||
private Opportunity buildOpportunity(CreateOpportunityCmd cmd) { |
|||
Opportunity opp = new Opportunity(); |
|||
opp.setOppName(cmd.opportunityName()); |
|||
opp.setIndustryCode(cmd.industryCode()); |
|||
opp.setPartyA(cmd.partyA()); |
|||
opp.setRemark(cmd.remark()); |
|||
// 省/市国标 code(与线索同构两级,由转商机弹窗地区选择器带出)
|
|||
opp.setProvinceCode(cmd.provinceCode()); |
|||
opp.setCityCode(cmd.cityCode()); |
|||
// 归属 / 来源快照(从线索带出)
|
|||
opp.setOwnerUserId(cmd.ownerUserId()); |
|||
opp.setOwnerDeptId(cmd.ownerDeptId()); |
|||
opp.setCreatorUserId(cmd.ownerUserId()); |
|||
opp.setSourceLeadId(cmd.sourceLeadId()); |
|||
opp.setSourceLeadName(cmd.sourceLeadName()); |
|||
opp.setSourcePhone(cmd.sourcePhone()); |
|||
opp.setSourceProductCode(cmd.sourceProductCode()); |
|||
// 转商机即领取,进入推进中;领取时间落库
|
|||
opp.setOppStatus(OpportunityStatus.STATUS_ADVANCING.getValue()); |
|||
opp.setClaimTime(LocalDateTime.now()); |
|||
// 意向客户名快照(走法 B:引用 + 快照并存);customerId 非空时下方刷主表冗余
|
|||
opp.setPrimaryCustomerNameSnapshot(cmd.intendedCustomer()); |
|||
// 票 02 步 2:阶段落位——按部门解析发布中模板版本 + 首节点(无模板容忍空,走法 A)
|
|||
applyInitialStage(opp, cmd); |
|||
return opp; |
|||
} |
|||
|
|||
/** |
|||
* 票 02 步 2 / 票 06:按商机负责人部门解析应绑定的发布中阶段模板版本, |
|||
* current_stage_id 落首节点(listNodesOfVersion 已按 seq_no 升序),并锁模板版本 id/号。 |
|||
* 无任何发布中模板(resolveBindingVersion 返 null)时三字段留空(容忍空,走法 A; |
|||
* 票 15 默认模板兜底使其常态不触发)。 |
|||
*/ |
|||
private void applyInitialStage(Opportunity opp, CreateOpportunityCmd cmd) { |
|||
OpportunityStageTemplate version = stageTemplateService.resolveBindingVersion(cmd.ownerDeptId()); |
|||
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, CreateOpportunityCmd cmd) { |
|||
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("SYSTEM"); |
|||
log.setOpTime(LocalDateTime.now()); |
|||
log.setOpUserId(cmd.ownerUserId()); |
|||
oplogMapper.insert(log); |
|||
} |
|||
|
|||
/** 票 02 步 4:领取人=商机负责人,插一条团队成员(project_role_01 / READ_WRITE)。 */ |
|||
private void insertOwnerTeamMember(Long oppId, CreateOpportunityCmd cmd) { |
|||
OpportunityTeam member = new OpportunityTeam(); |
|||
member.setOpportunityId(oppId); |
|||
member.setUserId(cmd.ownerUserId()); |
|||
member.setProjectRole(PROJECT_ROLE_OWNER); |
|||
member.setPermission(TeamMemberPermission.READ_WRITE.getValue()); |
|||
member.setDeleteKey(0L); |
|||
teamMapper.insert(member); |
|||
} |
|||
|
|||
/** 票 13:建 is_primary_intended=1 主要意向客户子表 + 刷主表冗余 primary_customer_id/name。 */ |
|||
private void linkPrimaryCustomer(Opportunity opp, CreateOpportunityCmd cmd) { |
|||
OpportunityCustomer link = new OpportunityCustomer(); |
|||
link.setOpportunityId(opp.getId()); |
|||
link.setCustomerId(cmd.customerId()); |
|||
link.setCustomerNameSnapshot(cmd.intendedCustomer()); |
|||
link.setCustomerRole(ROLE_INTENDED); |
|||
link.setIsPrimaryIntended(1); |
|||
customerMapper.insert(link); |
|||
|
|||
// 主表冗余(票 03 C3):主要意向客户 id/名,高频展示免 join
|
|||
opp.setPrimaryCustomerId(cmd.customerId()); |
|||
if (StrUtil.isNotBlank(cmd.intendedCustomer())) { |
|||
opp.setPrimaryCustomerNameSnapshot(cmd.intendedCustomer()); |
|||
return intake.open(spec); |
|||
} catch (OpportunityIntakeException e) { |
|||
throw new OpportunityCreationException(e.getMessage(), e); |
|||
} |
|||
oppMapper.updateById(opp); |
|||
} |
|||
} |
|||
|
|||
Loading…
Reference in new issue