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); + } +}