From 218b68af2885f7d4462720ed07db14482df16535 Mon Sep 17 00:00:00 2001 From: luoweijian <1329394916@qq.com> Date: Tue, 25 Aug 2026 16:25:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(opportunity):=20=E7=A5=A816=20=E8=BD=AC?= =?UTF-8?q?=E5=95=86=E6=9C=BA=E5=90=8C=E6=AD=A5=E4=BA=8B=E5=8A=A1=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=EF=BC=88=E9=98=B6=E6=AE=B5=E8=90=BD=E4=BD=8D+?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E6=97=A5=E5=BF=97+=E5=9B=A2=E9=98=9F?= =?UTF-8?q?=E6=88=90=E5=91=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 票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 全绿。 --- .../impl/OpportunityCreationPortImpl.java | 69 +++++++++++ .../OpportunityCreationPortImplTest.java | 112 +++++++++++++++++- 2 files changed, 180 insertions(+), 1 deletion(-) 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 index 778e9f4..e74f5ad 100644 --- 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 @@ -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 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(); 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 index 887e596..16de216 100644 --- 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 @@ -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 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 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 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 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() {