Browse Source

feat(opportunity): 票11/12/13 线索转商机新建端口实现 + ADR-0029 技术债

实现 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
luoweijian 2 weeks ago
parent
commit
042401c57b
  1. 7
      crm-opportunity/pom.xml
  2. 113
      crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityCreationPortImpl.java
  3. 145
      crm-opportunity/src/test/java/com/crm/opportunity/port/inbound/OpportunityCreationPortImplTest.java
  4. 35
      docs/adr/0029-opportunity-depends-on-lead-tech-debt.md

7
crm-opportunity/pom.xml

@ -41,6 +41,13 @@
<groupId>com.crm</groupId> <groupId>com.crm</groupId>
<artifactId>crm-preference</artifactId> <artifactId>crm-preference</artifactId>
</dependency> </dependency>
<!-- 技术债(ADR-0029):下游依赖上游。实现 crm-lead 定义的 outbound port
OpportunityCreationPort(线索转商机)。方向不干净,一旦 crm-lead 需读商机会成环;
当前同库单体阶段可接受(ADR-0020),独立部署时随 0020 supersede 重设。 -->
<dependency>
<groupId>com.crm</groupId>
<artifactId>crm-lead</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>

113
crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityCreationPortImpl.java

@ -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);
}
}

145
crm-opportunity/src/test/java/com/crm/opportunity/port/inbound/OpportunityCreationPortImplTest.java

@ -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 已占用 OpportunityCreationExceptioncustomerId 非空
* 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));
}
}

35
docs/adr/0029-opportunity-depends-on-lead-tech-debt.md

@ -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…
Cancel
Save