Browse Source

commit

master
luoweijian 2 weeks ago
parent
commit
fd5e9f0ccf
  1. 21
      AGENTS.md
  2. 45
      crm-opportunity/src/main/java/com/crm/opportunity/intake/IntakeSource.java
  3. 27
      crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntake.java
  4. 23
      crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeException.java
  5. 49
      crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeSpec.java
  6. 180
      crm-opportunity/src/main/java/com/crm/opportunity/intake/impl/OpportunityIntakeImpl.java
  7. 287
      crm-opportunity/src/test/java/com/crm/opportunity/intake/impl/OpportunityIntakeImplTest.java

21
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`. - 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.

45
crm-opportunity/src/main/java/com/crm/opportunity/intake/IntakeSource.java

@ -0,0 +1,45 @@
package com.crm.opportunity.intake;
/**
* 商机进入方式建档来源
*
* <p>两条建档入口的语义差异收敛于此枚举线索侧转商机{@link #LEAD_CONVERT}与商机侧直接
* 新建{@link #DIRECT}承载两处按来源分叉的细节初始操作日志的 {@code op_source}
* {@code op_desc}以及线索转入恒定的商机来源字典 code{@code defaultOppSource}</p>
*
* <p>{@code defaultOppSource}{@code LEAD_CONVERT} 恒为 {@code opp_source_01}线索转入
* {@code DIRECT} null直接创建的商机来源由用户在弹窗选择 spec 显式携带</p>
*/
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;
}
}

27
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新建商机的唯一核心机制
*
* <p>两条建档入口线索侧转商机 port{@code OpportunityCreationPortImpl}与商机侧直接
* 新建{@code OpportunityCreateServiceImpl}的公共机制收敛于此主表落库 + 阶段落位
* + 主要意向客户子表 + 初始操作日志 + 领取人团队成员 + source_lead_id 唯一性防护
* 一个事务内原子完成</p>
*
* <p>接口窄单方法实现厚调用方只需把各自入参翻译成 {@link OpportunityIntakeSpec}
* 建档的全部规则与副作用都在实现内不再两处各写一遍</p>
*/
public interface OpportunityIntake {
/**
* 按规格建档一个商机返回新商机 id
*
* <p>同库同事务ADR-0020本方法标 {@code @Transactional}线索转商机路径下并入
* crm-lead 外层事务REQUIRED 传播任一副作用失败整体回滚</p>
*
* @param spec 建档规格两条入口翻译后的统一内部契约
* @return 新建商机 id
* @throws OpportunityIntakeException source_lead_id 已被其他商机占用预检命中或并发唯一键冲突
*/
Long open(OpportunityIntakeSpec spec);
}

23
crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeException.java

@ -0,0 +1,23 @@
package com.crm.opportunity.intake;
/**
* 商机建档领域异常crm-opportunity 自有
*
* <p>{@link OpportunityIntake#open} 内部机制失败时抛出语义为建档本身的领域事实
* 当前唯一场景是 {@code source_lead_id} 已被其他商机占用应用层预检命中或并发窗口
* DB 唯一键冲突转译</p>
*
* <p>本异常<b>不跨模块边界</b>深模块只认自己的语言由各 adapter seam 处翻译
* 线索侧 port 译为 {@code com.crm.lead.port.outbound.OpportunityCreationException}
* 商机侧直接创建译为 {@code BusinessErrorException}HTTP 友好</p>
*/
public class OpportunityIntakeException extends RuntimeException {
public OpportunityIntakeException(String message) {
super(message);
}
public OpportunityIntakeException(String message, Throwable cause) {
super(message, cause);
}
}

49
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
*
* <p>{@code OpportunityIntake.open} 的唯一入参crm-opportunity 内部类型不导出
* 线索侧转商机 port{@code OpportunityCreationPortImpl}与商机侧直接新建
* {@code OpportunityCreateServiceImpl}各自把入参 DTO 翻译成本 spec深模块只认本语言</p>
*
* <p>差异收敛{@code source} 决定初始日志文案/op_source{@code oppSource} adapter
* {@code source} {@code LEAD_CONVERT} {@link IntakeSource#defaultOppSource()}
* opp_source_01{@code DIRECT} 取用户所选来源</p>
*
* @param source 进入方式决定 oplog 文案 + op_source
* @param oppSource 商机来源字典 codeLEAD_CONVERT opp_source_01DIRECT 用户所选
* @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唯一性预检 keyDIRECT 非线索来源时为 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
) {
}

180
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两条入口机制收敛一处
*
* <p> {@code OpportunityCreationPortImpl}线索转商机 port {@code OpportunityCreateServiceImpl}
* 商机侧直接新建逐字复制的五步建档机制主表 + 阶段落位 + 客户子表 + 初始日志 + 团队成员
* 在此合并差异点经 {@link OpportunityIntakeSpec} / {@code IntakeSource} 参数化oplog 文案/来源
* oppSource 取值均由 spec 携带实现不再分叉</p>
*
* <p>事务ADR-0020{@code open} {@code @Transactional}线索转商机路径并入 crm-lead
* 外层事务REQUIRED任一副作用失败整体回滚source_lead_id 唯一性预检 + 并发 DB 唯一键
* 冲突转译收在本模块抛自有 {@link OpportunityIntakeException}由各 adapter seam 翻译</p>
*/
@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<Opportunity>()
.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
* 三字段留空容忍空走法 AownerDeptId 为空亦然走默认模板兜底
*/
private void applyInitialStage(Opportunity opp, OpportunityIntakeSpec spec) {
OpportunityStageTemplate version = stageTemplateService.resolveBindingVersion(spec.ownerDeptId());
if (version == null) {
return;
}
List<OpportunityStageNode> 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);
}
}

287
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两条入口机制收敛一处
*
* <p>覆盖建档机制阶段落位命中/无模板容忍空初始日志两来源分支领取人团队成员
* 主要意向客户子表/ customerId初始状态推进中 + 快照source_lead_id 唯一性
* 预检 + 并发 DuplicateKey 转译adapter 侧的异常翻译由各自 adapter 测试覆盖</p>
*/
@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<Opportunity> 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<Opportunity> 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<Opportunity> 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<Opportunity> 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<OpportunityCustomer> 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<OpportunityOplog> 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<OpportunityOplog> 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<OpportunityTeam> 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());
}
}
Loading…
Cancel
Save