Browse Source
实现 crm-lead 的 outbound port OpportunityCreationPort(此前定义未实现,
根因是 crm-opportunity 未依赖 crm-lead):
- OpportunityCreationPortImpl(同库同事务,ADR-0020):
· 票12 source_lead_id 唯一性预检(应用层友好报错 + DB uk 并发最终防线,
DuplicateKeyException 转译为 OpportunityCreationException)
· 票11 建商机主表:初始推进中(2) + 落归属/来源快照 + claimTime
· 票13 customerId 非空 → 建 is_primary_intended=1 主要意向客户子表 +
刷主表冗余 primary_customer_id/name(走法甲:customerId 可空则跳过,
不倒退现有自由文本转商机)
- 5 单测:唯一性命中/带客户建子表刷冗余/无客户跳过/初始态快照/并发uk转译
- crm-opportunity/pom.xml 依赖 crm-lead
- ADR-0029 记技术债:下游反向依赖上游,埋循环依赖雷(crm-lead 将来读商机即死循环),
当前同库单体阶段可接受,触发条件出现时改抽共享 port 契约模块并 supersede
省市 TODO:cmd 单 regionCode → 主表 province/city 两列 not null,暂同填占位,
待省市拆分专票。mvn -pl crm-opportunity -am test 152 全绿。
master
4 changed files with 300 additions and 0 deletions
@ -0,0 +1,113 @@ |
|||||
|
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.enums.OpportunityStatus; |
||||
|
import com.crm.opportunity.mapper.OpportunityCustomerMapper; |
||||
|
import com.crm.opportunity.mapper.OpportunityMapper; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
/** |
||||
|
* 线索转商机新建端口实现(票 11 消费 + 票 12 唯一性预检 + 票 13 关联客户子表)。 |
||||
|
* |
||||
|
* <p>crm-lead 声明的出站契约 {@link OpportunityCreationPort} 的商机侧实现(同库同事务,ADR-0020)。 |
||||
|
* crm-lead 事务内调用,本方法任何失败抛 {@link OpportunityCreationException},crm-lead 捕获后整体回滚。</p> |
||||
|
* |
||||
|
* <p><b>票 12 唯一性预检</b>:新建前查 {@code source_lead_id} 是否已被占用,命中即抛业务错误 |
||||
|
* (避免依赖 DB 唯一键冲突走错误路径);DB {@code uk(source_lead_id)} 仍作并发最终防线。</p> |
||||
|
* |
||||
|
* <p><b>票 13 关联客户</b>:{@code cmd.customerId()} 非空时建一条 {@code is_primary_intended=1} |
||||
|
* 主要意向客户子表 + 刷主表冗余 {@code primary_customer_id/name}(票 03 C3)。 |
||||
|
* <b>customerId 走法甲(票 11)</b>:A4 客户下拉未就绪前 customerId 可空,此时跳过建子表 / |
||||
|
* 不刷冗余(不倒退现有自由文本转商机);A4 就绪后收紧为必填。</p> |
||||
|
* |
||||
|
* <p><b>省市 TODO</b>:cmd 仅带单个 {@code regionCode},主表分 {@code provinceCode}/{@code cityCode} |
||||
|
* 两列且 not null;省市拆分逻辑无 spec、非本票职责,暂两列同填 regionCode 占位,待「省市拆分」专票。</p> |
||||
|
*/ |
||||
|
@Service |
||||
|
@RequiredArgsConstructor |
||||
|
public class OpportunityCreationPortImpl implements OpportunityCreationPort { |
||||
|
|
||||
|
/** 转商机来源标记:线索转入的意向客户角色(票 13,customer_role 字典)。 */ |
||||
|
private static final String ROLE_INTENDED = "intended"; |
||||
|
|
||||
|
private final OpportunityMapper oppMapper; |
||||
|
private final OpportunityCustomerMapper customerMapper; |
||||
|
|
||||
|
@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); |
||||
|
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); |
||||
|
} |
||||
|
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()); |
||||
|
// 省市 TODO:单 regionCode → 两列 not null,暂同填占位,待省市拆分专票
|
||||
|
opp.setProvinceCode(cmd.regionCode()); |
||||
|
opp.setCityCode(cmd.regionCode()); |
||||
|
// 归属 / 来源快照(从线索带出)
|
||||
|
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()); |
||||
|
return opp; |
||||
|
} |
||||
|
|
||||
|
/** 票 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); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,145 @@ |
|||||
|
package com.crm.opportunity.port.inbound; |
||||
|
|
||||
|
import com.crm.lead.port.outbound.CreateOpportunityCmd; |
||||
|
import com.crm.lead.port.outbound.OpportunityCreationException; |
||||
|
import com.crm.opportunity.domain.entity.Opportunity; |
||||
|
import com.crm.opportunity.domain.entity.OpportunityCustomer; |
||||
|
import com.crm.opportunity.domain.enums.OpportunityStatus; |
||||
|
import com.crm.opportunity.mapper.OpportunityCustomerMapper; |
||||
|
import com.crm.opportunity.mapper.OpportunityMapper; |
||||
|
import com.crm.opportunity.port.inbound.impl.OpportunityCreationPortImpl; |
||||
|
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 org.springframework.dao.DuplicateKeyException; |
||||
|
|
||||
|
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.doThrow; |
||||
|
import static org.mockito.Mockito.never; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
|
||||
|
/** |
||||
|
* 线索转商机新建端口单测(票 11 消费 + 票 12 唯一性预检 + 票 13 关联客户)。 |
||||
|
* <p>契约:source_lead_id 已占用 → OpportunityCreationException;customerId 非空 → 建 |
||||
|
* is_primary_intended=1 子表 + 刷主表冗余;customerId 为空(走法甲)→ 跳过子表不刷冗余。</p> |
||||
|
*/ |
||||
|
@DisplayName("线索转商机新建端口 OpportunityCreationPort(票 11/12/13)") |
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
class OpportunityCreationPortImplTest { |
||||
|
|
||||
|
private static final Long LEAD_ID = 9001L; |
||||
|
private static final Long CUSTOMER_ID = 5001L; |
||||
|
private static final Long NEW_OPP_ID = 7001L; |
||||
|
|
||||
|
@Mock private OpportunityMapper oppMapper; |
||||
|
@Mock private OpportunityCustomerMapper customerMapper; |
||||
|
|
||||
|
@InjectMocks |
||||
|
private OpportunityCreationPortImpl port; |
||||
|
|
||||
|
private CreateOpportunityCmd cmd(Long customerId) { |
||||
|
return new CreateOpportunityCmd( |
||||
|
LEAD_ID, "商机A", "ind01", "甲方X", "意向客户名", customerId, |
||||
|
"110100", "备注", "线索A", "13800000000", "prod01", |
||||
|
100L, 200L, 300L); |
||||
|
} |
||||
|
|
||||
|
/** insert 时给实体回填自增 id(模拟 MyBatis-Plus useGeneratedKeys)。 */ |
||||
|
private void stubInsertAssignsId() { |
||||
|
doAnswer(inv -> { |
||||
|
((Opportunity) inv.getArgument(0)).setId(NEW_OPP_ID); |
||||
|
return 1; |
||||
|
}).when(oppMapper).insert(any(Opportunity.class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("source_lead_id 已占用 → 抛 OpportunityCreationException,不新建") |
||||
|
void create_leadAlreadyLinked_throws() { |
||||
|
when(oppMapper.selectCount(any())).thenReturn(1L); |
||||
|
|
||||
|
assertThatThrownBy(() -> port.createOpportunity(cmd(CUSTOMER_ID))) |
||||
|
.isInstanceOf(OpportunityCreationException.class) |
||||
|
.hasMessageContaining("已关联"); |
||||
|
|
||||
|
verify(oppMapper, never()).insert(any(Opportunity.class)); |
||||
|
verify(customerMapper, never()).insert(any(OpportunityCustomer.class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("customerId 非空 → 建 is_primary_intended=1 子表 + 刷主表冗余,返回新 id") |
||||
|
void create_withCustomer_linksPrimary() { |
||||
|
when(oppMapper.selectCount(any())).thenReturn(0L); |
||||
|
stubInsertAssignsId(); |
||||
|
|
||||
|
Long id = port.createOpportunity(cmd(CUSTOMER_ID)); |
||||
|
|
||||
|
assertThat(id).isEqualTo(NEW_OPP_ID); |
||||
|
|
||||
|
ArgumentCaptor<OpportunityCustomer> linkCap = ArgumentCaptor.forClass(OpportunityCustomer.class); |
||||
|
verify(customerMapper).insert(linkCap.capture()); |
||||
|
OpportunityCustomer link = linkCap.getValue(); |
||||
|
assertThat(link.getOpportunityId()).isEqualTo(NEW_OPP_ID); |
||||
|
assertThat(link.getCustomerId()).isEqualTo(CUSTOMER_ID); |
||||
|
assertThat(link.getIsPrimaryIntended()).isEqualTo(1); |
||||
|
assertThat(link.getCustomerNameSnapshot()).isEqualTo("意向客户名"); |
||||
|
|
||||
|
// 刷主表冗余走 updateById
|
||||
|
ArgumentCaptor<Opportunity> updCap = ArgumentCaptor.forClass(Opportunity.class); |
||||
|
verify(oppMapper).updateById(updCap.capture()); |
||||
|
assertThat(updCap.getValue().getPrimaryCustomerId()).isEqualTo(CUSTOMER_ID); |
||||
|
assertThat(updCap.getValue().getPrimaryCustomerNameSnapshot()).isEqualTo("意向客户名"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("customerId 为空(走法甲)→ 跳过建子表、不刷冗余,仍建商机") |
||||
|
void create_noCustomer_skipsLink() { |
||||
|
when(oppMapper.selectCount(any())).thenReturn(0L); |
||||
|
stubInsertAssignsId(); |
||||
|
|
||||
|
Long id = port.createOpportunity(cmd(null)); |
||||
|
|
||||
|
assertThat(id).isEqualTo(NEW_OPP_ID); |
||||
|
verify(customerMapper, never()).insert(any(OpportunityCustomer.class)); |
||||
|
verify(oppMapper, never()).updateById(any(Opportunity.class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("新建商机初始状态=推进中(2),落来源快照 + 意向客户名快照") |
||||
|
void create_setsAdvancingAndSnapshots() { |
||||
|
when(oppMapper.selectCount(any())).thenReturn(0L); |
||||
|
ArgumentCaptor<Opportunity> insCap = ArgumentCaptor.forClass(Opportunity.class); |
||||
|
doAnswer(inv -> { |
||||
|
((Opportunity) inv.getArgument(0)).setId(NEW_OPP_ID); |
||||
|
return 1; |
||||
|
}).when(oppMapper).insert(insCap.capture()); |
||||
|
|
||||
|
port.createOpportunity(cmd(null)); |
||||
|
|
||||
|
Opportunity saved = insCap.getValue(); |
||||
|
assertThat(saved.getOppStatus()).isEqualTo(OpportunityStatus.STATUS_ADVANCING.getValue()); |
||||
|
assertThat(saved.getSourceLeadId()).isEqualTo(LEAD_ID); |
||||
|
assertThat(saved.getClaimTime()).isNotNull(); |
||||
|
assertThat(saved.getPrimaryCustomerNameSnapshot()).isEqualTo("意向客户名"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("并发窗口:预检通过后 insert 撞 DB 唯一键 → 转译为 OpportunityCreationException") |
||||
|
void create_concurrentDuplicateKey_translated() { |
||||
|
when(oppMapper.selectCount(any())).thenReturn(0L); |
||||
|
doThrow(new DuplicateKeyException("uk_source_lead")).when(oppMapper).insert(any(Opportunity.class)); |
||||
|
|
||||
|
assertThatThrownBy(() -> port.createOpportunity(cmd(CUSTOMER_ID))) |
||||
|
.isInstanceOf(OpportunityCreationException.class) |
||||
|
.hasMessageContaining("已关联"); |
||||
|
|
||||
|
verify(customerMapper, never()).insert(any(OpportunityCustomer.class)); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,35 @@ |
|||||
|
# ADR-0029: crm-opportunity 编译期依赖 crm-lead —— 下游反向依赖上游的技术债 |
||||
|
|
||||
|
## Status |
||||
|
|
||||
|
Accepted(技术债,带触发重设计条件) |
||||
|
|
||||
|
## Context |
||||
|
|
||||
|
线索「转商机」由 crm-lead 定义 outbound port `OpportunityCreationPort`(`crm-lead/port/outbound`),商机模块提供实现(ADR-0020:首个实现须与 crm-lead 同库同事务)。 |
||||
|
|
||||
|
票 11/12/13 落地商机侧实现 `OpportunityCreationPortImpl` 时发现:要实现该 port,crm-opportunity 必须能在**编译期** import `com.crm.lead.port.outbound.*`(接口 `OpportunityCreationPort` + 命令 `CreateOpportunityCmd` + 异常 `OpportunityCreationException`)。而此前 `crm-opportunity/pom.xml` **并不依赖 crm-lead**——这正是该 port 长期「定义了但无人实现」的真实原因(不是遗漏,是依赖方向未解决)。 |
||||
|
|
||||
|
crm-lead 侧用 `ObjectProvider<OpportunityCreationPort>#getIfAvailable()` 注入,只解耦了「运行期 bean 是否存在」,**没有**解耦「编译期接口是否可见」——实现方仍需编译依赖接口所在模块。 |
||||
|
|
||||
|
领域上商机是线索的**下游**(线索转商机)。让 crm-opportunity 依赖 crm-lead = **下游反向依赖上游**,方向不干净。 |
||||
|
|
||||
|
## Considered Options |
||||
|
|
||||
|
- **crm-opportunity 直接依赖 crm-lead(采纳)**:pom 加一行即可,ADR-0020 的同库同事务立即成立。代价是方向倒置 + 埋循环依赖雷(见 Consequences)。 |
||||
|
- **抽共享 port 契约模块**(把 3 个 port 类移到 crm-base 或新建 crm-contract,两边都依赖它,谁都不直接依赖对方):干净、符合 hexagonal、无循环。否决于**当前阶段**——要动 crm-lead 现有 3 个类的包路径 + 全部 import + 可能新建模块 + 改两个 pom,工作量与影响面明显超出票 11/12/13 范围,属在「商机模块刚落地、crm-lead 尚不读商机」时提前付重构成本。留作终态方向。 |
||||
|
- **事件/最终一致**:ADR-0020 已否决为过度设计(近期大概率同库单体)。 |
||||
|
|
||||
|
## Decision |
||||
|
|
||||
|
`crm-opportunity/pom.xml` 显式依赖 `crm-lead`,以在编译期实现其 outbound port。pom 内以注释标注本 ADR 为技术债来源。 |
||||
|
|
||||
|
## Consequences |
||||
|
|
||||
|
- **技术债(方向倒置)**:下游 crm-opportunity 依赖上游 crm-lead,商机模块被拖进线索模块的依赖闭包。 |
||||
|
- **循环依赖雷**:一旦 crm-lead 将来需要**读商机**(如线索详情展示「已转的商机」),会形成 crm-lead ⇄ crm-opportunity 循环,Maven 直接编译失败。届时**必须**改走「抽共享 port 契约模块」方案(上方否决项),并 supersede 本 ADR。 |
||||
|
- **兜底**:本债与 ADR-0020 的「独立部署触发重设计」同源——当前同库单体阶段可接受;触发条件(crm-lead 读商机 / 商机独立部署)出现时一并重设计。 |
||||
|
|
||||
|
## 决策来源 |
||||
|
|
||||
|
票 11/12/13 实现(走法甲,customerId 可空占位)+ 用户拍板「甲,但需要记录这一技术债」(20260825 grill)。关联 ADR-0020。 |
||||
Loading…
Reference in new issue