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; |
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.CreateOpportunityCmd; |
||||
import com.crm.lead.port.outbound.OpportunityCreationException; |
import com.crm.lead.port.outbound.OpportunityCreationException; |
||||
import com.crm.lead.port.outbound.OpportunityCreationPort; |
import com.crm.lead.port.outbound.OpportunityCreationPort; |
||||
import com.crm.opportunity.domain.entity.Opportunity; |
import com.crm.opportunity.intake.IntakeSource; |
||||
import com.crm.opportunity.domain.entity.OpportunityCustomer; |
import com.crm.opportunity.intake.OpportunityIntake; |
||||
import com.crm.opportunity.domain.entity.OpportunityOplog; |
import com.crm.opportunity.intake.OpportunityIntakeException; |
||||
import com.crm.opportunity.domain.entity.OpportunityTeam; |
import com.crm.opportunity.intake.OpportunityIntakeSpec; |
||||
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 lombok.RequiredArgsConstructor; |
import lombok.RequiredArgsConstructor; |
||||
import org.springframework.stereotype.Service; |
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)。 |
* <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} 是否已被占用,命中即抛业务错误 |
* <p>建档五步机制(主表落库 + 阶段落位 + 主要意向客户子表 + 初始操作日志 + 团队成员)与 |
||||
* (避免依赖 DB 唯一键冲突走错误路径);DB {@code uk(source_lead_id)} 仍作并发最终防线。</p> |
* source_lead_id 唯一性防护均不再出现在本层(由 OpportunityIntakeImpl 承载并测试)。</p> |
||||
* |
* |
||||
* <p><b>沨 A4 走法甲</b>:{@code cmd.customerId()} 非空时建一条 {@code is_primary_intended=1} |
* <p>事务:本层不再标 @Transactional——crm-lead 外层事务 + {@code open} 的 REQUIRED 传播 |
||||
* 主要意向客户子表 + 刷主表冗余 {@code primary_customer_id/name}(票 03 C3)。 |
* 保证原子性(ADR-0020 语义不变)。cmd.poolId 为线索侧信息,商机建档不消费。</p> |
||||
* A4 客户下拉未就绪前 customerId 可空,此时跳过建子表 / 不刷冗余(不倒退现有自由文本转商机); |
|
||||
* A4 就绪后收紧为必填。</p> |
|
||||
*/ |
*/ |
||||
@Service |
@Service |
||||
@RequiredArgsConstructor |
@RequiredArgsConstructor |
||||
public class OpportunityCreationPortImpl implements OpportunityCreationPort { |
public class OpportunityCreationPortImpl implements OpportunityCreationPort { |
||||
|
|
||||
/** 转商机来源标记:线索转入的意向客户角色(票 13,customer_role 字典)。 */ |
private final OpportunityIntake intake; |
||||
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; |
|
||||
|
|
||||
@Override |
@Override |
||||
@Transactional(rollbackFor = Exception.class) |
|
||||
public Long createOpportunity(CreateOpportunityCmd cmd) { |
public Long createOpportunity(CreateOpportunityCmd cmd) { |
||||
// 票 12:source_lead_id 唯一性预检(应用层友好报错,DB 唯一键作并发最终防线)
|
OpportunityIntakeSpec spec = new OpportunityIntakeSpec( |
||||
Long existing = oppMapper.selectCount(new LambdaQueryWrapper<Opportunity>() |
IntakeSource.LEAD_CONVERT, |
||||
.eq(Opportunity::getSourceLeadId, cmd.sourceLeadId())); |
IntakeSource.LEAD_CONVERT.defaultOppSource(), |
||||
if (existing != null && existing > 0) { |
cmd.opportunityName(), |
||||
throw new OpportunityCreationException("该线索已关联其他商机:leadId=" + cmd.sourceLeadId()); |
cmd.industryCode(), |
||||
} |
cmd.partyA(), |
||||
|
cmd.provinceCode(), |
||||
Opportunity opp = buildOpportunity(cmd); |
cmd.cityCode(), |
||||
|
cmd.remark(), |
||||
|
cmd.ownerUserId(), |
||||
|
cmd.ownerDeptId(), |
||||
|
cmd.customerId(), |
||||
|
cmd.intendedCustomer(), |
||||
|
cmd.sourceLeadId(), |
||||
|
cmd.sourceLeadName(), |
||||
|
cmd.sourcePhone(), |
||||
|
cmd.sourceProductCode()); |
||||
try { |
try { |
||||
oppMapper.insert(opp); |
return intake.open(spec); |
||||
} catch (org.springframework.dao.DuplicateKeyException e) { |
} catch (OpportunityIntakeException e) { |
||||
// 并发窗口:预检通过后另一事务先插入 → DB uk(source_lead_id) 命中,转译为同一契约异常
|
throw new OpportunityCreationException(e.getMessage(), e); |
||||
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()); |
|
||||
} |
} |
||||
oppMapper.updateById(opp); |
|
||||
} |
} |
||||
} |
} |
||||
|
|||||
Loading…
Reference in new issue