Browse Source

feat(opportunity): 票16 转商机同步事务补齐(阶段落位+初始日志+团队成员)

票02簇D同步事务5步补齐剩余3步(均与主表同事务):
- 阶段落位:按负责人部门解析发布中模板版本,current_stage_id=首节点,
  锁 stage_template_id/version;resolveBindingVersion 返 null 时三字段留空
  (容忍空/走法A,票15默认模板兜底使其常态不触发)
- 初始操作日志:ROW_ADD/ROW_CHANGE/SYSTEM「由线索转入创建」
- 团队成员:领取人=商机负责人 project_role_01/READ_WRITE
扩 OpportunityCreationPortImplTest 3 用例(命中/容忍空/日志/团队)。
crm-opportunity 156 全绿。
master
luoweijian 2 weeks ago
parent
commit
218b68af28
  1. 69
      crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityCreationPortImpl.java
  2. 112
      crm-opportunity/src/test/java/com/crm/opportunity/port/inbound/OpportunityCreationPortImplTest.java

69
crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityCreationPortImpl.java

@ -7,14 +7,25 @@ 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.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.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;
/**
* 线索转商机新建端口实现 11 消费 + 12 唯一性预检 + 13 关联客户子表
@ -37,8 +48,14 @@ public class OpportunityCreationPortImpl implements OpportunityCreationPort {
/** 转商机来源标记:线索转入的意向客户角色(票 13,customer_role 字典)。 */
private static final String ROLE_INTENDED = "intended";
/** 领取人项目角色:商机负责人(crm-dict project_role 分组,票 02)。 */
private static final String PROJECT_ROLE_OWNER = "project_role_01";
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)
@ -62,6 +79,11 @@ public class OpportunityCreationPortImpl implements OpportunityCreationPort {
if (cmd.customerId() != null) {
linkPrimaryCustomer(opp, cmd);
}
// 票 02 步 3:初始操作日志(由线索转入创建)
writeInitialOplog(opp.getId(), cmd);
// 票 02 步 4:团队成员(领取人=商机负责人)
insertOwnerTeamMember(opp.getId(), cmd);
return opp.getId();
}
@ -87,9 +109,56 @@ public class OpportunityCreationPortImpl implements OpportunityCreationPort {
opp.setClaimTime(LocalDateTime.now());
// 意向客户名快照(走法 B:引用 + 快照并存);customerId 非空时下方刷主表冗余
opp.setPrimaryCustomerNameSnapshot(cmd.intendedCustomer());
// 票 02 步 2:阶段落位——按部门解析发布中模板版本 + 首节点(无模板容忍空,走法 A)
applyInitialStage(opp, cmd);
return opp;
}
/**
* 02 2 / 06按商机负责人部门解析应绑定的发布中阶段模板版本
* current_stage_id 落首节点listNodesOfVersion 已按 seq_no 升序并锁模板版本 id/
* 无任何发布中模板resolveBindingVersion null时三字段留空容忍空走法 A
* 15 默认模板兜底使其常态不触发
*/
private void applyInitialStage(Opportunity opp, CreateOpportunityCmd cmd) {
OpportunityStageTemplate version = stageTemplateService.resolveBindingVersion(cmd.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:写一条初始操作日志(整行新增 / 子表增删 / 系统自动,“由线索转入创建”)。 */
private void writeInitialOplog(Long oppId, CreateOpportunityCmd cmd) {
OpportunityOplog log = new OpportunityOplog();
log.setOppId(oppId);
log.setOpKind(OplogKind.ROW_ADD.getValue());
log.setLogType(OplogLogType.ROW_CHANGE.getValue());
log.setEntityName("商机");
log.setOpDesc("由线索转入创建");
log.setOpSource("SYSTEM");
log.setOpTime(LocalDateTime.now());
log.setOpUserId(cmd.ownerUserId());
oplogMapper.insert(log);
}
/** 票 02 步 4:领取人=商机负责人,插一条团队成员(project_role_01 / READ_WRITE)。 */
private void insertOwnerTeamMember(Long oppId, CreateOpportunityCmd cmd) {
OpportunityTeam member = new OpportunityTeam();
member.setOpportunityId(oppId);
member.setUserId(cmd.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, CreateOpportunityCmd cmd) {
OpportunityCustomer link = new OpportunityCustomer();

112
crm-opportunity/src/test/java/com/crm/opportunity/port/inbound/OpportunityCreationPortImplTest.java

@ -4,10 +4,18 @@ 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.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.mapper.OpportunityCustomerMapper;
import com.crm.opportunity.mapper.OpportunityMapper;
import com.crm.opportunity.mapper.OpportunityOplogMapper;
import com.crm.opportunity.mapper.OpportunityTeamMapper;
import com.crm.opportunity.port.inbound.impl.OpportunityCreationPortImpl;
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;
@ -17,6 +25,8 @@ 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;
@ -38,9 +48,16 @@ class OpportunityCreationPortImplTest {
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 OpportunityCreationPortImpl port;
@ -49,7 +66,23 @@ class OpportunityCreationPortImplTest {
return new CreateOpportunityCmd(
LEAD_ID, "商机A", "ind01", "甲方X", "意向客户名", customerId,
"110000", "110100", "备注", "线索A", "13800000000", "prod01",
100L, 200L, 300L);
OWNER_USER_ID, OWNER_DEPT_ID, 300L);
}
/** 桂定阶段模板命中:部门解析到发布中版本 + 首节点。 */
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)。 */
@ -133,6 +166,83 @@ class OpportunityCreationPortImplTest {
assertThat(saved.getCityCode()).isEqualTo("110100");
}
@Test
@DisplayName("阶段落位:命中默认模板 → current_stage_id=首节点 + 锁模板版本 id/号(票 02 步 2)")
void create_stagePlacement_bindsFirstNode() {
when(oppMapper.selectCount(any())).thenReturn(0L);
stubStageTemplateHit();
ArgumentCaptor<Opportunity> 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.getCurrentStageId()).isEqualTo(FIRST_NODE_ID);
assertThat(saved.getStageTemplateId()).isEqualTo(STAGE_TPL_VER_ID);
assertThat(saved.getStageTemplateVersion()).isEqualTo("V1.0");
}
@Test
@DisplayName("阶段落位:无发布中模板(resolveBindingVersion 返 null)→ 三字段空,商机仍建成(容忍空走法 A)")
void create_noStageTemplate_tolerated() {
when(oppMapper.selectCount(any())).thenReturn(0L);
when(stageTemplateService.resolveBindingVersion(OWNER_DEPT_ID)).thenReturn(null);
ArgumentCaptor<Opportunity> insCap = ArgumentCaptor.forClass(Opportunity.class);
doAnswer(inv -> {
((Opportunity) inv.getArgument(0)).setId(NEW_OPP_ID);
return 1;
}).when(oppMapper).insert(insCap.capture());
Long id = port.createOpportunity(cmd(null));
assertThat(id).isEqualTo(NEW_OPP_ID);
Opportunity saved = insCap.getValue();
assertThat(saved.getCurrentStageId()).isNull();
assertThat(saved.getStageTemplateId()).isNull();
assertThat(saved.getStageTemplateVersion()).isNull();
}
@Test
@DisplayName("初始操作日志:写一条 ROW_ADD/ROW_CHANGE/SYSTEM「由线索转入创建」(票 02 步 3)")
void create_writesInitialOplog() {
when(oppMapper.selectCount(any())).thenReturn(0L);
stubInsertAssignsId();
port.createOpportunity(cmd(null));
ArgumentCaptor<OpportunityOplog> logCap = ArgumentCaptor.forClass(OpportunityOplog.class);
verify(oplogMapper).insert(logCap.capture());
OpportunityOplog log = logCap.getValue();
assertThat(log.getOppId()).isEqualTo(NEW_OPP_ID);
assertThat(log.getOpKind()).isEqualTo("ROW_ADD");
assertThat(log.getLogType()).isEqualTo("ROW_CHANGE");
assertThat(log.getOpSource()).isEqualTo("SYSTEM");
assertThat(log.getOpDesc()).contains("由线索转入创建");
assertThat(log.getOpUserId()).isEqualTo(OWNER_USER_ID);
assertThat(log.getOpTime()).isNotNull();
}
@Test
@DisplayName("团队成员:插一条领取人=商机负责人 project_role_01/READ_WRITE(票 02 步 4)")
void create_insertsOwnerTeamMember() {
when(oppMapper.selectCount(any())).thenReturn(0L);
stubInsertAssignsId();
port.createOpportunity(cmd(null));
ArgumentCaptor<OpportunityTeam> teamCap = ArgumentCaptor.forClass(OpportunityTeam.class);
verify(teamMapper).insert(teamCap.capture());
OpportunityTeam member = teamCap.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());
assertThat(member.getDeleteKey()).isEqualTo(0L);
}
@Test
@DisplayName("并发窗口:预检通过后 insert 撞 DB 唯一键 → 转译为 OpportunityCreationException")
void create_concurrentDuplicateKey_translated() {

Loading…
Cancel
Save