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