diff --git a/AGENTS.md b/AGENTS.md index e888d10..af19347 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,3 +31,24 @@ All `.java` source files **must be UTF-8 without BOM** (no `EF BB BF` byte prefi } ``` - Run a full BOM scan across all `*.java` files after bulk edits (Write/SearchReplace) and before `mvn compile`. + +## Tooling / harness + +### Stream dropouts (`Error: Stream ended without finish_reason`) + +The LLM's streamed (SSE) response was cut off before the terminal `finish_reason` event. It is **not** a bug in your code or in a tool — it is the upstream/relay dropping the connection mid-generation. + +This repo runs through a relayed provider (`PI_PROVIDER=new-provider`, `PI_MODEL=claude-opus-4-8`, forwarded via `127.0.0.1`), which makes dropouts more likely on large single turns. + +**Most common triggers, worst first:** + +- **Oversized single turn** — reading several long files at once (e.g. both `crm-lead/CONTEXT.md` + `crm-opportunity/CONTEXT.md`) then immediately doing a large write. The turn right after a big context dump drops most often. +- **One huge output** — emitting a large file (a full HTML report, hundreds of lines) in a single `write`. +- **Network / relay idle timeout** — the local forwarder or an nginx/VPN layer closing an idle SSE long-connection. + +**How to avoid it:** + +- Read large files with `offset`/`limit` in chunks; do not inhale whole long docs in one call. +- Write large files in small steps: `write` a skeleton, then append with successive `edit` calls, instead of one giant `write`. +- `/compact` or start a fresh session when the conversation history has grown large. +- Deterministic recovery is usually just **retry** — if the same action succeeds on a retry, it was relay jitter, not a real failure. diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/intake/IntakeSource.java b/crm-opportunity/src/main/java/com/crm/opportunity/intake/IntakeSource.java new file mode 100644 index 0000000..1f9b16f --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/intake/IntakeSource.java @@ -0,0 +1,45 @@ +package com.crm.opportunity.intake; + +/** + * 商机进入方式(建档来源)。 + * + *

两条建档入口的语义差异收敛于此枚举:线索侧转商机({@link #LEAD_CONVERT})与商机侧直接 + * 新建({@link #DIRECT})。承载两处按来源分叉的细节——初始操作日志的 {@code op_source} 与 + * {@code op_desc},以及线索转入恒定的商机来源字典 code({@code defaultOppSource})。

+ * + *

{@code defaultOppSource}:{@code LEAD_CONVERT} 恒为 {@code opp_source_01}(线索转入); + * {@code DIRECT} 为 null——直接创建的商机来源由用户在弹窗选择,经 spec 显式携带。

+ */ +public enum IntakeSource { + + /** 线索侧转商机(crm-lead 出站端口触发):SYSTEM 记「由线索转入创建」,来源恒 opp_source_01。 */ + LEAD_CONVERT("opp_source_01", "SYSTEM", "由线索转入创建"), + + /** 商机侧直接新建:USER 记「直接创建商机」,来源由用户选择(spec 携带)。 */ + DIRECT(null, "USER", "直接创建商机"); + + private final String defaultOppSource; + private final String oplogOpSource; + private final String oplogOpDesc; + + IntakeSource(String defaultOppSource, String oplogOpSource, String oplogOpDesc) { + this.defaultOppSource = defaultOppSource; + this.oplogOpSource = oplogOpSource; + this.oplogOpDesc = oplogOpDesc; + } + + /** 线索转入恒定的商机来源字典 code;DIRECT 为 null(由 spec 显式携带用户所选来源)。 */ + public String defaultOppSource() { + return defaultOppSource; + } + + /** 初始操作日志 op_source(LEAD_CONVERT=SYSTEM,DIRECT=USER)。 */ + public String oplogOpSource() { + return oplogOpSource; + } + + /** 初始操作日志 op_desc(LEAD_CONVERT=由线索转入创建,DIRECT=直接创建商机)。 */ + public String oplogOpDesc() { + return oplogOpDesc; + } +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntake.java b/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntake.java new file mode 100644 index 0000000..2ec90d7 --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntake.java @@ -0,0 +1,27 @@ +package com.crm.opportunity.intake; + +/** + * 商机建档深模块(票 02/11/12/13/16:新建商机的唯一核心机制)。 + * + *

两条建档入口——线索侧转商机 port({@code OpportunityCreationPortImpl})与商机侧直接 + * 新建({@code OpportunityCreateServiceImpl})——的公共机制收敛于此:主表落库 + 阶段落位 + * + 主要意向客户子表 + 初始操作日志 + 领取人团队成员 + source_lead_id 唯一性防护, + * 一个事务内原子完成。

+ * + *

接口窄(单方法),实现厚:调用方只需把各自入参翻译成 {@link OpportunityIntakeSpec}, + * 建档的全部规则与副作用都在实现内,不再两处各写一遍。

+ */ +public interface OpportunityIntake { + + /** + * 按规格建档一个商机,返回新商机 id。 + * + *

同库同事务(ADR-0020):本方法标 {@code @Transactional},线索转商机路径下并入 + * crm-lead 外层事务(REQUIRED 传播),任一副作用失败整体回滚。

+ * + * @param spec 建档规格(两条入口翻译后的统一内部契约) + * @return 新建商机 id + * @throws OpportunityIntakeException source_lead_id 已被其他商机占用(预检命中或并发唯一键冲突) + */ + Long open(OpportunityIntakeSpec spec); +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeException.java b/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeException.java new file mode 100644 index 0000000..2ff84d4 --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeException.java @@ -0,0 +1,23 @@ +package com.crm.opportunity.intake; + +/** + * 商机建档领域异常(crm-opportunity 自有)。 + * + *

{@link OpportunityIntake#open} 内部机制失败时抛出,语义为建档本身的领域事实 + * ——当前唯一场景是 {@code source_lead_id} 已被其他商机占用(应用层预检命中,或并发窗口 + * DB 唯一键冲突转译)。

+ * + *

本异常不跨模块边界:深模块只认自己的语言,由各 adapter 在 seam 处翻译—— + * 线索侧 port 译为 {@code com.crm.lead.port.outbound.OpportunityCreationException}, + * 商机侧直接创建译为 {@code BusinessErrorException}(HTTP 友好)。

+ */ +public class OpportunityIntakeException extends RuntimeException { + + public OpportunityIntakeException(String message) { + super(message); + } + + public OpportunityIntakeException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeSpec.java b/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeSpec.java new file mode 100644 index 0000000..27900cb --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeSpec.java @@ -0,0 +1,49 @@ +package com.crm.opportunity.intake; + +/** + * 商机建档规格(两条入口翻译后的统一内部契约,票 02/11/12/13/16)。 + * + *

{@code OpportunityIntake.open} 的唯一入参。crm-opportunity 内部类型,不导出: + * 线索侧转商机 port({@code OpportunityCreationPortImpl})与商机侧直接新建 + * ({@code OpportunityCreateServiceImpl})各自把入参 DTO 翻译成本 spec,深模块只认本语言。

+ * + *

差异收敛:{@code source} 决定初始日志文案/op_source;{@code oppSource} 由 adapter 按 + * {@code source} 填——{@code LEAD_CONVERT} 取 {@link IntakeSource#defaultOppSource()} + * (opp_source_01),{@code DIRECT} 取用户所选来源。

+ * + * @param source 进入方式(决定 oplog 文案 + op_source) + * @param oppSource 商机来源字典 code(LEAD_CONVERT 恒 opp_source_01;DIRECT 用户所选) + * @param opportunityName 商机名称(必填) + * @param industryCode 行业字典 code + * @param partyA 甲方(自由文本,可空) + * @param provinceCode 省国标 code + * @param cityCode 市国标 code + * @param remark 备注(可空) + * @param ownerUserId 领取人 = 创建人 = 商机负责人 + * @param ownerDeptId 领取人所属部门快照(阶段模板按部门解析;可空 → 默认模板兜底) + * @param customerId 主要意向客户 id(可空;非空则建 is_primary_intended 子表 + 刷主表冗余) + * @param intendedCustomer 意向客户名快照 + * @param sourceLeadId 来源线索 id(唯一性预检 key;DIRECT 非线索来源时为 null,不参与 UNIQUE) + * @param sourceLeadName 线索名称快照(可空) + * @param sourcePhone 联系电话快照(可空) + * @param sourceProductCode 需求产品 code 快照(可空) + */ +public record OpportunityIntakeSpec( + IntakeSource source, + String oppSource, + String opportunityName, + String industryCode, + String partyA, + String provinceCode, + String cityCode, + String remark, + Long ownerUserId, + Long ownerDeptId, + Long customerId, + String intendedCustomer, + Long sourceLeadId, + String sourceLeadName, + String sourcePhone, + String sourceProductCode +) { +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/intake/impl/OpportunityIntakeImpl.java b/crm-opportunity/src/main/java/com/crm/opportunity/intake/impl/OpportunityIntakeImpl.java new file mode 100644 index 0000000..629a49c --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/intake/impl/OpportunityIntakeImpl.java @@ -0,0 +1,180 @@ +package com.crm.opportunity.intake.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.crm.opportunity.domain.entity.Opportunity; +import com.crm.opportunity.domain.entity.OpportunityCustomer; +import com.crm.opportunity.domain.entity.OpportunityOplog; +import com.crm.opportunity.domain.entity.OpportunityTeam; +import com.crm.opportunity.domain.enums.OplogKind; +import com.crm.opportunity.domain.enums.OplogLogType; +import com.crm.opportunity.domain.enums.OpportunityStatus; +import com.crm.opportunity.domain.enums.TeamMemberPermission; +import com.crm.opportunity.intake.OpportunityIntake; +import com.crm.opportunity.intake.OpportunityIntakeException; +import com.crm.opportunity.intake.OpportunityIntakeSpec; +import com.crm.opportunity.mapper.OpportunityCustomerMapper; +import com.crm.opportunity.mapper.OpportunityMapper; +import com.crm.opportunity.mapper.OpportunityOplogMapper; +import com.crm.opportunity.mapper.OpportunityTeamMapper; +import com.crm.rule.domain.entity.OpportunityStageNode; +import com.crm.rule.domain.entity.OpportunityStageTemplate; +import com.crm.rule.service.IOpportunityStageTemplateService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 商机建档深模块实现(Candidate #1:两条入口机制收敛一处)。 + * + *

原 {@code OpportunityCreationPortImpl}(线索转商机 port)与 {@code OpportunityCreateServiceImpl} + * (商机侧直接新建)逐字复制的五步建档机制(主表 + 阶段落位 + 客户子表 + 初始日志 + 团队成员) + * 在此合并。差异点经 {@link OpportunityIntakeSpec} / {@code IntakeSource} 参数化:oplog 文案/来源、 + * oppSource 取值均由 spec 携带,实现不再分叉。

+ * + *

事务(ADR-0020):{@code open} 标 {@code @Transactional},线索转商机路径并入 crm-lead + * 外层事务(REQUIRED),任一副作用失败整体回滚。source_lead_id 唯一性预检 + 并发 DB 唯一键 + * 冲突转译收在本模块,抛自有 {@link OpportunityIntakeException},由各 adapter 在 seam 翻译。

+ */ +@Service +@RequiredArgsConstructor +public class OpportunityIntakeImpl implements OpportunityIntake { + + /** 领取人项目角色:商机负责人(crm-dict project_role 分组,票 02)。 */ + private static final String PROJECT_ROLE_OWNER = "project_role_01"; + + /** 主要意向客户角色(票 13,customer_role 字典)。 */ + private static final String ROLE_INTENDED = "intended"; + + private final OpportunityMapper oppMapper; + private final OpportunityCustomerMapper customerMapper; + private final OpportunityOplogMapper oplogMapper; + private final OpportunityTeamMapper teamMapper; + private final IOpportunityStageTemplateService stageTemplateService; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long open(OpportunityIntakeSpec spec) { + // 票 12:source_lead_id 唯一性预检(应用层友好报错,DB 唯一键作并发最终防线)。 + // sourceLeadId 为空(DIRECT 非线索来源,NULL 不参与 UNIQUE)→ 跳过预检。 + if (spec.sourceLeadId() != null) { + Long existing = oppMapper.selectCount(new LambdaQueryWrapper() + .eq(Opportunity::getSourceLeadId, spec.sourceLeadId())); + if (existing != null && existing > 0) { + throw new OpportunityIntakeException("该线索已关联其他商机:leadId=" + spec.sourceLeadId()); + } + } + + Opportunity opp = buildOpportunity(spec); + try { + oppMapper.insert(opp); + } catch (org.springframework.dao.DuplicateKeyException e) { + // 并发窗口:预检通过后另一事务先插入 → DB uk(source_lead_id) 命中,转译为领域异常 + throw new OpportunityIntakeException("该线索已关联其他商机:leadId=" + spec.sourceLeadId(), e); + } + + // 票 13:customerId 非空 → 建主要意向客户子表 + 刷主表冗余(走法甲:可空则跳过) + if (spec.customerId() != null) { + linkPrimaryCustomer(opp, spec); + } + + // 票 02 步 3:初始操作日志(文案/来源按进入方式分叉) + writeInitialOplog(opp.getId(), spec); + // 票 02 步 4:团队成员(领取人=商机负责人) + insertOwnerTeamMember(opp.getId(), spec); + return opp.getId(); + } + + private Opportunity buildOpportunity(OpportunityIntakeSpec spec) { + Opportunity opp = new Opportunity(); + opp.setOppName(spec.opportunityName()); + // 商机来源:LEAD_CONVERT 恒 opp_source_01(adapter 已按枚举默认填入 spec),DIRECT 为用户所选。 + opp.setOppSource(spec.oppSource()); + opp.setIndustryCode(spec.industryCode()); + opp.setPartyA(spec.partyA()); + opp.setRemark(spec.remark()); + opp.setProvinceCode(spec.provinceCode()); + opp.setCityCode(spec.cityCode()); + opp.setOwnerUserId(spec.ownerUserId()); + opp.setOwnerDeptId(spec.ownerDeptId()); + opp.setCreatorUserId(spec.ownerUserId()); + // 来源线索快照(DIRECT 非线索来源时为 null) + opp.setSourceLeadId(spec.sourceLeadId()); + opp.setSourceLeadName(spec.sourceLeadName()); + opp.setSourcePhone(spec.sourcePhone()); + opp.setSourceProductCode(spec.sourceProductCode()); + // 新建即领取,进入推进中;领取时间落库 + opp.setOppStatus(OpportunityStatus.STATUS_ADVANCING.getValue()); + opp.setClaimTime(LocalDateTime.now()); + // 意向客户名快照(引用 + 快照并存);customerId 非空时下方刷主表冗余 + opp.setPrimaryCustomerNameSnapshot(spec.intendedCustomer()); + // 票 02 步 2:阶段落位——按负责人部门解析发布中模板 + 首节点(无模板容忍空,走法 A) + applyInitialStage(opp, spec); + return opp; + } + + /** + * 票 02 步 2 / 票 06:按负责人部门解析应绑定的发布中阶段模板版本,current_stage_id 落首节点 + * (listNodesOfVersion 已按 seq_no 升序),并锁模板版本 id/号。无发布中模板(返 null)时 + * 三字段留空(容忍空,走法 A;ownerDeptId 为空亦然,走默认模板兜底)。 + */ + private void applyInitialStage(Opportunity opp, OpportunityIntakeSpec spec) { + OpportunityStageTemplate version = stageTemplateService.resolveBindingVersion(spec.ownerDeptId()); + if (version == null) { + return; + } + List nodes = stageTemplateService.listNodesOfVersion(version.getId()); + if (nodes == null || nodes.isEmpty()) { + return; + } + opp.setStageTemplateId(version.getId()); + opp.setStageTemplateVersion(version.getVersionNo()); + opp.setCurrentStageId(nodes.get(0).getId()); + } + + /** 票 02 步 3:写一条初始操作日志(整行新增;文案 / op_source 按进入方式分叉,IntakeSource 承载)。 */ + private void writeInitialOplog(Long oppId, OpportunityIntakeSpec spec) { + OpportunityOplog log = new OpportunityOplog(); + log.setOppId(oppId); + log.setOpKind(OplogKind.ROW_ADD.getValue()); + log.setLogType(OplogLogType.ROW_CHANGE.getValue()); + log.setEntityName("商机"); + log.setOpDesc(spec.source().oplogOpDesc()); + log.setOpSource(spec.source().oplogOpSource()); + log.setOpTime(LocalDateTime.now()); + log.setOpUserId(spec.ownerUserId()); + oplogMapper.insert(log); + } + + /** 票 02 步 4:领取人=商机负责人,插一条团队成员(project_role_01 / READ_WRITE)。 */ + private void insertOwnerTeamMember(Long oppId, OpportunityIntakeSpec spec) { + OpportunityTeam member = new OpportunityTeam(); + member.setOpportunityId(oppId); + member.setUserId(spec.ownerUserId()); + member.setProjectRole(PROJECT_ROLE_OWNER); + member.setPermission(TeamMemberPermission.READ_WRITE.getValue()); + member.setDeleteKey(0L); + teamMapper.insert(member); + } + + /** 票 13:建 is_primary_intended=1 主要意向客户子表 + 刷主表冗余 primary_customer_id/name。 */ + private void linkPrimaryCustomer(Opportunity opp, OpportunityIntakeSpec spec) { + OpportunityCustomer link = new OpportunityCustomer(); + link.setOpportunityId(opp.getId()); + link.setCustomerId(spec.customerId()); + link.setCustomerNameSnapshot(spec.intendedCustomer()); + link.setCustomerRole(ROLE_INTENDED); + link.setIsPrimaryIntended(1); + customerMapper.insert(link); + + // 主表冗余(票 03 C3):主要意向客户 id/名,高频展示免 join + opp.setPrimaryCustomerId(spec.customerId()); + if (StrUtil.isNotBlank(spec.intendedCustomer())) { + opp.setPrimaryCustomerNameSnapshot(spec.intendedCustomer()); + } + oppMapper.updateById(opp); + } +} diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/intake/impl/OpportunityIntakeImplTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/intake/impl/OpportunityIntakeImplTest.java new file mode 100644 index 0000000..34c71e0 --- /dev/null +++ b/crm-opportunity/src/test/java/com/crm/opportunity/intake/impl/OpportunityIntakeImplTest.java @@ -0,0 +1,287 @@ +package com.crm.opportunity.intake.impl; + +import com.crm.opportunity.domain.entity.Opportunity; +import com.crm.opportunity.domain.entity.OpportunityCustomer; +import com.crm.opportunity.domain.entity.OpportunityOplog; +import com.crm.opportunity.domain.entity.OpportunityTeam; +import com.crm.opportunity.domain.enums.OpportunityStatus; +import com.crm.opportunity.domain.enums.TeamMemberPermission; +import com.crm.opportunity.intake.IntakeSource; +import com.crm.opportunity.intake.OpportunityIntakeException; +import com.crm.opportunity.intake.OpportunityIntakeSpec; +import com.crm.opportunity.mapper.OpportunityCustomerMapper; +import com.crm.opportunity.mapper.OpportunityMapper; +import com.crm.opportunity.mapper.OpportunityOplogMapper; +import com.crm.opportunity.mapper.OpportunityTeamMapper; +import com.crm.rule.domain.entity.OpportunityStageNode; +import com.crm.rule.domain.entity.OpportunityStageTemplate; +import com.crm.rule.service.IOpportunityStageTemplateService; +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 java.util.List; + +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; + +/** + * 商机建档深模块单测(Candidate #1:两条入口机制收敛一处)。 + * + *

覆盖建档机制:阶段落位(命中/无模板容忍空)、初始日志两来源分支、领取人团队成员、 + * 主要意向客户子表(有/无 customerId)、初始状态推进中 + 快照、source_lead_id 唯一性 + * 预检 + 并发 DuplicateKey 转译。adapter 侧的异常翻译由各自 adapter 测试覆盖。

+ */ +@DisplayName("商机建档深模块 OpportunityIntake") +@ExtendWith(MockitoExtension.class) +class OpportunityIntakeImplTest { + + private static final Long LEAD_ID = 9001L; + private static final Long CUSTOMER_ID = 5001L; + private static final Long NEW_OPP_ID = 7001L; + private static final Long STAGE_TPL_VER_ID = 6601L; + private static final Long FIRST_NODE_ID = 6611L; + private static final Long OWNER_USER_ID = 100L; + private static final Long OWNER_DEPT_ID = 200L; + + @Mock private OpportunityMapper oppMapper; + @Mock private OpportunityCustomerMapper customerMapper; + @Mock private OpportunityOplogMapper oplogMapper; + @Mock private OpportunityTeamMapper teamMapper; + @Mock private IOpportunityStageTemplateService stageTemplateService; + + @InjectMocks + private OpportunityIntakeImpl intake; + + /** 线索转入 spec(source=LEAD_CONVERT,oppSource 已由 adapter 填 opp_source_01)。 */ + private OpportunityIntakeSpec leadConvertSpec(Long customerId) { + return new OpportunityIntakeSpec( + IntakeSource.LEAD_CONVERT, "opp_source_01", "商机A", "ind01", "甲方X", + "110000", "110100", "备注", OWNER_USER_ID, OWNER_DEPT_ID, + customerId, "意向客户名", LEAD_ID, "线索A", "13800000000", "prod01"); + } + + /** 直接创建 spec(source=DIRECT,oppSource 用户所选,无来源线索)。 */ + private OpportunityIntakeSpec directSpec(String oppSource, Long customerId) { + return new OpportunityIntakeSpec( + IntakeSource.DIRECT, oppSource, "商机B", "ind02", "甲方Y", + "310000", "310100", "备注2", OWNER_USER_ID, OWNER_DEPT_ID, + customerId, "意向客户名2", null, null, null, null); + } + + /** 桩定阶段模板命中:部门解析到发布中版本 + 首节点。 */ + private void stubStageTemplateHit() { + OpportunityStageTemplate ver = new OpportunityStageTemplate(); + ver.setId(STAGE_TPL_VER_ID); + ver.setVersionNo("V1.0"); + when(stageTemplateService.resolveBindingVersion(OWNER_DEPT_ID)).thenReturn(ver); + OpportunityStageNode first = new OpportunityStageNode(); + first.setId(FIRST_NODE_ID); + first.setSeqNo(1); + OpportunityStageNode second = new OpportunityStageNode(); + second.setId(FIRST_NODE_ID + 1); + second.setSeqNo(2); + when(stageTemplateService.listNodesOfVersion(STAGE_TPL_VER_ID)) + .thenReturn(List.of(first, second)); + } + + /** 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 追加区 ==================== + + @Test + @DisplayName("source_lead_id 已占用 → 抛 OpportunityIntakeException,不新建") + void open_leadAlreadyLinked_throws() { + when(oppMapper.selectCount(any())).thenReturn(1L); + assertThatThrownBy(() -> intake.open(leadConvertSpec(null))) + .isInstanceOf(OpportunityIntakeException.class); + verify(oppMapper, never()).insert(any(Opportunity.class)); + } + + @Test + @DisplayName("并发窗口:预检通过后 insert 撞 DB 唯一键 → 转译为 OpportunityIntakeException") + void open_concurrentDuplicateKey_translated() { + when(oppMapper.selectCount(any())).thenReturn(0L); + doThrow(new DuplicateKeyException("uk(source_lead_id)")) + .when(oppMapper).insert(any(Opportunity.class)); + assertThatThrownBy(() -> intake.open(leadConvertSpec(null))) + .isInstanceOf(OpportunityIntakeException.class); + } + + @Test + @DisplayName("sourceLeadId 为空(DIRECT 非线索来源)→ 不做唯一性预检,正常建档") + void open_directNoLead_skipsUniquenessPrecheck() { + stubStageTemplateHit(); + stubInsertAssignsId(); + Long id = intake.open(directSpec("opp_source_02", null)); + assertThat(id).isEqualTo(NEW_OPP_ID); + verify(oppMapper, never()).selectCount(any()); + } + + @Test + @DisplayName("新建商机初始状态=推进中(2),落来源快照 + 意向客户名快照") + void open_setsAdvancingAndSnapshots() { + when(oppMapper.selectCount(any())).thenReturn(0L); + stubStageTemplateHit(); + stubInsertAssignsId(); + ArgumentCaptor captor = ArgumentCaptor.forClass(Opportunity.class); + + intake.open(leadConvertSpec(null)); + + verify(oppMapper).insert(captor.capture()); + Opportunity opp = captor.getValue(); + assertThat(opp.getOppStatus()).isEqualTo(OpportunityStatus.STATUS_ADVANCING.getValue()); + assertThat(opp.getClaimTime()).isNotNull(); + assertThat(opp.getSourceLeadName()).isEqualTo("线索A"); + assertThat(opp.getSourcePhone()).isEqualTo("13800000000"); + assertThat(opp.getSourceProductCode()).isEqualTo("prod01"); + assertThat(opp.getPrimaryCustomerNameSnapshot()).isEqualTo("意向客户名"); + } + + @Test + @DisplayName("BUG 修正:LEAD_CONVERT 建档 opp_source 落 opp_source_01(原转商机 port 漏填)") + void open_leadConvert_setsOppSource() { + when(oppMapper.selectCount(any())).thenReturn(0L); + stubStageTemplateHit(); + stubInsertAssignsId(); + ArgumentCaptor captor = ArgumentCaptor.forClass(Opportunity.class); + + intake.open(leadConvertSpec(null)); + + verify(oppMapper).insert(captor.capture()); + assertThat(captor.getValue().getOppSource()).isEqualTo("opp_source_01"); + } + + @Test + @DisplayName("阶段落位:命中默认模板 → current_stage_id=首节点 + 锁模板版本 id/号") + void open_stagePlacement_bindsFirstNode() { + when(oppMapper.selectCount(any())).thenReturn(0L); + stubStageTemplateHit(); + stubInsertAssignsId(); + ArgumentCaptor captor = ArgumentCaptor.forClass(Opportunity.class); + + intake.open(leadConvertSpec(null)); + + verify(oppMapper).insert(captor.capture()); + Opportunity opp = captor.getValue(); + assertThat(opp.getStageTemplateId()).isEqualTo(STAGE_TPL_VER_ID); + assertThat(opp.getStageTemplateVersion()).isEqualTo("V1.0"); + assertThat(opp.getCurrentStageId()).isEqualTo(FIRST_NODE_ID); + } + + @Test + @DisplayName("阶段落位:无发布中模板(resolveBindingVersion 返 null)→ 三字段空,商机仍建成(容忍空)") + void open_noStageTemplate_tolerated() { + when(oppMapper.selectCount(any())).thenReturn(0L); + when(stageTemplateService.resolveBindingVersion(OWNER_DEPT_ID)).thenReturn(null); + stubInsertAssignsId(); + ArgumentCaptor captor = ArgumentCaptor.forClass(Opportunity.class); + + Long id = intake.open(leadConvertSpec(null)); + + assertThat(id).isEqualTo(NEW_OPP_ID); + verify(oppMapper).insert(captor.capture()); + Opportunity opp = captor.getValue(); + assertThat(opp.getStageTemplateId()).isNull(); + assertThat(opp.getCurrentStageId()).isNull(); + } + + @Test + @DisplayName("customerId 非空 → 建 is_primary_intended=1 子表 + 刷主表冗余") + void open_withCustomer_linksPrimary() { + when(oppMapper.selectCount(any())).thenReturn(0L); + stubStageTemplateHit(); + stubInsertAssignsId(); + ArgumentCaptor captor = ArgumentCaptor.forClass(OpportunityCustomer.class); + + intake.open(leadConvertSpec(CUSTOMER_ID)); + + verify(customerMapper).insert(captor.capture()); + OpportunityCustomer link = captor.getValue(); + assertThat(link.getOpportunityId()).isEqualTo(NEW_OPP_ID); + assertThat(link.getCustomerId()).isEqualTo(CUSTOMER_ID); + assertThat(link.getIsPrimaryIntended()).isEqualTo(1); + verify(oppMapper).updateById(any(Opportunity.class)); + } + + @Test + @DisplayName("customerId 为空(走法甲)→ 跳过建子表、不刷冗余,仍建商机") + void open_noCustomer_skipsLink() { + when(oppMapper.selectCount(any())).thenReturn(0L); + stubStageTemplateHit(); + stubInsertAssignsId(); + + Long id = intake.open(leadConvertSpec(null)); + + assertThat(id).isEqualTo(NEW_OPP_ID); + verify(customerMapper, never()).insert(any(OpportunityCustomer.class)); + verify(oppMapper, never()).updateById(any(Opportunity.class)); + } + + @Test + @DisplayName("初始日志:LEAD_CONVERT → SYSTEM「由线索转入创建」") + void open_leadConvert_writesSystemOplog() { + when(oppMapper.selectCount(any())).thenReturn(0L); + stubStageTemplateHit(); + stubInsertAssignsId(); + ArgumentCaptor captor = ArgumentCaptor.forClass(OpportunityOplog.class); + + intake.open(leadConvertSpec(null)); + + verify(oplogMapper).insert(captor.capture()); + OpportunityOplog log = captor.getValue(); + assertThat(log.getOpSource()).isEqualTo("SYSTEM"); + assertThat(log.getOpDesc()).isEqualTo("由线索转入创建"); + assertThat(log.getOpUserId()).isEqualTo(OWNER_USER_ID); + } + + @Test + @DisplayName("初始日志:DIRECT → USER「直接创建商机」") + void open_direct_writesUserOplog() { + stubStageTemplateHit(); + stubInsertAssignsId(); + ArgumentCaptor captor = ArgumentCaptor.forClass(OpportunityOplog.class); + + intake.open(directSpec("opp_source_02", null)); + + verify(oplogMapper).insert(captor.capture()); + OpportunityOplog log = captor.getValue(); + assertThat(log.getOpSource()).isEqualTo("USER"); + assertThat(log.getOpDesc()).isEqualTo("直接创建商机"); + } + + @Test + @DisplayName("团队成员:插一条领取人=商机负责人 project_role_01/READ_WRITE") + void open_insertsOwnerTeamMember() { + when(oppMapper.selectCount(any())).thenReturn(0L); + stubStageTemplateHit(); + stubInsertAssignsId(); + ArgumentCaptor captor = ArgumentCaptor.forClass(OpportunityTeam.class); + + intake.open(leadConvertSpec(null)); + + verify(teamMapper).insert(captor.capture()); + OpportunityTeam member = captor.getValue(); + assertThat(member.getOpportunityId()).isEqualTo(NEW_OPP_ID); + assertThat(member.getUserId()).isEqualTo(OWNER_USER_ID); + assertThat(member.getProjectRole()).isEqualTo("project_role_01"); + assertThat(member.getPermission()).isEqualTo(TeamMemberPermission.READ_WRITE.getValue()); + } +}