diff --git a/crm-opportunity/pom.xml b/crm-opportunity/pom.xml index 8609641..bf43010 100644 --- a/crm-opportunity/pom.xml +++ b/crm-opportunity/pom.xml @@ -41,6 +41,13 @@ com.crm crm-preference + + + com.crm + crm-lead + org.projectlombok diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityCreationPortImpl.java b/crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityCreationPortImpl.java new file mode 100644 index 0000000..1ebf393 --- /dev/null +++ b/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 关联客户子表)。 + * + *

crm-lead 声明的出站契约 {@link OpportunityCreationPort} 的商机侧实现(同库同事务,ADR-0020)。 + * crm-lead 事务内调用,本方法任何失败抛 {@link OpportunityCreationException},crm-lead 捕获后整体回滚。

+ * + *

票 12 唯一性预检:新建前查 {@code source_lead_id} 是否已被占用,命中即抛业务错误 + * (避免依赖 DB 唯一键冲突走错误路径);DB {@code uk(source_lead_id)} 仍作并发最终防线。

+ * + *

票 13 关联客户:{@code cmd.customerId()} 非空时建一条 {@code is_primary_intended=1} + * 主要意向客户子表 + 刷主表冗余 {@code primary_customer_id/name}(票 03 C3)。 + * customerId 走法甲(票 11):A4 客户下拉未就绪前 customerId 可空,此时跳过建子表 / + * 不刷冗余(不倒退现有自由文本转商机);A4 就绪后收紧为必填。

+ * + *

省市 TODO:cmd 仅带单个 {@code regionCode},主表分 {@code provinceCode}/{@code cityCode} + * 两列且 not null;省市拆分逻辑无 spec、非本票职责,暂两列同填 regionCode 占位,待「省市拆分」专票。

+ */ +@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() + .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); + } +} diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/port/inbound/OpportunityCreationPortImplTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/port/inbound/OpportunityCreationPortImplTest.java new file mode 100644 index 0000000..932e279 --- /dev/null +++ b/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 关联客户)。 + *

契约:source_lead_id 已占用 → OpportunityCreationException;customerId 非空 → 建 + * is_primary_intended=1 子表 + 刷主表冗余;customerId 为空(走法甲)→ 跳过子表不刷冗余。

+ */ +@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 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 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 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)); + } +} diff --git a/docs/adr/0029-opportunity-depends-on-lead-tech-debt.md b/docs/adr/0029-opportunity-depends-on-lead-tech-debt.md new file mode 100644 index 0000000..d50fa1b --- /dev/null +++ b/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#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。