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。