|
|
|
@ -25,6 +25,7 @@ import org.mockito.Mock; |
|
|
|
import org.mockito.junit.jupiter.MockitoExtension; |
|
|
|
import org.springframework.dao.DuplicateKeyException; |
|
|
|
|
|
|
|
import java.math.BigDecimal; |
|
|
|
import java.util.List; |
|
|
|
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat; |
|
|
|
@ -42,6 +43,10 @@ import static org.mockito.Mockito.when; |
|
|
|
* <p>覆盖建档机制:阶段落位(命中 / 无发布中模板 fail-fast 拒绝,938aed9)、初始日志两来源分支、领取人团队成员、 |
|
|
|
* 主要意向客户子表(有/无 customerId)、初始状态推进中 + 快照、source_lead_id 唯一性 |
|
|
|
* 预检 + 并发 DuplicateKey 转译。adapter 侧的异常翻译由各自 adapter 测试覆盖。</p> |
|
|
|
* |
|
|
|
* <p>opp-create-fields 票 02 补:新字段(项目金额/项目详细地址/关键联系人快照)建档透传、 |
|
|
|
* 关联客户子表按 {@code isPrimaryIntended}/{@code customerRole} spec 取值分支 |
|
|
|
* (=0 普通关联不刷主表冗余;空回退 ROLE_INTENDED/1 保持线索转入路径兼容)。</p> |
|
|
|
*/ |
|
|
|
@DisplayName("商机建档深模块 OpportunityIntake") |
|
|
|
@ExtendWith(MockitoExtension.class) |
|
|
|
@ -64,22 +69,38 @@ class OpportunityIntakeImplTest { |
|
|
|
@InjectMocks |
|
|
|
private OpportunityIntakeImpl intake; |
|
|
|
|
|
|
|
/** 线索转入 spec(source=LEAD_CONVERT,oppSource 已由 adapter 填 opp_source_01)。 */ |
|
|
|
private static final Long KEY_CONTACT_ID = 88001L; |
|
|
|
|
|
|
|
/** 线索转入 spec(source=LEAD_CONVERT,oppSource 已由 adapter 填 opp_source_01;新字段传 null 走深模块回退)。 */ |
|
|
|
private OpportunityIntakeSpec leadConvertSpec(Long customerId) { |
|
|
|
return new OpportunityIntakeSpec( |
|
|
|
IntakeSource.LEAD_CONVERT, "opp_source_01", "商机A", |
|
|
|
null, null, null, null, |
|
|
|
"ind01", "甲方X", "110000", "110100", "备注", OWNER_USER_ID, OWNER_DEPT_ID, |
|
|
|
customerId, "意向客户名", LEAD_ID, "线索A", "13800000000", "prod01"); |
|
|
|
customerId, "意向客户名", LEAD_ID, "线索A", "13800000000", "prod01", |
|
|
|
null, null, null, null, null, null, null); |
|
|
|
} |
|
|
|
|
|
|
|
/** 直接创建 spec(source=DIRECT,oppSource 用户所选,无来源线索)。 */ |
|
|
|
/** 直接创建 spec(source=DIRECT,oppSource 用户所选,无来源线索;新字段不涉及)。 */ |
|
|
|
private OpportunityIntakeSpec directSpec(String oppSource, Long customerId) { |
|
|
|
return new OpportunityIntakeSpec( |
|
|
|
IntakeSource.DIRECT, oppSource, "商机B", |
|
|
|
null, null, null, null, |
|
|
|
"ind02", "甲方Y", "310000", "310100", "备注2", OWNER_USER_ID, OWNER_DEPT_ID, |
|
|
|
customerId, "意向客户名2", null, null, null, null); |
|
|
|
customerId, "意向客户名2", null, null, null, null, |
|
|
|
null, null, null, null, null, null, null); |
|
|
|
} |
|
|
|
|
|
|
|
/** 直接创建 spec(含 opp-create-fields 新字段:金额/地址/客户角色/是否主要/关键联系人快照)。 */ |
|
|
|
private OpportunityIntakeSpec directFullSpec(Long customerId, String customerRole, Integer isPrimaryIntended) { |
|
|
|
return new OpportunityIntakeSpec( |
|
|
|
IntakeSource.DIRECT, "opp_source_02", "商机B", |
|
|
|
null, null, null, null, |
|
|
|
"ind02", "甲方Y", "310000", "310100", "备注2", OWNER_USER_ID, OWNER_DEPT_ID, |
|
|
|
customerId, "意向客户名2", null, null, null, null, |
|
|
|
new BigDecimal("880000.00"), "成都市高新区天府大道1号", |
|
|
|
customerRole, isPrimaryIntended, |
|
|
|
KEY_CONTACT_ID, "王工", "甲方Y公司"); |
|
|
|
} |
|
|
|
|
|
|
|
/** 桩定阶段模板命中:部门解析到发布中版本 + 首节点。 */ |
|
|
|
@ -139,7 +160,7 @@ class OpportunityIntakeImplTest { |
|
|
|
} |
|
|
|
|
|
|
|
@Test |
|
|
|
@DisplayName("新建商机初始状态=推进中(2),落来源快照 + 意向客户名快照") |
|
|
|
@DisplayName("新建商机初始状态=推进中(2),落来源快照;customerId 空不刷主客户冗余(票02)") |
|
|
|
void open_setsAdvancingAndSnapshots() { |
|
|
|
when(oppMapper.selectCount(any())).thenReturn(0L); |
|
|
|
stubStageTemplateHit(); |
|
|
|
@ -155,7 +176,9 @@ class OpportunityIntakeImplTest { |
|
|
|
assertThat(opp.getSourceLeadName()).isEqualTo("线索A"); |
|
|
|
assertThat(opp.getSourcePhone()).isEqualTo("13800000000"); |
|
|
|
assertThat(opp.getSourceProductCode()).isEqualTo("prod01"); |
|
|
|
assertThat(opp.getPrimaryCustomerNameSnapshot()).isEqualTo("意向客户名"); |
|
|
|
// opp-create-fields 票02:主客户冗余不再无条件刷(旧版 buildOpportunity 会先落名快照),
|
|
|
|
// customerId 空时留空,由 linkPrimaryCustomer 按 isPrimaryIntended 分支管理
|
|
|
|
assertThat(opp.getPrimaryCustomerNameSnapshot()).isNull(); |
|
|
|
} |
|
|
|
|
|
|
|
@Test |
|
|
|
@ -202,7 +225,7 @@ class OpportunityIntakeImplTest { |
|
|
|
} |
|
|
|
|
|
|
|
@Test |
|
|
|
@DisplayName("customerId 非空 → 建 is_primary_intended=1 子表 + 刷主表冗余") |
|
|
|
@DisplayName("customerId 非空 → 建 is_primary_intended=1 子表 + 刷主表冗余(customerRole 空回退 intended)") |
|
|
|
void open_withCustomer_linksPrimary() { |
|
|
|
when(oppMapper.selectCount(any())).thenReturn(0L); |
|
|
|
stubStageTemplateHit(); |
|
|
|
@ -216,9 +239,45 @@ class OpportunityIntakeImplTest { |
|
|
|
assertThat(link.getOpportunityId()).isEqualTo(NEW_OPP_ID); |
|
|
|
assertThat(link.getCustomerId()).isEqualTo(CUSTOMER_ID); |
|
|
|
assertThat(link.getIsPrimaryIntended()).isEqualTo(1); |
|
|
|
assertThat(link.getCustomerRole()).isEqualTo("intended"); |
|
|
|
verify(oppMapper).updateById(any(Opportunity.class)); |
|
|
|
} |
|
|
|
|
|
|
|
@Test |
|
|
|
@DisplayName("opp-create-fields 票02:DIRECT 新字段(金额/地址/关键联系人快照)透传落主表") |
|
|
|
void open_directCreate_persistsNewFields() { |
|
|
|
stubStageTemplateHit(); |
|
|
|
stubInsertAssignsId(); |
|
|
|
ArgumentCaptor<Opportunity> captor = ArgumentCaptor.forClass(Opportunity.class); |
|
|
|
|
|
|
|
intake.open(directFullSpec(null, null, null)); |
|
|
|
|
|
|
|
verify(oppMapper).insert(captor.capture()); |
|
|
|
Opportunity opp = captor.getValue(); |
|
|
|
assertThat(opp.getProjectAmount()).isEqualByComparingTo(new BigDecimal("880000.00")); |
|
|
|
assertThat(opp.getAddress()).isEqualTo("成都市高新区天府大道1号"); |
|
|
|
assertThat(opp.getKeyContactId()).isEqualTo(KEY_CONTACT_ID); |
|
|
|
assertThat(opp.getKeyContactNameSnapshot()).isEqualTo("王工"); |
|
|
|
assertThat(opp.getKeyContactCompanySnapshot()).isEqualTo("甲方Y公司"); |
|
|
|
} |
|
|
|
|
|
|
|
@Test |
|
|
|
@DisplayName("opp-create-fields 票02:isPrimaryIntended=0 普通关联 → 子表落 0/role 用 spec 值,主表不回写(无空转 UPDATE)") |
|
|
|
void open_ordinaryLink_noPrimaryAnchor() { |
|
|
|
stubStageTemplateHit(); |
|
|
|
stubInsertAssignsId(); |
|
|
|
ArgumentCaptor<OpportunityCustomer> linkCap = ArgumentCaptor.forClass(OpportunityCustomer.class); |
|
|
|
|
|
|
|
intake.open(directFullSpec(CUSTOMER_ID, "customer_role_02", 0)); |
|
|
|
|
|
|
|
verify(customerMapper).insert(linkCap.capture()); |
|
|
|
OpportunityCustomer link = linkCap.getValue(); |
|
|
|
assertThat(link.getIsPrimaryIntended()).isZero(); |
|
|
|
assertThat(link.getCustomerRole()).isEqualTo("customer_role_02"); |
|
|
|
// 普通关联主表无变更:不回写、不触发乐观锁 +1(code-review P2 采纳)
|
|
|
|
verify(oppMapper, never()).updateById(any(Opportunity.class)); |
|
|
|
} |
|
|
|
|
|
|
|
@Test |
|
|
|
@DisplayName("customerId 为空(走法甲)→ 跳过建子表、不刷冗余,仍建商机") |
|
|
|
void open_noCustomer_skipsLink() { |
|
|
|
@ -282,4 +341,23 @@ class OpportunityIntakeImplTest { |
|
|
|
assertThat(member.getProjectRole()).isEqualTo("project_role_01"); |
|
|
|
assertThat(member.getPermission()).isEqualTo(TeamMemberPermission.READ_WRITE.getValue()); |
|
|
|
} |
|
|
|
|
|
|
|
@Test |
|
|
|
@DisplayName("opp-create-fields 票02:customerId 非空 + isPrimaryIntended=1 → 子表落 1/role 用 spec 值 + 刷主表冗余") |
|
|
|
void open_primaryLink_usesSpecValues() { |
|
|
|
stubStageTemplateHit(); |
|
|
|
stubInsertAssignsId(); |
|
|
|
ArgumentCaptor<OpportunityCustomer> linkCap = ArgumentCaptor.forClass(OpportunityCustomer.class); |
|
|
|
ArgumentCaptor<Opportunity> oppCap = ArgumentCaptor.forClass(Opportunity.class); |
|
|
|
|
|
|
|
intake.open(directFullSpec(CUSTOMER_ID, "customer_role_01", 1)); |
|
|
|
|
|
|
|
verify(customerMapper).insert(linkCap.capture()); |
|
|
|
OpportunityCustomer link = linkCap.getValue(); |
|
|
|
assertThat(link.getIsPrimaryIntended()).isEqualTo(1); |
|
|
|
assertThat(link.getCustomerRole()).isEqualTo("customer_role_01"); |
|
|
|
verify(oppMapper).updateById(oppCap.capture()); |
|
|
|
assertThat(oppCap.getValue().getPrimaryCustomerId()).isEqualTo(CUSTOMER_ID); |
|
|
|
assertThat(oppCap.getValue().getPrimaryCustomerNameSnapshot()).isEqualTo("意向客户名2"); |
|
|
|
} |
|
|
|
} |
|
|
|
|