Browse Source

feat(opportunity): 票 06 商机阶段模板 + 阶段运行时落地(轴2)

- crm-rule:阶段模板 V-CONFIG 版本状态机(头表/适用部门/阶段节点三实体 + 草稿三分支/发布顶替/默认顶替/复制/停用/仅草稿可删),固定节点「已转项目」系统规范化(入参拒绝伪造、末位补唯一),节点字典引用校验(仅「商机阶段」分组启用项可新增引用,票01 定稿方向引入 crm-dict 依赖),绑定匹配(部门专用优先/多命中取最近创建/默认兜底),错误码 64013-64017,权限种子仅菜单
- crm-opportunity:opportunity_stage_history 快照 + 阶段自由切换(CAS 锚 current_stage_id、仅推进中可切 66005、固定节点/跨版本禁切 66006)、三态进度/历史查询端点;转项目端口 E8 成功后联动驱动固定节点(系统身份 0L、幂等、缺固定节点告警跳过)
- crm-dict:新增「商机阶段」字典分组种子(OPP_STAGE_01..05)及初始化测试断言同步
- 测试:阶段模板状态机 41 用例 + 阶段运行时 17 用例 + ConvertPort 联动断言;全反应器 662 用例绿
master
luoweijian 2 weeks ago
parent
commit
01900ca76a
  1. 10
      crm-dict/src/main/java/com/crm/dict/config/DictDataInitializer.java
  2. 24
      crm-dict/src/test/java/com/crm/dict/config/DictDataInitializerTest.java
  3. 2
      crm-opportunity/src/main/java/com/crm/opportunity/constant/OpportunityConstants.java
  4. 55
      crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityStageController.java
  5. 40
      crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/StageHistoryDTO.java
  6. 74
      crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/StageProgressDTO.java
  7. 53
      crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityStageHistory.java
  8. 9
      crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityStageHistoryMapper.java
  9. 7
      crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityConvertPortImpl.java
  10. 46
      crm-opportunity/src/main/java/com/crm/opportunity/stage/OpportunityStageService.java
  11. 12
      crm-opportunity/src/main/java/com/crm/opportunity/stage/SwitchStageCmd.java
  12. 204
      crm-opportunity/src/main/java/com/crm/opportunity/stage/impl/OpportunityStageServiceImpl.java
  13. 11
      crm-opportunity/src/test/java/com/crm/opportunity/port/inbound/OpportunityConvertPortImplTest.java
  14. 376
      crm-opportunity/src/test/java/com/crm/opportunity/stage/impl/OpportunityStageServiceImplTest.java
  15. 7
      crm-rule/pom.xml
  16. 43
      crm-rule/src/main/java/com/crm/rule/config/OpportunityStageTemplatePermissionInitializer.java
  17. 36
      crm-rule/src/main/java/com/crm/rule/constant/OpportunityRuleConstants.java
  18. 76
      crm-rule/src/main/java/com/crm/rule/controller/OpportunityStageTemplateController.java
  19. 91
      crm-rule/src/main/java/com/crm/rule/domain/dto/OpportunityStageTemplateDTO.java
  20. 62
      crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunityStageNode.java
  21. 57
      crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunityStageTemplate.java
  22. 42
      crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunityStageTemplateDept.java
  23. 21
      crm-rule/src/main/java/com/crm/rule/domain/param/OpportunityStageTemplatePageParam.java
  24. 9
      crm-rule/src/main/java/com/crm/rule/mapper/OpportunityStageNodeMapper.java
  25. 9
      crm-rule/src/main/java/com/crm/rule/mapper/OpportunityStageTemplateDeptMapper.java
  26. 9
      crm-rule/src/main/java/com/crm/rule/mapper/OpportunityStageTemplateMapper.java
  27. 96
      crm-rule/src/main/java/com/crm/rule/service/IOpportunityStageTemplateService.java
  28. 535
      crm-rule/src/main/java/com/crm/rule/service/impl/OpportunityStageTemplateServiceImpl.java
  29. 767
      crm-rule/src/test/java/com/crm/rule/service/impl/OpportunityStageTemplateServiceImplTest.java

10
crm-dict/src/main/java/com/crm/dict/config/DictDataInitializer.java

@ -42,6 +42,7 @@ public class DictDataInitializer implements CommandLineRunner {
/*-------- 首批内置字典种子Excel 替换口见类注释 /*-------- 首批内置字典种子Excel 替换口见类注释
* 渠道/品牌/需求产品/需求场景选项取自蓝湖原型 A2-1-2-2 新增线索表单frontend-integration 票据 02 * 渠道/品牌/需求产品/需求场景选项取自蓝湖原型 A2-1-2-2 新增线索表单frontend-integration 票据 02
* 商机阶段取自原型 A7-3-2-1 阶段模板轨道图样例商机模块票 06OPP_STAGE_05 为固定节点契约码
* 原型渠道清单中的占位文本列表项1不入库--------*/ * 原型渠道清单中的占位文本列表项1不入库--------*/
private static final List<GroupSeed> SEED_GROUPS = List.of( private static final List<GroupSeed> SEED_GROUPS = List.of(
@ -148,7 +149,14 @@ public class DictDataInitializer implements CommandLineRunner {
new ItemSeed("energy", "能源", "energy", 15, null, false), new ItemSeed("energy", "能源", "energy", 15, null, false),
new ItemSeed("park", "园区", "park", 16, null, false), new ItemSeed("park", "园区", "park", 16, null, false),
new ItemSeed("cultural_tourism", "文旅", "cultural_tourism", 17, null, false), new ItemSeed("cultural_tourism", "文旅", "cultural_tourism", 17, null, false),
new ItemSeed("exhibition_hall", "展厅", "exhibition_hall", 18, null, false)))); new ItemSeed("exhibition_hall", "展厅", "exhibition_hall", 18, null, false))),
new GroupSeed("opp_stage", "商机阶段", 7, "商机阶段模板节点引用(原型 A7-3-2-1 轨道图样例;OPP_STAGE_05=已转项目固定节点,商机模块票 06)",
List.of(
new ItemSeed("OPP_STAGE_01", "客户圈定", "OPP_STAGE_01", 1, null, false),
new ItemSeed("OPP_STAGE_02", "关系摸排", "OPP_STAGE_02", 2, null, false),
new ItemSeed("OPP_STAGE_03", "资料采集", "OPP_STAGE_03", 3, null, false),
new ItemSeed("OPP_STAGE_04", "方案卡", "OPP_STAGE_04", 4, null, false),
new ItemSeed("OPP_STAGE_05", "已转项目", "OPP_STAGE_05", 5, null, false))));
@Override @Override
public void run(String... args) { public void run(String... args) {

24
crm-dict/src/test/java/com/crm/dict/config/DictDataInitializerTest.java

@ -31,24 +31,24 @@ class DictDataInitializerTest extends AbstractDictH2Test {
} }
@Test @Test
@DisplayName("首装:6 个内置分组 + 92 个内置项全部建库,ref_count 预建,种子默认项生效") @DisplayName("首装:7 个内置分组 + 97 个内置项全部建库,ref_count 预建,种子默认项生效")
void firstRun_createsBuiltinDictsWithDefaults() { void firstRun_createsBuiltinDictsWithDefaults() {
initializer.run(); initializer.run();
List<DictGroup> groups = dictGroupMapper.selectList(null); List<DictGroup> groups = dictGroupMapper.selectList(null);
assertThat(groups).hasSize(6); assertThat(groups).hasSize(7);
assertThat(groups).extracting(DictGroup::getCode) assertThat(groups).extracting(DictGroup::getCode)
.containsExactlyInAnyOrder("lead_source", "customer_status", "industry", .containsExactlyInAnyOrder("lead_source", "customer_status", "industry",
"brand", "product", "scene"); "brand", "product", "scene", "opp_stage");
assertThat(groups).allMatch(g -> Boolean.TRUE.equals(g.getBuiltin())); assertThat(groups).allMatch(g -> Boolean.TRUE.equals(g.getBuiltin()));
assertThat(groups).allMatch(g -> g.getStatus() == DictConstants.STATUS_ENABLED); assertThat(groups).allMatch(g -> g.getStatus() == DictConstants.STATUS_ENABLED);
List<DictItem> items = dictItemMapper.selectList(null); List<DictItem> items = dictItemMapper.selectList(null);
assertThat(items).hasSize(92); // 38 渠道 + 3 客户状态 + 8 行业 + 9 品牌 + 16 需求产品 + 18 需求场景 assertThat(items).hasSize(97); // 38 渠道 + 3 客户状态 + 8 行业 + 9 品牌 + 16 需求产品 + 18 需求场景 + 5 商机阶段
assertThat(items).allMatch(i -> Boolean.TRUE.equals(i.getBuiltin())); assertThat(items).allMatch(i -> Boolean.TRUE.equals(i.getBuiltin()));
// 引用计数行预建(与新建接口同口径) // 引用计数行预建(与新建接口同口径)
assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(92); assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(97);
DictGroup statusGroup = groups.stream() DictGroup statusGroup = groups.stream()
.filter(g -> g.getCode().equals("customer_status")).findFirst().orElseThrow(); .filter(g -> g.getCode().equals("customer_status")).findFirst().orElseThrow();
DictItem potential = dictItemMapper.selectList(null).stream() DictItem potential = dictItemMapper.selectList(null).stream()
@ -69,9 +69,9 @@ class DictDataInitializerTest extends AbstractDictH2Test {
initializer.run(); initializer.run();
initializer.run(); initializer.run();
assertThat(dictGroupMapper.selectCount(null)).isEqualTo(6); assertThat(dictGroupMapper.selectCount(null)).isEqualTo(7);
assertThat(dictItemMapper.selectCount(null)).isEqualTo(92); assertThat(dictItemMapper.selectCount(null)).isEqualTo(97);
assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(92); assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(97);
assertThat(dictGroupDefaultMapper.selectCount(null)).isEqualTo(1); assertThat(dictGroupDefaultMapper.selectCount(null)).isEqualTo(1);
} }
@ -194,12 +194,12 @@ class DictDataInitializerTest extends AbstractDictH2Test {
assertThat(dictGroupMapper.selectById(userGroup.getId()).getBuiltin()).isFalse(); assertThat(dictGroupMapper.selectById(userGroup.getId()).getBuiltin()).isFalse();
assertThat(dictItemMapper.selectList(null).stream() assertThat(dictItemMapper.selectList(null).stream()
.filter(i -> i.getGroupId().equals(userGroup.getId()))).isEmpty(); .filter(i -> i.getGroupId().equals(userGroup.getId()))).isEmpty();
// 种子其他分组不受影响(继续正常导入):customer_status + industry + brand + product + scene // 种子其他分组不受影响(继续正常导入):customer_status + industry + brand + product + scene + opp_stage
assertThat(dictGroupMapper.selectCount(null)).isEqualTo(6); // lead_source(用户) + 其余 5 个内置分组 assertThat(dictGroupMapper.selectCount(null)).isEqualTo(7); // lead_source(用户) + 其余 6 个内置分组
assertThat(dictGroupMapper.selectList(null).stream() assertThat(dictGroupMapper.selectList(null).stream()
.filter(g -> g.getCode().equals("industry"))).hasSize(1); .filter(g -> g.getCode().equals("industry"))).hasSize(1);
assertThat(dictItemMapper.selectCount(null)).isEqualTo(54); // 92 - 渠道 38(用户分组整组跳过) assertThat(dictItemMapper.selectCount(null)).isEqualTo(59); // 97 - 渠道 38(用户分组整组跳过)
assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(54); assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(59);
assertThat(dictGroupDefaultMapper.selectCount(null)).isEqualTo(1); // customer_status 默认项 assertThat(dictGroupDefaultMapper.selectCount(null)).isEqualTo(1); // customer_status 默认项
} }

2
crm-opportunity/src/main/java/com/crm/opportunity/constant/OpportunityConstants.java

@ -13,6 +13,8 @@ public interface OpportunityConstants {
int CODE_OPP_NOT_EXIST = 66002; // 商机不存在或已被删除 int CODE_OPP_NOT_EXIST = 66002; // 商机不存在或已被删除
int CODE_STATUS_NOT_ALLOWED = 66003; // 当前状态不允许此操作(守卫前置) int CODE_STATUS_NOT_ALLOWED = 66003; // 当前状态不允许此操作(守卫前置)
int CODE_CAS_FAIL = 66004; // 乐观锁/状态 CAS 冲突(ADR-0021) int CODE_CAS_FAIL = 66004; // 乐观锁/状态 CAS 冲突(ADR-0021)
int CODE_STAGE_NOT_ALLOWED = 66005; // 当前状态不允许阶段切换(票 06:仅推进中可切)
int CODE_STAGE_NODE_INVALID = 66006; // 目标阶段节点非法(不属绑定模板版本/固定节点/不存在,票 06)
/*-------- 进入公海原因 pool_reasonPRD §3.5 枚举四值封闭varchar(32) 存语义码-------- /*-------- 进入公海原因 pool_reasonPRD §3.5 枚举四值封闭varchar(32) 存语义码--------
* E3 三个来源值由调用方Service按操作场景传入E10 超期回收由 executeRecycle 写死 * E3 三个来源值由调用方Service按操作场景传入E10 超期回收由 executeRecycle 写死

55
crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityStageController.java

@ -0,0 +1,55 @@
package com.crm.opportunity.controller;
import com.crm.base.domain.result.Result;
import com.crm.base.security.SecurityUtils;
import com.crm.opportunity.domain.dto.StageHistoryDTO;
import com.crm.opportunity.domain.dto.StageProgressDTO;
import com.crm.opportunity.stage.OpportunityStageService;
import com.crm.opportunity.stage.SwitchStageCmd;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 商机阶段 2接口薄适配层只调 {@link OpportunityStageService} 06
* <p>权限策略不种子化 button 权限点ApiPermissionInterceptor 对未注册 URL fail-open
* 可推进节点权限点语义由前端按权限配置展/隐推进入口原型概览页三态展示
* 无独立推进按钮后端语义按自由切换定稿 06 owner guard 01</p>
*/
@Tag(name = "商机管理/商机阶段")
@RestController
@RequestMapping("/api/opportunity/stage")
@RequiredArgsConstructor
public class OpportunityStageController {
private final OpportunityStageService stageService;
@Operation(summary = "阶段推进指引(三态进度:已完成/当前/未完成 + 当前节点工作目标)")
@GetMapping("/progress")
public Result<StageProgressDTO> progress(@RequestParam("oppId") Long oppId) {
return Result.success(stageService.getStageProgress(oppId));
}
@Operation(summary = "阶段切换(自由切换:连跳/回退;「已转项目」固定节点不可手动切)")
@PostMapping("/switch")
public Result<Void> switchStage(@RequestParam("oppId") Long oppId,
@RequestParam("toStageId") Long toStageId,
@RequestParam(value = "remark", required = false) String remark) {
stageService.switchStage(new SwitchStageCmd(oppId, toStageId,
Long.valueOf(SecurityUtils.getRequiredUserId()), remark));
return Result.success();
}
@Operation(summary = "阶段推进历史(按操作时间倒序)")
@GetMapping("/history")
public Result<List<StageHistoryDTO>> history(@RequestParam("oppId") Long oppId) {
return Result.success(stageService.listStageHistory(oppId));
}
}

40
crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/StageHistoryDTO.java

@ -0,0 +1,40 @@
package com.crm.opportunity.domain.dto;
import com.crm.base.domain.dto.BaseDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 商机阶段推进历史条目 06
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class StageHistoryDTO extends BaseDTO {
@Schema(description = "商机ID")
private Long oppId;
@Schema(description = "原阶段节点ID")
private Long fromStageId;
@Schema(description = "目标阶段节点ID")
private Long toStageId;
@Schema(description = "操作人ID(系统动作为 0)")
private Long operatorUserId;
@Schema(description = "操作时间")
private LocalDateTime operateTime;
@Schema(description = "推进说明")
private String remark;
public static StageHistoryDTO fromEntity(com.crm.opportunity.domain.entity.OpportunityStageHistory entity) {
StageHistoryDTO dto = new StageHistoryDTO();
org.springframework.beans.BeanUtils.copyProperties(entity, dto);
return dto;
}
}

74
crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/StageProgressDTO.java

@ -0,0 +1,74 @@
package com.crm.opportunity.domain.dto;
import com.crm.base.domain.dto.BaseDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
/**
* 商机阶段推进指引 06/ 原型 A3-1-1-1-1 概览页三态进度展示
* <p>按商机绑定的模板版本锁版本发新版不迁移展开有序节点
* 节点展示名 = customNodeName 优先空则前端按 stageDictCode 带字典名</p>
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class StageProgressDTO extends BaseDTO {
@Schema(description = "商机ID")
private Long oppId;
@Schema(description = "商机状态(轴1:1待领取 2推进中 3暂缓中 4已关闭 5已转项目)")
private Integer oppStatus;
@Schema(description = "绑定阶段模板版本行ID(版本级锁死)")
private Long stageTemplateId;
@Schema(description = "阶段模板版本号(冗余展示)")
private String stageTemplateVersion;
@Schema(description = "当前阶段节点ID")
private Long currentStageId;
@Schema(description = "当前节点工作目标(推进指引区展示)")
private String currentWorkGoal;
@Schema(description = "有序节点列表(按 seq_no 升序,末位为固定节点「已转项目」)")
private List<StageNode> nodes;
/**
* 单个阶段节点含三态进度标记
*/
@Data
public static class StageNode {
/** 已完成(位于当前节点之前) */
public static final String STATE_COMPLETED = "COMPLETED";
/** 当前所在节点 */
public static final String STATE_CURRENT = "CURRENT";
/** 未到达 */
public static final String STATE_PENDING = "PENDING";
@Schema(description = "节点ID(opportunity_stage_node.id)")
private Long nodeId;
@Schema(description = "阶段序号")
private Integer seqNo;
@Schema(description = "阶段字典值(引用「商机阶段」字典分组项)")
private String stageDictCode;
@Schema(description = "自定义节点名称(空则前端带字典名)")
private String customNodeName;
@Schema(description = "阶段工作目标")
private String workGoal;
@Schema(description = "固定节点:1固定(已转项目)0普通")
private Integer isFixed;
@Schema(description = "进度状态:COMPLETED已完成 / CURRENT当前 / PENDING未到达")
private String nodeState;
}
}

53
crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityStageHistory.java

@ -0,0 +1,53 @@
package com.crm.opportunity.domain.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.crm.base.domain.entity.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.hibernate.annotations.Comment;
import java.time.LocalDateTime;
/**
* 商机阶段推进快照 06
* <p> 2 业务阶段每次切换写一条对称轴 1 {@link OpportunityStatusHistory} INSERT 语义
* 自由切换连跳/回退每次留痕已转项目固定节点由方案卡转项目自动驱动时也写一条
* 操作人=系统身份 0L与通用字段级审计 opportunity_oplog 09并存不重复</p>
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("opportunity_stage_history")
@Entity
@Table(name = "opportunity_stage_history", indexes = {
@Index(name = "idx_stage_history_opp", columnList = "opp_id, operate_time")
})
public class OpportunityStageHistory extends BaseEntity {
@Comment("商机ID")
@Column(columnDefinition = "bigint not null comment '商机ID'")
private Long oppId;
@Comment("原阶段节点ID(opportunity_stage_node.id)")
@Column(columnDefinition = "bigint not null comment '原阶段节点ID'")
private Long fromStageId;
@Comment("目标阶段节点ID(opportunity_stage_node.id)")
@Column(columnDefinition = "bigint not null comment '目标阶段节点ID'")
private Long toStageId;
@Comment("操作人ID(方案卡转项目等系统动作传 0L)")
@Column(columnDefinition = "bigint not null comment '操作人ID'")
private Long operatorUserId;
@Comment("操作时间")
@Column(columnDefinition = "datetime not null comment '操作时间'")
private LocalDateTime operateTime;
@Comment("推进说明")
@Column(columnDefinition = "varchar(500) comment '推进说明'")
private String remark;
}

9
crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityStageHistoryMapper.java

@ -0,0 +1,9 @@
package com.crm.opportunity.mapper;
import com.crm.base.mapper.CrmBaseMapper;
import com.crm.opportunity.domain.entity.OpportunityStageHistory;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface OpportunityStageHistoryMapper extends CrmBaseMapper<OpportunityStageHistory> {
}

7
crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityConvertPortImpl.java

@ -8,6 +8,7 @@ import com.crm.opportunity.mapper.OpportunityMapper;
import com.crm.opportunity.port.inbound.ConvertToProjectCmd; import com.crm.opportunity.port.inbound.ConvertToProjectCmd;
import com.crm.opportunity.port.inbound.OpportunityConvertException; import com.crm.opportunity.port.inbound.OpportunityConvertException;
import com.crm.opportunity.port.inbound.OpportunityConvertPort; import com.crm.opportunity.port.inbound.OpportunityConvertPort;
import com.crm.opportunity.stage.OpportunityStageService;
import com.crm.opportunity.state.OpportunityTransition; import com.crm.opportunity.state.OpportunityTransition;
import com.crm.opportunity.state.OpportunityTransitionCmd; import com.crm.opportunity.state.OpportunityTransitionCmd;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -21,6 +22,9 @@ import org.springframework.transaction.annotation.Transactional;
* 幂等已终态(5) 安静跳过返回非推进中/不存在抛 {@link OpportunityConvertException} * 幂等已终态(5) 安静跳过返回非推进中/不存在抛 {@link OpportunityConvertException}
* 并发窗口状态机 CAS 失败先复查终态并发重复回调当幂等命中安静跳过 * 并发窗口状态机 CAS 失败先复查终态并发重复回调当幂等命中安静跳过
* 其余 CAS 冲突转译为同一端口异常保持对外单一异常契约code review 20260824 补丁</p> * 其余 CAS 冲突转译为同一端口异常保持对外单一异常契约code review 20260824 补丁</p>
*
* <p> 2 联动 06E8 成功后同步驱动 current_stage_id 绑定模板版本固定节点
* 已转项目并写阶段推进历史幂等命中路径不重复驱动首次转换时已落</p>
*/ */
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@ -31,6 +35,7 @@ public class OpportunityConvertPortImpl implements OpportunityConvertPort {
private final OpportunityMapper oppMapper; private final OpportunityMapper oppMapper;
private final OpportunityTransition transition; private final OpportunityTransition transition;
private final OpportunityStageService stageService;
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@ -50,6 +55,8 @@ public class OpportunityConvertPortImpl implements OpportunityConvertPort {
transition.execute(cmd.opportunityId(), transition.execute(cmd.opportunityId(),
new OpportunityTransitionCmd.ConvertToProjectCmd( new OpportunityTransitionCmd.ConvertToProjectCmd(
SYSTEM_OPERATOR, cmd.projectId(), cmd.schemeCardId())); SYSTEM_OPERATOR, cmd.projectId(), cmd.schemeCardId()));
// 轴 2 联动(票 06「四」):E8 成功 → 阶段驱动至固定节点「已转项目」
stageService.onConvertedToProject(opp);
} catch (BusinessErrorException e) { } catch (BusinessErrorException e) {
// 并发幂等补丁:CAS 失败可能是并发重复回调(对方事务已先完成 E8),复查终态则当幂等命中 // 并发幂等补丁:CAS 失败可能是并发重复回调(对方事务已先完成 E8),复查终态则当幂等命中
Opportunity latest = oppMapper.selectById(cmd.opportunityId()); Opportunity latest = oppMapper.selectById(cmd.opportunityId());

46
crm-opportunity/src/main/java/com/crm/opportunity/stage/OpportunityStageService.java

@ -0,0 +1,46 @@
package com.crm.opportunity.stage;
import com.crm.opportunity.domain.dto.StageHistoryDTO;
import com.crm.opportunity.domain.dto.StageProgressDTO;
import com.crm.opportunity.domain.entity.Opportunity;
import java.util.List;
/**
* 商机阶段 2运行时服务 06
*
* <p>阶段推进语义产品拍板已转项目固定节点外其余阶段可自由切换
* 连跳+回退每次写 {@code opportunity_stage_history}固定节点仅由
* 方案卡转项目创建成功 {@link #onConvertedToProject} 自动驱动
* 切换走 CAS current_stage_id防并发仅推进中可切暂缓中保留暂缓前阶段
* 已关闭/已转项目受限禁切 owner guard 01可见即可点权限全走权限点</p>
*/
public interface OpportunityStageService {
/**
* 阶段切换自由切换连跳/回退均可
* <ul>
* <li>仅推进中(2)可切暂缓中(3)保留暂缓前阶段已关闭(4)/已转项目(5)禁切</li>
* <li>目标节点须属商机绑定的模板版本且不得为固定节点</li>
* <li>目标=当前 幂等无操作</li>
* </ul>
*/
void switchStage(SwitchStageCmd cmd);
/**
* 阶段推进指引三态进度展示已完成/当前/未完成 + 当前节点工作目标
*/
StageProgressDTO getStageProgress(Long oppId);
/**
* 阶段推进历史按操作时间倒序
*/
List<StageHistoryDTO> listStageHistory(Long oppId);
/**
* 转项目联动钩子 06E8 状态迁移成功后由转项目端口调用
* current_stage_id 驱动到绑定模板版本的固定节点已转项目并写推进历史
* 操作人=系统身份 0L固定节点缺失脏数据时记告警跳过不阻断转项目
*/
void onConvertedToProject(Opportunity opp);
}

12
crm-opportunity/src/main/java/com/crm/opportunity/stage/SwitchStageCmd.java

@ -0,0 +1,12 @@
package com.crm.opportunity.stage;
/**
* 阶段切换命令 06
*
* @param oppId 商机 id
* @param toStageId 目标阶段节点 id须属商机绑定的模板版本且非固定节点
* @param operatorId 操作人权限点/团队成员白名单由入口层判定本层无 owner guard 01
* @param remark 推进说明可空
*/
public record SwitchStageCmd(Long oppId, Long toStageId, Long operatorId, String remark) {
}

204
crm-opportunity/src/main/java/com/crm/opportunity/stage/impl/OpportunityStageServiceImpl.java

@ -0,0 +1,204 @@
package com.crm.opportunity.stage.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.crm.base.domain.exception.BusinessErrorException;
import com.crm.opportunity.constant.OpportunityConstants;
import com.crm.opportunity.domain.dto.StageHistoryDTO;
import com.crm.opportunity.domain.dto.StageProgressDTO;
import com.crm.opportunity.domain.entity.Opportunity;
import com.crm.opportunity.domain.entity.OpportunityStageHistory;
import com.crm.opportunity.domain.enums.OpportunityStatus;
import com.crm.opportunity.mapper.OpportunityMapper;
import com.crm.opportunity.mapper.OpportunityStageHistoryMapper;
import com.crm.opportunity.stage.OpportunityStageService;
import com.crm.opportunity.stage.SwitchStageCmd;
import com.crm.base.utils.EnumUtils;
import com.crm.rule.constant.OpportunityRuleConstants;
import com.crm.rule.domain.entity.OpportunityStageNode;
import com.crm.rule.service.IOpportunityStageTemplateService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* 商机阶段 2运行时服务实现 06
*
* <p>切换 CAS{@code UPDATE opportunity SET current_stage_id=? WHERE id=? AND current_stage_id=?}
* 行数=0 即并发冲突对称轴 1 状态机 CAS 口径不锚乐观锁 version
* 每次成功切换/转项目联动写 {@code opportunity_stage_history} 快照</p>
*
* <p>节点查询经 {@link IOpportunityStageTemplateService}crm-opportunity crm-rule
* 单向依赖 01模板配置侧对商机实体零反依赖</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OpportunityStageServiceImpl implements OpportunityStageService {
/** 转项目联动为系统动作,操作人落系统身份 0L(对称 E8/E10 口径) */
private static final Long SYSTEM_OPERATOR = 0L;
private final OpportunityMapper oppMapper;
private final OpportunityStageHistoryMapper stageHistoryMapper;
private final IOpportunityStageTemplateService stageTemplateService;
// ==================== 阶段切换 ====================
@Override
@Transactional(rollbackFor = Exception.class)
public void switchStage(SwitchStageCmd cmd) {
if (cmd == null || cmd.oppId() == null || cmd.toStageId() == null || cmd.operatorId() == null) {
throw new BusinessErrorException(OpportunityConstants.CODE_OPP_INVALID, "阶段切换参数缺失");
}
Opportunity opp = getOppOrThrow(cmd.oppId());
OpportunityStatus status = EnumUtils.getByValue(OpportunityStatus.class, opp.getOppStatus());
if (status != OpportunityStatus.STATUS_ADVANCING) {
// 暂缓中保留暂缓前阶段(取消暂缓后可继续切换);已关闭/已转项目受限禁切(票 06「六」)
throw new BusinessErrorException(OpportunityConstants.CODE_STAGE_NOT_ALLOWED,
"仅推进中的商机可切换阶段,当前状态:" + opp.getOppStatus());
}
if (Objects.equals(cmd.toStageId(), opp.getCurrentStageId())) {
return; // 幂等无操作:目标即当前节点
}
OpportunityStageNode target = stageTemplateService.getNodeById(cmd.toStageId());
if (target == null) {
throw new BusinessErrorException(OpportunityConstants.CODE_STAGE_NODE_INVALID,
"目标阶段节点不存在或已被删除");
}
if (!Objects.equals(target.getTemplateId(), opp.getStageTemplateId())) {
throw new BusinessErrorException(OpportunityConstants.CODE_STAGE_NODE_INVALID,
"目标阶段不属于本商机绑定的阶段模板版本(绑定后不随模板发新版迁移)");
}
if (target.getIsFixed() != null && target.getIsFixed() == OpportunityRuleConstants.FLAG_YES) {
throw new BusinessErrorException(OpportunityConstants.CODE_STAGE_NODE_INVALID,
"「已转项目」为固定节点,仅由方案卡转项目自动驱动,不可手动切换");
}
int rows = oppMapper.update(null, new LambdaUpdateWrapper<Opportunity>()
.eq(Opportunity::getId, opp.getId())
.eq(Opportunity::getCurrentStageId, opp.getCurrentStageId())
.set(Opportunity::getCurrentStageId, cmd.toStageId()));
if (rows == 0) {
throw new BusinessErrorException(OpportunityConstants.CODE_CAS_FAIL,
"阶段切换失败:阶段可能已被他人变更,请刷新后重试");
}
insertHistory(opp.getId(), opp.getCurrentStageId(), cmd.toStageId(),
cmd.operatorId(), cmd.remark());
}
// ==================== 转项目联动钩子 ====================
@Override
@Transactional(rollbackFor = Exception.class)
public void onConvertedToProject(Opportunity opp) {
if (opp == null || opp.getStageTemplateId() == null) {
return;
}
OpportunityStageNode fixed = stageTemplateService.listNodesOfVersion(opp.getStageTemplateId())
.stream()
.filter(n -> n.getIsFixed() != null
&& n.getIsFixed() == OpportunityRuleConstants.FLAG_YES)
.findFirst()
.orElse(null);
if (fixed == null) {
// 脏数据防御:模板版本缺固定节点不阻断转项目(保存侧已规范化保证,理论不可达)
log.warn("商机 {} 绑定的阶段模板版本 {} 缺固定节点「已转项目」,跳过阶段联动",
opp.getId(), opp.getStageTemplateId());
return;
}
if (Objects.equals(opp.getCurrentStageId(), fixed.getId())) {
return; // 已在固定节点(幂等)
}
oppMapper.update(null, new LambdaUpdateWrapper<Opportunity>()
.eq(Opportunity::getId, opp.getId())
.set(Opportunity::getCurrentStageId, fixed.getId()));
insertHistory(opp.getId(), opp.getCurrentStageId(), fixed.getId(),
SYSTEM_OPERATOR, "方案卡转项目创建成功,自动推进至「已转项目」");
}
// ==================== 查询 ====================
@Override
public StageProgressDTO getStageProgress(Long oppId) {
Opportunity opp = getOppOrThrow(oppId);
List<OpportunityStageNode> nodes = stageTemplateService.listNodesOfVersion(opp.getStageTemplateId());
StageProgressDTO dto = new StageProgressDTO();
dto.setOppId(opp.getId());
dto.setOppStatus(opp.getOppStatus());
dto.setStageTemplateId(opp.getStageTemplateId());
dto.setStageTemplateVersion(opp.getStageTemplateVersion());
dto.setCurrentStageId(opp.getCurrentStageId());
int currentIndex = -1;
for (int i = 0; i < nodes.size(); i++) {
if (Objects.equals(nodes.get(i).getId(), opp.getCurrentStageId())) {
currentIndex = i;
break;
}
}
List<StageProgressDTO.StageNode> result = new ArrayList<>(nodes.size());
for (int i = 0; i < nodes.size(); i++) {
OpportunityStageNode node = nodes.get(i);
StageProgressDTO.StageNode item = new StageProgressDTO.StageNode();
item.setNodeId(node.getId());
item.setSeqNo(node.getSeqNo());
item.setStageDictCode(node.getStageDictCode());
item.setCustomNodeName(node.getCustomNodeName());
item.setWorkGoal(node.getWorkGoal());
item.setIsFixed(node.getIsFixed());
if (currentIndex < 0) {
item.setNodeState(StageProgressDTO.StageNode.STATE_PENDING); // 脏数据防御:当前节点不在版本内
} else if (i < currentIndex) {
item.setNodeState(StageProgressDTO.StageNode.STATE_COMPLETED);
} else if (i == currentIndex) {
item.setNodeState(StageProgressDTO.StageNode.STATE_CURRENT);
dto.setCurrentWorkGoal(node.getWorkGoal());
} else {
item.setNodeState(StageProgressDTO.StageNode.STATE_PENDING);
}
result.add(item);
}
dto.setNodes(result);
return dto;
}
@Override
public List<StageHistoryDTO> listStageHistory(Long oppId) {
return stageHistoryMapper.selectList(new LambdaQueryWrapper<OpportunityStageHistory>()
.eq(OpportunityStageHistory::getOppId, oppId)
.orderByDesc(OpportunityStageHistory::getOperateTime))
.stream().map(StageHistoryDTO::fromEntity).toList();
}
// ==================== 内部工具 ====================
private Opportunity getOppOrThrow(Long oppId) {
Opportunity opp = oppMapper.selectById(oppId);
if (opp == null) {
throw new BusinessErrorException(OpportunityConstants.CODE_OPP_NOT_EXIST, "商机不存在或已被删除");
}
return opp;
}
private void insertHistory(Long oppId, Long fromStageId, Long toStageId, Long operatorId, String remark) {
OpportunityStageHistory history = new OpportunityStageHistory();
history.setOppId(oppId);
history.setFromStageId(fromStageId);
history.setToStageId(toStageId);
history.setOperatorUserId(operatorId);
history.setOperateTime(LocalDateTime.now());
history.setRemark(remark);
stageHistoryMapper.insert(history);
}
}

11
crm-opportunity/src/test/java/com/crm/opportunity/port/inbound/OpportunityConvertPortImplTest.java

@ -6,6 +6,7 @@ import com.crm.opportunity.domain.entity.Opportunity;
import com.crm.opportunity.domain.enums.OpportunityStatus; import com.crm.opportunity.domain.enums.OpportunityStatus;
import com.crm.opportunity.mapper.OpportunityMapper; import com.crm.opportunity.mapper.OpportunityMapper;
import com.crm.opportunity.port.inbound.impl.OpportunityConvertPortImpl; import com.crm.opportunity.port.inbound.impl.OpportunityConvertPortImpl;
import com.crm.opportunity.stage.OpportunityStageService;
import com.crm.opportunity.state.OpportunityTransition; import com.crm.opportunity.state.OpportunityTransition;
import com.crm.opportunity.state.OpportunityTransitionCmd; import com.crm.opportunity.state.OpportunityTransitionCmd;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
@ -40,6 +41,7 @@ class OpportunityConvertPortImplTest {
@Mock private OpportunityMapper oppMapper; @Mock private OpportunityMapper oppMapper;
@Mock private OpportunityTransition transition; @Mock private OpportunityTransition transition;
@Mock private OpportunityStageService stageService;
@InjectMocks @InjectMocks
private OpportunityConvertPortImpl port; private OpportunityConvertPortImpl port;
@ -73,6 +75,8 @@ class OpportunityConvertPortImplTest {
.doesNotThrowAnyException(); .doesNotThrowAnyException();
verify(transition, never()).execute(anyLong(), any()); verify(transition, never()).execute(anyLong(), any());
// 幂等命中路径不重复驱动阶段联动(首次转换时已落,票 06「四」)
verify(stageService, never()).onConvertedToProject(any());
} }
@Test @Test
@ -89,14 +93,17 @@ class OpportunityConvertPortImplTest {
} }
@Test @Test
@DisplayName("推进中(2) → 调状态机 E8(系统身份 0L + projectId/schemeCardId 落快照)") @DisplayName("推进中(2) → 调状态机 E8(系统身份 0L + projectId/schemeCardId 落快照)+ 阶段联动驱动固定节点")
void convert_advancing_invokesTransitionWithSystemOperator() { void convert_advancing_invokesTransitionWithSystemOperator() {
when(oppMapper.selectById(OPP_ID)).thenReturn(oppAt(OpportunityStatus.STATUS_ADVANCING)); Opportunity opp = oppAt(OpportunityStatus.STATUS_ADVANCING);
when(oppMapper.selectById(OPP_ID)).thenReturn(opp);
port.convert(new ConvertToProjectCmd(OPP_ID, SCHEME_CARD_ID, PROJECT_ID)); port.convert(new ConvertToProjectCmd(OPP_ID, SCHEME_CARD_ID, PROJECT_ID));
verify(transition).execute(eq(OPP_ID), eq( verify(transition).execute(eq(OPP_ID), eq(
new OpportunityTransitionCmd.ConvertToProjectCmd(0L, PROJECT_ID, SCHEME_CARD_ID))); new OpportunityTransitionCmd.ConvertToProjectCmd(0L, PROJECT_ID, SCHEME_CARD_ID)));
// 轴 2 联动(票 06「四」):E8 成功后驱动阶段至固定节点「已转项目」
verify(stageService).onConvertedToProject(opp);
} }
@Test @Test

376
crm-opportunity/src/test/java/com/crm/opportunity/stage/impl/OpportunityStageServiceImplTest.java

@ -0,0 +1,376 @@
package com.crm.opportunity.stage.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.crm.base.domain.exception.BusinessErrorException;
import com.crm.opportunity.constant.OpportunityConstants;
import com.crm.opportunity.domain.dto.StageHistoryDTO;
import com.crm.opportunity.domain.dto.StageProgressDTO;
import com.crm.opportunity.domain.entity.Opportunity;
import com.crm.opportunity.domain.entity.OpportunityStageHistory;
import com.crm.opportunity.domain.enums.OpportunityStatus;
import com.crm.opportunity.mapper.OpportunityMapper;
import com.crm.opportunity.mapper.OpportunityStageHistoryMapper;
import com.crm.opportunity.stage.SwitchStageCmd;
import com.crm.rule.constant.OpportunityRuleConstants;
import com.crm.rule.domain.entity.OpportunityStageNode;
import com.crm.rule.service.IOpportunityStageTemplateService;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
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 java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* 商机阶段 2运行时服务规格验证 06
*
* <p>断言点</p>
* <ul>
* <li>切换守卫仅推进中(2)可切暂缓/关闭/已转项目 66005目标=当前幂等无操作</li>
* <li>目标节点校验不存在 / 不属绑定模板版本 / 固定节点 66006</li>
* <li>CAS{@code current_stage_id} 锚冲突 66004成功写历史快照</li>
* <li>转项目联动驱动固定节点 + 系统操作人 0L缺固定节点告警跳过不阻断已在固定节点幂等</li>
* <li>进度展示三态COMPLETED/CURRENT/PENDING+ 当前节点工作目标脏数据全 PENDING 防御</li>
* </ul>
*/
@DisplayName("商机阶段运行时服务(票 06「五」「六」)")
@ExtendWith(MockitoExtension.class)
class OpportunityStageServiceImplTest {
private static final Long OPP_ID = 7001L;
private static final Long TPL_VERSION_ID = 8001L;
private static final Long NODE_A = 9001L;
private static final Long NODE_B = 9002L;
private static final Long NODE_FIXED = 9003L;
private static final Long OPERATOR = 55L;
@Mock private OpportunityMapper oppMapper;
@Mock private OpportunityStageHistoryMapper stageHistoryMapper;
@Mock private IOpportunityStageTemplateService stageTemplateService;
@InjectMocks
private OpportunityStageServiceImpl service;
@BeforeAll
static void initLambdaCache() {
MapperBuilderAssistant assistant =
new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, Opportunity.class);
TableInfoHelper.initTableInfo(assistant, OpportunityStageHistory.class);
}
// ==================== 工厂 ====================
private Opportunity opp(OpportunityStatus status, Long currentStageId) {
Opportunity opp = new Opportunity();
opp.setId(OPP_ID);
opp.setOppStatus(status.getValue());
opp.setStageTemplateId(TPL_VERSION_ID);
opp.setCurrentStageId(currentStageId);
return opp;
}
private OpportunityStageNode node(Long id, Long tplVersionId, int seqNo, String dictCode, int isFixed) {
OpportunityStageNode n = new OpportunityStageNode();
n.setId(id);
n.setTemplateId(tplVersionId);
n.setSeqNo(seqNo);
n.setStageDictCode(dictCode);
n.setCustomNodeName("节点" + dictCode);
n.setWorkGoal("目标" + dictCode);
n.setIsFixed(isFixed);
return n;
}
// ==================== 阶段切换守卫(票 06「六」) ====================
@Test
@DisplayName("参数缺失 → 拒绝 66001,不查库")
void switchStage_nullParams_rejected() {
assertThatThrownBy(() -> service.switchStage(null))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityConstants.CODE_OPP_INVALID);
assertThatThrownBy(() -> service.switchStage(new SwitchStageCmd(OPP_ID, null, OPERATOR, null)))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityConstants.CODE_OPP_INVALID);
verifyNoInteractions(oppMapper);
}
@Test
@DisplayName("商机不存在 → 拒绝 66002")
void switchStage_oppNotExist_rejected() {
when(oppMapper.selectById(OPP_ID)).thenReturn(null);
assertThatThrownBy(() -> service.switchStage(new SwitchStageCmd(OPP_ID, NODE_B, OPERATOR, null)))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityConstants.CODE_OPP_NOT_EXIST);
}
@Test
@DisplayName("守卫:暂缓中保留阶段 → 拒绝 66005(取消暂缓后可继续切换)")
void switchStage_paused_rejected() {
when(oppMapper.selectById(OPP_ID)).thenReturn(opp(OpportunityStatus.STATUS_PAUSED, NODE_A));
assertThatThrownBy(() -> service.switchStage(new SwitchStageCmd(OPP_ID, NODE_B, OPERATOR, null)))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityConstants.CODE_STAGE_NOT_ALLOWED);
verify(oppMapper, never()).update(any(), any());
}
@Test
@DisplayName("守卫:已关闭/已转项目禁切 → 拒绝 66005")
void switchStage_closedOrConverted_rejected() {
when(oppMapper.selectById(OPP_ID)).thenReturn(opp(OpportunityStatus.STATUS_CLOSED, NODE_A));
assertThatThrownBy(() -> service.switchStage(new SwitchStageCmd(OPP_ID, NODE_B, OPERATOR, null)))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityConstants.CODE_STAGE_NOT_ALLOWED);
when(oppMapper.selectById(OPP_ID)).thenReturn(opp(OpportunityStatus.STATUS_CONVERTED, NODE_FIXED));
assertThatThrownBy(() -> service.switchStage(new SwitchStageCmd(OPP_ID, NODE_B, OPERATOR, null)))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityConstants.CODE_STAGE_NOT_ALLOWED);
}
@Test
@DisplayName("幂等:目标即当前节点 → 无操作直接返回,不触达更新/历史")
void switchStage_sameTarget_noOp() {
when(oppMapper.selectById(OPP_ID)).thenReturn(opp(OpportunityStatus.STATUS_ADVANCING, NODE_A));
service.switchStage(new SwitchStageCmd(OPP_ID, NODE_A, OPERATOR, null));
verify(oppMapper, never()).update(any(), any());
verify(stageHistoryMapper, never()).insert(any(OpportunityStageHistory.class));
}
// ==================== 目标节点校验 ====================
@Test
@DisplayName("目标节点不存在 → 拒绝 66006")
void switchStage_nodeNotExist_rejected() {
when(oppMapper.selectById(OPP_ID)).thenReturn(opp(OpportunityStatus.STATUS_ADVANCING, NODE_A));
when(stageTemplateService.getNodeById(NODE_B)).thenReturn(null);
assertThatThrownBy(() -> service.switchStage(new SwitchStageCmd(OPP_ID, NODE_B, OPERATOR, null)))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityConstants.CODE_STAGE_NODE_INVALID);
}
@Test
@DisplayName("目标节点不属绑定模板版本(锁版本,不随发新版迁移)→ 拒绝 66006")
void switchStage_nodeOfOtherVersion_rejected() {
when(oppMapper.selectById(OPP_ID)).thenReturn(opp(OpportunityStatus.STATUS_ADVANCING, NODE_A));
when(stageTemplateService.getNodeById(NODE_B)).thenReturn(
node(NODE_B, 8999L, 1, "OPP_STAGE_02", OpportunityRuleConstants.FLAG_NO));
assertThatThrownBy(() -> service.switchStage(new SwitchStageCmd(OPP_ID, NODE_B, OPERATOR, null)))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityConstants.CODE_STAGE_NODE_INVALID);
}
@Test
@DisplayName("目标为固定节点「已转项目」→ 拒绝 66006(仅方案卡转项目自动驱动)")
void switchStage_fixedNode_rejected() {
when(oppMapper.selectById(OPP_ID)).thenReturn(opp(OpportunityStatus.STATUS_ADVANCING, NODE_A));
when(stageTemplateService.getNodeById(NODE_FIXED)).thenReturn(
node(NODE_FIXED, TPL_VERSION_ID, 3, OpportunityRuleConstants.FIXED_STAGE_DICT_CODE,
OpportunityRuleConstants.FLAG_YES));
assertThatThrownBy(() -> service.switchStage(new SwitchStageCmd(OPP_ID, NODE_FIXED, OPERATOR, null)))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityConstants.CODE_STAGE_NODE_INVALID)
.hasMessageContaining("固定节点");
}
// ==================== CAS 与历史快照 ====================
@Test
@DisplayName("CAS 冲突(阶段已被他人变更)→ 拒绝 66004,不写历史")
void switchStage_casFail_rejected() {
when(oppMapper.selectById(OPP_ID)).thenReturn(opp(OpportunityStatus.STATUS_ADVANCING, NODE_A));
when(stageTemplateService.getNodeById(NODE_B)).thenReturn(
node(NODE_B, TPL_VERSION_ID, 2, "OPP_STAGE_02", OpportunityRuleConstants.FLAG_NO));
when(oppMapper.update(any(), any())).thenReturn(0);
assertThatThrownBy(() -> service.switchStage(new SwitchStageCmd(OPP_ID, NODE_B, OPERATOR, null)))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityConstants.CODE_CAS_FAIL);
verify(stageHistoryMapper, never()).insert(any(OpportunityStageHistory.class));
}
@Test
@DisplayName("成功切换(连跳/回退皆可):CAS 更新 current_stage_id + 写历史快照")
void switchStage_success_updatesAndWritesHistory() {
when(oppMapper.selectById(OPP_ID)).thenReturn(opp(OpportunityStatus.STATUS_ADVANCING, NODE_A));
when(stageTemplateService.getNodeById(NODE_B)).thenReturn(
node(NODE_B, TPL_VERSION_ID, 2, "OPP_STAGE_02", OpportunityRuleConstants.FLAG_NO));
when(oppMapper.update(any(), any())).thenReturn(1);
service.switchStage(new SwitchStageCmd(OPP_ID, NODE_B, OPERATOR, "跳过摸排直入采集"));
verify(oppMapper).update(any(), any());
ArgumentCaptor<OpportunityStageHistory> captor = ArgumentCaptor.forClass(OpportunityStageHistory.class);
verify(stageHistoryMapper).insert(captor.capture());
OpportunityStageHistory history = captor.getValue();
assertThat(history.getOppId()).isEqualTo(OPP_ID);
assertThat(history.getFromStageId()).isEqualTo(NODE_A);
assertThat(history.getToStageId()).isEqualTo(NODE_B);
assertThat(history.getOperatorUserId()).isEqualTo(OPERATOR);
assertThat(history.getRemark()).isEqualTo("跳过摸排直入采集");
assertThat(history.getOperateTime()).isNotNull();
}
// ==================== 转项目联动钩子(票 06「四」) ====================
@Test
@DisplayName("联动:商机或绑定版本为空 → 安静跳过")
void onConverted_nullSafe() {
assertThatCode(() -> service.onConvertedToProject(null)).doesNotThrowAnyException();
Opportunity unbound = new Opportunity();
unbound.setId(OPP_ID);
assertThatCode(() -> service.onConvertedToProject(unbound)).doesNotThrowAnyException();
verify(oppMapper, never()).update(any(), any());
}
@Test
@DisplayName("联动:驱动至固定节点 + 系统操作人 0L + 自动推进备注")
void onConverted_drivesToFixedNodeWithSystemOperator() {
Opportunity opp = opp(OpportunityStatus.STATUS_CONVERTED, NODE_B);
when(stageTemplateService.listNodesOfVersion(TPL_VERSION_ID)).thenReturn(List.of(
node(NODE_A, TPL_VERSION_ID, 1, "OPP_STAGE_01", OpportunityRuleConstants.FLAG_NO),
node(NODE_B, TPL_VERSION_ID, 2, "OPP_STAGE_02", OpportunityRuleConstants.FLAG_NO),
node(NODE_FIXED, TPL_VERSION_ID, 3, OpportunityRuleConstants.FIXED_STAGE_DICT_CODE,
OpportunityRuleConstants.FLAG_YES)));
service.onConvertedToProject(opp);
verify(oppMapper).update(any(), any());
ArgumentCaptor<OpportunityStageHistory> captor = ArgumentCaptor.forClass(OpportunityStageHistory.class);
verify(stageHistoryMapper).insert(captor.capture());
OpportunityStageHistory history = captor.getValue();
assertThat(history.getFromStageId()).isEqualTo(NODE_B);
assertThat(history.getToStageId()).isEqualTo(NODE_FIXED);
assertThat(history.getOperatorUserId()).isZero(); // 系统身份 0L(对称 E8 口径)
assertThat(history.getRemark()).contains("已转项目");
}
@Test
@DisplayName("联动:已在固定节点 → 幂等不重复写历史")
void onConverted_alreadyAtFixed_noOp() {
Opportunity opp = opp(OpportunityStatus.STATUS_CONVERTED, NODE_FIXED);
when(stageTemplateService.listNodesOfVersion(TPL_VERSION_ID)).thenReturn(List.of(
node(NODE_FIXED, TPL_VERSION_ID, 3, OpportunityRuleConstants.FIXED_STAGE_DICT_CODE,
OpportunityRuleConstants.FLAG_YES)));
service.onConvertedToProject(opp);
verify(oppMapper, never()).update(any(), any());
verify(stageHistoryMapper, never()).insert(any(OpportunityStageHistory.class));
}
@Test
@DisplayName("联动:绑定版本缺固定节点(脏数据)→ 告警跳过,不阻断转项目")
void onConverted_missingFixedNode_skipsSilently() {
Opportunity opp = opp(OpportunityStatus.STATUS_CONVERTED, NODE_A);
when(stageTemplateService.listNodesOfVersion(TPL_VERSION_ID)).thenReturn(List.of(
node(NODE_A, TPL_VERSION_ID, 1, "OPP_STAGE_01", OpportunityRuleConstants.FLAG_NO)));
assertThatCode(() -> service.onConvertedToProject(opp)).doesNotThrowAnyException();
verify(oppMapper, never()).update(any(), any());
verify(stageHistoryMapper, never()).insert(any(OpportunityStageHistory.class));
}
// ==================== 进度展示(票 06「六」三态) ====================
@Test
@DisplayName("进度:已完成/当前/未完成三态 + 当前节点工作目标回显")
void getStageProgress_threeStates() {
when(oppMapper.selectById(OPP_ID)).thenReturn(opp(OpportunityStatus.STATUS_ADVANCING, NODE_B));
when(stageTemplateService.listNodesOfVersion(TPL_VERSION_ID)).thenReturn(List.of(
node(NODE_A, TPL_VERSION_ID, 1, "OPP_STAGE_01", OpportunityRuleConstants.FLAG_NO),
node(NODE_B, TPL_VERSION_ID, 2, "OPP_STAGE_02", OpportunityRuleConstants.FLAG_NO),
node(NODE_FIXED, TPL_VERSION_ID, 3, OpportunityRuleConstants.FIXED_STAGE_DICT_CODE,
OpportunityRuleConstants.FLAG_YES)));
StageProgressDTO dto = service.getStageProgress(OPP_ID);
assertThat(dto.getOppId()).isEqualTo(OPP_ID);
assertThat(dto.getCurrentStageId()).isEqualTo(NODE_B);
assertThat(dto.getCurrentWorkGoal()).isEqualTo("目标OPP_STAGE_02");
assertThat(dto.getNodes()).hasSize(3);
assertThat(dto.getNodes()).extracting(StageProgressDTO.StageNode::getNodeState)
.containsExactly(StageProgressDTO.StageNode.STATE_COMPLETED,
StageProgressDTO.StageNode.STATE_CURRENT,
StageProgressDTO.StageNode.STATE_PENDING);
}
@Test
@DisplayName("进度:当前节点不在绑定版本内(脏数据防御)→ 全部 PENDING")
void getStageProgress_dirtyCurrentStage_allPending() {
when(oppMapper.selectById(OPP_ID)).thenReturn(opp(OpportunityStatus.STATUS_ADVANCING, 99999L));
when(stageTemplateService.listNodesOfVersion(TPL_VERSION_ID)).thenReturn(List.of(
node(NODE_A, TPL_VERSION_ID, 1, "OPP_STAGE_01", OpportunityRuleConstants.FLAG_NO)));
StageProgressDTO dto = service.getStageProgress(OPP_ID);
assertThat(dto.getNodes()).extracting(StageProgressDTO.StageNode::getNodeState)
.containsExactly(StageProgressDTO.StageNode.STATE_PENDING);
assertThat(dto.getCurrentWorkGoal()).isNull();
}
@Test
@DisplayName("进度:商机不存在 → 拒绝 66002")
void getStageProgress_oppNotExist_rejected() {
when(oppMapper.selectById(OPP_ID)).thenReturn(null);
assertThatThrownBy(() -> service.getStageProgress(OPP_ID))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityConstants.CODE_OPP_NOT_EXIST);
}
// ==================== 历史查询 ====================
@Test
@DisplayName("历史:按操作时间倒序映射 DTO")
void listStageHistory_mapsToDto() {
OpportunityStageHistory h = new OpportunityStageHistory();
h.setId(1L);
h.setOppId(OPP_ID);
h.setFromStageId(NODE_A);
h.setToStageId(NODE_B);
h.setOperatorUserId(OPERATOR);
h.setOperateTime(LocalDateTime.of(2026, 8, 24, 10, 0));
h.setRemark("推进");
when(stageHistoryMapper.selectList(any())).thenReturn(List.of(h));
List<StageHistoryDTO> result = service.listStageHistory(OPP_ID);
assertThat(result).hasSize(1);
StageHistoryDTO dto = result.get(0);
assertThat(dto.getOppId()).isEqualTo(OPP_ID);
assertThat(dto.getFromStageId()).isEqualTo(NODE_A);
assertThat(dto.getToStageId()).isEqualTo(NODE_B);
assertThat(dto.getOperatorUserId()).isEqualTo(OPERATOR);
assertThat(dto.getRemark()).isEqualTo("推进");
}
}

7
crm-rule/pom.xml

@ -15,7 +15,7 @@
<name>crm-rule</name> <name>crm-rule</name>
<description>CRM 线索规则模块:公海池配置(归属部门/负责市场/人员/领取规则/时效上限)+ 行政区划查找表(sys_region)</description> <description>CRM 线索规则模块:公海池配置(归属部门/负责市场/人员/领取规则/时效上限)+ 行政区划查找表(sys_region)</description>
<!-- 依赖方向单向:crm-rule → (crm-auth / crm-base),不得反向依赖 crm-lead --> <!-- 依赖方向单向:crm-rule → (crm-auth / crm-dict / crm-base)(票 01 定稿),不得反向依赖 crm-lead/crm-opportunity -->
<dependencies> <dependencies>
<dependency> <dependency>
<groupId>com.crm</groupId> <groupId>com.crm</groupId>
@ -25,6 +25,11 @@
<groupId>com.crm</groupId> <groupId>com.crm</groupId>
<artifactId>crm-auth</artifactId> <artifactId>crm-auth</artifactId>
</dependency> </dependency>
<!-- 商机阶段模板节点引用「商机阶段」字典项(票 06「二」字典状态规则:仅启用项可新增引用) -->
<dependency>
<groupId>com.crm</groupId>
<artifactId>crm-dict</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>

43
crm-rule/src/main/java/com/crm/rule/config/OpportunityStageTemplatePermissionInitializer.java

@ -0,0 +1,43 @@
package com.crm.rule.config;
import com.crm.base.domain.dto.PermissionModuleDescriptor;
import com.crm.base.service.PermissionSeeder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 商机阶段模板权限种子化仅菜单 06
* <p> {@link OpportunityRulePermissionInitializer}公海与提醒规则分列同属商机规则子域
* 菜单挂商机管理目录原型 A7-3-2-1 商机阶段设置同策略不种子化 button
* ApiPermissionInterceptor 对未注册 URL fail-open</p>
*/
@Slf4j
@Component
@Order(14)
@RequiredArgsConstructor
public class OpportunityStageTemplatePermissionInitializer implements CommandLineRunner {
private final PermissionSeeder permissionSeeder;
@Override
public void run(String... args) {
log.info("执行商机阶段模板权限种子化检查(仅菜单)...");
permissionSeeder.seedModule(new PermissionModuleDescriptor(
"商机管理",
"商机阶段设置",
"/opportunity/stage-template",
"opportunity/stage-template/index",
6,
List.of(), // 不种子化 button,由管理员后续配置
List.of("ROLE_ADMIN")
));
log.info("商机阶段模板权限种子化检查完成:仅菜单(0 个 button)");
}
}

36
crm-rule/src/main/java/com/crm/rule/constant/OpportunityRuleConstants.java

@ -1,10 +1,11 @@
package com.crm.rule.constant; package com.crm.rule.constant;
/** /**
* 商机规则模块常量公海规则配置 05 / PRD §5 * 商机规则模块常量公海规则配置 05 + 阶段模板 06 / PRD §5-§6
* *
* <p> {@link RuleConstants}线索规则子域分域维护商机规则业务错误码同段 64xxx 顺延 * <p> {@link RuleConstants}线索规则子域分域维护商机规则业务错误码同段 64xxx 顺延
* 64008 切片 B 版本状态机校验时启用</p> * 64008-64012 公海规则64013 起阶段模板版本状态/适用范围/是否标记为票 05/06/07 共享的
* 版本化配置公共约定 V-CONFIG 值域 06</p>
*/ */
public interface OpportunityRuleConstants { public interface OpportunityRuleConstants {
@ -36,6 +37,20 @@ public interface OpportunityRuleConstants {
String RULE_CODE_PREFIX = "POOL_RULE_"; String RULE_CODE_PREFIX = "POOL_RULE_";
/*-------- 阶段模板(票 06):编码前缀 + 固定节点 ---------*/
/** 阶段模板编码前缀(系统生成,序号顺延,V-CONFIG 1) */
String TEMPLATE_CODE_PREFIX = "OPP_STAGE_TPL_";
/** 「已转项目」固定节点字典 code(票 06「四」:每模板自带、不可删、仅方案卡转项目驱动到达) */
String FIXED_STAGE_DICT_CODE = "OPP_STAGE_05";
/** 「商机阶段」字典分组 code(节点引用源,票 06「二」:仅启用项可新增引用) */
String STAGE_DICT_GROUP_CODE = "opp_stage";
/** 「已转项目」固定节点展示名(custom_node_name 为空时的字典名兜底) */
String FIXED_STAGE_NAME = "已转项目";
/*-------- 业务错误码(64xxx,接 RuleConstants 64007 顺延) --------*/ /*-------- 业务错误码(64xxx,接 RuleConstants 64007 顺延) --------*/
/** 规则参数/状态校验失败 */ /** 规则参数/状态校验失败 */
@ -52,4 +67,21 @@ public interface OpportunityRuleConstants {
/** 发布时适用部门与其它发布中规则重叠(部门覆盖唯一) */ /** 发布时适用部门与其它发布中规则重叠(部门覆盖唯一) */
int CODE_OPP_RULE_DEPT_CONFLICT = 64012; int CODE_OPP_RULE_DEPT_CONFLICT = 64012;
/*-------- 阶段模板业务错误码(票 06,64013 起顺延) --------*/
/** 模板参数/状态校验失败 */
int CODE_STAGE_TPL_INVALID = 64013;
/** 模板版本不存在 */
int CODE_STAGE_TPL_NOT_EXIST = 64014;
/** 仅草稿可删(发布中、已停用不可直接删) */
int CODE_STAGE_TPL_NOT_DRAFT = 64015;
/** 同模板编码已存在草稿(编辑发布中/停用版本时不再生成新草稿) */
int CODE_STAGE_TPL_DRAFT_EXISTS = 64016;
/** 发布校验失败:阶段节点配置不完整(无普通节点/字典 code 缺失) */
int CODE_STAGE_TPL_NODE_INVALID = 64017;
} }

76
crm-rule/src/main/java/com/crm/rule/controller/OpportunityStageTemplateController.java

@ -0,0 +1,76 @@
package com.crm.rule.controller;
import com.crm.base.domain.result.PageResult;
import com.crm.base.domain.result.Result;
import com.crm.rule.domain.dto.OpportunityStageTemplateDTO;
import com.crm.rule.domain.param.OpportunityStageTemplatePageParam;
import com.crm.rule.service.IOpportunityStageTemplateService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 商机阶段模板接口薄适配层只调 {@link IOpportunityStageTemplateService} 06
* <p>权限策略不种子化 button 权限点同公海规则ApiPermissionInterceptor 对未注册
* URL fail-open行操作矩阵草稿=详情·编辑·删除发布中/停用=详情·编辑·复制由服务层
* 状态机校验兜底原型列表列绑定商机(数量)归商机域统计不在本接口回显</p>
*/
@Tag(name = "商机管理/商机规则")
@RestController
@RequestMapping("/api/rule/opp-stage-template")
@RequiredArgsConstructor
public class OpportunityStageTemplateController {
private final IOpportunityStageTemplateService templateService;
@Operation(summary = "阶段模板分页列表(按版本维度展示)")
@PostMapping("/page")
public Result<PageResult<OpportunityStageTemplateDTO>> page(OpportunityStageTemplatePageParam param) {
return Result.success(templateService.pageTemplates(param));
}
@Operation(summary = "阶段模板详情(含适用部门 + 阶段节点有序列表)")
@GetMapping("/detail")
public Result<OpportunityStageTemplateDTO> detail(@RequestParam("id") Long id) {
return Result.success(templateService.getTemplateDetail(id));
}
@Operation(summary = "保存草稿(新建/原位编辑草稿/从发布中停用生成新草稿;节点全量替换)")
@PostMapping("/save-draft")
public Result<Void> saveDraft(OpportunityStageTemplateDTO dto) {
templateService.saveDraft(dto);
return Result.success();
}
@Operation(summary = "保存并发布(除固定节点外至少一个阶段节点;原发布中转停用、原默认取消默认)")
@PostMapping("/publish")
public Result<Void> publish(OpportunityStageTemplateDTO dto) {
templateService.saveAndPublish(dto);
return Result.success();
}
@Operation(summary = "复制模板(独立新模板:新编码 + V1.0 草稿,节点随复制)")
@PostMapping("/copy")
public Result<Long> copy(@RequestParam("id") Long id) {
return Result.success(templateService.copyTemplate(id));
}
@Operation(summary = "停用(仅发布中可停用)")
@PostMapping("/disable")
public Result<Void> disable(@RequestParam("id") Long id) {
templateService.disableTemplate(id);
return Result.success();
}
@Operation(summary = "删除(仅草稿可删,级联删节点与适用部门)")
@PostMapping("/delete")
public Result<Void> delete(@RequestParam("id") Long id) {
templateService.deleteDraft(id);
return Result.success();
}
}

91
crm-rule/src/main/java/com/crm/rule/domain/dto/OpportunityStageTemplateDTO.java

@ -0,0 +1,91 @@
package com.crm.rule.domain.dto;
import com.crm.base.domain.dto.BaseDTO;
import com.crm.rule.domain.entity.OpportunityStageTemplate;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
/**
* 商机阶段模板传输对象写入参 + 出参双向ADR-0017 Route A 06
*
* <p>templateCode/versionNo/status 系统生成只读入参侧忽略出参侧回显
* 适用部门以 ID 列表随主对象上送/回显阶段节点以 {@link NodeItem} 有序列表随主对象
* 整体提交节点级"保存当前选中阶段"为前端本地态落库粒度=整版节点全量替换
* Service 层负责拆写子表</p>
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class OpportunityStageTemplateDTO extends BaseDTO {
@Schema(description = "模板编码(系统生成,只读)")
private String templateCode;
@Schema(description = "模板名称")
private String templateName;
@Schema(description = "版本号(系统生成只读:V1.0/V1.1…)")
private String versionNo;
@Schema(description = "状态:1草稿 2发布中 3已停用(只读)")
private Integer status;
@Schema(description = "适用范围:1所有商机 2指定部门(两档单选)")
private Integer applyScope;
@Schema(description = "是否默认:1是 0否(仅所有商机范围可设)")
private Integer isDefault;
@Schema(description = "模板说明")
private String templateDesc;
@Schema(description = "适用部门ID列表(applyScope=2 时必填至少一个)")
private List<Long> deptIds;
@Schema(description = "适用部门名称列表(出参回显)")
private List<String> deptNames;
@Schema(description = "阶段节点有序列表(序号按列表顺序重算;固定节点系统保证,可省略)")
private List<NodeItem> nodes;
public static OpportunityStageTemplateDTO fromEntity(OpportunityStageTemplate entity) {
OpportunityStageTemplateDTO dto = new OpportunityStageTemplateDTO();
org.springframework.beans.BeanUtils.copyProperties(entity, dto);
return dto;
}
public OpportunityStageTemplate toEntity() {
OpportunityStageTemplate entity = new OpportunityStageTemplate();
org.springframework.beans.BeanUtils.copyProperties(this, entity);
return entity;
}
/**
* 阶段节点项节点配置字典值必填 + 自定义名 + 工作目标
* <p>入参侧 isFixed 不可伪造服务层规范化统一保证唯一固定节点字典值恒
* {@code OPP_STAGE_05}末位出参侧回显真实 isFixed</p>
*/
@Data
public static class NodeItem {
@Schema(description = "节点ID(出参回显;新建无)")
private Long id;
@Schema(description = "阶段序号(出参回显;入参按列表顺序重算)")
private Integer seqNo;
@Schema(description = "阶段字典值(必填,引用「商机阶段」字典分组项)")
private String stageDictCode;
@Schema(description = "自定义节点名称(空则带入字典名)")
private String customNodeName;
@Schema(description = "阶段工作目标")
private String workGoal;
@Schema(description = "固定节点:1固定(已转项目)0普通(出参回显,入参忽略)")
private Integer isFixed;
}
}

62
crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunityStageNode.java

@ -0,0 +1,62 @@
package com.crm.rule.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.Data;
import org.hibernate.annotations.Comment;
/**
* 商机阶段节点 06从属模板版本
*
* <p>一个模板版本 N 个有序节点轨道图客户圈定关系摸排已转项目
* {@code template_id} 指向版本行 code 每版本一套节点快照
* 节点引用商机阶段字典分组项{@code stage_dict_code}模板内可覆盖显示名
* {@code custom_node_name}/工作目标不反改字典</p>
*
* <p>固定节点{@code is_fixed=1}字典值恒为 {@code OPP_STAGE_05} 已转项目每模板
* 有且仅一个位于末位不可删不可拖到非末位仅由方案卡转项目创建成功自动驱动
* 到达 06保存时由服务层规范化保证</p>
*/
@Data
@TableName("opportunity_stage_node")
@Entity
@Table(name = "opportunity_stage_node", indexes = {
@Index(name = "idx_stage_node_template", columnList = "template_id, seq_no")
})
public class OpportunityStageNode {
@TableId(type = IdType.ASSIGN_ID)
@Id
@Column(name = "id", columnDefinition = "bigint comment '主键'")
private Long id;
@Comment("所属模板版本ID(opportunity_stage_template.id,版本级非 code 级)")
@Column(columnDefinition = "bigint not null comment '所属模板版本ID'")
private Long templateId;
@Comment("阶段序号(推进顺序,保存时按节点顺序统一重算)")
@Column(columnDefinition = "int not null comment '阶段序号'")
private Integer seqNo;
@Comment("阶段字典值(引用「商机阶段」字典分组项,如 OPP_STAGE_01;仅引用不反写字典)")
@Column(columnDefinition = "varchar(64) not null comment '阶段字典值'")
private String stageDictCode;
@Comment("自定义节点名称(本模板实际展示名;空则带入字典名)")
@Column(columnDefinition = "varchar(100) comment '自定义节点名称'")
private String customNodeName;
@Comment("阶段工作目标(进入本阶段要完成的工作/业务目标)")
@Column(columnDefinition = "varchar(500) comment '阶段工作目标'")
private String workGoal;
@Comment("固定节点:1=固定(已转项目)0=普通")
@Column(columnDefinition = "tinyint not null default 0 comment '固定节点:1固定 0普通'")
private Integer isFixed;
}

57
crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunityStageTemplate.java

@ -0,0 +1,57 @@
package com.crm.rule.domain.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.crm.base.domain.entity.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.hibernate.annotations.Comment;
/**
* 商机阶段模板头表版本化配置 06/ PRD §6
*
* <p> 2 业务阶段的配置主体同一 {@code template_code} 一个模板多版本每版本一行
* 版本号系统生成只读V1.0 V1.1 minor 顺延版本状态机约束 code 仅一草稿一发布中
* 默认唯一由服务层校验主表不建库级唯一约束template_code 本就允许多版本共存</p>
*
* <p> {@link OpportunityPoolRule} 05共享 V-CONFIG 版本化配置范式仅语义从
* "规则""模板"不共表不共基类 06适用范围两档单选1=所有商机 / 2=指定部门
* 本期不做"指定业务类型"绑定实体锁版本行 idV-CONFIG 12发新版不迁移在途商机</p>
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("opportunity_stage_template")
@Entity
@Table(name = "opportunity_stage_template")
public class OpportunityStageTemplate extends BaseEntity {
@Comment("模板编码(系统生成,同模板多版本共用,如 OPP_STAGE_TPL_01)")
@Column(columnDefinition = "varchar(64) not null comment '模板编码'")
private String templateCode;
@Comment("模板名称(同模板多版本沿用同名)")
@Column(columnDefinition = "varchar(100) not null comment '模板名称'")
private String templateName;
@Comment("版本号(系统生成只读:V1.0/V1.1/V2.0…)")
@Column(columnDefinition = "varchar(20) not null comment '版本号'")
private String versionNo;
@Comment("状态:1=草稿 2=发布中 3=已停用(V-CONFIG 3-5)")
@Column(columnDefinition = "tinyint not null comment '状态:1草稿 2发布中 3已停用'")
private Integer status;
@Comment("适用范围:1=所有商机 2=指定部门(两档单选,票 06 产品拍板)")
@Column(columnDefinition = "tinyint not null comment '适用范围:1所有商机 2指定部门'")
private Integer applyScope;
@Comment("是否默认:1=是 0=否;仅所有商机范围可设,发布中默认唯一(V-CONFIG 6)")
@Column(columnDefinition = "tinyint not null default 0 comment '是否默认'")
private Integer isDefault;
@Comment("模板说明(业务用途/适用场景)")
@Column(columnDefinition = "varchar(500) comment '模板说明'")
private String templateDesc;
}

42
crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunityStageTemplateDept.java

@ -0,0 +1,42 @@
package com.crm.rule.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Data;
import org.hibernate.annotations.Comment;
/**
* 商机阶段模板-适用部门关联apply_scope=2 指定部门的多值 06
*
* <p>关联表无审计字段硬删除模板版本保存时先删后插
* apply_scope=1所有商机时本表无行原型适用对象展示 --
* 与公海规则不同阶段模板无"部门覆盖唯一"强约束同部门可被多个发布中模板覆盖
* 绑定匹配时取最近发布者 {@code OpportunityStageTemplateServiceImpl#resolveBindingVersion}</p>
*/
@Data
@TableName("opportunity_stage_template_dept")
@Entity
@Table(name = "opportunity_stage_template_dept", uniqueConstraints = {
@UniqueConstraint(name = "uk_ost_version_dept", columnNames = {"template_version_id", "dept_id"})
})
public class OpportunityStageTemplateDept {
@TableId(type = IdType.ASSIGN_ID)
@Id
@Column(name = "id", columnDefinition = "bigint comment '主键'")
private Long id;
@Comment("模板版本ID(opportunity_stage_template.id,版本行主键)")
@Column(columnDefinition = "bigint not null comment '模板版本ID'")
private Long templateVersionId;
@Comment("适用部门ID")
@Column(columnDefinition = "bigint not null comment '适用部门ID'")
private Long deptId;
}

21
crm-rule/src/main/java/com/crm/rule/domain/param/OpportunityStageTemplatePageParam.java

@ -0,0 +1,21 @@
package com.crm.rule.domain.param;
import com.crm.base.domain.param.BaseParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 商机阶段模板分页查询参数 06
* <p>keyword 匹配模板名称/模板编码/版本号列表按版本维度展示 code 草稿/发布中/停用各占一行</p>
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class OpportunityStageTemplatePageParam extends BaseParam {
@Schema(description = "适用范围筛选:1所有商机 2指定部门")
private Integer applyScope;
@Schema(description = "状态筛选:1草稿 2发布中 3已停用")
private Integer status;
}

9
crm-rule/src/main/java/com/crm/rule/mapper/OpportunityStageNodeMapper.java

@ -0,0 +1,9 @@
package com.crm.rule.mapper;
import com.crm.base.mapper.CrmBaseMapper;
import com.crm.rule.domain.entity.OpportunityStageNode;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface OpportunityStageNodeMapper extends CrmBaseMapper<OpportunityStageNode> {
}

9
crm-rule/src/main/java/com/crm/rule/mapper/OpportunityStageTemplateDeptMapper.java

@ -0,0 +1,9 @@
package com.crm.rule.mapper;
import com.crm.base.mapper.CrmBaseMapper;
import com.crm.rule.domain.entity.OpportunityStageTemplateDept;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface OpportunityStageTemplateDeptMapper extends CrmBaseMapper<OpportunityStageTemplateDept> {
}

9
crm-rule/src/main/java/com/crm/rule/mapper/OpportunityStageTemplateMapper.java

@ -0,0 +1,9 @@
package com.crm.rule.mapper;
import com.crm.base.mapper.CrmBaseMapper;
import com.crm.rule.domain.entity.OpportunityStageTemplate;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface OpportunityStageTemplateMapper extends CrmBaseMapper<OpportunityStageTemplate> {
}

96
crm-rule/src/main/java/com/crm/rule/service/IOpportunityStageTemplateService.java

@ -0,0 +1,96 @@
package com.crm.rule.service;
import com.crm.base.domain.result.PageResult;
import com.crm.rule.domain.dto.OpportunityStageTemplateDTO;
import com.crm.rule.domain.entity.OpportunityStageNode;
import com.crm.rule.domain.entity.OpportunityStageTemplate;
import com.crm.rule.domain.param.OpportunityStageTemplatePageParam;
import java.util.List;
/**
* 商机阶段模板版本状态机服务 06 / PRD §6
*
* <p> 2 业务阶段的配置侧版本化配置范式 V-CONFIG 06与票 05 公海规则同族
* 同一 template_code 多版本每版本一行 code 仅一草稿一发布中发布使原发布中转停用
* 原默认自动取消默认仅草稿可删编辑发布中/停用生新草稿复制 code 新主体
* 每版本一套有序阶段节点末位固定已转项目节点字典值恒 {@code OPP_STAGE_05}
* 不可删保存时系统规范化保证</p>
*
* <p>运行时查询缝{@code getNodeById}/{@code listNodesOfVersion}/{@code resolveBindingVersion}
* {@code crm-opportunity} 阶段切换/绑定时调用本接口不反向依赖商机实体 01</p>
*/
public interface IOpportunityStageTemplateService {
/**
* 分页查询模板列表按版本维度展示keyword 匹配名//版本
* <p>原型列绑定商机(数量)归商机域统计本接口不回显crm-rule 零反依赖</p>
*/
PageResult<OpportunityStageTemplateDTO> pageTemplates(OpportunityStageTemplatePageParam param);
/**
* 模板版本详情含适用部门 ID/名称回显 + 阶段节点有序列表
*/
OpportunityStageTemplateDTO getTemplateDetail(Long id);
/**
* 保存草稿
* <ul>
* <li> id 新模板新草稿系统生成 templateCode + V1.0</li>
* <li>id 指向草稿 原位编辑templateCode/versionNo/status 不变</li>
* <li>id 指向发布中/停用 生成新草稿 code版本号 minor 顺延
* code 已有草稿则拒绝进已有草稿编辑</li>
* </ul>
* 节点全量替换固定节点系统规范化唯一末位字典值恒 {@code OPP_STAGE_05}
*/
void saveDraft(OpportunityStageTemplateDTO dto);
/**
* 保存并发布先按 {@link #saveDraft} 语义落草稿再执行发布校验
* 除固定节点外至少一个阶段节点 原发布中转停用 原默认取消默认 本版本转发布中
*/
void saveAndPublish(OpportunityStageTemplateDTO dto);
/**
* 复制模板 独立新模板 templateCodeV1.0 草稿适用范围/节点配置带过来
*
* @return 新草稿版本 id
*/
Long copyTemplate(Long id);
/**
* 停用仅发布中可停用发布中 已停用作历史快照保留
*/
void disableTemplate(Long id);
/**
* 删除仅草稿可删级联硬删适用部门子表 + 阶段节点
*/
void deleteDraft(Long id);
// ==================== 运行时查询缝(供 crm-opportunity 消费) ====================
/**
* 按节点 id 查阶段节点阶段切换时校验目标节点归属/固定性
*
* @return 节点不存在返回 {@code null}
*/
OpportunityStageNode getNodeById(Long nodeId);
/**
* 模板版本下全部节点 seq_no 升序阶段推进指引/绑定时取首节点
*/
List<OpportunityStageNode> listNodesOfVersion(Long templateVersionId);
/**
* 绑定匹配按部门解析应绑定的**发布中**模板版本V-CONFIG 12新建商机用
* <ul>
* <li>优先指定部门apply_scope=2覆盖该部门的发布中模板多命中取最近创建者</li>
* <li>兜底所有商机apply_scope=1的发布中默认模板</li>
* </ul>
*
* @param deptId 商机负责人部门可空仅走默认兜底
* @return 命中模板版本行无任何发布中模板可匹配时返回 {@code null}
*/
OpportunityStageTemplate resolveBindingVersion(Long deptId);
}

535
crm-rule/src/main/java/com/crm/rule/service/impl/OpportunityStageTemplateServiceImpl.java

@ -0,0 +1,535 @@
package com.crm.rule.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.crm.auth.domain.entity.SysDept;
import com.crm.auth.service.ISysDeptService;
import com.crm.base.domain.exception.BusinessErrorException;
import com.crm.base.domain.result.PageResult;
import com.crm.base.service.impl.BaseServiceImpl;
import com.crm.base.utils.PageConverter;
import com.crm.dict.domain.dto.DictItemDTO;
import com.crm.dict.service.DictQueryService;
import com.crm.rule.constant.OpportunityRuleConstants;
import com.crm.rule.domain.dto.OpportunityStageTemplateDTO;
import com.crm.rule.domain.entity.OpportunityStageNode;
import com.crm.rule.domain.entity.OpportunityStageTemplate;
import com.crm.rule.domain.entity.OpportunityStageTemplateDept;
import com.crm.rule.domain.param.OpportunityStageTemplatePageParam;
import com.crm.rule.mapper.OpportunityStageNodeMapper;
import com.crm.rule.mapper.OpportunityStageTemplateDeptMapper;
import com.crm.rule.mapper.OpportunityStageTemplateMapper;
import com.crm.rule.service.IOpportunityStageTemplateService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 商机阶段模板版本状态机实现 06 / PRD §6.2
*
* <p>V-CONFIG 版本化配置范式 06与票 05 公海规则同族不共表同一
* template_code 多版本每版本一行 code 仅一草稿一发布中发布使原发布中转停用
* 原默认自动取消默认仅草稿可删编辑发布中/停用生新草稿复制 code 新主体
* 绑定实体锁版本行 id发新版不迁移</p>
*
* <p>阶段节点差异化约定相对票 05</p>
* <ul>
* <li>每版本一套有序节点随版本保存全量替换seq_no 按提交顺序重算</li>
* <li>固定节点已转项目系统规范化入参含固定字典值节点直接拒绝防伪造
* 末位由系统补唯一固定节点</li>
* <li>节点字典引用校验 06字典状态规则商机阶段分组启用项可新增引用
* 已停用项历史保留但新建/编辑不可选</li>
* <li>发布校验除固定节点外至少一个阶段节点原型除固定节点外至少创建一个阶段节点</li>
* <li>"部门覆盖唯一"强约束异于票 05绑定匹配多命中取最近创建者</li>
* </ul>
*/
@Service
@RequiredArgsConstructor
public class OpportunityStageTemplateServiceImpl
extends BaseServiceImpl<OpportunityStageTemplateMapper, OpportunityStageTemplate>
implements IOpportunityStageTemplateService {
private final OpportunityStageTemplateDeptMapper deptMapper;
private final OpportunityStageNodeMapper nodeMapper;
private final ISysDeptService sysDeptService;
private final DictQueryService dictQueryService;
// ==================== 查询 ====================
@Override
public PageResult<OpportunityStageTemplateDTO> pageTemplates(OpportunityStageTemplatePageParam param) {
String keyword = param.getKeyword();
Page<OpportunityStageTemplate> page = this.page(PageConverter.toMpPage(param),
new LambdaQueryWrapper<OpportunityStageTemplate>()
.and(StrUtil.isNotBlank(keyword), w -> w
.like(OpportunityStageTemplate::getTemplateName, keyword)
.or().like(OpportunityStageTemplate::getTemplateCode, keyword)
.or().like(OpportunityStageTemplate::getVersionNo, keyword))
.eq(param.getApplyScope() != null,
OpportunityStageTemplate::getApplyScope, param.getApplyScope())
.eq(param.getStatus() != null,
OpportunityStageTemplate::getStatus, param.getStatus())
.orderByDesc(OpportunityStageTemplate::getCreateTime));
PageResult<OpportunityStageTemplateDTO> result =
new PageResult<>(page).convert(OpportunityStageTemplateDTO::fromEntity);
fillDeptNames(result.getContent());
return result;
}
@Override
public OpportunityStageTemplateDTO getTemplateDetail(Long id) {
OpportunityStageTemplate template = getTemplateByIdOrThrow(id);
OpportunityStageTemplateDTO dto = OpportunityStageTemplateDTO.fromEntity(template);
fillDeptNames(List.of(dto));
dto.setNodes(listNodesOfVersion(id).stream()
.map(OpportunityStageTemplateServiceImpl::toNodeItem)
.collect(Collectors.toList()));
return dto;
}
@Override
public OpportunityStageNode getNodeById(Long nodeId) {
if (nodeId == null) {
return null;
}
return nodeMapper.selectById(nodeId);
}
@Override
public List<OpportunityStageNode> listNodesOfVersion(Long templateVersionId) {
if (templateVersionId == null) {
return List.of();
}
return nodeMapper.selectList(new LambdaQueryWrapper<OpportunityStageNode>()
.eq(OpportunityStageNode::getTemplateId, templateVersionId)
.orderByAsc(OpportunityStageNode::getSeqNo));
}
@Override
public OpportunityStageTemplate resolveBindingVersion(Long deptId) {
List<OpportunityStageTemplate> published = this.list(
new LambdaQueryWrapper<OpportunityStageTemplate>()
.eq(OpportunityStageTemplate::getStatus, OpportunityRuleConstants.RULE_STATUS_PUBLISHED));
if (CollUtil.isEmpty(published)) {
return null;
}
if (deptId != null) {
Map<Long, OpportunityStageTemplate> specificById = published.stream()
.filter(t -> Integer.valueOf(OpportunityRuleConstants.APPLY_SCOPE_DEPT)
.equals(t.getApplyScope()))
.collect(Collectors.toMap(OpportunityStageTemplate::getId, t -> t, (first, dup) -> first));
if (!specificById.isEmpty()) {
List<OpportunityStageTemplateDept> rows = deptMapper.selectList(
new LambdaQueryWrapper<OpportunityStageTemplateDept>()
.in(OpportunityStageTemplateDept::getTemplateVersionId, specificById.keySet())
.eq(OpportunityStageTemplateDept::getDeptId, deptId));
OpportunityStageTemplate hit = rows.stream()
.map(row -> specificById.get(row.getTemplateVersionId()))
.filter(Objects::nonNull)
// 无部门覆盖唯一约束(异于票 05):多命中取最近创建者,保证绑定确定
.max(Comparator.comparing(OpportunityStageTemplate::getCreateTime,
Comparator.nullsFirst(Comparator.naturalOrder())))
.orElse(null);
if (hit != null) {
return hit;
}
}
}
return published.stream()
.filter(t -> Integer.valueOf(OpportunityRuleConstants.APPLY_SCOPE_ALL)
.equals(t.getApplyScope()))
.filter(t -> Integer.valueOf(OpportunityRuleConstants.FLAG_YES)
.equals(t.getIsDefault()))
.findFirst()
.orElse(null);
}
// ==================== 保存草稿 / 保存并发布 ====================
@Override
@Transactional(rollbackFor = Exception.class)
public void saveDraft(OpportunityStageTemplateDTO dto) {
validateTemplate(dto);
resolveDraft(dto);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void saveAndPublish(OpportunityStageTemplateDTO dto) {
validateTemplate(dto);
validateForPublish(dto);
OpportunityStageTemplate draft = resolveDraft(dto);
publish(draft);
}
/**
* 落草稿三分支新建新编码 + V1.0/ 原位编辑草稿 / 从发布中-停用生成新草稿minor 顺延
* 节点 + 适用部门子表均随版本全量替换
*
* @return 已持久化的草稿行 id
*/
private OpportunityStageTemplate resolveDraft(OpportunityStageTemplateDTO dto) {
if (dto.getId() == null) {
OpportunityStageTemplate template = dto.toEntity();
template.setTemplateCode(nextTemplateCode());
template.setVersionNo("V1.0");
template.setStatus(OpportunityRuleConstants.RULE_STATUS_DRAFT);
this.save(template);
replaceChildRows(template.getId(), dto);
return template;
}
OpportunityStageTemplate exist = getTemplateByIdOrThrow(dto.getId());
if (exist.getStatus() != null && exist.getStatus() == OpportunityRuleConstants.RULE_STATUS_DRAFT) {
// 草稿原位编辑:编码/版本号/状态不变(只读字段防前端伪造)
OpportunityStageTemplate template = dto.toEntity();
template.setTemplateCode(exist.getTemplateCode());
template.setVersionNo(exist.getVersionNo());
template.setStatus(OpportunityRuleConstants.RULE_STATUS_DRAFT);
this.updateById(template);
replaceChildRows(exist.getId(), dto);
return template;
}
// 发布中/停用 → 生成新草稿;同 code 已有草稿则拒绝(进已有草稿编辑)
List<OpportunityStageTemplate> versions = this.list(
new LambdaQueryWrapper<OpportunityStageTemplate>()
.eq(OpportunityStageTemplate::getTemplateCode, exist.getTemplateCode()));
boolean hasDraft = versions.stream().anyMatch(v ->
v.getStatus() != null && v.getStatus() == OpportunityRuleConstants.RULE_STATUS_DRAFT);
if (hasDraft) {
throw new BusinessErrorException(OpportunityRuleConstants.CODE_STAGE_TPL_DRAFT_EXISTS,
"该模板已有草稿版本,请进入草稿编辑");
}
OpportunityStageTemplate template = dto.toEntity();
template.setTemplateCode(exist.getTemplateCode());
template.setVersionNo(nextVersionNo(versions.stream()
.map(OpportunityStageTemplate::getVersionNo).collect(Collectors.toList())));
template.setStatus(OpportunityRuleConstants.RULE_STATUS_DRAFT);
this.save(template);
replaceChildRows(template.getId(), dto);
return template;
}
/**
* 发布原发布中转停用 code 自我顶替 新默认顶替旧默认 本版本转发布中
* 节点完整性校验前置在 {@link #validateForPublish}
*/
private void publish(OpportunityStageTemplate template) {
List<OpportunityStageTemplate> olds = this.list(
new LambdaQueryWrapper<OpportunityStageTemplate>()
.eq(OpportunityStageTemplate::getTemplateCode, template.getTemplateCode())
.eq(OpportunityStageTemplate::getStatus, OpportunityRuleConstants.RULE_STATUS_PUBLISHED)
.ne(OpportunityStageTemplate::getId, template.getId()));
for (OpportunityStageTemplate old : olds) {
OpportunityStageTemplate change = new OpportunityStageTemplate();
change.setId(old.getId());
change.setStatus(OpportunityRuleConstants.RULE_STATUS_DISABLED);
this.updateById(change);
}
// 新默认顶替旧默认(发布中默认唯一,自动取消而非拒绝;V-CONFIG 6)
if (template.getIsDefault() != null && template.getIsDefault() == OpportunityRuleConstants.FLAG_YES) {
List<OpportunityStageTemplate> oldDefaults = this.list(
new LambdaQueryWrapper<OpportunityStageTemplate>()
.eq(OpportunityStageTemplate::getStatus, OpportunityRuleConstants.RULE_STATUS_PUBLISHED)
.eq(OpportunityStageTemplate::getIsDefault, OpportunityRuleConstants.FLAG_YES)
.ne(OpportunityStageTemplate::getId, template.getId()));
for (OpportunityStageTemplate old : oldDefaults) {
OpportunityStageTemplate change = new OpportunityStageTemplate();
change.setId(old.getId());
change.setIsDefault(OpportunityRuleConstants.FLAG_NO);
this.updateById(change);
}
}
OpportunityStageTemplate self = new OpportunityStageTemplate();
self.setId(template.getId());
self.setStatus(OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
this.updateById(self);
}
// ==================== 停用 / 删除 / 复制(行操作矩阵) ====================
@Override
@Transactional(rollbackFor = Exception.class)
public void disableTemplate(Long id) {
OpportunityStageTemplate exist = getTemplateByIdOrThrow(id);
if (exist.getStatus() == null || exist.getStatus() != OpportunityRuleConstants.RULE_STATUS_PUBLISHED) {
throw new BusinessErrorException(OpportunityRuleConstants.CODE_STAGE_TPL_INVALID,
"仅发布中的模板可停用");
}
OpportunityStageTemplate change = new OpportunityStageTemplate();
change.setId(id);
change.setStatus(OpportunityRuleConstants.RULE_STATUS_DISABLED);
this.updateById(change);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteDraft(Long id) {
OpportunityStageTemplate exist = getTemplateByIdOrThrow(id);
if (exist.getStatus() == null || exist.getStatus() != OpportunityRuleConstants.RULE_STATUS_DRAFT) {
throw new BusinessErrorException(OpportunityRuleConstants.CODE_STAGE_TPL_NOT_DRAFT,
"仅草稿版本可删除");
}
this.removeById(id);
deptMapper.delete(new LambdaQueryWrapper<OpportunityStageTemplateDept>()
.eq(OpportunityStageTemplateDept::getTemplateVersionId, id));
nodeMapper.delete(new LambdaQueryWrapper<OpportunityStageNode>()
.eq(OpportunityStageNode::getTemplateId, id));
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long copyTemplate(Long id) {
OpportunityStageTemplate source = getTemplateByIdOrThrow(id);
OpportunityStageTemplate copy = new OpportunityStageTemplate();
copy.setTemplateCode(nextTemplateCode());
copy.setTemplateName(source.getTemplateName());
copy.setVersionNo("V1.0");
copy.setStatus(OpportunityRuleConstants.RULE_STATUS_DRAFT);
copy.setApplyScope(source.getApplyScope());
copy.setIsDefault(source.getIsDefault());
copy.setTemplateDesc(source.getTemplateDesc());
this.save(copy);
// 适用部门随复制
deptMapper.selectList(new LambdaQueryWrapper<OpportunityStageTemplateDept>()
.eq(OpportunityStageTemplateDept::getTemplateVersionId, id))
.forEach(row -> {
OpportunityStageTemplateDept target = new OpportunityStageTemplateDept();
target.setTemplateVersionId(copy.getId());
target.setDeptId(row.getDeptId());
deptMapper.insert(target);
});
// 节点随复制(保序;固定节点本就随源版本末位存在)
listNodesOfVersion(id).forEach(node -> {
OpportunityStageNode target = new OpportunityStageNode();
target.setTemplateId(copy.getId());
target.setSeqNo(node.getSeqNo());
target.setStageDictCode(node.getStageDictCode());
target.setCustomNodeName(node.getCustomNodeName());
target.setWorkGoal(node.getWorkGoal());
target.setIsFixed(node.getIsFixed());
nodeMapper.insert(target);
});
return copy.getId();
}
// ==================== 校验 ====================
/** 头 + 节点基础校验(草稿/发布共用):入参节点字典值必填;固定字典值节点不可手工配置 */
private void validateTemplate(OpportunityStageTemplateDTO dto) {
if (dto == null) {
throw invalid("请求参数缺失");
}
if (StrUtil.isBlank(dto.getTemplateName())) {
throw invalid("模板名称不能为空");
}
Integer scope = dto.getApplyScope();
if (scope == null || (scope != OpportunityRuleConstants.APPLY_SCOPE_ALL
&& scope != OpportunityRuleConstants.APPLY_SCOPE_DEPT)) {
throw invalid("适用范围参数非法");
}
Integer isDefault = dto.getIsDefault();
if (isDefault == null || (isDefault != OpportunityRuleConstants.FLAG_NO
&& isDefault != OpportunityRuleConstants.FLAG_YES)) {
throw invalid("是否默认参数非法");
}
if (scope == OpportunityRuleConstants.APPLY_SCOPE_DEPT
&& isDefault == OpportunityRuleConstants.FLAG_YES) {
throw invalid("仅所有商机范围的模板可设为默认");
}
if (scope == OpportunityRuleConstants.APPLY_SCOPE_DEPT && CollUtil.isEmpty(dto.getDeptIds())) {
throw invalid("指定部门模板须选择至少一个适用部门");
}
Set<String> enabledDictCodes = null; // 懒加载:无节点时不查字典(字典读路径带 10s 缓存)
for (OpportunityStageTemplateDTO.NodeItem node : safeNodes(dto)) {
if (StrUtil.isBlank(node.getStageDictCode())) {
throw invalid("阶段节点字典值不能为空(保存时统一校验所有节点无未完成配置)");
}
if (OpportunityRuleConstants.FIXED_STAGE_DICT_CODE.equals(node.getStageDictCode())) {
throw invalid("「已转项目」为系统固定节点,不可在阶段列表中手工配置");
}
if (enabledDictCodes == null) {
enabledDictCodes = dictQueryService.listEnabledItems(OpportunityRuleConstants.STAGE_DICT_GROUP_CODE)
.stream().map(DictItemDTO::getCode).collect(Collectors.toSet());
}
// 字典状态规则(票 06「二」):仅启用项可新增引用;已停用项历史模板保留,但新建/编辑不再可选;
// 分组停用/不存在时 listEnabledItems 返回空 → 整组不可选,同口径拒绝
if (!enabledDictCodes.contains(node.getStageDictCode())) {
throw invalid("阶段节点须引用「商机阶段」分组的启用字典项:" + node.getStageDictCode());
}
}
}
/** 发布附加校验:除固定节点外至少创建一个阶段节点(原型校验文案) */
private void validateForPublish(OpportunityStageTemplateDTO dto) {
if (safeNodes(dto).isEmpty()) {
throw new BusinessErrorException(OpportunityRuleConstants.CODE_STAGE_TPL_NODE_INVALID,
"除固定节点外至少创建一个阶段节点");
}
}
private List<OpportunityStageTemplateDTO.NodeItem> safeNodes(OpportunityStageTemplateDTO dto) {
return dto.getNodes() == null ? List.of() : dto.getNodes().stream()
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
private BusinessErrorException invalid(String message) {
return new BusinessErrorException(OpportunityRuleConstants.CODE_STAGE_TPL_INVALID, message);
}
// ==================== 内部方法 ====================
private OpportunityStageTemplate getTemplateByIdOrThrow(Long id) {
OpportunityStageTemplate template = this.getById(id);
if (template == null) {
throw new BusinessErrorException(OpportunityRuleConstants.CODE_STAGE_TPL_NOT_EXIST,
"模板版本不存在或已被删除");
}
return template;
}
/**
* 子表全量替换适用部门 + 阶段节点先删后插
* 节点规范化入参顺序=普通节点顺序seq 1..n末位系统补唯一固定节点已转项目
* 字典值恒 {@code OPP_STAGE_05} 06配模板时自带不可删
*/
private void replaceChildRows(Long templateVersionId, OpportunityStageTemplateDTO dto) {
deptMapper.delete(new LambdaQueryWrapper<OpportunityStageTemplateDept>()
.eq(OpportunityStageTemplateDept::getTemplateVersionId, templateVersionId));
if (dto.getApplyScope() != null
&& dto.getApplyScope() == OpportunityRuleConstants.APPLY_SCOPE_DEPT
&& CollUtil.isNotEmpty(dto.getDeptIds())) {
for (Long deptId : dto.getDeptIds()) {
if (deptId == null) {
continue;
}
OpportunityStageTemplateDept row = new OpportunityStageTemplateDept();
row.setTemplateVersionId(templateVersionId);
row.setDeptId(deptId);
deptMapper.insert(row);
}
}
nodeMapper.delete(new LambdaQueryWrapper<OpportunityStageNode>()
.eq(OpportunityStageNode::getTemplateId, templateVersionId));
int seq = 1;
for (OpportunityStageTemplateDTO.NodeItem item : safeNodes(dto)) {
// 固定字典值节点入参侧已在校验中拒绝,此处防御性跳过
if (OpportunityRuleConstants.FIXED_STAGE_DICT_CODE.equals(item.getStageDictCode())) {
continue;
}
nodeMapper.insert(buildNode(templateVersionId, seq++, item.getStageDictCode(),
item.getCustomNodeName(), item.getWorkGoal(), OpportunityRuleConstants.FLAG_NO));
}
nodeMapper.insert(buildNode(templateVersionId, seq, OpportunityRuleConstants.FIXED_STAGE_DICT_CODE,
OpportunityRuleConstants.FIXED_STAGE_NAME, null, OpportunityRuleConstants.FLAG_YES));
}
private OpportunityStageNode buildNode(Long templateVersionId, int seqNo, String stageDictCode,
String customNodeName, String workGoal, int isFixed) {
OpportunityStageNode node = new OpportunityStageNode();
node.setTemplateId(templateVersionId);
node.setSeqNo(seqNo);
node.setStageDictCode(stageDictCode);
node.setCustomNodeName(customNodeName);
node.setWorkGoal(workGoal);
node.setIsFixed(isFixed);
return node;
}
private static OpportunityStageTemplateDTO.NodeItem toNodeItem(OpportunityStageNode node) {
OpportunityStageTemplateDTO.NodeItem item = new OpportunityStageTemplateDTO.NodeItem();
item.setId(node.getId());
item.setSeqNo(node.getSeqNo());
item.setStageDictCode(node.getStageDictCode());
item.setCustomNodeName(node.getCustomNodeName());
item.setWorkGoal(node.getWorkGoal());
item.setIsFixed(node.getIsFixed());
return item;
}
/** 批量回显适用部门 ID 与名称(一次子表查询 + 一次部门名查询) */
private void fillDeptNames(List<OpportunityStageTemplateDTO> dtos) {
if (CollUtil.isEmpty(dtos)) {
return;
}
List<OpportunityStageTemplateDept> rows = deptMapper.selectList(
new LambdaQueryWrapper<OpportunityStageTemplateDept>()
.in(OpportunityStageTemplateDept::getTemplateVersionId,
dtos.stream().map(OpportunityStageTemplateDTO::getId)
.filter(Objects::nonNull).collect(Collectors.toSet())));
Map<Long, List<Long>> versionToDepts = rows.stream().collect(Collectors.groupingBy(
OpportunityStageTemplateDept::getTemplateVersionId,
Collectors.mapping(OpportunityStageTemplateDept::getDeptId, Collectors.toList())));
Set<Long> deptIds = rows.stream().map(OpportunityStageTemplateDept::getDeptId)
.filter(Objects::nonNull).collect(Collectors.toSet());
Map<Long, String> nameMap = deptIds.isEmpty() ? Map.of()
: sysDeptService.listByIds(deptIds).stream()
.collect(Collectors.toMap(SysDept::getId, SysDept::getDeptName));
dtos.forEach(dto -> {
List<Long> ids = versionToDepts.getOrDefault(dto.getId(), List.of());
dto.setDeptIds(ids);
dto.setDeptNames(ids.stream().map(nameMap::get)
.filter(Objects::nonNull).collect(Collectors.toList()));
});
}
/** 模板编码顺延:OPP_STAGE_TPL_ + 现有最大序号+1(两位补零) */
private String nextTemplateCode() {
int max = this.list(new LambdaQueryWrapper<OpportunityStageTemplate>()
.likeRight(OpportunityStageTemplate::getTemplateCode,
OpportunityRuleConstants.TEMPLATE_CODE_PREFIX))
.stream().map(OpportunityStageTemplate::getTemplateCode)
.filter(Objects::nonNull)
.map(code -> code.substring(OpportunityRuleConstants.TEMPLATE_CODE_PREFIX.length()))
.filter(suffix -> suffix.matches("\\d+"))
.mapToInt(Integer::parseInt)
.max().orElse(0);
return OpportunityRuleConstants.TEMPLATE_CODE_PREFIX + String.format("%02d", max + 1);
}
/**
* 版本号 minor 顺延取全版本最大 (major, minor) minor+1major 不动
* 无历史版本 V1.0非法格式行防御性忽略同票 05 口径V-CONFIG 2
*/
static String nextVersionNo(List<String> versionNos) {
int bestMajor = -1;
int bestMinor = -1;
for (String v : new ArrayList<>(versionNos)) {
if (v == null || !v.matches("V\\d+\\.\\d+")) {
continue;
}
String[] parts = v.substring(1).split("\\.");
int major = Integer.parseInt(parts[0]);
int minor = Integer.parseInt(parts[1]);
if (major > bestMajor || (major == bestMajor && minor > bestMinor)) {
bestMajor = major;
bestMinor = minor;
}
}
if (bestMajor < 0) {
return "V1.0";
}
return "V" + bestMajor + "." + (bestMinor + 1);
}
}

767
crm-rule/src/test/java/com/crm/rule/service/impl/OpportunityStageTemplateServiceImplTest.java

@ -0,0 +1,767 @@
package com.crm.rule.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.crm.auth.domain.entity.SysDept;
import com.crm.auth.service.ISysDeptService;
import com.crm.base.domain.exception.BusinessErrorException;
import com.crm.dict.domain.dto.DictItemDTO;
import com.crm.dict.service.DictQueryService;
import com.crm.rule.constant.OpportunityRuleConstants;
import com.crm.rule.domain.dto.OpportunityStageTemplateDTO;
import com.crm.rule.domain.entity.OpportunityStageNode;
import com.crm.rule.domain.entity.OpportunityStageTemplate;
import com.crm.rule.domain.entity.OpportunityStageTemplateDept;
import com.crm.rule.domain.param.OpportunityStageTemplatePageParam;
import com.crm.rule.mapper.OpportunityStageNodeMapper;
import com.crm.rule.mapper.OpportunityStageTemplateDeptMapper;
import com.crm.rule.mapper.OpportunityStageTemplateMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
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.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
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.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 商机阶段模板版本状态机规格验证 06/ PRD §6.2
*
* <p>断言点</p>
* <ul>
* <li>+节点校验名称必填范围合法仅所有商机可设默认指定部门须选部门
* 节点字典值必填固定字典值 {@code OPP_STAGE_05} 入参直接拒绝防伪造</li>
* <li>保存草稿三分支新建V1.0 + 编码顺延/ 原位编辑 / 发布中-停用生新草稿minor 顺延
* code 已有草稿拒绝 64016</li>
* <li>节点规范化入参顺序=普通节点 seq 1..n末位系统补唯一固定节点已转项目
* 发布校验除固定节点外至少一个普通节点64017</li>
* <li>发布原发布中转停用自我顶替新默认顶替旧默认</li>
* <li>行操作矩阵仅草稿可删64015级联删部门+节点仅发布中可停用
* 复制 新编码 + V1.0 + 草稿部门与节点随复制</li>
* <li>绑定匹配部门专用发布模板优先多命中取最近创建兜底=所有商机+默认</li>
* </ul>
*/
@DisplayName("商机阶段模板版本状态机(票 06 / PRD §6.2)")
@ExtendWith(MockitoExtension.class)
class OpportunityStageTemplateServiceImplTest {
private static final Long DRAFT_ID = 6201L;
private static final Long PUB_ID = 6202L;
private static final Long NEW_TPL_ID = 6203L;
private static final Long OLD_DEFAULT_ID = 6204L;
private static final Long COPY_ID = 6205L;
private static final Long OTHER_TPL_ID = 6206L;
private static final Long DEPT_10 = 4001L;
private static final Long DEPT_20 = 4002L;
private static final String CODE_C1 = "OPP_STAGE_TPL_01";
@Mock private OpportunityStageTemplateMapper templateMapper;
@Mock private OpportunityStageTemplateDeptMapper deptMapper;
@Mock private OpportunityStageNodeMapper nodeMapper;
@Mock private ISysDeptService sysDeptService;
@Mock private DictQueryService dictQueryService;
@InjectMocks
private OpportunityStageTemplateServiceImpl service;
@BeforeAll
static void initLambdaCache() {
MapperBuilderAssistant assistant =
new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, OpportunityStageTemplate.class);
TableInfoHelper.initTableInfo(assistant, OpportunityStageTemplateDept.class);
TableInfoHelper.initTableInfo(assistant, OpportunityStageNode.class);
}
@BeforeEach
void setUp() {
// ServiceImpl.getEntityClass() 纯 Mockito 下需手动设值,lambdaQuery() 才能工作(同票 05 测试)
ReflectionTestUtils.setField(service, "baseMapper", templateMapper);
ReflectionTestUtils.setField(service, "entityClass", OpportunityStageTemplate.class);
// 默认桩:「商机阶段」分组启用项(不含固定项 OPP_STAGE_05,固定值先于字典校验被拒);
// lenient:校验前即失败的用例(名称空等)不触达字典查询,避免严格模式报多余桩
lenient().when(dictQueryService.listEnabledItems(OpportunityRuleConstants.STAGE_DICT_GROUP_CODE))
.thenReturn(List.of(dictItemDto("OPP_STAGE_01"), dictItemDto("OPP_STAGE_02"),
dictItemDto("OPP_STAGE_03"), dictItemDto("OPP_STAGE_04")));
}
// ==================== 工厂 ====================
private OpportunityStageTemplateDTO validDto() {
OpportunityStageTemplateDTO dto = new OpportunityStageTemplateDTO();
dto.setTemplateName("标准商机阶段模板");
dto.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_ALL);
dto.setIsDefault(OpportunityRuleConstants.FLAG_NO);
dto.setNodes(List.of(
nodeItem("OPP_STAGE_01", "客户圈定"),
nodeItem("OPP_STAGE_02", "关系摸排")));
return dto;
}
private OpportunityStageTemplateDTO.NodeItem nodeItem(String dictCode, String name) {
OpportunityStageTemplateDTO.NodeItem item = new OpportunityStageTemplateDTO.NodeItem();
item.setStageDictCode(dictCode);
item.setCustomNodeName(name);
return item;
}
private DictItemDTO dictItemDto(String code) {
DictItemDTO dto = new DictItemDTO();
dto.setCode(code);
dto.setStatus(1);
return dto;
}
private OpportunityStageTemplate tplRow(Long id, String code, String versionNo, int status) {
OpportunityStageTemplate t = new OpportunityStageTemplate();
t.setId(id);
t.setTemplateCode(code);
t.setTemplateName("模板" + code);
t.setVersionNo(versionNo);
t.setStatus(status);
t.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_ALL);
t.setIsDefault(OpportunityRuleConstants.FLAG_NO);
return t;
}
private OpportunityStageTemplateDept deptRow(Long tplVersionId, Long deptId) {
OpportunityStageTemplateDept d = new OpportunityStageTemplateDept();
d.setTemplateVersionId(tplVersionId);
d.setDeptId(deptId);
return d;
}
private OpportunityStageNode nodeRow(Long id, Long tplVersionId, int seqNo, String dictCode, int isFixed) {
OpportunityStageNode n = new OpportunityStageNode();
n.setId(id);
n.setTemplateId(tplVersionId);
n.setSeqNo(seqNo);
n.setStageDictCode(dictCode);
n.setCustomNodeName("节点" + dictCode);
n.setIsFixed(isFixed);
return n;
}
private SysDept sysDept(Long id, String name) {
SysDept d = new SysDept();
d.setId(id);
d.setDeptName(name);
return d;
}
/** mock mapper.insert 并回填 id(ASSIGN_ID 由 MP 在 SQL 执行期填充,纯 mock 不触发) */
private void stubInsertWithId(Long id) {
doAnswer(inv -> {
OpportunityStageTemplate t = inv.getArgument(0);
t.setId(id);
return 1;
}).when(templateMapper).insert(any(OpportunityStageTemplate.class));
}
// ==================== 头 + 节点校验 ====================
@Test
@DisplayName("校验:模板名称为空 → 拒绝 64013")
void saveDraft_blankName_rejected() {
OpportunityStageTemplateDTO dto = validDto();
dto.setTemplateName(" ");
assertThatThrownBy(() -> service.saveDraft(dto))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_INVALID);
}
@Test
@DisplayName("校验:适用范围非法 → 拒绝 64013")
void saveDraft_invalidScope_rejected() {
OpportunityStageTemplateDTO dto = validDto();
dto.setApplyScope(9);
assertThatThrownBy(() -> service.saveDraft(dto))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_INVALID);
}
@Test
@DisplayName("校验:仅所有商机范围可设默认,指定部门设默认 → 拒绝")
void saveDraft_deptScopeWithDefault_rejected() {
OpportunityStageTemplateDTO dto = validDto();
dto.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_DEPT);
dto.setIsDefault(OpportunityRuleConstants.FLAG_YES);
dto.setDeptIds(List.of(DEPT_10));
assertThatThrownBy(() -> service.saveDraft(dto))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_INVALID);
}
@Test
@DisplayName("校验:指定部门未选适用部门 → 拒绝")
void saveDraft_deptScopeWithoutDeptIds_rejected() {
OpportunityStageTemplateDTO dto = validDto();
dto.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_DEPT);
dto.setDeptIds(List.of());
assertThatThrownBy(() -> service.saveDraft(dto))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_INVALID);
}
@Test
@DisplayName("校验:节点字典值为空 → 拒绝(保存时统一校验所有节点无未完成配置)")
void saveDraft_nodeBlankDictCode_rejected() {
OpportunityStageTemplateDTO dto = validDto();
dto.setNodes(List.of(nodeItem(" ", null)));
assertThatThrownBy(() -> service.saveDraft(dto))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_INVALID);
}
@Test
@DisplayName("校验:入参节点含固定字典值 OPP_STAGE_05 → 拒绝(固定节点不可手工配置,防伪造)")
void saveDraft_nodeWithFixedDictCode_rejected() {
OpportunityStageTemplateDTO dto = validDto();
dto.setNodes(List.of(nodeItem("OPP_STAGE_01", "客户圈定"),
nodeItem(OpportunityRuleConstants.FIXED_STAGE_DICT_CODE, "已转项目")));
assertThatThrownBy(() -> service.saveDraft(dto))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_INVALID)
.hasMessageContaining("固定节点");
}
@Test
@DisplayName("校验:节点引用停用/不存在的字典项 → 拒绝(字典状态规则:仅启用项可新增引用)")
void saveDraft_nodeWithDisabledDictItem_rejected() {
OpportunityStageTemplateDTO dto = validDto();
dto.setNodes(List.of(nodeItem("OPP_STAGE_99", "已停用项")));
assertThatThrownBy(() -> service.saveDraft(dto))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_INVALID)
.hasMessageContaining("启用字典项");
}
@Test
@DisplayName("校验:「商机阶段」分组停用/不存在(启用项列表为空)→ 节点一律拒绝")
void saveDraft_stageGroupDisabled_rejected() {
when(dictQueryService.listEnabledItems(OpportunityRuleConstants.STAGE_DICT_GROUP_CODE))
.thenReturn(List.of());
OpportunityStageTemplateDTO dto = validDto();
assertThatThrownBy(() -> service.saveDraft(dto))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_INVALID);
}
// ==================== 保存草稿三分支 + 节点规范化 ====================
@Test
@DisplayName("新建草稿:编码顺延 + V1.0 + 草稿态;普通节点按序落库,末位系统补唯一固定节点")
void saveDraft_newTemplate_createsV1DraftWithFixedNode() {
OpportunityStageTemplateDTO dto = validDto();
when(templateMapper.selectList(any())).thenReturn(List.of());
stubInsertWithId(NEW_TPL_ID);
service.saveDraft(dto);
ArgumentCaptor<OpportunityStageTemplate> tplCaptor = ArgumentCaptor.forClass(OpportunityStageTemplate.class);
verify(templateMapper).insert(tplCaptor.capture());
OpportunityStageTemplate saved = tplCaptor.getValue();
assertThat(saved.getTemplateCode()).isEqualTo("OPP_STAGE_TPL_01");
assertThat(saved.getVersionNo()).isEqualTo("V1.0");
assertThat(saved.getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_DRAFT);
// 节点全量替换:先删后插;2 个普通节点 + 末位固定节点
verify(nodeMapper).delete(any());
ArgumentCaptor<OpportunityStageNode> nodeCaptor = ArgumentCaptor.forClass(OpportunityStageNode.class);
verify(nodeMapper, times(3)).insert(nodeCaptor.capture());
List<OpportunityStageNode> nodes = nodeCaptor.getAllValues();
assertThat(nodes).extracting(OpportunityStageNode::getSeqNo).containsExactly(1, 2, 3);
assertThat(nodes.get(0).getStageDictCode()).isEqualTo("OPP_STAGE_01");
assertThat(nodes.get(0).getIsFixed()).isEqualTo(OpportunityRuleConstants.FLAG_NO);
// 末位固定节点:字典值恒 OPP_STAGE_05、名称「已转项目」、isFixed=1
OpportunityStageNode fixed = nodes.get(2);
assertThat(fixed.getStageDictCode()).isEqualTo(OpportunityRuleConstants.FIXED_STAGE_DICT_CODE);
assertThat(fixed.getCustomNodeName()).isEqualTo(OpportunityRuleConstants.FIXED_STAGE_NAME);
assertThat(fixed.getIsFixed()).isEqualTo(OpportunityRuleConstants.FLAG_YES);
// 所有商机范围不写部门子表(只做清空)
verify(deptMapper).delete(any());
verify(deptMapper, never()).insert(any(OpportunityStageTemplateDept.class));
}
@Test
@DisplayName("新建指定部门草稿:适用部门逐行落子表(先删后插)")
void saveDraft_newDeptScopeTemplate_persistsDeptRows() {
OpportunityStageTemplateDTO dto = validDto();
dto.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_DEPT);
dto.setDeptIds(List.of(DEPT_10, DEPT_20));
when(templateMapper.selectList(any())).thenReturn(List.of());
stubInsertWithId(NEW_TPL_ID);
service.saveDraft(dto);
verify(deptMapper).delete(any());
verify(deptMapper, times(2)).insert(any(OpportunityStageTemplateDept.class));
}
@Test
@DisplayName("编辑草稿:原位更新(编码/版本号/草稿态不变,只读字段防伪造),不插入新版本行")
void saveDraft_editDraft_updatesInPlace() {
OpportunityStageTemplateDTO dto = validDto();
dto.setId(DRAFT_ID);
dto.setTemplateCode("FAKE_CODE"); // 前端伪造只读字段 → 忽略
dto.setVersionNo("V9.9");
when(templateMapper.selectById(DRAFT_ID)).thenReturn(
tplRow(DRAFT_ID, CODE_C1, "V1.1", OpportunityRuleConstants.RULE_STATUS_DRAFT));
when(templateMapper.updateById(any(OpportunityStageTemplate.class))).thenReturn(1);
service.saveDraft(dto);
ArgumentCaptor<OpportunityStageTemplate> captor = ArgumentCaptor.forClass(OpportunityStageTemplate.class);
verify(templateMapper).updateById(captor.capture());
OpportunityStageTemplate updated = captor.getValue();
assertThat(updated.getId()).isEqualTo(DRAFT_ID);
assertThat(updated.getTemplateCode()).isEqualTo(CODE_C1);
assertThat(updated.getVersionNo()).isEqualTo("V1.1");
assertThat(updated.getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_DRAFT);
verify(templateMapper, never()).insert(any(OpportunityStageTemplate.class));
}
@Test
@DisplayName("编辑发布中版本:不动原行,生成新草稿(同编码、版本号 minor 顺延 V1.0→V1.1)")
void saveDraft_fromPublished_createsNewDraft() {
OpportunityStageTemplateDTO dto = validDto();
dto.setId(PUB_ID);
OpportunityStageTemplate published = tplRow(PUB_ID, CODE_C1, "V1.0", OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
when(templateMapper.selectById(PUB_ID)).thenReturn(published);
when(templateMapper.selectList(any())).thenReturn(List.of(published));
stubInsertWithId(NEW_TPL_ID);
service.saveDraft(dto);
ArgumentCaptor<OpportunityStageTemplate> captor = ArgumentCaptor.forClass(OpportunityStageTemplate.class);
verify(templateMapper).insert(captor.capture());
OpportunityStageTemplate draft = captor.getValue();
assertThat(draft.getTemplateCode()).isEqualTo(CODE_C1);
assertThat(draft.getVersionNo()).isEqualTo("V1.1");
assertThat(draft.getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_DRAFT);
// 原发布中行不动
verify(templateMapper, never()).updateById(any(OpportunityStageTemplate.class));
}
@Test
@DisplayName("编辑发布中版本但同编码已有草稿:拒绝 64016(进已有草稿编辑)")
void saveDraft_fromPublished_draftExists_rejected() {
OpportunityStageTemplateDTO dto = validDto();
dto.setId(PUB_ID);
OpportunityStageTemplate published = tplRow(PUB_ID, CODE_C1, "V1.0", OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
OpportunityStageTemplate draft = tplRow(DRAFT_ID, CODE_C1, "V1.1", OpportunityRuleConstants.RULE_STATUS_DRAFT);
when(templateMapper.selectById(PUB_ID)).thenReturn(published);
when(templateMapper.selectList(any())).thenReturn(List.of(published, draft));
assertThatThrownBy(() -> service.saveDraft(dto))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_DRAFT_EXISTS);
verify(templateMapper, never()).insert(any(OpportunityStageTemplate.class));
}
@Test
@DisplayName("编辑不存在的版本 → 拒绝 64014")
void saveDraft_notExist_rejected() {
OpportunityStageTemplateDTO dto = validDto();
dto.setId(999L);
when(templateMapper.selectById(999L)).thenReturn(null);
assertThatThrownBy(() -> service.saveDraft(dto))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_NOT_EXIST);
}
// ==================== 保存并发布(状态机) ====================
@Test
@DisplayName("发布校验:除固定节点外无普通节点 → 拒绝 64017,不触达落库")
void saveAndPublish_noNormalNodes_rejected() {
OpportunityStageTemplateDTO dto = validDto();
dto.setNodes(List.of());
assertThatThrownBy(() -> service.saveAndPublish(dto))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_NODE_INVALID)
.hasMessageContaining("除固定节点外至少创建一个阶段节点");
verify(templateMapper, never()).insert(any(OpportunityStageTemplate.class));
verify(templateMapper, never()).updateById(any(OpportunityStageTemplate.class));
}
@Test
@DisplayName("新模板直接发布:落草稿后转发布中")
void saveAndPublish_newTemplate_published() {
OpportunityStageTemplateDTO dto = validDto();
// selectList 序列:#1 编码生成、#2 顶替查询(同 code 无发布中)
when(templateMapper.selectList(any())).thenReturn(List.of(), List.of());
stubInsertWithId(NEW_TPL_ID);
when(templateMapper.updateById(any(OpportunityStageTemplate.class))).thenReturn(1);
service.saveAndPublish(dto);
verify(templateMapper).insert(any(OpportunityStageTemplate.class));
ArgumentCaptor<OpportunityStageTemplate> captor = ArgumentCaptor.forClass(OpportunityStageTemplate.class);
verify(templateMapper).updateById(captor.capture());
assertThat(captor.getValue().getId()).isEqualTo(NEW_TPL_ID);
assertThat(captor.getValue().getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
}
@Test
@DisplayName("发布新版本顶替原发布中:V1.1 转停用、V1.2 转发布中(同 code 自我顶替)")
void saveAndPublish_replacesOldPublished() {
OpportunityStageTemplateDTO dto = validDto();
dto.setId(PUB_ID);
OpportunityStageTemplate v1 = tplRow(PUB_ID, CODE_C1, "V1.1", OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
when(templateMapper.selectById(PUB_ID)).thenReturn(v1);
// selectList 序列:#1 同 code 全版本(无草稿→生成 V1.2)、#2 同 code 原发布中(V1.1)
when(templateMapper.selectList(any())).thenReturn(List.of(v1), List.of(v1));
stubInsertWithId(NEW_TPL_ID);
when(templateMapper.updateById(any(OpportunityStageTemplate.class))).thenReturn(1);
service.saveAndPublish(dto);
ArgumentCaptor<OpportunityStageTemplate> insertCaptor = ArgumentCaptor.forClass(OpportunityStageTemplate.class);
verify(templateMapper).insert(insertCaptor.capture());
assertThat(insertCaptor.getValue().getVersionNo()).isEqualTo("V1.2");
// 两笔状态迁移:V1.1 → 停用;V1.2 → 发布中
ArgumentCaptor<OpportunityStageTemplate> updateCaptor = ArgumentCaptor.forClass(OpportunityStageTemplate.class);
verify(templateMapper, times(2)).updateById(updateCaptor.capture());
List<OpportunityStageTemplate> updates = updateCaptor.getAllValues();
assertThat(updates.get(0).getId()).isEqualTo(PUB_ID);
assertThat(updates.get(0).getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_DISABLED);
assertThat(updates.get(1).getId()).isEqualTo(NEW_TPL_ID);
assertThat(updates.get(1).getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
}
@Test
@DisplayName("发布新默认:原发布中默认自动取消默认(默认唯一是自动顶替,非拒绝)")
void saveAndPublish_newDefault_unsetsOldDefault() {
OpportunityStageTemplateDTO dto = validDto();
dto.setIsDefault(OpportunityRuleConstants.FLAG_YES);
OpportunityStageTemplate oldDefault = tplRow(OLD_DEFAULT_ID, "OPP_STAGE_TPL_88", "V1.0",
OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
oldDefault.setIsDefault(OpportunityRuleConstants.FLAG_YES);
// selectList 序列:#1 编码生成、#2 同 code 原发布中(无)、#3 发布中的旧默认(OLD_DEFAULT)
when(templateMapper.selectList(any())).thenReturn(List.of(), List.of(), List.of(oldDefault));
stubInsertWithId(NEW_TPL_ID);
when(templateMapper.updateById(any(OpportunityStageTemplate.class))).thenReturn(1);
service.saveAndPublish(dto);
ArgumentCaptor<OpportunityStageTemplate> updateCaptor = ArgumentCaptor.forClass(OpportunityStageTemplate.class);
verify(templateMapper, times(2)).updateById(updateCaptor.capture());
List<OpportunityStageTemplate> updates = updateCaptor.getAllValues();
assertThat(updates.get(0).getId()).isEqualTo(OLD_DEFAULT_ID);
assertThat(updates.get(0).getIsDefault()).isEqualTo(OpportunityRuleConstants.FLAG_NO);
assertThat(updates.get(1).getId()).isEqualTo(NEW_TPL_ID);
assertThat(updates.get(1).getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
}
@Test
@DisplayName("草稿直接保存并发布:原位编辑后该草稿转发布中,不新建版本行")
void saveAndPublish_draftDirect_publishesDraftOnly() {
OpportunityStageTemplateDTO dto = validDto();
dto.setId(DRAFT_ID);
when(templateMapper.selectById(DRAFT_ID)).thenReturn(
tplRow(DRAFT_ID, CODE_C1, "V1.1", OpportunityRuleConstants.RULE_STATUS_DRAFT));
// publish 顶替查询:同 code 无其它发布中
when(templateMapper.selectList(any())).thenReturn(List.of());
when(templateMapper.updateById(any(OpportunityStageTemplate.class))).thenReturn(1);
service.saveAndPublish(dto);
verify(templateMapper, never()).insert(any(OpportunityStageTemplate.class));
ArgumentCaptor<OpportunityStageTemplate> updateCaptor = ArgumentCaptor.forClass(OpportunityStageTemplate.class);
verify(templateMapper, times(2)).updateById(updateCaptor.capture());
List<OpportunityStageTemplate> updates = updateCaptor.getAllValues();
// #1 原位编辑(仍草稿)、#2 转发布中
assertThat(updates.get(0).getId()).isEqualTo(DRAFT_ID);
assertThat(updates.get(0).getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_DRAFT);
assertThat(updates.get(1).getId()).isEqualTo(DRAFT_ID);
assertThat(updates.get(1).getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
}
// ==================== 停用 / 删除 / 复制(行操作矩阵) ====================
@Test
@DisplayName("停用:发布中 → 已停用")
void disableTemplate_publishedToDisabled() {
when(templateMapper.selectById(PUB_ID)).thenReturn(
tplRow(PUB_ID, CODE_C1, "V1.0", OpportunityRuleConstants.RULE_STATUS_PUBLISHED));
when(templateMapper.updateById(any(OpportunityStageTemplate.class))).thenReturn(1);
service.disableTemplate(PUB_ID);
ArgumentCaptor<OpportunityStageTemplate> captor = ArgumentCaptor.forClass(OpportunityStageTemplate.class);
verify(templateMapper).updateById(captor.capture());
assertThat(captor.getValue().getId()).isEqualTo(PUB_ID);
assertThat(captor.getValue().getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_DISABLED);
}
@Test
@DisplayName("停用:非发布中(草稿)→ 拒绝 64013,不触达更新")
void disableTemplate_notPublished_rejected() {
when(templateMapper.selectById(DRAFT_ID)).thenReturn(
tplRow(DRAFT_ID, CODE_C1, "V1.1", OpportunityRuleConstants.RULE_STATUS_DRAFT));
assertThatThrownBy(() -> service.disableTemplate(DRAFT_ID))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_INVALID);
verify(templateMapper, never()).updateById(any(OpportunityStageTemplate.class));
}
@Test
@DisplayName("删除草稿:主表逻辑删 + 部门/节点子表级联硬删")
void deleteDraft_draftRemovedWithChildren() {
when(templateMapper.selectById(DRAFT_ID)).thenReturn(
tplRow(DRAFT_ID, CODE_C1, "V1.1", OpportunityRuleConstants.RULE_STATUS_DRAFT));
service.deleteDraft(DRAFT_ID);
verify(templateMapper).deleteById(DRAFT_ID);
verify(deptMapper).delete(any());
verify(nodeMapper).delete(any());
}
@Test
@DisplayName("删除:非草稿(发布中/停用不可删)→ 拒绝 64015")
void deleteDraft_notDraft_rejected() {
when(templateMapper.selectById(PUB_ID)).thenReturn(
tplRow(PUB_ID, CODE_C1, "V1.0", OpportunityRuleConstants.RULE_STATUS_PUBLISHED));
assertThatThrownBy(() -> service.deleteDraft(PUB_ID))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_NOT_DRAFT);
verify(templateMapper, never()).deleteById(any());
verify(deptMapper, never()).delete(any());
verify(nodeMapper, never()).delete(any());
}
@Test
@DisplayName("复制:独立新模板(编码顺延 + V1.0 + 草稿),部门与节点随复制")
void copyTemplate_createsIndependentDraft() {
OpportunityStageTemplate source = tplRow(PUB_ID, CODE_C1, "V1.1", OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
when(templateMapper.selectById(PUB_ID)).thenReturn(source);
// 编码顺延:现有最大 OPP_STAGE_TPL_01 → 新编码 OPP_STAGE_TPL_02
when(templateMapper.selectList(any())).thenReturn(List.of(source));
when(deptMapper.selectList(any())).thenReturn(List.of(deptRow(PUB_ID, DEPT_10)));
when(nodeMapper.selectList(any())).thenReturn(List.of(
nodeRow(7001L, PUB_ID, 1, "OPP_STAGE_01", OpportunityRuleConstants.FLAG_NO),
nodeRow(7002L, PUB_ID, 2, OpportunityRuleConstants.FIXED_STAGE_DICT_CODE, OpportunityRuleConstants.FLAG_YES)));
stubInsertWithId(COPY_ID);
Long newId = service.copyTemplate(PUB_ID);
assertThat(newId).isEqualTo(COPY_ID);
ArgumentCaptor<OpportunityStageTemplate> captor = ArgumentCaptor.forClass(OpportunityStageTemplate.class);
verify(templateMapper).insert(captor.capture());
OpportunityStageTemplate copied = captor.getValue();
assertThat(copied.getTemplateCode()).isEqualTo("OPP_STAGE_TPL_02");
assertThat(copied.getVersionNo()).isEqualTo("V1.0");
assertThat(copied.getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_DRAFT);
// 部门与节点随复制(节点保序,含末位固定节点)
verify(deptMapper).insert(any(OpportunityStageTemplateDept.class));
ArgumentCaptor<OpportunityStageNode> nodeCaptor = ArgumentCaptor.forClass(OpportunityStageNode.class);
verify(nodeMapper, times(2)).insert(nodeCaptor.capture());
assertThat(nodeCaptor.getAllValues()).extracting(OpportunityStageNode::getTemplateId)
.containsOnly(COPY_ID);
assertThat(nodeCaptor.getAllValues()).extracting(OpportunityStageNode::getSeqNo)
.containsExactly(1, 2);
}
@Test
@DisplayName("复制:版本不存在 → 拒绝 64014")
void copyTemplate_notExist_rejected() {
when(templateMapper.selectById(999L)).thenReturn(null);
assertThatThrownBy(() -> service.copyTemplate(999L))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_NOT_EXIST);
}
// ==================== 版本号生成(minor 顺延) ====================
@Test
@DisplayName("版本号顺延:取全版本最大 (major, minor) 的 minor+1;无历史 → V1.0")
void nextVersionNo_minorBump() {
assertThat(OpportunityStageTemplateServiceImpl.nextVersionNo(List.of("V1.0", "V1.1", "V2.0"))).isEqualTo("V2.1");
assertThat(OpportunityStageTemplateServiceImpl.nextVersionNo(List.of("V1.0"))).isEqualTo("V1.1");
assertThat(OpportunityStageTemplateServiceImpl.nextVersionNo(List.of())).isEqualTo("V1.0");
// 防御:非法版本号行忽略,不参与顺延
assertThat(OpportunityStageTemplateServiceImpl.nextVersionNo(List.of("V1.0", "bad"))).isEqualTo("V1.1");
}
// ==================== 绑定匹配(商机创建时解析绑定版本) ====================
@Test
@DisplayName("绑定匹配:无发布中模板 → null")
void resolveBindingVersion_noPublished_returnsNull() {
when(templateMapper.selectList(any())).thenReturn(List.of());
assertThat(service.resolveBindingVersion(DEPT_10)).isNull();
}
@Test
@DisplayName("绑定匹配:部门专用发布模板命中 → 优先于所有商机默认兜底")
void resolveBindingVersion_deptSpecificWinsOverDefault() {
OpportunityStageTemplate specific = tplRow(OTHER_TPL_ID, "OPP_STAGE_TPL_02", "V1.0",
OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
specific.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_DEPT);
OpportunityStageTemplate defaultAll = tplRow(PUB_ID, CODE_C1, "V1.0",
OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
defaultAll.setIsDefault(OpportunityRuleConstants.FLAG_YES);
when(templateMapper.selectList(any())).thenReturn(List.of(specific, defaultAll));
when(deptMapper.selectList(any())).thenReturn(List.of(deptRow(OTHER_TPL_ID, DEPT_10)));
assertThat(service.resolveBindingVersion(DEPT_10)).isEqualTo(specific);
}
@Test
@DisplayName("绑定匹配:多部门专用命中 → 取最近创建者(无部门覆盖唯一约束,绑定须确定)")
void resolveBindingVersion_multipleHits_latestCreatedWins() {
OpportunityStageTemplate older = tplRow(OTHER_TPL_ID, "OPP_STAGE_TPL_02", "V1.0",
OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
older.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_DEPT);
older.setCreateTime(LocalDateTime.of(2026, 8, 1, 10, 0));
OpportunityStageTemplate newer = tplRow(PUB_ID, "OPP_STAGE_TPL_03", "V1.0",
OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
newer.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_DEPT);
newer.setCreateTime(LocalDateTime.of(2026, 8, 20, 10, 0));
when(templateMapper.selectList(any())).thenReturn(List.of(older, newer));
when(deptMapper.selectList(any())).thenReturn(List.of(
deptRow(OTHER_TPL_ID, DEPT_10), deptRow(PUB_ID, DEPT_10)));
assertThat(service.resolveBindingVersion(DEPT_10)).isEqualTo(newer);
}
@Test
@DisplayName("绑定匹配:部门无专用命中 → 回落所有商机+默认模板")
void resolveBindingVersion_noDeptHit_fallsBackToDefault() {
OpportunityStageTemplate specific = tplRow(OTHER_TPL_ID, "OPP_STAGE_TPL_02", "V1.0",
OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
specific.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_DEPT);
OpportunityStageTemplate defaultAll = tplRow(PUB_ID, CODE_C1, "V1.0",
OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
defaultAll.setIsDefault(OpportunityRuleConstants.FLAG_YES);
when(templateMapper.selectList(any())).thenReturn(List.of(specific, defaultAll));
when(deptMapper.selectList(any())).thenReturn(List.of());
assertThat(service.resolveBindingVersion(DEPT_10)).isEqualTo(defaultAll);
}
@Test
@DisplayName("绑定匹配:部门专用命中后不再回落默认;无默认兜底时返回 null")
void resolveBindingVersion_noDefaultFallback_returnsNull() {
OpportunityStageTemplate nonDefault = tplRow(PUB_ID, CODE_C1, "V1.0",
OpportunityRuleConstants.RULE_STATUS_PUBLISHED);
when(templateMapper.selectList(any())).thenReturn(List.of(nonDefault));
assertThat(service.resolveBindingVersion(DEPT_10)).isNull();
}
// ==================== 运行时查询缝(供商机模块消费) ====================
@Test
@DisplayName("getNodeById:null 直接返回空,不查库")
void getNodeById_nullSafe() {
assertThat(service.getNodeById(null)).isNull();
verify(nodeMapper, never()).selectById(any());
}
@Test
@DisplayName("getNodeById:委托节点 Mapper 按主键查询")
void getNodeById_delegatesToMapper() {
OpportunityStageNode node = nodeRow(7001L, PUB_ID, 1, "OPP_STAGE_01", OpportunityRuleConstants.FLAG_NO);
when(nodeMapper.selectById(7001L)).thenReturn(node);
assertThat(service.getNodeById(7001L)).isEqualTo(node);
}
@Test
@DisplayName("listNodesOfVersion:null 返回空列表,不查库")
void listNodesOfVersion_nullSafe() {
assertThat(service.listNodesOfVersion(null)).isEmpty();
verify(nodeMapper, never()).selectList(any());
}
// ==================== 查询(列表/详情 + 部门回显) ====================
@Test
@DisplayName("分页列表:按版本维度返回,回显适用部门 ID 与名称")
void pageTemplates_fillsDeptNames() {
Page<OpportunityStageTemplate> page = new Page<>(1, 10);
page.setRecords(List.of(tplRow(PUB_ID, CODE_C1, "V1.0", OpportunityRuleConstants.RULE_STATUS_PUBLISHED)));
when(templateMapper.selectPage(any(), any())).thenReturn(page);
when(deptMapper.selectList(any())).thenReturn(List.of(deptRow(PUB_ID, DEPT_10)));
when(sysDeptService.listByIds(any())).thenReturn(List.of(sysDept(DEPT_10, "华东销售部")));
var result = service.pageTemplates(new OpportunityStageTemplatePageParam());
assertThat(result.getContent()).hasSize(1);
OpportunityStageTemplateDTO dto = result.getContent().get(0);
assertThat(dto.getTemplateCode()).isEqualTo(CODE_C1);
assertThat(dto.getDeptIds()).containsExactly(DEPT_10);
assertThat(dto.getDeptNames()).containsExactly("华东销售部");
}
@Test
@DisplayName("详情:不存在 → 拒绝 64014")
void getTemplateDetail_notExist_rejected() {
when(templateMapper.selectById(999L)).thenReturn(null);
assertThatThrownBy(() -> service.getTemplateDetail(999L))
.isInstanceOf(BusinessErrorException.class)
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_NOT_EXIST);
}
@Test
@DisplayName("详情:主表字段 + 适用部门回显 + 有序节点(末位固定节点)")
void getTemplateDetail_fillsDeptsAndNodes() {
when(templateMapper.selectById(PUB_ID)).thenReturn(
tplRow(PUB_ID, CODE_C1, "V1.0", OpportunityRuleConstants.RULE_STATUS_PUBLISHED));
when(deptMapper.selectList(any())).thenReturn(List.of(deptRow(PUB_ID, DEPT_10)));
when(sysDeptService.listByIds(any())).thenReturn(List.of(sysDept(DEPT_10, "华东销售部")));
when(nodeMapper.selectList(any())).thenReturn(List.of(
nodeRow(7001L, PUB_ID, 1, "OPP_STAGE_01", OpportunityRuleConstants.FLAG_NO),
nodeRow(7002L, PUB_ID, 2, OpportunityRuleConstants.FIXED_STAGE_DICT_CODE, OpportunityRuleConstants.FLAG_YES)));
OpportunityStageTemplateDTO dto = service.getTemplateDetail(PUB_ID);
assertThat(dto.getTemplateCode()).isEqualTo(CODE_C1);
assertThat(dto.getDeptIds()).containsExactly(DEPT_10);
assertThat(dto.getDeptNames()).containsExactly("华东销售部");
assertThat(dto.getNodes()).hasSize(2);
assertThat(dto.getNodes().get(1).getStageDictCode())
.isEqualTo(OpportunityRuleConstants.FIXED_STAGE_DICT_CODE);
assertThat(dto.getNodes().get(1).getIsFixed()).isEqualTo(OpportunityRuleConstants.FLAG_YES);
}
}
Loading…
Cancel
Save