diff --git a/crm-dict/src/main/java/com/crm/dict/config/DictDataInitializer.java b/crm-dict/src/main/java/com/crm/dict/config/DictDataInitializer.java
index 1d622f1..44490c0 100644
--- a/crm-dict/src/main/java/com/crm/dict/config/DictDataInitializer.java
+++ b/crm-dict/src/main/java/com/crm/dict/config/DictDataInitializer.java
@@ -42,6 +42,7 @@ public class DictDataInitializer implements CommandLineRunner {
/*-------- 首批内置字典种子(Excel 替换口,见类注释)
* 渠道/品牌/需求产品/需求场景选项取自蓝湖原型 A2-1-2-2 新增线索表单(frontend-integration 票据 02);
+ * 商机阶段取自原型 A7-3-2-1 阶段模板轨道图样例(商机模块票 06,OPP_STAGE_05 为固定节点契约码);
* 原型渠道清单中的占位文本「列表项1」不入库。--------*/
private static final List SEED_GROUPS = List.of(
@@ -148,7 +149,14 @@ public class DictDataInitializer implements CommandLineRunner {
new ItemSeed("energy", "能源", "energy", 15, null, false),
new ItemSeed("park", "园区", "park", 16, 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
public void run(String... args) {
diff --git a/crm-dict/src/test/java/com/crm/dict/config/DictDataInitializerTest.java b/crm-dict/src/test/java/com/crm/dict/config/DictDataInitializerTest.java
index 7a34dab..16b8e10 100644
--- a/crm-dict/src/test/java/com/crm/dict/config/DictDataInitializerTest.java
+++ b/crm-dict/src/test/java/com/crm/dict/config/DictDataInitializerTest.java
@@ -31,24 +31,24 @@ class DictDataInitializerTest extends AbstractDictH2Test {
}
@Test
- @DisplayName("首装:6 个内置分组 + 92 个内置项全部建库,ref_count 预建,种子默认项生效")
+ @DisplayName("首装:7 个内置分组 + 97 个内置项全部建库,ref_count 预建,种子默认项生效")
void firstRun_createsBuiltinDictsWithDefaults() {
initializer.run();
List groups = dictGroupMapper.selectList(null);
- assertThat(groups).hasSize(6);
+ assertThat(groups).hasSize(7);
assertThat(groups).extracting(DictGroup::getCode)
.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 -> g.getStatus() == DictConstants.STATUS_ENABLED);
List 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(dictRefCountMapper.selectCount(null)).isEqualTo(92);
+ assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(97);
DictGroup statusGroup = groups.stream()
.filter(g -> g.getCode().equals("customer_status")).findFirst().orElseThrow();
DictItem potential = dictItemMapper.selectList(null).stream()
@@ -69,9 +69,9 @@ class DictDataInitializerTest extends AbstractDictH2Test {
initializer.run();
initializer.run();
- assertThat(dictGroupMapper.selectCount(null)).isEqualTo(6);
- assertThat(dictItemMapper.selectCount(null)).isEqualTo(92);
- assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(92);
+ assertThat(dictGroupMapper.selectCount(null)).isEqualTo(7);
+ assertThat(dictItemMapper.selectCount(null)).isEqualTo(97);
+ assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(97);
assertThat(dictGroupDefaultMapper.selectCount(null)).isEqualTo(1);
}
@@ -194,12 +194,12 @@ class DictDataInitializerTest extends AbstractDictH2Test {
assertThat(dictGroupMapper.selectById(userGroup.getId()).getBuiltin()).isFalse();
assertThat(dictItemMapper.selectList(null).stream()
.filter(i -> i.getGroupId().equals(userGroup.getId()))).isEmpty();
- // 种子其他分组不受影响(继续正常导入):customer_status + industry + brand + product + scene
- assertThat(dictGroupMapper.selectCount(null)).isEqualTo(6); // lead_source(用户) + 其余 5 个内置分组
+ // 种子其他分组不受影响(继续正常导入):customer_status + industry + brand + product + scene + opp_stage
+ assertThat(dictGroupMapper.selectCount(null)).isEqualTo(7); // lead_source(用户) + 其余 6 个内置分组
assertThat(dictGroupMapper.selectList(null).stream()
.filter(g -> g.getCode().equals("industry"))).hasSize(1);
- assertThat(dictItemMapper.selectCount(null)).isEqualTo(54); // 92 - 渠道 38(用户分组整组跳过)
- assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(54);
+ assertThat(dictItemMapper.selectCount(null)).isEqualTo(59); // 97 - 渠道 38(用户分组整组跳过)
+ assertThat(dictRefCountMapper.selectCount(null)).isEqualTo(59);
assertThat(dictGroupDefaultMapper.selectCount(null)).isEqualTo(1); // customer_status 默认项
}
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/constant/OpportunityConstants.java b/crm-opportunity/src/main/java/com/crm/opportunity/constant/OpportunityConstants.java
index cbbbba8..27fe7f7 100644
--- a/crm-opportunity/src/main/java/com/crm/opportunity/constant/OpportunityConstants.java
+++ b/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_STATUS_NOT_ALLOWED = 66003; // 当前状态不允许此操作(守卫前置)
int CODE_CAS_FAIL = 66004; // 乐观锁/状态 CAS 冲突(ADR-0021)
+ int CODE_STAGE_NOT_ALLOWED = 66005; // 当前状态不允许阶段切换(票 06:仅推进中可切)
+ int CODE_STAGE_NODE_INVALID = 66006; // 目标阶段节点非法(不属绑定模板版本/固定节点/不存在,票 06)
/*-------- 进入公海原因 pool_reason(PRD §3.5 枚举,四值封闭;varchar(32) 存语义码)--------
* E3 三个来源值由调用方(Service)按操作场景传入;E10 超期回收由 executeRecycle 写死。
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityStageController.java b/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityStageController.java
new file mode 100644
index 0000000..172fdfd
--- /dev/null
+++ b/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)
+ * 权限策略:不种子化 button 权限点,ApiPermissionInterceptor 对未注册 URL fail-open;
+ * 「可推进节点」权限点语义由前端按权限配置展/隐推进入口(原型概览页三态展示,
+ * 无独立推进按钮——后端语义按自由切换定稿,票 06「六」)。无 owner guard(票 01)。
+ */
+@Tag(name = "商机管理/商机阶段")
+@RestController
+@RequestMapping("/api/opportunity/stage")
+@RequiredArgsConstructor
+public class OpportunityStageController {
+
+ private final OpportunityStageService stageService;
+
+ @Operation(summary = "阶段推进指引(三态进度:已完成/当前/未完成 + 当前节点工作目标)")
+ @GetMapping("/progress")
+ public Result progress(@RequestParam("oppId") Long oppId) {
+ return Result.success(stageService.getStageProgress(oppId));
+ }
+
+ @Operation(summary = "阶段切换(自由切换:连跳/回退;「已转项目」固定节点不可手动切)")
+ @PostMapping("/switch")
+ public Result 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> history(@RequestParam("oppId") Long oppId) {
+ return Result.success(stageService.listStageHistory(oppId));
+ }
+}
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/StageHistoryDTO.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/StageHistoryDTO.java
new file mode 100644
index 0000000..4adc96f
--- /dev/null
+++ b/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;
+ }
+}
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/StageProgressDTO.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/dto/StageProgressDTO.java
new file mode 100644
index 0000000..6a42d76
--- /dev/null
+++ b/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 概览页三态进度展示)
+ * 按商机绑定的模板版本(锁版本,发新版不迁移)展开有序节点;
+ * 节点展示名 = customNodeName 优先,空则前端按 stageDictCode 带字典名。
+ */
+@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 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;
+ }
+}
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityStageHistory.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityStageHistory.java
new file mode 100644
index 0000000..b60e243
--- /dev/null
+++ b/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「五」)
+ * 轴 2 业务阶段每次切换写一条(对称轴 1 的 {@link OpportunityStatusHistory} INSERT 语义):
+ * 自由切换(连跳/回退)每次留痕;「已转项目」固定节点由方案卡转项目自动驱动时也写一条
+ * (操作人=系统身份 0L)。与通用字段级审计 opportunity_oplog(票 09)并存不重复。
+ */
+@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;
+}
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityStageHistoryMapper.java b/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityStageHistoryMapper.java
new file mode 100644
index 0000000..f8ef895
--- /dev/null
+++ b/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 {
+}
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityConvertPortImpl.java b/crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityConvertPortImpl.java
index 5c1d908..f8485d0 100644
--- a/crm-opportunity/src/main/java/com/crm/opportunity/port/inbound/impl/OpportunityConvertPortImpl.java
+++ b/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.OpportunityConvertException;
import com.crm.opportunity.port.inbound.OpportunityConvertPort;
+import com.crm.opportunity.stage.OpportunityStageService;
import com.crm.opportunity.state.OpportunityTransition;
import com.crm.opportunity.state.OpportunityTransitionCmd;
import lombok.RequiredArgsConstructor;
@@ -21,6 +22,9 @@ import org.springframework.transaction.annotation.Transactional;
* 幂等:已终态(5) 安静跳过返回;非推进中/不存在抛 {@link OpportunityConvertException};
* 并发窗口(状态机 CAS 失败)先复查终态——并发重复回调当幂等命中安静跳过,
* 其余 CAS 冲突转译为同一端口异常,保持对外单一异常契约(code review 20260824 补丁)。
+ *
+ * 轴 2 联动(票 06「四」):E8 成功后同步驱动 current_stage_id → 绑定模板版本固定节点
+ * 「已转项目」并写阶段推进历史;幂等命中路径不重复驱动(首次转换时已落)。
*/
@Service
@RequiredArgsConstructor
@@ -31,6 +35,7 @@ public class OpportunityConvertPortImpl implements OpportunityConvertPort {
private final OpportunityMapper oppMapper;
private final OpportunityTransition transition;
+ private final OpportunityStageService stageService;
@Override
@Transactional(rollbackFor = Exception.class)
@@ -50,6 +55,8 @@ public class OpportunityConvertPortImpl implements OpportunityConvertPort {
transition.execute(cmd.opportunityId(),
new OpportunityTransitionCmd.ConvertToProjectCmd(
SYSTEM_OPERATOR, cmd.projectId(), cmd.schemeCardId()));
+ // 轴 2 联动(票 06「四」):E8 成功 → 阶段驱动至固定节点「已转项目」
+ stageService.onConvertedToProject(opp);
} catch (BusinessErrorException e) {
// 并发幂等补丁:CAS 失败可能是并发重复回调(对方事务已先完成 E8),复查终态则当幂等命中
Opportunity latest = oppMapper.selectById(cmd.opportunityId());
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/stage/OpportunityStageService.java b/crm-opportunity/src/main/java/com/crm/opportunity/stage/OpportunityStageService.java
new file mode 100644
index 0000000..2d753d0
--- /dev/null
+++ b/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「五」「六」)
+ *
+ * 阶段推进语义(产品拍板):除「已转项目」固定节点外,其余阶段可自由切换
+ * (连跳+回退),每次写 {@code opportunity_stage_history};固定节点仅由
+ * 「方案卡转项目创建成功」经 {@link #onConvertedToProject} 自动驱动。
+ * 切换走 CAS(锚 current_stage_id)防并发;仅推进中可切(暂缓中保留暂缓前阶段,
+ * 已关闭/已转项目受限禁切)。无 owner guard(票 01:可见即可点,权限全走权限点)。
+ */
+public interface OpportunityStageService {
+
+ /**
+ * 阶段切换(自由切换:连跳/回退均可)。
+ *
+ * - 仅推进中(2)可切:暂缓中(3)保留暂缓前阶段、已关闭(4)/已转项目(5)禁切
+ * - 目标节点须属商机绑定的模板版本,且不得为固定节点
+ * - 目标=当前 → 幂等无操作
+ *
+ */
+ void switchStage(SwitchStageCmd cmd);
+
+ /**
+ * 阶段推进指引(三态进度展示:已完成/当前/未完成 + 当前节点工作目标)。
+ */
+ StageProgressDTO getStageProgress(Long oppId);
+
+ /**
+ * 阶段推进历史(按操作时间倒序)。
+ */
+ List listStageHistory(Long oppId);
+
+ /**
+ * 转项目联动钩子(票 06「四」):E8 状态迁移成功后由转项目端口调用,
+ * 将 current_stage_id 驱动到绑定模板版本的固定节点「已转项目」并写推进历史
+ * (操作人=系统身份 0L)。固定节点缺失(脏数据)时记告警跳过,不阻断转项目。
+ */
+ void onConvertedToProject(Opportunity opp);
+}
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/stage/SwitchStageCmd.java b/crm-opportunity/src/main/java/com/crm/opportunity/stage/SwitchStageCmd.java
new file mode 100644
index 0000000..16a6685
--- /dev/null
+++ b/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) {
+}
diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/stage/impl/OpportunityStageServiceImpl.java b/crm-opportunity/src/main/java/com/crm/opportunity/stage/impl/OpportunityStageServiceImpl.java
new file mode 100644
index 0000000..ac00df4
--- /dev/null
+++ b/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「五」「六」)
+ *
+ * 切换 CAS:{@code UPDATE opportunity SET current_stage_id=? WHERE id=? AND current_stage_id=?原}
+ * 行数=0 即并发冲突(对称轴 1 状态机 CAS 口径,不锚乐观锁 version)。
+ * 每次成功切换/转项目联动写 {@code opportunity_stage_history} 快照。
+ *
+ * 节点查询经 {@link IOpportunityStageTemplateService}(crm-opportunity → crm-rule
+ * 单向依赖,票 01);模板配置侧对商机实体零反依赖。
+ */
+@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()
+ .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()
+ .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 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 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 listStageHistory(Long oppId) {
+ return stageHistoryMapper.selectList(new LambdaQueryWrapper()
+ .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);
+ }
+}
diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/port/inbound/OpportunityConvertPortImplTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/port/inbound/OpportunityConvertPortImplTest.java
index 317e163..01d9532 100644
--- a/crm-opportunity/src/test/java/com/crm/opportunity/port/inbound/OpportunityConvertPortImplTest.java
+++ b/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.mapper.OpportunityMapper;
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.OpportunityTransitionCmd;
import org.junit.jupiter.api.DisplayName;
@@ -40,6 +41,7 @@ class OpportunityConvertPortImplTest {
@Mock private OpportunityMapper oppMapper;
@Mock private OpportunityTransition transition;
+ @Mock private OpportunityStageService stageService;
@InjectMocks
private OpportunityConvertPortImpl port;
@@ -73,6 +75,8 @@ class OpportunityConvertPortImplTest {
.doesNotThrowAnyException();
verify(transition, never()).execute(anyLong(), any());
+ // 幂等命中路径不重复驱动阶段联动(首次转换时已落,票 06「四」)
+ verify(stageService, never()).onConvertedToProject(any());
}
@Test
@@ -89,14 +93,17 @@ class OpportunityConvertPortImplTest {
}
@Test
- @DisplayName("推进中(2) → 调状态机 E8(系统身份 0L + projectId/schemeCardId 落快照)")
+ @DisplayName("推进中(2) → 调状态机 E8(系统身份 0L + projectId/schemeCardId 落快照)+ 阶段联动驱动固定节点")
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));
verify(transition).execute(eq(OPP_ID), eq(
new OpportunityTransitionCmd.ConvertToProjectCmd(0L, PROJECT_ID, SCHEME_CARD_ID)));
+ // 轴 2 联动(票 06「四」):E8 成功后驱动阶段至固定节点「已转项目」
+ verify(stageService).onConvertedToProject(opp);
}
@Test
diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/stage/impl/OpportunityStageServiceImplTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/stage/impl/OpportunityStageServiceImplTest.java
new file mode 100644
index 0000000..4d9288e
--- /dev/null
+++ b/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「五」「六」)。
+ *
+ * 断言点:
+ *
+ * - 切换守卫:仅推进中(2)可切(暂缓/关闭/已转项目 → 66005);目标=当前幂等无操作
+ * - 目标节点校验:不存在 / 不属绑定模板版本 / 固定节点 → 66006
+ * - CAS:{@code current_stage_id} 锚冲突 → 66004;成功写历史快照
+ * - 转项目联动:驱动固定节点 + 系统操作人 0L;缺固定节点告警跳过不阻断;已在固定节点幂等
+ * - 进度展示:三态(COMPLETED/CURRENT/PENDING)+ 当前节点工作目标;脏数据全 PENDING 防御
+ *
+ */
+@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 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 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 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("推进");
+ }
+}
diff --git a/crm-rule/pom.xml b/crm-rule/pom.xml
index f395c5a..2ff2dbd 100644
--- a/crm-rule/pom.xml
+++ b/crm-rule/pom.xml
@@ -15,7 +15,7 @@
crm-rule
CRM 线索规则模块:公海池配置(归属部门/负责市场/人员/领取规则/时效上限)+ 行政区划查找表(sys_region)
-
+
com.crm
@@ -25,6 +25,11 @@
com.crm
crm-auth
+
+
+ com.crm
+ crm-dict
+
org.projectlombok
diff --git a/crm-rule/src/main/java/com/crm/rule/config/OpportunityStageTemplatePermissionInitializer.java b/crm-rule/src/main/java/com/crm/rule/config/OpportunityStageTemplatePermissionInitializer.java
new file mode 100644
index 0000000..a4762ff
--- /dev/null
+++ b/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)
+ * 与 {@link OpportunityRulePermissionInitializer}(公海与提醒规则)分列:同属商机规则子域,
+ * 菜单挂「商机管理」目录(原型 A7-3-2-1 商机阶段设置)。同策略不种子化 button,
+ * ApiPermissionInterceptor 对未注册 URL fail-open。
+ */
+@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)");
+ }
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/constant/OpportunityRuleConstants.java b/crm-rule/src/main/java/com/crm/rule/constant/OpportunityRuleConstants.java
index 50a902a..2cb7c88 100644
--- a/crm-rule/src/main/java/com/crm/rule/constant/OpportunityRuleConstants.java
+++ b/crm-rule/src/main/java/com/crm/rule/constant/OpportunityRuleConstants.java
@@ -1,10 +1,11 @@
package com.crm.rule.constant;
/**
- * 商机规则模块常量(公海规则配置,票 05 / PRD §5)
+ * 商机规则模块常量(公海规则配置 票 05 + 阶段模板 票 06 / PRD §5-§6)
*
* 与 {@link RuleConstants}(线索规则子域)分域维护;商机规则业务错误码同段 64xxx 顺延
- * (64008 起,切片 B 版本状态机校验时启用)。
+ * (64008-64012 公海规则,64013 起阶段模板)。版本状态/适用范围/是否标记为票 05/06/07 共享的
+ * 「版本化配置」公共约定 V-CONFIG 值域(票 06「零」)。
*/
public interface OpportunityRuleConstants {
@@ -36,6 +37,20 @@ public interface OpportunityRuleConstants {
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 顺延) --------*/
/** 规则参数/状态校验失败 */
@@ -52,4 +67,21 @@ public interface OpportunityRuleConstants {
/** 发布时适用部门与其它发布中规则重叠(部门覆盖唯一) */
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;
}
diff --git a/crm-rule/src/main/java/com/crm/rule/controller/OpportunityStageTemplateController.java b/crm-rule/src/main/java/com/crm/rule/controller/OpportunityStageTemplateController.java
new file mode 100644
index 0000000..2f796d2
--- /dev/null
+++ b/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)
+ * 权限策略:不种子化 button 权限点(同公海规则),ApiPermissionInterceptor 对未注册
+ * URL fail-open;行操作矩阵(草稿=详情·编辑·删除,发布中/停用=详情·编辑·复制)由服务层
+ * 状态机校验兜底。原型列表列「绑定商机(数量)」归商机域统计,不在本接口回显。
+ */
+@Tag(name = "商机管理/商机规则")
+@RestController
+@RequestMapping("/api/rule/opp-stage-template")
+@RequiredArgsConstructor
+public class OpportunityStageTemplateController {
+
+ private final IOpportunityStageTemplateService templateService;
+
+ @Operation(summary = "阶段模板分页列表(按版本维度展示)")
+ @PostMapping("/page")
+ public Result> page(OpportunityStageTemplatePageParam param) {
+ return Result.success(templateService.pageTemplates(param));
+ }
+
+ @Operation(summary = "阶段模板详情(含适用部门 + 阶段节点有序列表)")
+ @GetMapping("/detail")
+ public Result detail(@RequestParam("id") Long id) {
+ return Result.success(templateService.getTemplateDetail(id));
+ }
+
+ @Operation(summary = "保存草稿(新建/原位编辑草稿/从发布中停用生成新草稿;节点全量替换)")
+ @PostMapping("/save-draft")
+ public Result saveDraft(OpportunityStageTemplateDTO dto) {
+ templateService.saveDraft(dto);
+ return Result.success();
+ }
+
+ @Operation(summary = "保存并发布(除固定节点外至少一个阶段节点;原发布中转停用、原默认取消默认)")
+ @PostMapping("/publish")
+ public Result publish(OpportunityStageTemplateDTO dto) {
+ templateService.saveAndPublish(dto);
+ return Result.success();
+ }
+
+ @Operation(summary = "复制模板(独立新模板:新编码 + V1.0 草稿,节点随复制)")
+ @PostMapping("/copy")
+ public Result copy(@RequestParam("id") Long id) {
+ return Result.success(templateService.copyTemplate(id));
+ }
+
+ @Operation(summary = "停用(仅发布中可停用)")
+ @PostMapping("/disable")
+ public Result disable(@RequestParam("id") Long id) {
+ templateService.disableTemplate(id);
+ return Result.success();
+ }
+
+ @Operation(summary = "删除(仅草稿可删,级联删节点与适用部门)")
+ @PostMapping("/delete")
+ public Result delete(@RequestParam("id") Long id) {
+ templateService.deleteDraft(id);
+ return Result.success();
+ }
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/domain/dto/OpportunityStageTemplateDTO.java b/crm-rule/src/main/java/com/crm/rule/domain/dto/OpportunityStageTemplateDTO.java
new file mode 100644
index 0000000..34bfdfa
--- /dev/null
+++ b/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)
+ *
+ * templateCode/versionNo/status 系统生成只读:入参侧忽略,出参侧回显;
+ * 适用部门以 ID 列表随主对象上送/回显,阶段节点以 {@link NodeItem} 有序列表随主对象
+ * 整体提交(节点级"保存当前选中阶段"为前端本地态,落库粒度=整版节点全量替换),
+ * Service 层负责拆写子表。
+ */
+@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 deptIds;
+
+ @Schema(description = "适用部门名称列表(出参回显)")
+ private List deptNames;
+
+ @Schema(description = "阶段节点有序列表(序号按列表顺序重算;固定节点系统保证,可省略)")
+ private List 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;
+ }
+
+ /**
+ * 阶段节点项(节点配置:字典值必填 + 自定义名 + 工作目标)。
+ * 入参侧 isFixed 不可伪造——服务层规范化统一保证唯一固定节点(字典值恒
+ * {@code OPP_STAGE_05}、末位);出参侧回显真实 isFixed。
+ */
+ @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;
+ }
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunityStageNode.java b/crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunityStageNode.java
new file mode 100644
index 0000000..014b48e
--- /dev/null
+++ b/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「二」,从属模板版本)
+ *
+ * 一个模板版本 N 个有序节点(轨道图:客户圈定→关系摸排→…→已转项目);
+ * {@code template_id} 指向版本行(非 code 级),每版本一套节点快照。
+ * 节点引用「商机阶段」字典分组项({@code stage_dict_code}),模板内可覆盖显示名
+ * ({@code custom_node_name})/工作目标,不反改字典。
+ *
+ * 固定节点({@code is_fixed=1},字典值恒为 {@code OPP_STAGE_05} 已转项目)每模板
+ * 有且仅一个、位于末位、不可删、不可拖到非末位;仅由「方案卡转项目创建成功」自动驱动
+ * 到达(票 06「四」),保存时由服务层规范化保证。
+ */
+@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;
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunityStageTemplate.java b/crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunityStageTemplate.java
new file mode 100644
index 0000000..1bb9afb
--- /dev/null
+++ b/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)
+ *
+ * 轴 2 业务阶段的配置主体:同一 {@code template_code} 一个模板、多版本,每版本一行;
+ * 版本号系统生成只读(V1.0 → V1.1 minor 顺延)。版本状态机约束(同 code 仅一草稿一发布中、
+ * 默认唯一)由服务层校验,主表不建库级唯一约束(template_code 本就允许多版本共存)。
+ *
+ * 与 {@link OpportunityPoolRule}(票 05)共享 V-CONFIG 版本化配置范式,仅语义从
+ * "规则"→"模板",不共表不共基类(票 06「零」)。适用范围两档单选:1=所有商机 / 2=指定部门,
+ * 本期不做"指定业务类型"档。绑定实体锁版本行 id(V-CONFIG 12),发新版不迁移在途商机。
+ */
+@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;
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunityStageTemplateDept.java b/crm-rule/src/main/java/com/crm/rule/domain/entity/OpportunityStageTemplateDept.java
new file mode 100644
index 0000000..53fbcac
--- /dev/null
+++ b/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「一」)
+ *
+ * 关联表,无审计字段,硬删除(模板版本保存时先删后插);
+ * apply_scope=1(所有商机)时本表无行(原型「适用对象展示 --」)。
+ * 与公海规则不同,阶段模板无"部门覆盖唯一"强约束——同部门可被多个发布中模板覆盖,
+ * 绑定匹配时取最近发布者(见 {@code OpportunityStageTemplateServiceImpl#resolveBindingVersion})。
+ */
+@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;
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/domain/param/OpportunityStageTemplatePageParam.java b/crm-rule/src/main/java/com/crm/rule/domain/param/OpportunityStageTemplatePageParam.java
new file mode 100644
index 0000000..278378a
--- /dev/null
+++ b/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)
+ * keyword 匹配模板名称/模板编码/版本号;列表按版本维度展示(同 code 草稿/发布中/停用各占一行)。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class OpportunityStageTemplatePageParam extends BaseParam {
+
+ @Schema(description = "适用范围筛选:1所有商机 2指定部门")
+ private Integer applyScope;
+
+ @Schema(description = "状态筛选:1草稿 2发布中 3已停用")
+ private Integer status;
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/mapper/OpportunityStageNodeMapper.java b/crm-rule/src/main/java/com/crm/rule/mapper/OpportunityStageNodeMapper.java
new file mode 100644
index 0000000..9b5e608
--- /dev/null
+++ b/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 {
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/mapper/OpportunityStageTemplateDeptMapper.java b/crm-rule/src/main/java/com/crm/rule/mapper/OpportunityStageTemplateDeptMapper.java
new file mode 100644
index 0000000..f0788d8
--- /dev/null
+++ b/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 {
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/mapper/OpportunityStageTemplateMapper.java b/crm-rule/src/main/java/com/crm/rule/mapper/OpportunityStageTemplateMapper.java
new file mode 100644
index 0000000..0cb86ee
--- /dev/null
+++ b/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 {
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/service/IOpportunityStageTemplateService.java b/crm-rule/src/main/java/com/crm/rule/service/IOpportunityStageTemplateService.java
new file mode 100644
index 0000000..5e1693d
--- /dev/null
+++ b/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)
+ *
+ * 轴 2 业务阶段的配置侧:版本化配置范式 V-CONFIG(票 06「零」,与票 05 公海规则同族)——
+ * 同一 template_code 多版本每版本一行;同 code 仅一草稿一发布中;发布使原发布中转停用、
+ * 原默认自动取消默认;仅草稿可删;编辑发布中/停用→生新草稿;复制→新 code 新主体。
+ * 每版本一套有序阶段节点,末位固定「已转项目」节点(字典值恒 {@code OPP_STAGE_05},
+ * 不可删,保存时系统规范化保证)。
+ *
+ * 运行时查询缝({@code getNodeById}/{@code listNodesOfVersion}/{@code resolveBindingVersion})
+ * 供 {@code crm-opportunity} 阶段切换/绑定时调用;本接口不反向依赖商机实体(票 01)。
+ */
+public interface IOpportunityStageTemplateService {
+
+ /**
+ * 分页查询模板列表(按版本维度展示;keyword 匹配名/码/版本)。
+ * 原型列「绑定商机(数量)」归商机域统计,本接口不回显(crm-rule 零反依赖)。
+ */
+ PageResult pageTemplates(OpportunityStageTemplatePageParam param);
+
+ /**
+ * 模板版本详情(含适用部门 ID/名称回显 + 阶段节点有序列表)。
+ */
+ OpportunityStageTemplateDTO getTemplateDetail(Long id);
+
+ /**
+ * 保存草稿:
+ *
+ * - 无 id → 新模板新草稿(系统生成 templateCode + V1.0)
+ * - id 指向草稿 → 原位编辑(templateCode/versionNo/status 不变)
+ * - id 指向发布中/停用 → 生成新草稿(同 code,版本号 minor 顺延;
+ * 同 code 已有草稿则拒绝,进已有草稿编辑)
+ *
+ * 节点全量替换;固定节点系统规范化(唯一、末位、字典值恒 {@code OPP_STAGE_05})。
+ */
+ void saveDraft(OpportunityStageTemplateDTO dto);
+
+ /**
+ * 保存并发布:先按 {@link #saveDraft} 语义落草稿,再执行发布校验
+ * (除固定节点外至少一个阶段节点 → 原发布中转停用 → 原默认取消默认 → 本版本转发布中)。
+ */
+ void saveAndPublish(OpportunityStageTemplateDTO dto);
+
+ /**
+ * 复制模板 → 独立新模板(新 templateCode、V1.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 listNodesOfVersion(Long templateVersionId);
+
+ /**
+ * 绑定匹配:按部门解析应绑定的**发布中**模板版本(V-CONFIG 12,新建商机用)。
+ *
+ * - 优先:指定部门(apply_scope=2)覆盖该部门的发布中模板,多命中取最近创建者
+ * - 兜底:所有商机(apply_scope=1)的发布中默认模板
+ *
+ *
+ * @param deptId 商机负责人部门(可空:仅走默认兜底)
+ * @return 命中模板版本行;无任何发布中模板可匹配时返回 {@code null}
+ */
+ OpportunityStageTemplate resolveBindingVersion(Long deptId);
+}
diff --git a/crm-rule/src/main/java/com/crm/rule/service/impl/OpportunityStageTemplateServiceImpl.java b/crm-rule/src/main/java/com/crm/rule/service/impl/OpportunityStageTemplateServiceImpl.java
new file mode 100644
index 0000000..27c0a61
--- /dev/null
+++ b/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)
+ *
+ * V-CONFIG 版本化配置范式(票 06「零」,与票 05 公海规则同族不共表):同一
+ * template_code 多版本每版本一行;同 code 仅一草稿一发布中;发布使原发布中转停用、
+ * 原默认自动取消默认;仅草稿可删;编辑发布中/停用→生新草稿;复制→新 code 新主体;
+ * 绑定实体锁版本行 id,发新版不迁移。
+ *
+ * 阶段节点差异化约定(相对票 05):
+ *
+ * - 每版本一套有序节点,随版本保存全量替换(seq_no 按提交顺序重算);
+ * - 固定节点「已转项目」系统规范化:入参含固定字典值节点直接拒绝(防伪造),
+ * 末位由系统补唯一固定节点;
+ * - 节点字典引用校验(票 06「二」字典状态规则):仅「商机阶段」分组启用项可新增引用,
+ * 已停用项历史保留但新建/编辑不可选;
+ * - 发布校验:除固定节点外至少一个阶段节点(原型「除固定节点外至少创建一个阶段节点」);
+ * - 无"部门覆盖唯一"强约束(异于票 05):绑定匹配多命中取最近创建者。
+ *
+ */
+@Service
+@RequiredArgsConstructor
+public class OpportunityStageTemplateServiceImpl
+ extends BaseServiceImpl
+ implements IOpportunityStageTemplateService {
+
+ private final OpportunityStageTemplateDeptMapper deptMapper;
+ private final OpportunityStageNodeMapper nodeMapper;
+ private final ISysDeptService sysDeptService;
+ private final DictQueryService dictQueryService;
+
+ // ==================== 查询 ====================
+
+ @Override
+ public PageResult pageTemplates(OpportunityStageTemplatePageParam param) {
+ String keyword = param.getKeyword();
+ Page page = this.page(PageConverter.toMpPage(param),
+ new LambdaQueryWrapper()
+ .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 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 listNodesOfVersion(Long templateVersionId) {
+ if (templateVersionId == null) {
+ return List.of();
+ }
+ return nodeMapper.selectList(new LambdaQueryWrapper()
+ .eq(OpportunityStageNode::getTemplateId, templateVersionId)
+ .orderByAsc(OpportunityStageNode::getSeqNo));
+ }
+
+ @Override
+ public OpportunityStageTemplate resolveBindingVersion(Long deptId) {
+ List published = this.list(
+ new LambdaQueryWrapper()
+ .eq(OpportunityStageTemplate::getStatus, OpportunityRuleConstants.RULE_STATUS_PUBLISHED));
+ if (CollUtil.isEmpty(published)) {
+ return null;
+ }
+ if (deptId != null) {
+ Map 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 rows = deptMapper.selectList(
+ new LambdaQueryWrapper()
+ .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 versions = this.list(
+ new LambdaQueryWrapper()
+ .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 olds = this.list(
+ new LambdaQueryWrapper()
+ .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 oldDefaults = this.list(
+ new LambdaQueryWrapper()
+ .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()
+ .eq(OpportunityStageTemplateDept::getTemplateVersionId, id));
+ nodeMapper.delete(new LambdaQueryWrapper()
+ .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()
+ .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 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 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()
+ .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()
+ .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 dtos) {
+ if (CollUtil.isEmpty(dtos)) {
+ return;
+ }
+ List rows = deptMapper.selectList(
+ new LambdaQueryWrapper()
+ .in(OpportunityStageTemplateDept::getTemplateVersionId,
+ dtos.stream().map(OpportunityStageTemplateDTO::getId)
+ .filter(Objects::nonNull).collect(Collectors.toSet())));
+ Map> versionToDepts = rows.stream().collect(Collectors.groupingBy(
+ OpportunityStageTemplateDept::getTemplateVersionId,
+ Collectors.mapping(OpportunityStageTemplateDept::getDeptId, Collectors.toList())));
+
+ Set deptIds = rows.stream().map(OpportunityStageTemplateDept::getDeptId)
+ .filter(Objects::nonNull).collect(Collectors.toSet());
+ Map nameMap = deptIds.isEmpty() ? Map.of()
+ : sysDeptService.listByIds(deptIds).stream()
+ .collect(Collectors.toMap(SysDept::getId, SysDept::getDeptName));
+
+ dtos.forEach(dto -> {
+ List 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()
+ .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+1(major 不动);
+ * 无历史版本 → V1.0;非法格式行防御性忽略。(同票 05 口径,V-CONFIG 2)
+ */
+ static String nextVersionNo(List 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);
+ }
+}
diff --git a/crm-rule/src/test/java/com/crm/rule/service/impl/OpportunityStageTemplateServiceImplTest.java b/crm-rule/src/test/java/com/crm/rule/service/impl/OpportunityStageTemplateServiceImplTest.java
new file mode 100644
index 0000000..72cd4d1
--- /dev/null
+++ b/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)。
+ *
+ * 断言点:
+ *
+ * - 头+节点校验:名称必填、范围合法、仅所有商机可设默认、指定部门须选部门、
+ * 节点字典值必填、固定字典值 {@code OPP_STAGE_05} 入参直接拒绝(防伪造)
+ * - 保存草稿三分支:新建(V1.0 + 编码顺延)/ 原位编辑 / 发布中-停用生新草稿(minor 顺延,
+ * 同 code 已有草稿拒绝 64016)
+ * - 节点规范化:入参顺序=普通节点 seq 1..n,末位系统补唯一固定节点「已转项目」;
+ * 发布校验除固定节点外至少一个普通节点(64017)
+ * - 发布:原发布中转停用(自我顶替)、新默认顶替旧默认
+ * - 行操作矩阵:仅草稿可删(64015,级联删部门+节点)、仅发布中可停用、
+ * 复制 → 新编码 + V1.0 + 草稿(部门与节点随复制)
+ * - 绑定匹配:部门专用发布模板优先(多命中取最近创建),兜底=所有商机+默认
+ *
+ */
+@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 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 nodeCaptor = ArgumentCaptor.forClass(OpportunityStageNode.class);
+ verify(nodeMapper, times(3)).insert(nodeCaptor.capture());
+ List 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 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 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 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 insertCaptor = ArgumentCaptor.forClass(OpportunityStageTemplate.class);
+ verify(templateMapper).insert(insertCaptor.capture());
+ assertThat(insertCaptor.getValue().getVersionNo()).isEqualTo("V1.2");
+ // 两笔状态迁移:V1.1 → 停用;V1.2 → 发布中
+ ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(OpportunityStageTemplate.class);
+ verify(templateMapper, times(2)).updateById(updateCaptor.capture());
+ List 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 updateCaptor = ArgumentCaptor.forClass(OpportunityStageTemplate.class);
+ verify(templateMapper, times(2)).updateById(updateCaptor.capture());
+ List 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 updateCaptor = ArgumentCaptor.forClass(OpportunityStageTemplate.class);
+ verify(templateMapper, times(2)).updateById(updateCaptor.capture());
+ List 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 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 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 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 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);
+ }
+}