Browse Source
- crm-rule:阶段模板 V-CONFIG 版本状态机(头表/适用部门/阶段节点三实体 + 草稿三分支/发布顶替/默认顶替/复制/停用/仅草稿可删),固定节点「已转项目」系统规范化(入参拒绝伪造、末位补唯一),节点字典引用校验(仅「商机阶段」分组启用项可新增引用,票01 定稿方向引入 crm-dict 依赖),绑定匹配(部门专用优先/多命中取最近创建/默认兜底),错误码 64013-64017,权限种子仅菜单 - crm-opportunity:opportunity_stage_history 快照 + 阶段自由切换(CAS 锚 current_stage_id、仅推进中可切 66005、固定节点/跨版本禁切 66006)、三态进度/历史查询端点;转项目端口 E8 成功后联动驱动固定节点(系统身份 0L、幂等、缺固定节点告警跳过) - crm-dict:新增「商机阶段」字典分组种子(OPP_STAGE_01..05)及初始化测试断言同步 - 测试:阶段模板状态机 41 用例 + 阶段运行时 17 用例 + ConvertPort 联动断言;全反应器 662 用例绿master
29 changed files with 2765 additions and 18 deletions
@ -0,0 +1,55 @@ |
|||||
|
package com.crm.opportunity.controller; |
||||
|
|
||||
|
import com.crm.base.domain.result.Result; |
||||
|
import com.crm.base.security.SecurityUtils; |
||||
|
import com.crm.opportunity.domain.dto.StageHistoryDTO; |
||||
|
import com.crm.opportunity.domain.dto.StageProgressDTO; |
||||
|
import com.crm.opportunity.stage.OpportunityStageService; |
||||
|
import com.crm.opportunity.stage.SwitchStageCmd; |
||||
|
import io.swagger.v3.oas.annotations.Operation; |
||||
|
import io.swagger.v3.oas.annotations.tags.Tag; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.web.bind.annotation.GetMapping; |
||||
|
import org.springframework.web.bind.annotation.PostMapping; |
||||
|
import org.springframework.web.bind.annotation.RequestMapping; |
||||
|
import org.springframework.web.bind.annotation.RequestParam; |
||||
|
import org.springframework.web.bind.annotation.RestController; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
/** |
||||
|
* 商机阶段(轴 2)接口(薄适配层,只调 {@link OpportunityStageService},票 06) |
||||
|
* <p>权限策略:不种子化 button 权限点,ApiPermissionInterceptor 对未注册 URL fail-open; |
||||
|
* 「可推进节点」权限点语义由前端按权限配置展/隐推进入口(原型概览页三态展示, |
||||
|
* 无独立推进按钮——后端语义按自由切换定稿,票 06「六」)。无 owner guard(票 01)。</p> |
||||
|
*/ |
||||
|
@Tag(name = "商机管理/商机阶段") |
||||
|
@RestController |
||||
|
@RequestMapping("/api/opportunity/stage") |
||||
|
@RequiredArgsConstructor |
||||
|
public class OpportunityStageController { |
||||
|
|
||||
|
private final OpportunityStageService stageService; |
||||
|
|
||||
|
@Operation(summary = "阶段推进指引(三态进度:已完成/当前/未完成 + 当前节点工作目标)") |
||||
|
@GetMapping("/progress") |
||||
|
public Result<StageProgressDTO> progress(@RequestParam("oppId") Long oppId) { |
||||
|
return Result.success(stageService.getStageProgress(oppId)); |
||||
|
} |
||||
|
|
||||
|
@Operation(summary = "阶段切换(自由切换:连跳/回退;「已转项目」固定节点不可手动切)") |
||||
|
@PostMapping("/switch") |
||||
|
public Result<Void> switchStage(@RequestParam("oppId") Long oppId, |
||||
|
@RequestParam("toStageId") Long toStageId, |
||||
|
@RequestParam(value = "remark", required = false) String remark) { |
||||
|
stageService.switchStage(new SwitchStageCmd(oppId, toStageId, |
||||
|
Long.valueOf(SecurityUtils.getRequiredUserId()), remark)); |
||||
|
return Result.success(); |
||||
|
} |
||||
|
|
||||
|
@Operation(summary = "阶段推进历史(按操作时间倒序)") |
||||
|
@GetMapping("/history") |
||||
|
public Result<List<StageHistoryDTO>> history(@RequestParam("oppId") Long oppId) { |
||||
|
return Result.success(stageService.listStageHistory(oppId)); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,74 @@ |
|||||
|
package com.crm.opportunity.domain.dto; |
||||
|
|
||||
|
import com.crm.base.domain.dto.BaseDTO; |
||||
|
import io.swagger.v3.oas.annotations.media.Schema; |
||||
|
import lombok.Data; |
||||
|
import lombok.EqualsAndHashCode; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
/** |
||||
|
* 商机阶段推进指引(票 06「六」/ 原型 A3-1-1-1-1 概览页三态进度展示) |
||||
|
* <p>按商机绑定的模板版本(锁版本,发新版不迁移)展开有序节点; |
||||
|
* 节点展示名 = customNodeName 优先,空则前端按 stageDictCode 带字典名。</p> |
||||
|
*/ |
||||
|
@Data |
||||
|
@EqualsAndHashCode(callSuper = true) |
||||
|
public class StageProgressDTO extends BaseDTO { |
||||
|
|
||||
|
@Schema(description = "商机ID") |
||||
|
private Long oppId; |
||||
|
|
||||
|
@Schema(description = "商机状态(轴1:1待领取 2推进中 3暂缓中 4已关闭 5已转项目)") |
||||
|
private Integer oppStatus; |
||||
|
|
||||
|
@Schema(description = "绑定阶段模板版本行ID(版本级锁死)") |
||||
|
private Long stageTemplateId; |
||||
|
|
||||
|
@Schema(description = "阶段模板版本号(冗余展示)") |
||||
|
private String stageTemplateVersion; |
||||
|
|
||||
|
@Schema(description = "当前阶段节点ID") |
||||
|
private Long currentStageId; |
||||
|
|
||||
|
@Schema(description = "当前节点工作目标(推进指引区展示)") |
||||
|
private String currentWorkGoal; |
||||
|
|
||||
|
@Schema(description = "有序节点列表(按 seq_no 升序,末位为固定节点「已转项目」)") |
||||
|
private List<StageNode> nodes; |
||||
|
|
||||
|
/** |
||||
|
* 单个阶段节点(含三态进度标记)。 |
||||
|
*/ |
||||
|
@Data |
||||
|
public static class StageNode { |
||||
|
|
||||
|
/** 已完成(位于当前节点之前) */ |
||||
|
public static final String STATE_COMPLETED = "COMPLETED"; |
||||
|
/** 当前所在节点 */ |
||||
|
public static final String STATE_CURRENT = "CURRENT"; |
||||
|
/** 未到达 */ |
||||
|
public static final String STATE_PENDING = "PENDING"; |
||||
|
|
||||
|
@Schema(description = "节点ID(opportunity_stage_node.id)") |
||||
|
private Long nodeId; |
||||
|
|
||||
|
@Schema(description = "阶段序号") |
||||
|
private Integer seqNo; |
||||
|
|
||||
|
@Schema(description = "阶段字典值(引用「商机阶段」字典分组项)") |
||||
|
private String stageDictCode; |
||||
|
|
||||
|
@Schema(description = "自定义节点名称(空则前端带字典名)") |
||||
|
private String customNodeName; |
||||
|
|
||||
|
@Schema(description = "阶段工作目标") |
||||
|
private String workGoal; |
||||
|
|
||||
|
@Schema(description = "固定节点:1固定(已转项目)0普通") |
||||
|
private Integer isFixed; |
||||
|
|
||||
|
@Schema(description = "进度状态:COMPLETED已完成 / CURRENT当前 / PENDING未到达") |
||||
|
private String nodeState; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,53 @@ |
|||||
|
package com.crm.opportunity.domain.entity; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.annotation.TableName; |
||||
|
import com.crm.base.domain.entity.BaseEntity; |
||||
|
import jakarta.persistence.Column; |
||||
|
import jakarta.persistence.Entity; |
||||
|
import jakarta.persistence.Index; |
||||
|
import jakarta.persistence.Table; |
||||
|
import lombok.Data; |
||||
|
import lombok.EqualsAndHashCode; |
||||
|
import org.hibernate.annotations.Comment; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
/** |
||||
|
* 商机阶段推进快照(票 06「五」) |
||||
|
* <p>轴 2 业务阶段每次切换写一条(对称轴 1 的 {@link OpportunityStatusHistory} INSERT 语义): |
||||
|
* 自由切换(连跳/回退)每次留痕;「已转项目」固定节点由方案卡转项目自动驱动时也写一条 |
||||
|
* (操作人=系统身份 0L)。与通用字段级审计 opportunity_oplog(票 09)并存不重复。</p> |
||||
|
*/ |
||||
|
@Data |
||||
|
@EqualsAndHashCode(callSuper = true) |
||||
|
@TableName("opportunity_stage_history") |
||||
|
@Entity |
||||
|
@Table(name = "opportunity_stage_history", indexes = { |
||||
|
@Index(name = "idx_stage_history_opp", columnList = "opp_id, operate_time") |
||||
|
}) |
||||
|
public class OpportunityStageHistory extends BaseEntity { |
||||
|
|
||||
|
@Comment("商机ID") |
||||
|
@Column(columnDefinition = "bigint not null comment '商机ID'") |
||||
|
private Long oppId; |
||||
|
|
||||
|
@Comment("原阶段节点ID(opportunity_stage_node.id)") |
||||
|
@Column(columnDefinition = "bigint not null comment '原阶段节点ID'") |
||||
|
private Long fromStageId; |
||||
|
|
||||
|
@Comment("目标阶段节点ID(opportunity_stage_node.id)") |
||||
|
@Column(columnDefinition = "bigint not null comment '目标阶段节点ID'") |
||||
|
private Long toStageId; |
||||
|
|
||||
|
@Comment("操作人ID(方案卡转项目等系统动作传 0L)") |
||||
|
@Column(columnDefinition = "bigint not null comment '操作人ID'") |
||||
|
private Long operatorUserId; |
||||
|
|
||||
|
@Comment("操作时间") |
||||
|
@Column(columnDefinition = "datetime not null comment '操作时间'") |
||||
|
private LocalDateTime operateTime; |
||||
|
|
||||
|
@Comment("推进说明") |
||||
|
@Column(columnDefinition = "varchar(500) comment '推进说明'") |
||||
|
private String remark; |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
package com.crm.opportunity.mapper; |
||||
|
|
||||
|
import com.crm.base.mapper.CrmBaseMapper; |
||||
|
import com.crm.opportunity.domain.entity.OpportunityStageHistory; |
||||
|
import org.apache.ibatis.annotations.Mapper; |
||||
|
|
||||
|
@Mapper |
||||
|
public interface OpportunityStageHistoryMapper extends CrmBaseMapper<OpportunityStageHistory> { |
||||
|
} |
||||
@ -0,0 +1,46 @@ |
|||||
|
package com.crm.opportunity.stage; |
||||
|
|
||||
|
import com.crm.opportunity.domain.dto.StageHistoryDTO; |
||||
|
import com.crm.opportunity.domain.dto.StageProgressDTO; |
||||
|
import com.crm.opportunity.domain.entity.Opportunity; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
/** |
||||
|
* 商机阶段(轴 2)运行时服务(票 06「五」「六」) |
||||
|
* |
||||
|
* <p>阶段推进语义(产品拍板):除「已转项目」固定节点外,其余阶段可自由切换 |
||||
|
* (连跳+回退),每次写 {@code opportunity_stage_history};固定节点仅由 |
||||
|
* 「方案卡转项目创建成功」经 {@link #onConvertedToProject} 自动驱动。 |
||||
|
* 切换走 CAS(锚 current_stage_id)防并发;仅推进中可切(暂缓中保留暂缓前阶段, |
||||
|
* 已关闭/已转项目受限禁切)。无 owner guard(票 01:可见即可点,权限全走权限点)。</p> |
||||
|
*/ |
||||
|
public interface OpportunityStageService { |
||||
|
|
||||
|
/** |
||||
|
* 阶段切换(自由切换:连跳/回退均可)。 |
||||
|
* <ul> |
||||
|
* <li>仅推进中(2)可切:暂缓中(3)保留暂缓前阶段、已关闭(4)/已转项目(5)禁切</li> |
||||
|
* <li>目标节点须属商机绑定的模板版本,且不得为固定节点</li> |
||||
|
* <li>目标=当前 → 幂等无操作</li> |
||||
|
* </ul> |
||||
|
*/ |
||||
|
void switchStage(SwitchStageCmd cmd); |
||||
|
|
||||
|
/** |
||||
|
* 阶段推进指引(三态进度展示:已完成/当前/未完成 + 当前节点工作目标)。 |
||||
|
*/ |
||||
|
StageProgressDTO getStageProgress(Long oppId); |
||||
|
|
||||
|
/** |
||||
|
* 阶段推进历史(按操作时间倒序)。 |
||||
|
*/ |
||||
|
List<StageHistoryDTO> listStageHistory(Long oppId); |
||||
|
|
||||
|
/** |
||||
|
* 转项目联动钩子(票 06「四」):E8 状态迁移成功后由转项目端口调用, |
||||
|
* 将 current_stage_id 驱动到绑定模板版本的固定节点「已转项目」并写推进历史 |
||||
|
* (操作人=系统身份 0L)。固定节点缺失(脏数据)时记告警跳过,不阻断转项目。 |
||||
|
*/ |
||||
|
void onConvertedToProject(Opportunity opp); |
||||
|
} |
||||
@ -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) { |
||||
|
} |
||||
@ -0,0 +1,204 @@ |
|||||
|
package com.crm.opportunity.stage.impl; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; |
||||
|
import com.crm.base.domain.exception.BusinessErrorException; |
||||
|
import com.crm.opportunity.constant.OpportunityConstants; |
||||
|
import com.crm.opportunity.domain.dto.StageHistoryDTO; |
||||
|
import com.crm.opportunity.domain.dto.StageProgressDTO; |
||||
|
import com.crm.opportunity.domain.entity.Opportunity; |
||||
|
import com.crm.opportunity.domain.entity.OpportunityStageHistory; |
||||
|
import com.crm.opportunity.domain.enums.OpportunityStatus; |
||||
|
import com.crm.opportunity.mapper.OpportunityMapper; |
||||
|
import com.crm.opportunity.mapper.OpportunityStageHistoryMapper; |
||||
|
import com.crm.opportunity.stage.OpportunityStageService; |
||||
|
import com.crm.opportunity.stage.SwitchStageCmd; |
||||
|
import com.crm.base.utils.EnumUtils; |
||||
|
import com.crm.rule.constant.OpportunityRuleConstants; |
||||
|
import com.crm.rule.domain.entity.OpportunityStageNode; |
||||
|
import com.crm.rule.service.IOpportunityStageTemplateService; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.ArrayList; |
||||
|
import java.util.List; |
||||
|
import java.util.Objects; |
||||
|
|
||||
|
/** |
||||
|
* 商机阶段(轴 2)运行时服务实现(票 06「五」「六」) |
||||
|
* |
||||
|
* <p>切换 CAS:{@code UPDATE opportunity SET current_stage_id=? WHERE id=? AND current_stage_id=?原} |
||||
|
* 行数=0 即并发冲突(对称轴 1 状态机 CAS 口径,不锚乐观锁 version)。 |
||||
|
* 每次成功切换/转项目联动写 {@code opportunity_stage_history} 快照。</p> |
||||
|
* |
||||
|
* <p>节点查询经 {@link IOpportunityStageTemplateService}(crm-opportunity → crm-rule |
||||
|
* 单向依赖,票 01);模板配置侧对商机实体零反依赖。</p> |
||||
|
*/ |
||||
|
@Slf4j |
||||
|
@Service |
||||
|
@RequiredArgsConstructor |
||||
|
public class OpportunityStageServiceImpl implements OpportunityStageService { |
||||
|
|
||||
|
/** 转项目联动为系统动作,操作人落系统身份 0L(对称 E8/E10 口径) */ |
||||
|
private static final Long SYSTEM_OPERATOR = 0L; |
||||
|
|
||||
|
private final OpportunityMapper oppMapper; |
||||
|
private final OpportunityStageHistoryMapper stageHistoryMapper; |
||||
|
private final IOpportunityStageTemplateService stageTemplateService; |
||||
|
|
||||
|
// ==================== 阶段切换 ====================
|
||||
|
|
||||
|
@Override |
||||
|
@Transactional(rollbackFor = Exception.class) |
||||
|
public void switchStage(SwitchStageCmd cmd) { |
||||
|
if (cmd == null || cmd.oppId() == null || cmd.toStageId() == null || cmd.operatorId() == null) { |
||||
|
throw new BusinessErrorException(OpportunityConstants.CODE_OPP_INVALID, "阶段切换参数缺失"); |
||||
|
} |
||||
|
Opportunity opp = getOppOrThrow(cmd.oppId()); |
||||
|
|
||||
|
OpportunityStatus status = EnumUtils.getByValue(OpportunityStatus.class, opp.getOppStatus()); |
||||
|
if (status != OpportunityStatus.STATUS_ADVANCING) { |
||||
|
// 暂缓中保留暂缓前阶段(取消暂缓后可继续切换);已关闭/已转项目受限禁切(票 06「六」)
|
||||
|
throw new BusinessErrorException(OpportunityConstants.CODE_STAGE_NOT_ALLOWED, |
||||
|
"仅推进中的商机可切换阶段,当前状态:" + opp.getOppStatus()); |
||||
|
} |
||||
|
|
||||
|
if (Objects.equals(cmd.toStageId(), opp.getCurrentStageId())) { |
||||
|
return; // 幂等无操作:目标即当前节点
|
||||
|
} |
||||
|
|
||||
|
OpportunityStageNode target = stageTemplateService.getNodeById(cmd.toStageId()); |
||||
|
if (target == null) { |
||||
|
throw new BusinessErrorException(OpportunityConstants.CODE_STAGE_NODE_INVALID, |
||||
|
"目标阶段节点不存在或已被删除"); |
||||
|
} |
||||
|
if (!Objects.equals(target.getTemplateId(), opp.getStageTemplateId())) { |
||||
|
throw new BusinessErrorException(OpportunityConstants.CODE_STAGE_NODE_INVALID, |
||||
|
"目标阶段不属于本商机绑定的阶段模板版本(绑定后不随模板发新版迁移)"); |
||||
|
} |
||||
|
if (target.getIsFixed() != null && target.getIsFixed() == OpportunityRuleConstants.FLAG_YES) { |
||||
|
throw new BusinessErrorException(OpportunityConstants.CODE_STAGE_NODE_INVALID, |
||||
|
"「已转项目」为固定节点,仅由方案卡转项目自动驱动,不可手动切换"); |
||||
|
} |
||||
|
|
||||
|
int rows = oppMapper.update(null, new LambdaUpdateWrapper<Opportunity>() |
||||
|
.eq(Opportunity::getId, opp.getId()) |
||||
|
.eq(Opportunity::getCurrentStageId, opp.getCurrentStageId()) |
||||
|
.set(Opportunity::getCurrentStageId, cmd.toStageId())); |
||||
|
if (rows == 0) { |
||||
|
throw new BusinessErrorException(OpportunityConstants.CODE_CAS_FAIL, |
||||
|
"阶段切换失败:阶段可能已被他人变更,请刷新后重试"); |
||||
|
} |
||||
|
insertHistory(opp.getId(), opp.getCurrentStageId(), cmd.toStageId(), |
||||
|
cmd.operatorId(), cmd.remark()); |
||||
|
} |
||||
|
|
||||
|
// ==================== 转项目联动钩子 ====================
|
||||
|
|
||||
|
@Override |
||||
|
@Transactional(rollbackFor = Exception.class) |
||||
|
public void onConvertedToProject(Opportunity opp) { |
||||
|
if (opp == null || opp.getStageTemplateId() == null) { |
||||
|
return; |
||||
|
} |
||||
|
OpportunityStageNode fixed = stageTemplateService.listNodesOfVersion(opp.getStageTemplateId()) |
||||
|
.stream() |
||||
|
.filter(n -> n.getIsFixed() != null |
||||
|
&& n.getIsFixed() == OpportunityRuleConstants.FLAG_YES) |
||||
|
.findFirst() |
||||
|
.orElse(null); |
||||
|
if (fixed == null) { |
||||
|
// 脏数据防御:模板版本缺固定节点不阻断转项目(保存侧已规范化保证,理论不可达)
|
||||
|
log.warn("商机 {} 绑定的阶段模板版本 {} 缺固定节点「已转项目」,跳过阶段联动", |
||||
|
opp.getId(), opp.getStageTemplateId()); |
||||
|
return; |
||||
|
} |
||||
|
if (Objects.equals(opp.getCurrentStageId(), fixed.getId())) { |
||||
|
return; // 已在固定节点(幂等)
|
||||
|
} |
||||
|
oppMapper.update(null, new LambdaUpdateWrapper<Opportunity>() |
||||
|
.eq(Opportunity::getId, opp.getId()) |
||||
|
.set(Opportunity::getCurrentStageId, fixed.getId())); |
||||
|
insertHistory(opp.getId(), opp.getCurrentStageId(), fixed.getId(), |
||||
|
SYSTEM_OPERATOR, "方案卡转项目创建成功,自动推进至「已转项目」"); |
||||
|
} |
||||
|
|
||||
|
// ==================== 查询 ====================
|
||||
|
|
||||
|
@Override |
||||
|
public StageProgressDTO getStageProgress(Long oppId) { |
||||
|
Opportunity opp = getOppOrThrow(oppId); |
||||
|
List<OpportunityStageNode> nodes = stageTemplateService.listNodesOfVersion(opp.getStageTemplateId()); |
||||
|
|
||||
|
StageProgressDTO dto = new StageProgressDTO(); |
||||
|
dto.setOppId(opp.getId()); |
||||
|
dto.setOppStatus(opp.getOppStatus()); |
||||
|
dto.setStageTemplateId(opp.getStageTemplateId()); |
||||
|
dto.setStageTemplateVersion(opp.getStageTemplateVersion()); |
||||
|
dto.setCurrentStageId(opp.getCurrentStageId()); |
||||
|
|
||||
|
int currentIndex = -1; |
||||
|
for (int i = 0; i < nodes.size(); i++) { |
||||
|
if (Objects.equals(nodes.get(i).getId(), opp.getCurrentStageId())) { |
||||
|
currentIndex = i; |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
List<StageProgressDTO.StageNode> result = new ArrayList<>(nodes.size()); |
||||
|
for (int i = 0; i < nodes.size(); i++) { |
||||
|
OpportunityStageNode node = nodes.get(i); |
||||
|
StageProgressDTO.StageNode item = new StageProgressDTO.StageNode(); |
||||
|
item.setNodeId(node.getId()); |
||||
|
item.setSeqNo(node.getSeqNo()); |
||||
|
item.setStageDictCode(node.getStageDictCode()); |
||||
|
item.setCustomNodeName(node.getCustomNodeName()); |
||||
|
item.setWorkGoal(node.getWorkGoal()); |
||||
|
item.setIsFixed(node.getIsFixed()); |
||||
|
if (currentIndex < 0) { |
||||
|
item.setNodeState(StageProgressDTO.StageNode.STATE_PENDING); // 脏数据防御:当前节点不在版本内
|
||||
|
} else if (i < currentIndex) { |
||||
|
item.setNodeState(StageProgressDTO.StageNode.STATE_COMPLETED); |
||||
|
} else if (i == currentIndex) { |
||||
|
item.setNodeState(StageProgressDTO.StageNode.STATE_CURRENT); |
||||
|
dto.setCurrentWorkGoal(node.getWorkGoal()); |
||||
|
} else { |
||||
|
item.setNodeState(StageProgressDTO.StageNode.STATE_PENDING); |
||||
|
} |
||||
|
result.add(item); |
||||
|
} |
||||
|
dto.setNodes(result); |
||||
|
return dto; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public List<StageHistoryDTO> listStageHistory(Long oppId) { |
||||
|
return stageHistoryMapper.selectList(new LambdaQueryWrapper<OpportunityStageHistory>() |
||||
|
.eq(OpportunityStageHistory::getOppId, oppId) |
||||
|
.orderByDesc(OpportunityStageHistory::getOperateTime)) |
||||
|
.stream().map(StageHistoryDTO::fromEntity).toList(); |
||||
|
} |
||||
|
|
||||
|
// ==================== 内部工具 ====================
|
||||
|
|
||||
|
private Opportunity getOppOrThrow(Long oppId) { |
||||
|
Opportunity opp = oppMapper.selectById(oppId); |
||||
|
if (opp == null) { |
||||
|
throw new BusinessErrorException(OpportunityConstants.CODE_OPP_NOT_EXIST, "商机不存在或已被删除"); |
||||
|
} |
||||
|
return opp; |
||||
|
} |
||||
|
|
||||
|
private void insertHistory(Long oppId, Long fromStageId, Long toStageId, Long operatorId, String remark) { |
||||
|
OpportunityStageHistory history = new OpportunityStageHistory(); |
||||
|
history.setOppId(oppId); |
||||
|
history.setFromStageId(fromStageId); |
||||
|
history.setToStageId(toStageId); |
||||
|
history.setOperatorUserId(operatorId); |
||||
|
history.setOperateTime(LocalDateTime.now()); |
||||
|
history.setRemark(remark); |
||||
|
stageHistoryMapper.insert(history); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,376 @@ |
|||||
|
package com.crm.opportunity.stage.impl; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration; |
||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; |
||||
|
import com.crm.base.domain.exception.BusinessErrorException; |
||||
|
import com.crm.opportunity.constant.OpportunityConstants; |
||||
|
import com.crm.opportunity.domain.dto.StageHistoryDTO; |
||||
|
import com.crm.opportunity.domain.dto.StageProgressDTO; |
||||
|
import com.crm.opportunity.domain.entity.Opportunity; |
||||
|
import com.crm.opportunity.domain.entity.OpportunityStageHistory; |
||||
|
import com.crm.opportunity.domain.enums.OpportunityStatus; |
||||
|
import com.crm.opportunity.mapper.OpportunityMapper; |
||||
|
import com.crm.opportunity.mapper.OpportunityStageHistoryMapper; |
||||
|
import com.crm.opportunity.stage.SwitchStageCmd; |
||||
|
import com.crm.rule.constant.OpportunityRuleConstants; |
||||
|
import com.crm.rule.domain.entity.OpportunityStageNode; |
||||
|
import com.crm.rule.service.IOpportunityStageTemplateService; |
||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant; |
||||
|
import org.junit.jupiter.api.BeforeAll; |
||||
|
import org.junit.jupiter.api.DisplayName; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.ArgumentCaptor; |
||||
|
import org.mockito.InjectMocks; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.List; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.assertj.core.api.Assertions.assertThatCode; |
||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.Mockito.never; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
import static org.mockito.Mockito.verifyNoInteractions; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
|
||||
|
/** |
||||
|
* 商机阶段(轴 2)运行时服务规格验证(票 06「五」「六」)。 |
||||
|
* |
||||
|
* <p>断言点:</p> |
||||
|
* <ul> |
||||
|
* <li>切换守卫:仅推进中(2)可切(暂缓/关闭/已转项目 → 66005);目标=当前幂等无操作</li> |
||||
|
* <li>目标节点校验:不存在 / 不属绑定模板版本 / 固定节点 → 66006</li> |
||||
|
* <li>CAS:{@code current_stage_id} 锚冲突 → 66004;成功写历史快照</li> |
||||
|
* <li>转项目联动:驱动固定节点 + 系统操作人 0L;缺固定节点告警跳过不阻断;已在固定节点幂等</li> |
||||
|
* <li>进度展示:三态(COMPLETED/CURRENT/PENDING)+ 当前节点工作目标;脏数据全 PENDING 防御</li> |
||||
|
* </ul> |
||||
|
*/ |
||||
|
@DisplayName("商机阶段运行时服务(票 06「五」「六」)") |
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
class OpportunityStageServiceImplTest { |
||||
|
|
||||
|
private static final Long OPP_ID = 7001L; |
||||
|
private static final Long TPL_VERSION_ID = 8001L; |
||||
|
private static final Long NODE_A = 9001L; |
||||
|
private static final Long NODE_B = 9002L; |
||||
|
private static final Long NODE_FIXED = 9003L; |
||||
|
private static final Long OPERATOR = 55L; |
||||
|
|
||||
|
@Mock private OpportunityMapper oppMapper; |
||||
|
@Mock private OpportunityStageHistoryMapper stageHistoryMapper; |
||||
|
@Mock private IOpportunityStageTemplateService stageTemplateService; |
||||
|
|
||||
|
@InjectMocks |
||||
|
private OpportunityStageServiceImpl service; |
||||
|
|
||||
|
@BeforeAll |
||||
|
static void initLambdaCache() { |
||||
|
MapperBuilderAssistant assistant = |
||||
|
new MapperBuilderAssistant(new MybatisConfiguration(), ""); |
||||
|
TableInfoHelper.initTableInfo(assistant, Opportunity.class); |
||||
|
TableInfoHelper.initTableInfo(assistant, OpportunityStageHistory.class); |
||||
|
} |
||||
|
|
||||
|
// ==================== 工厂 ====================
|
||||
|
|
||||
|
private Opportunity opp(OpportunityStatus status, Long currentStageId) { |
||||
|
Opportunity opp = new Opportunity(); |
||||
|
opp.setId(OPP_ID); |
||||
|
opp.setOppStatus(status.getValue()); |
||||
|
opp.setStageTemplateId(TPL_VERSION_ID); |
||||
|
opp.setCurrentStageId(currentStageId); |
||||
|
return opp; |
||||
|
} |
||||
|
|
||||
|
private OpportunityStageNode node(Long id, Long tplVersionId, int seqNo, String dictCode, int isFixed) { |
||||
|
OpportunityStageNode n = new OpportunityStageNode(); |
||||
|
n.setId(id); |
||||
|
n.setTemplateId(tplVersionId); |
||||
|
n.setSeqNo(seqNo); |
||||
|
n.setStageDictCode(dictCode); |
||||
|
n.setCustomNodeName("节点" + dictCode); |
||||
|
n.setWorkGoal("目标" + dictCode); |
||||
|
n.setIsFixed(isFixed); |
||||
|
return n; |
||||
|
} |
||||
|
|
||||
|
// ==================== 阶段切换守卫(票 06「六」) ====================
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("参数缺失 → 拒绝 66001,不查库") |
||||
|
void switchStage_nullParams_rejected() { |
||||
|
assertThatThrownBy(() -> service.switchStage(null)) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityConstants.CODE_OPP_INVALID); |
||||
|
assertThatThrownBy(() -> service.switchStage(new SwitchStageCmd(OPP_ID, null, OPERATOR, null))) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityConstants.CODE_OPP_INVALID); |
||||
|
verifyNoInteractions(oppMapper); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("商机不存在 → 拒绝 66002") |
||||
|
void switchStage_oppNotExist_rejected() { |
||||
|
when(oppMapper.selectById(OPP_ID)).thenReturn(null); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.switchStage(new SwitchStageCmd(OPP_ID, NODE_B, OPERATOR, null))) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityConstants.CODE_OPP_NOT_EXIST); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("守卫:暂缓中保留阶段 → 拒绝 66005(取消暂缓后可继续切换)") |
||||
|
void switchStage_paused_rejected() { |
||||
|
when(oppMapper.selectById(OPP_ID)).thenReturn(opp(OpportunityStatus.STATUS_PAUSED, NODE_A)); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.switchStage(new SwitchStageCmd(OPP_ID, NODE_B, OPERATOR, null))) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityConstants.CODE_STAGE_NOT_ALLOWED); |
||||
|
|
||||
|
verify(oppMapper, never()).update(any(), any()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("守卫:已关闭/已转项目禁切 → 拒绝 66005") |
||||
|
void switchStage_closedOrConverted_rejected() { |
||||
|
when(oppMapper.selectById(OPP_ID)).thenReturn(opp(OpportunityStatus.STATUS_CLOSED, NODE_A)); |
||||
|
assertThatThrownBy(() -> service.switchStage(new SwitchStageCmd(OPP_ID, NODE_B, OPERATOR, null))) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityConstants.CODE_STAGE_NOT_ALLOWED); |
||||
|
|
||||
|
when(oppMapper.selectById(OPP_ID)).thenReturn(opp(OpportunityStatus.STATUS_CONVERTED, NODE_FIXED)); |
||||
|
assertThatThrownBy(() -> service.switchStage(new SwitchStageCmd(OPP_ID, NODE_B, OPERATOR, null))) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityConstants.CODE_STAGE_NOT_ALLOWED); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("幂等:目标即当前节点 → 无操作直接返回,不触达更新/历史") |
||||
|
void switchStage_sameTarget_noOp() { |
||||
|
when(oppMapper.selectById(OPP_ID)).thenReturn(opp(OpportunityStatus.STATUS_ADVANCING, NODE_A)); |
||||
|
|
||||
|
service.switchStage(new SwitchStageCmd(OPP_ID, NODE_A, OPERATOR, null)); |
||||
|
|
||||
|
verify(oppMapper, never()).update(any(), any()); |
||||
|
verify(stageHistoryMapper, never()).insert(any(OpportunityStageHistory.class)); |
||||
|
} |
||||
|
|
||||
|
// ==================== 目标节点校验 ====================
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("目标节点不存在 → 拒绝 66006") |
||||
|
void switchStage_nodeNotExist_rejected() { |
||||
|
when(oppMapper.selectById(OPP_ID)).thenReturn(opp(OpportunityStatus.STATUS_ADVANCING, NODE_A)); |
||||
|
when(stageTemplateService.getNodeById(NODE_B)).thenReturn(null); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.switchStage(new SwitchStageCmd(OPP_ID, NODE_B, OPERATOR, null))) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityConstants.CODE_STAGE_NODE_INVALID); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("目标节点不属绑定模板版本(锁版本,不随发新版迁移)→ 拒绝 66006") |
||||
|
void switchStage_nodeOfOtherVersion_rejected() { |
||||
|
when(oppMapper.selectById(OPP_ID)).thenReturn(opp(OpportunityStatus.STATUS_ADVANCING, NODE_A)); |
||||
|
when(stageTemplateService.getNodeById(NODE_B)).thenReturn( |
||||
|
node(NODE_B, 8999L, 1, "OPP_STAGE_02", OpportunityRuleConstants.FLAG_NO)); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.switchStage(new SwitchStageCmd(OPP_ID, NODE_B, OPERATOR, null))) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityConstants.CODE_STAGE_NODE_INVALID); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("目标为固定节点「已转项目」→ 拒绝 66006(仅方案卡转项目自动驱动)") |
||||
|
void switchStage_fixedNode_rejected() { |
||||
|
when(oppMapper.selectById(OPP_ID)).thenReturn(opp(OpportunityStatus.STATUS_ADVANCING, NODE_A)); |
||||
|
when(stageTemplateService.getNodeById(NODE_FIXED)).thenReturn( |
||||
|
node(NODE_FIXED, TPL_VERSION_ID, 3, OpportunityRuleConstants.FIXED_STAGE_DICT_CODE, |
||||
|
OpportunityRuleConstants.FLAG_YES)); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.switchStage(new SwitchStageCmd(OPP_ID, NODE_FIXED, OPERATOR, null))) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityConstants.CODE_STAGE_NODE_INVALID) |
||||
|
.hasMessageContaining("固定节点"); |
||||
|
} |
||||
|
|
||||
|
// ==================== CAS 与历史快照 ====================
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("CAS 冲突(阶段已被他人变更)→ 拒绝 66004,不写历史") |
||||
|
void switchStage_casFail_rejected() { |
||||
|
when(oppMapper.selectById(OPP_ID)).thenReturn(opp(OpportunityStatus.STATUS_ADVANCING, NODE_A)); |
||||
|
when(stageTemplateService.getNodeById(NODE_B)).thenReturn( |
||||
|
node(NODE_B, TPL_VERSION_ID, 2, "OPP_STAGE_02", OpportunityRuleConstants.FLAG_NO)); |
||||
|
when(oppMapper.update(any(), any())).thenReturn(0); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.switchStage(new SwitchStageCmd(OPP_ID, NODE_B, OPERATOR, null))) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityConstants.CODE_CAS_FAIL); |
||||
|
|
||||
|
verify(stageHistoryMapper, never()).insert(any(OpportunityStageHistory.class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("成功切换(连跳/回退皆可):CAS 更新 current_stage_id + 写历史快照") |
||||
|
void switchStage_success_updatesAndWritesHistory() { |
||||
|
when(oppMapper.selectById(OPP_ID)).thenReturn(opp(OpportunityStatus.STATUS_ADVANCING, NODE_A)); |
||||
|
when(stageTemplateService.getNodeById(NODE_B)).thenReturn( |
||||
|
node(NODE_B, TPL_VERSION_ID, 2, "OPP_STAGE_02", OpportunityRuleConstants.FLAG_NO)); |
||||
|
when(oppMapper.update(any(), any())).thenReturn(1); |
||||
|
|
||||
|
service.switchStage(new SwitchStageCmd(OPP_ID, NODE_B, OPERATOR, "跳过摸排直入采集")); |
||||
|
|
||||
|
verify(oppMapper).update(any(), any()); |
||||
|
ArgumentCaptor<OpportunityStageHistory> captor = ArgumentCaptor.forClass(OpportunityStageHistory.class); |
||||
|
verify(stageHistoryMapper).insert(captor.capture()); |
||||
|
OpportunityStageHistory history = captor.getValue(); |
||||
|
assertThat(history.getOppId()).isEqualTo(OPP_ID); |
||||
|
assertThat(history.getFromStageId()).isEqualTo(NODE_A); |
||||
|
assertThat(history.getToStageId()).isEqualTo(NODE_B); |
||||
|
assertThat(history.getOperatorUserId()).isEqualTo(OPERATOR); |
||||
|
assertThat(history.getRemark()).isEqualTo("跳过摸排直入采集"); |
||||
|
assertThat(history.getOperateTime()).isNotNull(); |
||||
|
} |
||||
|
|
||||
|
// ==================== 转项目联动钩子(票 06「四」) ====================
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("联动:商机或绑定版本为空 → 安静跳过") |
||||
|
void onConverted_nullSafe() { |
||||
|
assertThatCode(() -> service.onConvertedToProject(null)).doesNotThrowAnyException(); |
||||
|
|
||||
|
Opportunity unbound = new Opportunity(); |
||||
|
unbound.setId(OPP_ID); |
||||
|
assertThatCode(() -> service.onConvertedToProject(unbound)).doesNotThrowAnyException(); |
||||
|
|
||||
|
verify(oppMapper, never()).update(any(), any()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("联动:驱动至固定节点 + 系统操作人 0L + 自动推进备注") |
||||
|
void onConverted_drivesToFixedNodeWithSystemOperator() { |
||||
|
Opportunity opp = opp(OpportunityStatus.STATUS_CONVERTED, NODE_B); |
||||
|
when(stageTemplateService.listNodesOfVersion(TPL_VERSION_ID)).thenReturn(List.of( |
||||
|
node(NODE_A, TPL_VERSION_ID, 1, "OPP_STAGE_01", OpportunityRuleConstants.FLAG_NO), |
||||
|
node(NODE_B, TPL_VERSION_ID, 2, "OPP_STAGE_02", OpportunityRuleConstants.FLAG_NO), |
||||
|
node(NODE_FIXED, TPL_VERSION_ID, 3, OpportunityRuleConstants.FIXED_STAGE_DICT_CODE, |
||||
|
OpportunityRuleConstants.FLAG_YES))); |
||||
|
|
||||
|
service.onConvertedToProject(opp); |
||||
|
|
||||
|
verify(oppMapper).update(any(), any()); |
||||
|
ArgumentCaptor<OpportunityStageHistory> captor = ArgumentCaptor.forClass(OpportunityStageHistory.class); |
||||
|
verify(stageHistoryMapper).insert(captor.capture()); |
||||
|
OpportunityStageHistory history = captor.getValue(); |
||||
|
assertThat(history.getFromStageId()).isEqualTo(NODE_B); |
||||
|
assertThat(history.getToStageId()).isEqualTo(NODE_FIXED); |
||||
|
assertThat(history.getOperatorUserId()).isZero(); // 系统身份 0L(对称 E8 口径)
|
||||
|
assertThat(history.getRemark()).contains("已转项目"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("联动:已在固定节点 → 幂等不重复写历史") |
||||
|
void onConverted_alreadyAtFixed_noOp() { |
||||
|
Opportunity opp = opp(OpportunityStatus.STATUS_CONVERTED, NODE_FIXED); |
||||
|
when(stageTemplateService.listNodesOfVersion(TPL_VERSION_ID)).thenReturn(List.of( |
||||
|
node(NODE_FIXED, TPL_VERSION_ID, 3, OpportunityRuleConstants.FIXED_STAGE_DICT_CODE, |
||||
|
OpportunityRuleConstants.FLAG_YES))); |
||||
|
|
||||
|
service.onConvertedToProject(opp); |
||||
|
|
||||
|
verify(oppMapper, never()).update(any(), any()); |
||||
|
verify(stageHistoryMapper, never()).insert(any(OpportunityStageHistory.class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("联动:绑定版本缺固定节点(脏数据)→ 告警跳过,不阻断转项目") |
||||
|
void onConverted_missingFixedNode_skipsSilently() { |
||||
|
Opportunity opp = opp(OpportunityStatus.STATUS_CONVERTED, NODE_A); |
||||
|
when(stageTemplateService.listNodesOfVersion(TPL_VERSION_ID)).thenReturn(List.of( |
||||
|
node(NODE_A, TPL_VERSION_ID, 1, "OPP_STAGE_01", OpportunityRuleConstants.FLAG_NO))); |
||||
|
|
||||
|
assertThatCode(() -> service.onConvertedToProject(opp)).doesNotThrowAnyException(); |
||||
|
|
||||
|
verify(oppMapper, never()).update(any(), any()); |
||||
|
verify(stageHistoryMapper, never()).insert(any(OpportunityStageHistory.class)); |
||||
|
} |
||||
|
|
||||
|
// ==================== 进度展示(票 06「六」三态) ====================
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("进度:已完成/当前/未完成三态 + 当前节点工作目标回显") |
||||
|
void getStageProgress_threeStates() { |
||||
|
when(oppMapper.selectById(OPP_ID)).thenReturn(opp(OpportunityStatus.STATUS_ADVANCING, NODE_B)); |
||||
|
when(stageTemplateService.listNodesOfVersion(TPL_VERSION_ID)).thenReturn(List.of( |
||||
|
node(NODE_A, TPL_VERSION_ID, 1, "OPP_STAGE_01", OpportunityRuleConstants.FLAG_NO), |
||||
|
node(NODE_B, TPL_VERSION_ID, 2, "OPP_STAGE_02", OpportunityRuleConstants.FLAG_NO), |
||||
|
node(NODE_FIXED, TPL_VERSION_ID, 3, OpportunityRuleConstants.FIXED_STAGE_DICT_CODE, |
||||
|
OpportunityRuleConstants.FLAG_YES))); |
||||
|
|
||||
|
StageProgressDTO dto = service.getStageProgress(OPP_ID); |
||||
|
|
||||
|
assertThat(dto.getOppId()).isEqualTo(OPP_ID); |
||||
|
assertThat(dto.getCurrentStageId()).isEqualTo(NODE_B); |
||||
|
assertThat(dto.getCurrentWorkGoal()).isEqualTo("目标OPP_STAGE_02"); |
||||
|
assertThat(dto.getNodes()).hasSize(3); |
||||
|
assertThat(dto.getNodes()).extracting(StageProgressDTO.StageNode::getNodeState) |
||||
|
.containsExactly(StageProgressDTO.StageNode.STATE_COMPLETED, |
||||
|
StageProgressDTO.StageNode.STATE_CURRENT, |
||||
|
StageProgressDTO.StageNode.STATE_PENDING); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("进度:当前节点不在绑定版本内(脏数据防御)→ 全部 PENDING") |
||||
|
void getStageProgress_dirtyCurrentStage_allPending() { |
||||
|
when(oppMapper.selectById(OPP_ID)).thenReturn(opp(OpportunityStatus.STATUS_ADVANCING, 99999L)); |
||||
|
when(stageTemplateService.listNodesOfVersion(TPL_VERSION_ID)).thenReturn(List.of( |
||||
|
node(NODE_A, TPL_VERSION_ID, 1, "OPP_STAGE_01", OpportunityRuleConstants.FLAG_NO))); |
||||
|
|
||||
|
StageProgressDTO dto = service.getStageProgress(OPP_ID); |
||||
|
|
||||
|
assertThat(dto.getNodes()).extracting(StageProgressDTO.StageNode::getNodeState) |
||||
|
.containsExactly(StageProgressDTO.StageNode.STATE_PENDING); |
||||
|
assertThat(dto.getCurrentWorkGoal()).isNull(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("进度:商机不存在 → 拒绝 66002") |
||||
|
void getStageProgress_oppNotExist_rejected() { |
||||
|
when(oppMapper.selectById(OPP_ID)).thenReturn(null); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.getStageProgress(OPP_ID)) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityConstants.CODE_OPP_NOT_EXIST); |
||||
|
} |
||||
|
|
||||
|
// ==================== 历史查询 ====================
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("历史:按操作时间倒序映射 DTO") |
||||
|
void listStageHistory_mapsToDto() { |
||||
|
OpportunityStageHistory h = new OpportunityStageHistory(); |
||||
|
h.setId(1L); |
||||
|
h.setOppId(OPP_ID); |
||||
|
h.setFromStageId(NODE_A); |
||||
|
h.setToStageId(NODE_B); |
||||
|
h.setOperatorUserId(OPERATOR); |
||||
|
h.setOperateTime(LocalDateTime.of(2026, 8, 24, 10, 0)); |
||||
|
h.setRemark("推进"); |
||||
|
when(stageHistoryMapper.selectList(any())).thenReturn(List.of(h)); |
||||
|
|
||||
|
List<StageHistoryDTO> result = service.listStageHistory(OPP_ID); |
||||
|
|
||||
|
assertThat(result).hasSize(1); |
||||
|
StageHistoryDTO dto = result.get(0); |
||||
|
assertThat(dto.getOppId()).isEqualTo(OPP_ID); |
||||
|
assertThat(dto.getFromStageId()).isEqualTo(NODE_A); |
||||
|
assertThat(dto.getToStageId()).isEqualTo(NODE_B); |
||||
|
assertThat(dto.getOperatorUserId()).isEqualTo(OPERATOR); |
||||
|
assertThat(dto.getRemark()).isEqualTo("推进"); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,43 @@ |
|||||
|
package com.crm.rule.config; |
||||
|
|
||||
|
import com.crm.base.domain.dto.PermissionModuleDescriptor; |
||||
|
import com.crm.base.service.PermissionSeeder; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.boot.CommandLineRunner; |
||||
|
import org.springframework.core.annotation.Order; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
/** |
||||
|
* 商机阶段模板权限种子化(仅菜单,票 06) |
||||
|
* <p>与 {@link OpportunityRulePermissionInitializer}(公海与提醒规则)分列:同属商机规则子域, |
||||
|
* 菜单挂「商机管理」目录(原型 A7-3-2-1 商机阶段设置)。同策略不种子化 button, |
||||
|
* ApiPermissionInterceptor 对未注册 URL fail-open。</p> |
||||
|
*/ |
||||
|
@Slf4j |
||||
|
@Component |
||||
|
@Order(14) |
||||
|
@RequiredArgsConstructor |
||||
|
public class OpportunityStageTemplatePermissionInitializer implements CommandLineRunner { |
||||
|
|
||||
|
private final PermissionSeeder permissionSeeder; |
||||
|
|
||||
|
@Override |
||||
|
public void run(String... args) { |
||||
|
log.info("执行商机阶段模板权限种子化检查(仅菜单)..."); |
||||
|
|
||||
|
permissionSeeder.seedModule(new PermissionModuleDescriptor( |
||||
|
"商机管理", |
||||
|
"商机阶段设置", |
||||
|
"/opportunity/stage-template", |
||||
|
"opportunity/stage-template/index", |
||||
|
6, |
||||
|
List.of(), // 不种子化 button,由管理员后续配置
|
||||
|
List.of("ROLE_ADMIN") |
||||
|
)); |
||||
|
|
||||
|
log.info("商机阶段模板权限种子化检查完成:仅菜单(0 个 button)"); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,76 @@ |
|||||
|
package com.crm.rule.controller; |
||||
|
|
||||
|
import com.crm.base.domain.result.PageResult; |
||||
|
import com.crm.base.domain.result.Result; |
||||
|
import com.crm.rule.domain.dto.OpportunityStageTemplateDTO; |
||||
|
import com.crm.rule.domain.param.OpportunityStageTemplatePageParam; |
||||
|
import com.crm.rule.service.IOpportunityStageTemplateService; |
||||
|
import io.swagger.v3.oas.annotations.Operation; |
||||
|
import io.swagger.v3.oas.annotations.tags.Tag; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.web.bind.annotation.GetMapping; |
||||
|
import org.springframework.web.bind.annotation.PostMapping; |
||||
|
import org.springframework.web.bind.annotation.RequestMapping; |
||||
|
import org.springframework.web.bind.annotation.RequestParam; |
||||
|
import org.springframework.web.bind.annotation.RestController; |
||||
|
|
||||
|
/** |
||||
|
* 商机阶段模板接口(薄适配层,只调 {@link IOpportunityStageTemplateService},票 06) |
||||
|
* <p>权限策略:不种子化 button 权限点(同公海规则),ApiPermissionInterceptor 对未注册 |
||||
|
* URL fail-open;行操作矩阵(草稿=详情·编辑·删除,发布中/停用=详情·编辑·复制)由服务层 |
||||
|
* 状态机校验兜底。原型列表列「绑定商机(数量)」归商机域统计,不在本接口回显。</p> |
||||
|
*/ |
||||
|
@Tag(name = "商机管理/商机规则") |
||||
|
@RestController |
||||
|
@RequestMapping("/api/rule/opp-stage-template") |
||||
|
@RequiredArgsConstructor |
||||
|
public class OpportunityStageTemplateController { |
||||
|
|
||||
|
private final IOpportunityStageTemplateService templateService; |
||||
|
|
||||
|
@Operation(summary = "阶段模板分页列表(按版本维度展示)") |
||||
|
@PostMapping("/page") |
||||
|
public Result<PageResult<OpportunityStageTemplateDTO>> page(OpportunityStageTemplatePageParam param) { |
||||
|
return Result.success(templateService.pageTemplates(param)); |
||||
|
} |
||||
|
|
||||
|
@Operation(summary = "阶段模板详情(含适用部门 + 阶段节点有序列表)") |
||||
|
@GetMapping("/detail") |
||||
|
public Result<OpportunityStageTemplateDTO> detail(@RequestParam("id") Long id) { |
||||
|
return Result.success(templateService.getTemplateDetail(id)); |
||||
|
} |
||||
|
|
||||
|
@Operation(summary = "保存草稿(新建/原位编辑草稿/从发布中停用生成新草稿;节点全量替换)") |
||||
|
@PostMapping("/save-draft") |
||||
|
public Result<Void> saveDraft(OpportunityStageTemplateDTO dto) { |
||||
|
templateService.saveDraft(dto); |
||||
|
return Result.success(); |
||||
|
} |
||||
|
|
||||
|
@Operation(summary = "保存并发布(除固定节点外至少一个阶段节点;原发布中转停用、原默认取消默认)") |
||||
|
@PostMapping("/publish") |
||||
|
public Result<Void> publish(OpportunityStageTemplateDTO dto) { |
||||
|
templateService.saveAndPublish(dto); |
||||
|
return Result.success(); |
||||
|
} |
||||
|
|
||||
|
@Operation(summary = "复制模板(独立新模板:新编码 + V1.0 草稿,节点随复制)") |
||||
|
@PostMapping("/copy") |
||||
|
public Result<Long> copy(@RequestParam("id") Long id) { |
||||
|
return Result.success(templateService.copyTemplate(id)); |
||||
|
} |
||||
|
|
||||
|
@Operation(summary = "停用(仅发布中可停用)") |
||||
|
@PostMapping("/disable") |
||||
|
public Result<Void> disable(@RequestParam("id") Long id) { |
||||
|
templateService.disableTemplate(id); |
||||
|
return Result.success(); |
||||
|
} |
||||
|
|
||||
|
@Operation(summary = "删除(仅草稿可删,级联删节点与适用部门)") |
||||
|
@PostMapping("/delete") |
||||
|
public Result<Void> delete(@RequestParam("id") Long id) { |
||||
|
templateService.deleteDraft(id); |
||||
|
return Result.success(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,91 @@ |
|||||
|
package com.crm.rule.domain.dto; |
||||
|
|
||||
|
import com.crm.base.domain.dto.BaseDTO; |
||||
|
import com.crm.rule.domain.entity.OpportunityStageTemplate; |
||||
|
import io.swagger.v3.oas.annotations.media.Schema; |
||||
|
import lombok.Data; |
||||
|
import lombok.EqualsAndHashCode; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
/** |
||||
|
* 商机阶段模板传输对象(写入参 + 出参双向,ADR-0017 Route A,票 06) |
||||
|
* |
||||
|
* <p>templateCode/versionNo/status 系统生成只读:入参侧忽略,出参侧回显; |
||||
|
* 适用部门以 ID 列表随主对象上送/回显,阶段节点以 {@link NodeItem} 有序列表随主对象 |
||||
|
* 整体提交(节点级"保存当前选中阶段"为前端本地态,落库粒度=整版节点全量替换), |
||||
|
* Service 层负责拆写子表。</p> |
||||
|
*/ |
||||
|
@Data |
||||
|
@EqualsAndHashCode(callSuper = true) |
||||
|
public class OpportunityStageTemplateDTO extends BaseDTO { |
||||
|
|
||||
|
@Schema(description = "模板编码(系统生成,只读)") |
||||
|
private String templateCode; |
||||
|
|
||||
|
@Schema(description = "模板名称") |
||||
|
private String templateName; |
||||
|
|
||||
|
@Schema(description = "版本号(系统生成只读:V1.0/V1.1…)") |
||||
|
private String versionNo; |
||||
|
|
||||
|
@Schema(description = "状态:1草稿 2发布中 3已停用(只读)") |
||||
|
private Integer status; |
||||
|
|
||||
|
@Schema(description = "适用范围:1所有商机 2指定部门(两档单选)") |
||||
|
private Integer applyScope; |
||||
|
|
||||
|
@Schema(description = "是否默认:1是 0否(仅所有商机范围可设)") |
||||
|
private Integer isDefault; |
||||
|
|
||||
|
@Schema(description = "模板说明") |
||||
|
private String templateDesc; |
||||
|
|
||||
|
@Schema(description = "适用部门ID列表(applyScope=2 时必填至少一个)") |
||||
|
private List<Long> deptIds; |
||||
|
|
||||
|
@Schema(description = "适用部门名称列表(出参回显)") |
||||
|
private List<String> deptNames; |
||||
|
|
||||
|
@Schema(description = "阶段节点有序列表(序号按列表顺序重算;固定节点系统保证,可省略)") |
||||
|
private List<NodeItem> nodes; |
||||
|
|
||||
|
public static OpportunityStageTemplateDTO fromEntity(OpportunityStageTemplate entity) { |
||||
|
OpportunityStageTemplateDTO dto = new OpportunityStageTemplateDTO(); |
||||
|
org.springframework.beans.BeanUtils.copyProperties(entity, dto); |
||||
|
return dto; |
||||
|
} |
||||
|
|
||||
|
public OpportunityStageTemplate toEntity() { |
||||
|
OpportunityStageTemplate entity = new OpportunityStageTemplate(); |
||||
|
org.springframework.beans.BeanUtils.copyProperties(this, entity); |
||||
|
return entity; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 阶段节点项(节点配置:字典值必填 + 自定义名 + 工作目标)。 |
||||
|
* <p>入参侧 isFixed 不可伪造——服务层规范化统一保证唯一固定节点(字典值恒 |
||||
|
* {@code OPP_STAGE_05}、末位);出参侧回显真实 isFixed。</p> |
||||
|
*/ |
||||
|
@Data |
||||
|
public static class NodeItem { |
||||
|
|
||||
|
@Schema(description = "节点ID(出参回显;新建无)") |
||||
|
private Long id; |
||||
|
|
||||
|
@Schema(description = "阶段序号(出参回显;入参按列表顺序重算)") |
||||
|
private Integer seqNo; |
||||
|
|
||||
|
@Schema(description = "阶段字典值(必填,引用「商机阶段」字典分组项)") |
||||
|
private String stageDictCode; |
||||
|
|
||||
|
@Schema(description = "自定义节点名称(空则带入字典名)") |
||||
|
private String customNodeName; |
||||
|
|
||||
|
@Schema(description = "阶段工作目标") |
||||
|
private String workGoal; |
||||
|
|
||||
|
@Schema(description = "固定节点:1固定(已转项目)0普通(出参回显,入参忽略)") |
||||
|
private Integer isFixed; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,62 @@ |
|||||
|
package com.crm.rule.domain.entity; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.annotation.IdType; |
||||
|
import com.baomidou.mybatisplus.annotation.TableId; |
||||
|
import com.baomidou.mybatisplus.annotation.TableName; |
||||
|
import jakarta.persistence.Column; |
||||
|
import jakarta.persistence.Entity; |
||||
|
import jakarta.persistence.Id; |
||||
|
import jakarta.persistence.Index; |
||||
|
import jakarta.persistence.Table; |
||||
|
import lombok.Data; |
||||
|
import org.hibernate.annotations.Comment; |
||||
|
|
||||
|
/** |
||||
|
* 商机阶段节点(票 06「二」,从属模板版本) |
||||
|
* |
||||
|
* <p>一个模板版本 N 个有序节点(轨道图:客户圈定→关系摸排→…→已转项目); |
||||
|
* {@code template_id} 指向版本行(非 code 级),每版本一套节点快照。 |
||||
|
* 节点引用「商机阶段」字典分组项({@code stage_dict_code}),模板内可覆盖显示名 |
||||
|
* ({@code custom_node_name})/工作目标,不反改字典。</p> |
||||
|
* |
||||
|
* <p>固定节点({@code is_fixed=1},字典值恒为 {@code OPP_STAGE_05} 已转项目)每模板 |
||||
|
* 有且仅一个、位于末位、不可删、不可拖到非末位;仅由「方案卡转项目创建成功」自动驱动 |
||||
|
* 到达(票 06「四」),保存时由服务层规范化保证。</p> |
||||
|
*/ |
||||
|
@Data |
||||
|
@TableName("opportunity_stage_node") |
||||
|
@Entity |
||||
|
@Table(name = "opportunity_stage_node", indexes = { |
||||
|
@Index(name = "idx_stage_node_template", columnList = "template_id, seq_no") |
||||
|
}) |
||||
|
public class OpportunityStageNode { |
||||
|
|
||||
|
@TableId(type = IdType.ASSIGN_ID) |
||||
|
@Id |
||||
|
@Column(name = "id", columnDefinition = "bigint comment '主键'") |
||||
|
private Long id; |
||||
|
|
||||
|
@Comment("所属模板版本ID(opportunity_stage_template.id,版本级非 code 级)") |
||||
|
@Column(columnDefinition = "bigint not null comment '所属模板版本ID'") |
||||
|
private Long templateId; |
||||
|
|
||||
|
@Comment("阶段序号(推进顺序,保存时按节点顺序统一重算)") |
||||
|
@Column(columnDefinition = "int not null comment '阶段序号'") |
||||
|
private Integer seqNo; |
||||
|
|
||||
|
@Comment("阶段字典值(引用「商机阶段」字典分组项,如 OPP_STAGE_01;仅引用不反写字典)") |
||||
|
@Column(columnDefinition = "varchar(64) not null comment '阶段字典值'") |
||||
|
private String stageDictCode; |
||||
|
|
||||
|
@Comment("自定义节点名称(本模板实际展示名;空则带入字典名)") |
||||
|
@Column(columnDefinition = "varchar(100) comment '自定义节点名称'") |
||||
|
private String customNodeName; |
||||
|
|
||||
|
@Comment("阶段工作目标(进入本阶段要完成的工作/业务目标)") |
||||
|
@Column(columnDefinition = "varchar(500) comment '阶段工作目标'") |
||||
|
private String workGoal; |
||||
|
|
||||
|
@Comment("固定节点:1=固定(已转项目)0=普通") |
||||
|
@Column(columnDefinition = "tinyint not null default 0 comment '固定节点:1固定 0普通'") |
||||
|
private Integer isFixed; |
||||
|
} |
||||
@ -0,0 +1,57 @@ |
|||||
|
package com.crm.rule.domain.entity; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.annotation.TableName; |
||||
|
import com.crm.base.domain.entity.BaseEntity; |
||||
|
import jakarta.persistence.Column; |
||||
|
import jakarta.persistence.Entity; |
||||
|
import jakarta.persistence.Table; |
||||
|
import lombok.Data; |
||||
|
import lombok.EqualsAndHashCode; |
||||
|
import org.hibernate.annotations.Comment; |
||||
|
|
||||
|
/** |
||||
|
* 商机阶段模板头表(版本化配置,票 06「一」/ PRD §6) |
||||
|
* |
||||
|
* <p>轴 2 业务阶段的配置主体:同一 {@code template_code} 一个模板、多版本,每版本一行; |
||||
|
* 版本号系统生成只读(V1.0 → V1.1 minor 顺延)。版本状态机约束(同 code 仅一草稿一发布中、 |
||||
|
* 默认唯一)由服务层校验,主表不建库级唯一约束(template_code 本就允许多版本共存)。</p> |
||||
|
* |
||||
|
* <p>与 {@link OpportunityPoolRule}(票 05)共享 V-CONFIG 版本化配置范式,仅语义从 |
||||
|
* "规则"→"模板",不共表不共基类(票 06「零」)。适用范围两档单选:1=所有商机 / 2=指定部门, |
||||
|
* 本期不做"指定业务类型"档。绑定实体锁版本行 id(V-CONFIG 12),发新版不迁移在途商机。</p> |
||||
|
*/ |
||||
|
@Data |
||||
|
@EqualsAndHashCode(callSuper = true) |
||||
|
@TableName("opportunity_stage_template") |
||||
|
@Entity |
||||
|
@Table(name = "opportunity_stage_template") |
||||
|
public class OpportunityStageTemplate extends BaseEntity { |
||||
|
|
||||
|
@Comment("模板编码(系统生成,同模板多版本共用,如 OPP_STAGE_TPL_01)") |
||||
|
@Column(columnDefinition = "varchar(64) not null comment '模板编码'") |
||||
|
private String templateCode; |
||||
|
|
||||
|
@Comment("模板名称(同模板多版本沿用同名)") |
||||
|
@Column(columnDefinition = "varchar(100) not null comment '模板名称'") |
||||
|
private String templateName; |
||||
|
|
||||
|
@Comment("版本号(系统生成只读:V1.0/V1.1/V2.0…)") |
||||
|
@Column(columnDefinition = "varchar(20) not null comment '版本号'") |
||||
|
private String versionNo; |
||||
|
|
||||
|
@Comment("状态:1=草稿 2=发布中 3=已停用(V-CONFIG 3-5)") |
||||
|
@Column(columnDefinition = "tinyint not null comment '状态:1草稿 2发布中 3已停用'") |
||||
|
private Integer status; |
||||
|
|
||||
|
@Comment("适用范围:1=所有商机 2=指定部门(两档单选,票 06 产品拍板)") |
||||
|
@Column(columnDefinition = "tinyint not null comment '适用范围:1所有商机 2指定部门'") |
||||
|
private Integer applyScope; |
||||
|
|
||||
|
@Comment("是否默认:1=是 0=否;仅所有商机范围可设,发布中默认唯一(V-CONFIG 6)") |
||||
|
@Column(columnDefinition = "tinyint not null default 0 comment '是否默认'") |
||||
|
private Integer isDefault; |
||||
|
|
||||
|
@Comment("模板说明(业务用途/适用场景)") |
||||
|
@Column(columnDefinition = "varchar(500) comment '模板说明'") |
||||
|
private String templateDesc; |
||||
|
} |
||||
@ -0,0 +1,42 @@ |
|||||
|
package com.crm.rule.domain.entity; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.annotation.IdType; |
||||
|
import com.baomidou.mybatisplus.annotation.TableId; |
||||
|
import com.baomidou.mybatisplus.annotation.TableName; |
||||
|
import jakarta.persistence.Column; |
||||
|
import jakarta.persistence.Entity; |
||||
|
import jakarta.persistence.Id; |
||||
|
import jakarta.persistence.Table; |
||||
|
import jakarta.persistence.UniqueConstraint; |
||||
|
import lombok.Data; |
||||
|
import org.hibernate.annotations.Comment; |
||||
|
|
||||
|
/** |
||||
|
* 商机阶段模板-适用部门关联(apply_scope=2 指定部门的多值,票 06「一」) |
||||
|
* |
||||
|
* <p>关联表,无审计字段,硬删除(模板版本保存时先删后插); |
||||
|
* apply_scope=1(所有商机)时本表无行(原型「适用对象展示 --」)。 |
||||
|
* 与公海规则不同,阶段模板无"部门覆盖唯一"强约束——同部门可被多个发布中模板覆盖, |
||||
|
* 绑定匹配时取最近发布者(见 {@code OpportunityStageTemplateServiceImpl#resolveBindingVersion})。</p> |
||||
|
*/ |
||||
|
@Data |
||||
|
@TableName("opportunity_stage_template_dept") |
||||
|
@Entity |
||||
|
@Table(name = "opportunity_stage_template_dept", uniqueConstraints = { |
||||
|
@UniqueConstraint(name = "uk_ost_version_dept", columnNames = {"template_version_id", "dept_id"}) |
||||
|
}) |
||||
|
public class OpportunityStageTemplateDept { |
||||
|
|
||||
|
@TableId(type = IdType.ASSIGN_ID) |
||||
|
@Id |
||||
|
@Column(name = "id", columnDefinition = "bigint comment '主键'") |
||||
|
private Long id; |
||||
|
|
||||
|
@Comment("模板版本ID(opportunity_stage_template.id,版本行主键)") |
||||
|
@Column(columnDefinition = "bigint not null comment '模板版本ID'") |
||||
|
private Long templateVersionId; |
||||
|
|
||||
|
@Comment("适用部门ID") |
||||
|
@Column(columnDefinition = "bigint not null comment '适用部门ID'") |
||||
|
private Long deptId; |
||||
|
} |
||||
@ -0,0 +1,21 @@ |
|||||
|
package com.crm.rule.domain.param; |
||||
|
|
||||
|
import com.crm.base.domain.param.BaseParam; |
||||
|
import io.swagger.v3.oas.annotations.media.Schema; |
||||
|
import lombok.Data; |
||||
|
import lombok.EqualsAndHashCode; |
||||
|
|
||||
|
/** |
||||
|
* 商机阶段模板分页查询参数(票 06) |
||||
|
* <p>keyword 匹配模板名称/模板编码/版本号;列表按版本维度展示(同 code 草稿/发布中/停用各占一行)。</p> |
||||
|
*/ |
||||
|
@Data |
||||
|
@EqualsAndHashCode(callSuper = true) |
||||
|
public class OpportunityStageTemplatePageParam extends BaseParam { |
||||
|
|
||||
|
@Schema(description = "适用范围筛选:1所有商机 2指定部门") |
||||
|
private Integer applyScope; |
||||
|
|
||||
|
@Schema(description = "状态筛选:1草稿 2发布中 3已停用") |
||||
|
private Integer status; |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
package com.crm.rule.mapper; |
||||
|
|
||||
|
import com.crm.base.mapper.CrmBaseMapper; |
||||
|
import com.crm.rule.domain.entity.OpportunityStageNode; |
||||
|
import org.apache.ibatis.annotations.Mapper; |
||||
|
|
||||
|
@Mapper |
||||
|
public interface OpportunityStageNodeMapper extends CrmBaseMapper<OpportunityStageNode> { |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
package com.crm.rule.mapper; |
||||
|
|
||||
|
import com.crm.base.mapper.CrmBaseMapper; |
||||
|
import com.crm.rule.domain.entity.OpportunityStageTemplateDept; |
||||
|
import org.apache.ibatis.annotations.Mapper; |
||||
|
|
||||
|
@Mapper |
||||
|
public interface OpportunityStageTemplateDeptMapper extends CrmBaseMapper<OpportunityStageTemplateDept> { |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
package com.crm.rule.mapper; |
||||
|
|
||||
|
import com.crm.base.mapper.CrmBaseMapper; |
||||
|
import com.crm.rule.domain.entity.OpportunityStageTemplate; |
||||
|
import org.apache.ibatis.annotations.Mapper; |
||||
|
|
||||
|
@Mapper |
||||
|
public interface OpportunityStageTemplateMapper extends CrmBaseMapper<OpportunityStageTemplate> { |
||||
|
} |
||||
@ -0,0 +1,96 @@ |
|||||
|
package com.crm.rule.service; |
||||
|
|
||||
|
import com.crm.base.domain.result.PageResult; |
||||
|
import com.crm.rule.domain.dto.OpportunityStageTemplateDTO; |
||||
|
import com.crm.rule.domain.entity.OpportunityStageNode; |
||||
|
import com.crm.rule.domain.entity.OpportunityStageTemplate; |
||||
|
import com.crm.rule.domain.param.OpportunityStageTemplatePageParam; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
/** |
||||
|
* 商机阶段模板版本状态机服务(票 06 / PRD §6) |
||||
|
* |
||||
|
* <p>轴 2 业务阶段的配置侧:版本化配置范式 V-CONFIG(票 06「零」,与票 05 公海规则同族)—— |
||||
|
* 同一 template_code 多版本每版本一行;同 code 仅一草稿一发布中;发布使原发布中转停用、 |
||||
|
* 原默认自动取消默认;仅草稿可删;编辑发布中/停用→生新草稿;复制→新 code 新主体。 |
||||
|
* 每版本一套有序阶段节点,末位固定「已转项目」节点(字典值恒 {@code OPP_STAGE_05}, |
||||
|
* 不可删,保存时系统规范化保证)。</p> |
||||
|
* |
||||
|
* <p>运行时查询缝({@code getNodeById}/{@code listNodesOfVersion}/{@code resolveBindingVersion}) |
||||
|
* 供 {@code crm-opportunity} 阶段切换/绑定时调用;本接口不反向依赖商机实体(票 01)。</p> |
||||
|
*/ |
||||
|
public interface IOpportunityStageTemplateService { |
||||
|
|
||||
|
/** |
||||
|
* 分页查询模板列表(按版本维度展示;keyword 匹配名/码/版本)。 |
||||
|
* <p>原型列「绑定商机(数量)」归商机域统计,本接口不回显(crm-rule 零反依赖)。</p> |
||||
|
*/ |
||||
|
PageResult<OpportunityStageTemplateDTO> pageTemplates(OpportunityStageTemplatePageParam param); |
||||
|
|
||||
|
/** |
||||
|
* 模板版本详情(含适用部门 ID/名称回显 + 阶段节点有序列表)。 |
||||
|
*/ |
||||
|
OpportunityStageTemplateDTO getTemplateDetail(Long id); |
||||
|
|
||||
|
/** |
||||
|
* 保存草稿: |
||||
|
* <ul> |
||||
|
* <li>无 id → 新模板新草稿(系统生成 templateCode + V1.0)</li> |
||||
|
* <li>id 指向草稿 → 原位编辑(templateCode/versionNo/status 不变)</li> |
||||
|
* <li>id 指向发布中/停用 → 生成新草稿(同 code,版本号 minor 顺延; |
||||
|
* 同 code 已有草稿则拒绝,进已有草稿编辑)</li> |
||||
|
* </ul> |
||||
|
* 节点全量替换;固定节点系统规范化(唯一、末位、字典值恒 {@code OPP_STAGE_05})。 |
||||
|
*/ |
||||
|
void saveDraft(OpportunityStageTemplateDTO dto); |
||||
|
|
||||
|
/** |
||||
|
* 保存并发布:先按 {@link #saveDraft} 语义落草稿,再执行发布校验 |
||||
|
* (除固定节点外至少一个阶段节点 → 原发布中转停用 → 原默认取消默认 → 本版本转发布中)。 |
||||
|
*/ |
||||
|
void saveAndPublish(OpportunityStageTemplateDTO dto); |
||||
|
|
||||
|
/** |
||||
|
* 复制模板 → 独立新模板(新 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<OpportunityStageNode> listNodesOfVersion(Long templateVersionId); |
||||
|
|
||||
|
/** |
||||
|
* 绑定匹配:按部门解析应绑定的**发布中**模板版本(V-CONFIG 12,新建商机用)。 |
||||
|
* <ul> |
||||
|
* <li>优先:指定部门(apply_scope=2)覆盖该部门的发布中模板,多命中取最近创建者</li> |
||||
|
* <li>兜底:所有商机(apply_scope=1)的发布中默认模板</li> |
||||
|
* </ul> |
||||
|
* |
||||
|
* @param deptId 商机负责人部门(可空:仅走默认兜底) |
||||
|
* @return 命中模板版本行;无任何发布中模板可匹配时返回 {@code null} |
||||
|
*/ |
||||
|
OpportunityStageTemplate resolveBindingVersion(Long deptId); |
||||
|
} |
||||
@ -0,0 +1,535 @@ |
|||||
|
package com.crm.rule.service.impl; |
||||
|
|
||||
|
import cn.hutool.core.collection.CollUtil; |
||||
|
import cn.hutool.core.util.StrUtil; |
||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; |
||||
|
import com.crm.auth.domain.entity.SysDept; |
||||
|
import com.crm.auth.service.ISysDeptService; |
||||
|
import com.crm.base.domain.exception.BusinessErrorException; |
||||
|
import com.crm.base.domain.result.PageResult; |
||||
|
import com.crm.base.service.impl.BaseServiceImpl; |
||||
|
import com.crm.base.utils.PageConverter; |
||||
|
import com.crm.dict.domain.dto.DictItemDTO; |
||||
|
import com.crm.dict.service.DictQueryService; |
||||
|
import com.crm.rule.constant.OpportunityRuleConstants; |
||||
|
import com.crm.rule.domain.dto.OpportunityStageTemplateDTO; |
||||
|
import com.crm.rule.domain.entity.OpportunityStageNode; |
||||
|
import com.crm.rule.domain.entity.OpportunityStageTemplate; |
||||
|
import com.crm.rule.domain.entity.OpportunityStageTemplateDept; |
||||
|
import com.crm.rule.domain.param.OpportunityStageTemplatePageParam; |
||||
|
import com.crm.rule.mapper.OpportunityStageNodeMapper; |
||||
|
import com.crm.rule.mapper.OpportunityStageTemplateDeptMapper; |
||||
|
import com.crm.rule.mapper.OpportunityStageTemplateMapper; |
||||
|
import com.crm.rule.service.IOpportunityStageTemplateService; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.util.ArrayList; |
||||
|
import java.util.Comparator; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
import java.util.Objects; |
||||
|
import java.util.Set; |
||||
|
import java.util.stream.Collectors; |
||||
|
|
||||
|
/** |
||||
|
* 商机阶段模板版本状态机实现(票 06 / PRD §6.2) |
||||
|
* |
||||
|
* <p>V-CONFIG 版本化配置范式(票 06「零」,与票 05 公海规则同族不共表):同一 |
||||
|
* template_code 多版本每版本一行;同 code 仅一草稿一发布中;发布使原发布中转停用、 |
||||
|
* 原默认自动取消默认;仅草稿可删;编辑发布中/停用→生新草稿;复制→新 code 新主体; |
||||
|
* 绑定实体锁版本行 id,发新版不迁移。</p> |
||||
|
* |
||||
|
* <p>阶段节点差异化约定(相对票 05):</p> |
||||
|
* <ul> |
||||
|
* <li>每版本一套有序节点,随版本保存全量替换(seq_no 按提交顺序重算);</li> |
||||
|
* <li>固定节点「已转项目」系统规范化:入参含固定字典值节点直接拒绝(防伪造), |
||||
|
* 末位由系统补唯一固定节点;</li> |
||||
|
* <li>节点字典引用校验(票 06「二」字典状态规则):仅「商机阶段」分组启用项可新增引用, |
||||
|
* 已停用项历史保留但新建/编辑不可选;</li> |
||||
|
* <li>发布校验:除固定节点外至少一个阶段节点(原型「除固定节点外至少创建一个阶段节点」);</li> |
||||
|
* <li>无"部门覆盖唯一"强约束(异于票 05):绑定匹配多命中取最近创建者。</li> |
||||
|
* </ul> |
||||
|
*/ |
||||
|
@Service |
||||
|
@RequiredArgsConstructor |
||||
|
public class OpportunityStageTemplateServiceImpl |
||||
|
extends BaseServiceImpl<OpportunityStageTemplateMapper, OpportunityStageTemplate> |
||||
|
implements IOpportunityStageTemplateService { |
||||
|
|
||||
|
private final OpportunityStageTemplateDeptMapper deptMapper; |
||||
|
private final OpportunityStageNodeMapper nodeMapper; |
||||
|
private final ISysDeptService sysDeptService; |
||||
|
private final DictQueryService dictQueryService; |
||||
|
|
||||
|
// ==================== 查询 ====================
|
||||
|
|
||||
|
@Override |
||||
|
public PageResult<OpportunityStageTemplateDTO> pageTemplates(OpportunityStageTemplatePageParam param) { |
||||
|
String keyword = param.getKeyword(); |
||||
|
Page<OpportunityStageTemplate> page = this.page(PageConverter.toMpPage(param), |
||||
|
new LambdaQueryWrapper<OpportunityStageTemplate>() |
||||
|
.and(StrUtil.isNotBlank(keyword), w -> w |
||||
|
.like(OpportunityStageTemplate::getTemplateName, keyword) |
||||
|
.or().like(OpportunityStageTemplate::getTemplateCode, keyword) |
||||
|
.or().like(OpportunityStageTemplate::getVersionNo, keyword)) |
||||
|
.eq(param.getApplyScope() != null, |
||||
|
OpportunityStageTemplate::getApplyScope, param.getApplyScope()) |
||||
|
.eq(param.getStatus() != null, |
||||
|
OpportunityStageTemplate::getStatus, param.getStatus()) |
||||
|
.orderByDesc(OpportunityStageTemplate::getCreateTime)); |
||||
|
|
||||
|
PageResult<OpportunityStageTemplateDTO> result = |
||||
|
new PageResult<>(page).convert(OpportunityStageTemplateDTO::fromEntity); |
||||
|
fillDeptNames(result.getContent()); |
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public OpportunityStageTemplateDTO getTemplateDetail(Long id) { |
||||
|
OpportunityStageTemplate template = getTemplateByIdOrThrow(id); |
||||
|
OpportunityStageTemplateDTO dto = OpportunityStageTemplateDTO.fromEntity(template); |
||||
|
fillDeptNames(List.of(dto)); |
||||
|
dto.setNodes(listNodesOfVersion(id).stream() |
||||
|
.map(OpportunityStageTemplateServiceImpl::toNodeItem) |
||||
|
.collect(Collectors.toList())); |
||||
|
return dto; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public OpportunityStageNode getNodeById(Long nodeId) { |
||||
|
if (nodeId == null) { |
||||
|
return null; |
||||
|
} |
||||
|
return nodeMapper.selectById(nodeId); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public List<OpportunityStageNode> listNodesOfVersion(Long templateVersionId) { |
||||
|
if (templateVersionId == null) { |
||||
|
return List.of(); |
||||
|
} |
||||
|
return nodeMapper.selectList(new LambdaQueryWrapper<OpportunityStageNode>() |
||||
|
.eq(OpportunityStageNode::getTemplateId, templateVersionId) |
||||
|
.orderByAsc(OpportunityStageNode::getSeqNo)); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public OpportunityStageTemplate resolveBindingVersion(Long deptId) { |
||||
|
List<OpportunityStageTemplate> published = this.list( |
||||
|
new LambdaQueryWrapper<OpportunityStageTemplate>() |
||||
|
.eq(OpportunityStageTemplate::getStatus, OpportunityRuleConstants.RULE_STATUS_PUBLISHED)); |
||||
|
if (CollUtil.isEmpty(published)) { |
||||
|
return null; |
||||
|
} |
||||
|
if (deptId != null) { |
||||
|
Map<Long, OpportunityStageTemplate> specificById = published.stream() |
||||
|
.filter(t -> Integer.valueOf(OpportunityRuleConstants.APPLY_SCOPE_DEPT) |
||||
|
.equals(t.getApplyScope())) |
||||
|
.collect(Collectors.toMap(OpportunityStageTemplate::getId, t -> t, (first, dup) -> first)); |
||||
|
if (!specificById.isEmpty()) { |
||||
|
List<OpportunityStageTemplateDept> rows = deptMapper.selectList( |
||||
|
new LambdaQueryWrapper<OpportunityStageTemplateDept>() |
||||
|
.in(OpportunityStageTemplateDept::getTemplateVersionId, specificById.keySet()) |
||||
|
.eq(OpportunityStageTemplateDept::getDeptId, deptId)); |
||||
|
OpportunityStageTemplate hit = rows.stream() |
||||
|
.map(row -> specificById.get(row.getTemplateVersionId())) |
||||
|
.filter(Objects::nonNull) |
||||
|
// 无部门覆盖唯一约束(异于票 05):多命中取最近创建者,保证绑定确定
|
||||
|
.max(Comparator.comparing(OpportunityStageTemplate::getCreateTime, |
||||
|
Comparator.nullsFirst(Comparator.naturalOrder()))) |
||||
|
.orElse(null); |
||||
|
if (hit != null) { |
||||
|
return hit; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return published.stream() |
||||
|
.filter(t -> Integer.valueOf(OpportunityRuleConstants.APPLY_SCOPE_ALL) |
||||
|
.equals(t.getApplyScope())) |
||||
|
.filter(t -> Integer.valueOf(OpportunityRuleConstants.FLAG_YES) |
||||
|
.equals(t.getIsDefault())) |
||||
|
.findFirst() |
||||
|
.orElse(null); |
||||
|
} |
||||
|
|
||||
|
// ==================== 保存草稿 / 保存并发布 ====================
|
||||
|
|
||||
|
@Override |
||||
|
@Transactional(rollbackFor = Exception.class) |
||||
|
public void saveDraft(OpportunityStageTemplateDTO dto) { |
||||
|
validateTemplate(dto); |
||||
|
resolveDraft(dto); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
@Transactional(rollbackFor = Exception.class) |
||||
|
public void saveAndPublish(OpportunityStageTemplateDTO dto) { |
||||
|
validateTemplate(dto); |
||||
|
validateForPublish(dto); |
||||
|
OpportunityStageTemplate draft = resolveDraft(dto); |
||||
|
publish(draft); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 落草稿三分支:新建(新编码 + V1.0)/ 原位编辑草稿 / 从发布中-停用生成新草稿(minor 顺延)。 |
||||
|
* 节点 + 适用部门子表均随版本全量替换。 |
||||
|
* |
||||
|
* @return 已持久化的草稿行(含 id) |
||||
|
*/ |
||||
|
private OpportunityStageTemplate resolveDraft(OpportunityStageTemplateDTO dto) { |
||||
|
if (dto.getId() == null) { |
||||
|
OpportunityStageTemplate template = dto.toEntity(); |
||||
|
template.setTemplateCode(nextTemplateCode()); |
||||
|
template.setVersionNo("V1.0"); |
||||
|
template.setStatus(OpportunityRuleConstants.RULE_STATUS_DRAFT); |
||||
|
this.save(template); |
||||
|
replaceChildRows(template.getId(), dto); |
||||
|
return template; |
||||
|
} |
||||
|
|
||||
|
OpportunityStageTemplate exist = getTemplateByIdOrThrow(dto.getId()); |
||||
|
if (exist.getStatus() != null && exist.getStatus() == OpportunityRuleConstants.RULE_STATUS_DRAFT) { |
||||
|
// 草稿原位编辑:编码/版本号/状态不变(只读字段防前端伪造)
|
||||
|
OpportunityStageTemplate template = dto.toEntity(); |
||||
|
template.setTemplateCode(exist.getTemplateCode()); |
||||
|
template.setVersionNo(exist.getVersionNo()); |
||||
|
template.setStatus(OpportunityRuleConstants.RULE_STATUS_DRAFT); |
||||
|
this.updateById(template); |
||||
|
replaceChildRows(exist.getId(), dto); |
||||
|
return template; |
||||
|
} |
||||
|
|
||||
|
// 发布中/停用 → 生成新草稿;同 code 已有草稿则拒绝(进已有草稿编辑)
|
||||
|
List<OpportunityStageTemplate> versions = this.list( |
||||
|
new LambdaQueryWrapper<OpportunityStageTemplate>() |
||||
|
.eq(OpportunityStageTemplate::getTemplateCode, exist.getTemplateCode())); |
||||
|
boolean hasDraft = versions.stream().anyMatch(v -> |
||||
|
v.getStatus() != null && v.getStatus() == OpportunityRuleConstants.RULE_STATUS_DRAFT); |
||||
|
if (hasDraft) { |
||||
|
throw new BusinessErrorException(OpportunityRuleConstants.CODE_STAGE_TPL_DRAFT_EXISTS, |
||||
|
"该模板已有草稿版本,请进入草稿编辑"); |
||||
|
} |
||||
|
OpportunityStageTemplate template = dto.toEntity(); |
||||
|
template.setTemplateCode(exist.getTemplateCode()); |
||||
|
template.setVersionNo(nextVersionNo(versions.stream() |
||||
|
.map(OpportunityStageTemplate::getVersionNo).collect(Collectors.toList()))); |
||||
|
template.setStatus(OpportunityRuleConstants.RULE_STATUS_DRAFT); |
||||
|
this.save(template); |
||||
|
replaceChildRows(template.getId(), dto); |
||||
|
return template; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 发布:原发布中转停用(同 code 自我顶替)→ 新默认顶替旧默认 → 本版本转发布中。 |
||||
|
* 节点完整性校验前置在 {@link #validateForPublish}。 |
||||
|
*/ |
||||
|
private void publish(OpportunityStageTemplate template) { |
||||
|
List<OpportunityStageTemplate> olds = this.list( |
||||
|
new LambdaQueryWrapper<OpportunityStageTemplate>() |
||||
|
.eq(OpportunityStageTemplate::getTemplateCode, template.getTemplateCode()) |
||||
|
.eq(OpportunityStageTemplate::getStatus, OpportunityRuleConstants.RULE_STATUS_PUBLISHED) |
||||
|
.ne(OpportunityStageTemplate::getId, template.getId())); |
||||
|
for (OpportunityStageTemplate old : olds) { |
||||
|
OpportunityStageTemplate change = new OpportunityStageTemplate(); |
||||
|
change.setId(old.getId()); |
||||
|
change.setStatus(OpportunityRuleConstants.RULE_STATUS_DISABLED); |
||||
|
this.updateById(change); |
||||
|
} |
||||
|
|
||||
|
// 新默认顶替旧默认(发布中默认唯一,自动取消而非拒绝;V-CONFIG 6)
|
||||
|
if (template.getIsDefault() != null && template.getIsDefault() == OpportunityRuleConstants.FLAG_YES) { |
||||
|
List<OpportunityStageTemplate> oldDefaults = this.list( |
||||
|
new LambdaQueryWrapper<OpportunityStageTemplate>() |
||||
|
.eq(OpportunityStageTemplate::getStatus, OpportunityRuleConstants.RULE_STATUS_PUBLISHED) |
||||
|
.eq(OpportunityStageTemplate::getIsDefault, OpportunityRuleConstants.FLAG_YES) |
||||
|
.ne(OpportunityStageTemplate::getId, template.getId())); |
||||
|
for (OpportunityStageTemplate old : oldDefaults) { |
||||
|
OpportunityStageTemplate change = new OpportunityStageTemplate(); |
||||
|
change.setId(old.getId()); |
||||
|
change.setIsDefault(OpportunityRuleConstants.FLAG_NO); |
||||
|
this.updateById(change); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
OpportunityStageTemplate self = new OpportunityStageTemplate(); |
||||
|
self.setId(template.getId()); |
||||
|
self.setStatus(OpportunityRuleConstants.RULE_STATUS_PUBLISHED); |
||||
|
this.updateById(self); |
||||
|
} |
||||
|
|
||||
|
// ==================== 停用 / 删除 / 复制(行操作矩阵) ====================
|
||||
|
|
||||
|
@Override |
||||
|
@Transactional(rollbackFor = Exception.class) |
||||
|
public void disableTemplate(Long id) { |
||||
|
OpportunityStageTemplate exist = getTemplateByIdOrThrow(id); |
||||
|
if (exist.getStatus() == null || exist.getStatus() != OpportunityRuleConstants.RULE_STATUS_PUBLISHED) { |
||||
|
throw new BusinessErrorException(OpportunityRuleConstants.CODE_STAGE_TPL_INVALID, |
||||
|
"仅发布中的模板可停用"); |
||||
|
} |
||||
|
OpportunityStageTemplate change = new OpportunityStageTemplate(); |
||||
|
change.setId(id); |
||||
|
change.setStatus(OpportunityRuleConstants.RULE_STATUS_DISABLED); |
||||
|
this.updateById(change); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
@Transactional(rollbackFor = Exception.class) |
||||
|
public void deleteDraft(Long id) { |
||||
|
OpportunityStageTemplate exist = getTemplateByIdOrThrow(id); |
||||
|
if (exist.getStatus() == null || exist.getStatus() != OpportunityRuleConstants.RULE_STATUS_DRAFT) { |
||||
|
throw new BusinessErrorException(OpportunityRuleConstants.CODE_STAGE_TPL_NOT_DRAFT, |
||||
|
"仅草稿版本可删除"); |
||||
|
} |
||||
|
this.removeById(id); |
||||
|
deptMapper.delete(new LambdaQueryWrapper<OpportunityStageTemplateDept>() |
||||
|
.eq(OpportunityStageTemplateDept::getTemplateVersionId, id)); |
||||
|
nodeMapper.delete(new LambdaQueryWrapper<OpportunityStageNode>() |
||||
|
.eq(OpportunityStageNode::getTemplateId, id)); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
@Transactional(rollbackFor = Exception.class) |
||||
|
public Long copyTemplate(Long id) { |
||||
|
OpportunityStageTemplate source = getTemplateByIdOrThrow(id); |
||||
|
|
||||
|
OpportunityStageTemplate copy = new OpportunityStageTemplate(); |
||||
|
copy.setTemplateCode(nextTemplateCode()); |
||||
|
copy.setTemplateName(source.getTemplateName()); |
||||
|
copy.setVersionNo("V1.0"); |
||||
|
copy.setStatus(OpportunityRuleConstants.RULE_STATUS_DRAFT); |
||||
|
copy.setApplyScope(source.getApplyScope()); |
||||
|
copy.setIsDefault(source.getIsDefault()); |
||||
|
copy.setTemplateDesc(source.getTemplateDesc()); |
||||
|
this.save(copy); |
||||
|
|
||||
|
// 适用部门随复制
|
||||
|
deptMapper.selectList(new LambdaQueryWrapper<OpportunityStageTemplateDept>() |
||||
|
.eq(OpportunityStageTemplateDept::getTemplateVersionId, id)) |
||||
|
.forEach(row -> { |
||||
|
OpportunityStageTemplateDept target = new OpportunityStageTemplateDept(); |
||||
|
target.setTemplateVersionId(copy.getId()); |
||||
|
target.setDeptId(row.getDeptId()); |
||||
|
deptMapper.insert(target); |
||||
|
}); |
||||
|
|
||||
|
// 节点随复制(保序;固定节点本就随源版本末位存在)
|
||||
|
listNodesOfVersion(id).forEach(node -> { |
||||
|
OpportunityStageNode target = new OpportunityStageNode(); |
||||
|
target.setTemplateId(copy.getId()); |
||||
|
target.setSeqNo(node.getSeqNo()); |
||||
|
target.setStageDictCode(node.getStageDictCode()); |
||||
|
target.setCustomNodeName(node.getCustomNodeName()); |
||||
|
target.setWorkGoal(node.getWorkGoal()); |
||||
|
target.setIsFixed(node.getIsFixed()); |
||||
|
nodeMapper.insert(target); |
||||
|
}); |
||||
|
return copy.getId(); |
||||
|
} |
||||
|
|
||||
|
// ==================== 校验 ====================
|
||||
|
|
||||
|
/** 头 + 节点基础校验(草稿/发布共用):入参节点字典值必填;固定字典值节点不可手工配置 */ |
||||
|
private void validateTemplate(OpportunityStageTemplateDTO dto) { |
||||
|
if (dto == null) { |
||||
|
throw invalid("请求参数缺失"); |
||||
|
} |
||||
|
if (StrUtil.isBlank(dto.getTemplateName())) { |
||||
|
throw invalid("模板名称不能为空"); |
||||
|
} |
||||
|
Integer scope = dto.getApplyScope(); |
||||
|
if (scope == null || (scope != OpportunityRuleConstants.APPLY_SCOPE_ALL |
||||
|
&& scope != OpportunityRuleConstants.APPLY_SCOPE_DEPT)) { |
||||
|
throw invalid("适用范围参数非法"); |
||||
|
} |
||||
|
Integer isDefault = dto.getIsDefault(); |
||||
|
if (isDefault == null || (isDefault != OpportunityRuleConstants.FLAG_NO |
||||
|
&& isDefault != OpportunityRuleConstants.FLAG_YES)) { |
||||
|
throw invalid("是否默认参数非法"); |
||||
|
} |
||||
|
if (scope == OpportunityRuleConstants.APPLY_SCOPE_DEPT |
||||
|
&& isDefault == OpportunityRuleConstants.FLAG_YES) { |
||||
|
throw invalid("仅所有商机范围的模板可设为默认"); |
||||
|
} |
||||
|
if (scope == OpportunityRuleConstants.APPLY_SCOPE_DEPT && CollUtil.isEmpty(dto.getDeptIds())) { |
||||
|
throw invalid("指定部门模板须选择至少一个适用部门"); |
||||
|
} |
||||
|
Set<String> enabledDictCodes = null; // 懒加载:无节点时不查字典(字典读路径带 10s 缓存)
|
||||
|
for (OpportunityStageTemplateDTO.NodeItem node : safeNodes(dto)) { |
||||
|
if (StrUtil.isBlank(node.getStageDictCode())) { |
||||
|
throw invalid("阶段节点字典值不能为空(保存时统一校验所有节点无未完成配置)"); |
||||
|
} |
||||
|
if (OpportunityRuleConstants.FIXED_STAGE_DICT_CODE.equals(node.getStageDictCode())) { |
||||
|
throw invalid("「已转项目」为系统固定节点,不可在阶段列表中手工配置"); |
||||
|
} |
||||
|
if (enabledDictCodes == null) { |
||||
|
enabledDictCodes = dictQueryService.listEnabledItems(OpportunityRuleConstants.STAGE_DICT_GROUP_CODE) |
||||
|
.stream().map(DictItemDTO::getCode).collect(Collectors.toSet()); |
||||
|
} |
||||
|
// 字典状态规则(票 06「二」):仅启用项可新增引用;已停用项历史模板保留,但新建/编辑不再可选;
|
||||
|
// 分组停用/不存在时 listEnabledItems 返回空 → 整组不可选,同口径拒绝
|
||||
|
if (!enabledDictCodes.contains(node.getStageDictCode())) { |
||||
|
throw invalid("阶段节点须引用「商机阶段」分组的启用字典项:" + node.getStageDictCode()); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** 发布附加校验:除固定节点外至少创建一个阶段节点(原型校验文案) */ |
||||
|
private void validateForPublish(OpportunityStageTemplateDTO dto) { |
||||
|
if (safeNodes(dto).isEmpty()) { |
||||
|
throw new BusinessErrorException(OpportunityRuleConstants.CODE_STAGE_TPL_NODE_INVALID, |
||||
|
"除固定节点外至少创建一个阶段节点"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private List<OpportunityStageTemplateDTO.NodeItem> safeNodes(OpportunityStageTemplateDTO dto) { |
||||
|
return dto.getNodes() == null ? List.of() : dto.getNodes().stream() |
||||
|
.filter(Objects::nonNull) |
||||
|
.collect(Collectors.toList()); |
||||
|
} |
||||
|
|
||||
|
private BusinessErrorException invalid(String message) { |
||||
|
return new BusinessErrorException(OpportunityRuleConstants.CODE_STAGE_TPL_INVALID, message); |
||||
|
} |
||||
|
|
||||
|
// ==================== 内部方法 ====================
|
||||
|
|
||||
|
private OpportunityStageTemplate getTemplateByIdOrThrow(Long id) { |
||||
|
OpportunityStageTemplate template = this.getById(id); |
||||
|
if (template == null) { |
||||
|
throw new BusinessErrorException(OpportunityRuleConstants.CODE_STAGE_TPL_NOT_EXIST, |
||||
|
"模板版本不存在或已被删除"); |
||||
|
} |
||||
|
return template; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 子表全量替换:适用部门 + 阶段节点(先删后插)。 |
||||
|
* 节点规范化:入参顺序=普通节点顺序(seq 1..n),末位系统补唯一固定节点「已转项目」 |
||||
|
* (字典值恒 {@code OPP_STAGE_05},票 06「四」:配模板时自带、不可删)。 |
||||
|
*/ |
||||
|
private void replaceChildRows(Long templateVersionId, OpportunityStageTemplateDTO dto) { |
||||
|
deptMapper.delete(new LambdaQueryWrapper<OpportunityStageTemplateDept>() |
||||
|
.eq(OpportunityStageTemplateDept::getTemplateVersionId, templateVersionId)); |
||||
|
if (dto.getApplyScope() != null |
||||
|
&& dto.getApplyScope() == OpportunityRuleConstants.APPLY_SCOPE_DEPT |
||||
|
&& CollUtil.isNotEmpty(dto.getDeptIds())) { |
||||
|
for (Long deptId : dto.getDeptIds()) { |
||||
|
if (deptId == null) { |
||||
|
continue; |
||||
|
} |
||||
|
OpportunityStageTemplateDept row = new OpportunityStageTemplateDept(); |
||||
|
row.setTemplateVersionId(templateVersionId); |
||||
|
row.setDeptId(deptId); |
||||
|
deptMapper.insert(row); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
nodeMapper.delete(new LambdaQueryWrapper<OpportunityStageNode>() |
||||
|
.eq(OpportunityStageNode::getTemplateId, templateVersionId)); |
||||
|
int seq = 1; |
||||
|
for (OpportunityStageTemplateDTO.NodeItem item : safeNodes(dto)) { |
||||
|
// 固定字典值节点入参侧已在校验中拒绝,此处防御性跳过
|
||||
|
if (OpportunityRuleConstants.FIXED_STAGE_DICT_CODE.equals(item.getStageDictCode())) { |
||||
|
continue; |
||||
|
} |
||||
|
nodeMapper.insert(buildNode(templateVersionId, seq++, item.getStageDictCode(), |
||||
|
item.getCustomNodeName(), item.getWorkGoal(), OpportunityRuleConstants.FLAG_NO)); |
||||
|
} |
||||
|
nodeMapper.insert(buildNode(templateVersionId, seq, OpportunityRuleConstants.FIXED_STAGE_DICT_CODE, |
||||
|
OpportunityRuleConstants.FIXED_STAGE_NAME, null, OpportunityRuleConstants.FLAG_YES)); |
||||
|
} |
||||
|
|
||||
|
private OpportunityStageNode buildNode(Long templateVersionId, int seqNo, String stageDictCode, |
||||
|
String customNodeName, String workGoal, int isFixed) { |
||||
|
OpportunityStageNode node = new OpportunityStageNode(); |
||||
|
node.setTemplateId(templateVersionId); |
||||
|
node.setSeqNo(seqNo); |
||||
|
node.setStageDictCode(stageDictCode); |
||||
|
node.setCustomNodeName(customNodeName); |
||||
|
node.setWorkGoal(workGoal); |
||||
|
node.setIsFixed(isFixed); |
||||
|
return node; |
||||
|
} |
||||
|
|
||||
|
private static OpportunityStageTemplateDTO.NodeItem toNodeItem(OpportunityStageNode node) { |
||||
|
OpportunityStageTemplateDTO.NodeItem item = new OpportunityStageTemplateDTO.NodeItem(); |
||||
|
item.setId(node.getId()); |
||||
|
item.setSeqNo(node.getSeqNo()); |
||||
|
item.setStageDictCode(node.getStageDictCode()); |
||||
|
item.setCustomNodeName(node.getCustomNodeName()); |
||||
|
item.setWorkGoal(node.getWorkGoal()); |
||||
|
item.setIsFixed(node.getIsFixed()); |
||||
|
return item; |
||||
|
} |
||||
|
|
||||
|
/** 批量回显适用部门 ID 与名称(一次子表查询 + 一次部门名查询) */ |
||||
|
private void fillDeptNames(List<OpportunityStageTemplateDTO> dtos) { |
||||
|
if (CollUtil.isEmpty(dtos)) { |
||||
|
return; |
||||
|
} |
||||
|
List<OpportunityStageTemplateDept> rows = deptMapper.selectList( |
||||
|
new LambdaQueryWrapper<OpportunityStageTemplateDept>() |
||||
|
.in(OpportunityStageTemplateDept::getTemplateVersionId, |
||||
|
dtos.stream().map(OpportunityStageTemplateDTO::getId) |
||||
|
.filter(Objects::nonNull).collect(Collectors.toSet()))); |
||||
|
Map<Long, List<Long>> versionToDepts = rows.stream().collect(Collectors.groupingBy( |
||||
|
OpportunityStageTemplateDept::getTemplateVersionId, |
||||
|
Collectors.mapping(OpportunityStageTemplateDept::getDeptId, Collectors.toList()))); |
||||
|
|
||||
|
Set<Long> deptIds = rows.stream().map(OpportunityStageTemplateDept::getDeptId) |
||||
|
.filter(Objects::nonNull).collect(Collectors.toSet()); |
||||
|
Map<Long, String> nameMap = deptIds.isEmpty() ? Map.of() |
||||
|
: sysDeptService.listByIds(deptIds).stream() |
||||
|
.collect(Collectors.toMap(SysDept::getId, SysDept::getDeptName)); |
||||
|
|
||||
|
dtos.forEach(dto -> { |
||||
|
List<Long> ids = versionToDepts.getOrDefault(dto.getId(), List.of()); |
||||
|
dto.setDeptIds(ids); |
||||
|
dto.setDeptNames(ids.stream().map(nameMap::get) |
||||
|
.filter(Objects::nonNull).collect(Collectors.toList())); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** 模板编码顺延:OPP_STAGE_TPL_ + 现有最大序号+1(两位补零) */ |
||||
|
private String nextTemplateCode() { |
||||
|
int max = this.list(new LambdaQueryWrapper<OpportunityStageTemplate>() |
||||
|
.likeRight(OpportunityStageTemplate::getTemplateCode, |
||||
|
OpportunityRuleConstants.TEMPLATE_CODE_PREFIX)) |
||||
|
.stream().map(OpportunityStageTemplate::getTemplateCode) |
||||
|
.filter(Objects::nonNull) |
||||
|
.map(code -> code.substring(OpportunityRuleConstants.TEMPLATE_CODE_PREFIX.length())) |
||||
|
.filter(suffix -> suffix.matches("\\d+")) |
||||
|
.mapToInt(Integer::parseInt) |
||||
|
.max().orElse(0); |
||||
|
return OpportunityRuleConstants.TEMPLATE_CODE_PREFIX + String.format("%02d", max + 1); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 版本号 minor 顺延:取全版本最大 (major, minor) 的 minor+1(major 不动); |
||||
|
* 无历史版本 → V1.0;非法格式行防御性忽略。(同票 05 口径,V-CONFIG 2) |
||||
|
*/ |
||||
|
static String nextVersionNo(List<String> versionNos) { |
||||
|
int bestMajor = -1; |
||||
|
int bestMinor = -1; |
||||
|
for (String v : new ArrayList<>(versionNos)) { |
||||
|
if (v == null || !v.matches("V\\d+\\.\\d+")) { |
||||
|
continue; |
||||
|
} |
||||
|
String[] parts = v.substring(1).split("\\."); |
||||
|
int major = Integer.parseInt(parts[0]); |
||||
|
int minor = Integer.parseInt(parts[1]); |
||||
|
if (major > bestMajor || (major == bestMajor && minor > bestMinor)) { |
||||
|
bestMajor = major; |
||||
|
bestMinor = minor; |
||||
|
} |
||||
|
} |
||||
|
if (bestMajor < 0) { |
||||
|
return "V1.0"; |
||||
|
} |
||||
|
return "V" + bestMajor + "." + (bestMinor + 1); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,767 @@ |
|||||
|
package com.crm.rule.service.impl; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration; |
||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; |
||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; |
||||
|
import com.crm.auth.domain.entity.SysDept; |
||||
|
import com.crm.auth.service.ISysDeptService; |
||||
|
import com.crm.base.domain.exception.BusinessErrorException; |
||||
|
import com.crm.dict.domain.dto.DictItemDTO; |
||||
|
import com.crm.dict.service.DictQueryService; |
||||
|
import com.crm.rule.constant.OpportunityRuleConstants; |
||||
|
import com.crm.rule.domain.dto.OpportunityStageTemplateDTO; |
||||
|
import com.crm.rule.domain.entity.OpportunityStageNode; |
||||
|
import com.crm.rule.domain.entity.OpportunityStageTemplate; |
||||
|
import com.crm.rule.domain.entity.OpportunityStageTemplateDept; |
||||
|
import com.crm.rule.domain.param.OpportunityStageTemplatePageParam; |
||||
|
import com.crm.rule.mapper.OpportunityStageNodeMapper; |
||||
|
import com.crm.rule.mapper.OpportunityStageTemplateDeptMapper; |
||||
|
import com.crm.rule.mapper.OpportunityStageTemplateMapper; |
||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant; |
||||
|
import org.junit.jupiter.api.BeforeAll; |
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.DisplayName; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.ArgumentCaptor; |
||||
|
import org.mockito.InjectMocks; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.springframework.test.util.ReflectionTestUtils; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.List; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.Mockito.doAnswer; |
||||
|
import static org.mockito.Mockito.lenient; |
||||
|
import static org.mockito.Mockito.never; |
||||
|
import static org.mockito.Mockito.times; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
|
||||
|
/** |
||||
|
* 商机阶段模板版本状态机规格验证(票 06「二」/ PRD §6.2)。 |
||||
|
* |
||||
|
* <p>断言点:</p> |
||||
|
* <ul> |
||||
|
* <li>头+节点校验:名称必填、范围合法、仅所有商机可设默认、指定部门须选部门、 |
||||
|
* 节点字典值必填、固定字典值 {@code OPP_STAGE_05} 入参直接拒绝(防伪造)</li> |
||||
|
* <li>保存草稿三分支:新建(V1.0 + 编码顺延)/ 原位编辑 / 发布中-停用生新草稿(minor 顺延, |
||||
|
* 同 code 已有草稿拒绝 64016)</li> |
||||
|
* <li>节点规范化:入参顺序=普通节点 seq 1..n,末位系统补唯一固定节点「已转项目」; |
||||
|
* 发布校验除固定节点外至少一个普通节点(64017)</li> |
||||
|
* <li>发布:原发布中转停用(自我顶替)、新默认顶替旧默认</li> |
||||
|
* <li>行操作矩阵:仅草稿可删(64015,级联删部门+节点)、仅发布中可停用、 |
||||
|
* 复制 → 新编码 + V1.0 + 草稿(部门与节点随复制)</li> |
||||
|
* <li>绑定匹配:部门专用发布模板优先(多命中取最近创建),兜底=所有商机+默认</li> |
||||
|
* </ul> |
||||
|
*/ |
||||
|
@DisplayName("商机阶段模板版本状态机(票 06 / PRD §6.2)") |
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
class OpportunityStageTemplateServiceImplTest { |
||||
|
|
||||
|
private static final Long DRAFT_ID = 6201L; |
||||
|
private static final Long PUB_ID = 6202L; |
||||
|
private static final Long NEW_TPL_ID = 6203L; |
||||
|
private static final Long OLD_DEFAULT_ID = 6204L; |
||||
|
private static final Long COPY_ID = 6205L; |
||||
|
private static final Long OTHER_TPL_ID = 6206L; |
||||
|
private static final Long DEPT_10 = 4001L; |
||||
|
private static final Long DEPT_20 = 4002L; |
||||
|
private static final String CODE_C1 = "OPP_STAGE_TPL_01"; |
||||
|
|
||||
|
@Mock private OpportunityStageTemplateMapper templateMapper; |
||||
|
@Mock private OpportunityStageTemplateDeptMapper deptMapper; |
||||
|
@Mock private OpportunityStageNodeMapper nodeMapper; |
||||
|
@Mock private ISysDeptService sysDeptService; |
||||
|
@Mock private DictQueryService dictQueryService; |
||||
|
|
||||
|
@InjectMocks |
||||
|
private OpportunityStageTemplateServiceImpl service; |
||||
|
|
||||
|
@BeforeAll |
||||
|
static void initLambdaCache() { |
||||
|
MapperBuilderAssistant assistant = |
||||
|
new MapperBuilderAssistant(new MybatisConfiguration(), ""); |
||||
|
TableInfoHelper.initTableInfo(assistant, OpportunityStageTemplate.class); |
||||
|
TableInfoHelper.initTableInfo(assistant, OpportunityStageTemplateDept.class); |
||||
|
TableInfoHelper.initTableInfo(assistant, OpportunityStageNode.class); |
||||
|
} |
||||
|
|
||||
|
@BeforeEach |
||||
|
void setUp() { |
||||
|
// ServiceImpl.getEntityClass() 纯 Mockito 下需手动设值,lambdaQuery() 才能工作(同票 05 测试)
|
||||
|
ReflectionTestUtils.setField(service, "baseMapper", templateMapper); |
||||
|
ReflectionTestUtils.setField(service, "entityClass", OpportunityStageTemplate.class); |
||||
|
// 默认桩:「商机阶段」分组启用项(不含固定项 OPP_STAGE_05,固定值先于字典校验被拒);
|
||||
|
// lenient:校验前即失败的用例(名称空等)不触达字典查询,避免严格模式报多余桩
|
||||
|
lenient().when(dictQueryService.listEnabledItems(OpportunityRuleConstants.STAGE_DICT_GROUP_CODE)) |
||||
|
.thenReturn(List.of(dictItemDto("OPP_STAGE_01"), dictItemDto("OPP_STAGE_02"), |
||||
|
dictItemDto("OPP_STAGE_03"), dictItemDto("OPP_STAGE_04"))); |
||||
|
} |
||||
|
|
||||
|
// ==================== 工厂 ====================
|
||||
|
|
||||
|
private OpportunityStageTemplateDTO validDto() { |
||||
|
OpportunityStageTemplateDTO dto = new OpportunityStageTemplateDTO(); |
||||
|
dto.setTemplateName("标准商机阶段模板"); |
||||
|
dto.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_ALL); |
||||
|
dto.setIsDefault(OpportunityRuleConstants.FLAG_NO); |
||||
|
dto.setNodes(List.of( |
||||
|
nodeItem("OPP_STAGE_01", "客户圈定"), |
||||
|
nodeItem("OPP_STAGE_02", "关系摸排"))); |
||||
|
return dto; |
||||
|
} |
||||
|
|
||||
|
private OpportunityStageTemplateDTO.NodeItem nodeItem(String dictCode, String name) { |
||||
|
OpportunityStageTemplateDTO.NodeItem item = new OpportunityStageTemplateDTO.NodeItem(); |
||||
|
item.setStageDictCode(dictCode); |
||||
|
item.setCustomNodeName(name); |
||||
|
return item; |
||||
|
} |
||||
|
|
||||
|
private DictItemDTO dictItemDto(String code) { |
||||
|
DictItemDTO dto = new DictItemDTO(); |
||||
|
dto.setCode(code); |
||||
|
dto.setStatus(1); |
||||
|
return dto; |
||||
|
} |
||||
|
|
||||
|
private OpportunityStageTemplate tplRow(Long id, String code, String versionNo, int status) { |
||||
|
OpportunityStageTemplate t = new OpportunityStageTemplate(); |
||||
|
t.setId(id); |
||||
|
t.setTemplateCode(code); |
||||
|
t.setTemplateName("模板" + code); |
||||
|
t.setVersionNo(versionNo); |
||||
|
t.setStatus(status); |
||||
|
t.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_ALL); |
||||
|
t.setIsDefault(OpportunityRuleConstants.FLAG_NO); |
||||
|
return t; |
||||
|
} |
||||
|
|
||||
|
private OpportunityStageTemplateDept deptRow(Long tplVersionId, Long deptId) { |
||||
|
OpportunityStageTemplateDept d = new OpportunityStageTemplateDept(); |
||||
|
d.setTemplateVersionId(tplVersionId); |
||||
|
d.setDeptId(deptId); |
||||
|
return d; |
||||
|
} |
||||
|
|
||||
|
private OpportunityStageNode nodeRow(Long id, Long tplVersionId, int seqNo, String dictCode, int isFixed) { |
||||
|
OpportunityStageNode n = new OpportunityStageNode(); |
||||
|
n.setId(id); |
||||
|
n.setTemplateId(tplVersionId); |
||||
|
n.setSeqNo(seqNo); |
||||
|
n.setStageDictCode(dictCode); |
||||
|
n.setCustomNodeName("节点" + dictCode); |
||||
|
n.setIsFixed(isFixed); |
||||
|
return n; |
||||
|
} |
||||
|
|
||||
|
private SysDept sysDept(Long id, String name) { |
||||
|
SysDept d = new SysDept(); |
||||
|
d.setId(id); |
||||
|
d.setDeptName(name); |
||||
|
return d; |
||||
|
} |
||||
|
|
||||
|
/** mock mapper.insert 并回填 id(ASSIGN_ID 由 MP 在 SQL 执行期填充,纯 mock 不触发) */ |
||||
|
private void stubInsertWithId(Long id) { |
||||
|
doAnswer(inv -> { |
||||
|
OpportunityStageTemplate t = inv.getArgument(0); |
||||
|
t.setId(id); |
||||
|
return 1; |
||||
|
}).when(templateMapper).insert(any(OpportunityStageTemplate.class)); |
||||
|
} |
||||
|
|
||||
|
// ==================== 头 + 节点校验 ====================
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("校验:模板名称为空 → 拒绝 64013") |
||||
|
void saveDraft_blankName_rejected() { |
||||
|
OpportunityStageTemplateDTO dto = validDto(); |
||||
|
dto.setTemplateName(" "); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.saveDraft(dto)) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_INVALID); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("校验:适用范围非法 → 拒绝 64013") |
||||
|
void saveDraft_invalidScope_rejected() { |
||||
|
OpportunityStageTemplateDTO dto = validDto(); |
||||
|
dto.setApplyScope(9); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.saveDraft(dto)) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_INVALID); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("校验:仅所有商机范围可设默认,指定部门设默认 → 拒绝") |
||||
|
void saveDraft_deptScopeWithDefault_rejected() { |
||||
|
OpportunityStageTemplateDTO dto = validDto(); |
||||
|
dto.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_DEPT); |
||||
|
dto.setIsDefault(OpportunityRuleConstants.FLAG_YES); |
||||
|
dto.setDeptIds(List.of(DEPT_10)); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.saveDraft(dto)) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_INVALID); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("校验:指定部门未选适用部门 → 拒绝") |
||||
|
void saveDraft_deptScopeWithoutDeptIds_rejected() { |
||||
|
OpportunityStageTemplateDTO dto = validDto(); |
||||
|
dto.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_DEPT); |
||||
|
dto.setDeptIds(List.of()); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.saveDraft(dto)) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_INVALID); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("校验:节点字典值为空 → 拒绝(保存时统一校验所有节点无未完成配置)") |
||||
|
void saveDraft_nodeBlankDictCode_rejected() { |
||||
|
OpportunityStageTemplateDTO dto = validDto(); |
||||
|
dto.setNodes(List.of(nodeItem(" ", null))); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.saveDraft(dto)) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_INVALID); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("校验:入参节点含固定字典值 OPP_STAGE_05 → 拒绝(固定节点不可手工配置,防伪造)") |
||||
|
void saveDraft_nodeWithFixedDictCode_rejected() { |
||||
|
OpportunityStageTemplateDTO dto = validDto(); |
||||
|
dto.setNodes(List.of(nodeItem("OPP_STAGE_01", "客户圈定"), |
||||
|
nodeItem(OpportunityRuleConstants.FIXED_STAGE_DICT_CODE, "已转项目"))); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.saveDraft(dto)) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_INVALID) |
||||
|
.hasMessageContaining("固定节点"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("校验:节点引用停用/不存在的字典项 → 拒绝(字典状态规则:仅启用项可新增引用)") |
||||
|
void saveDraft_nodeWithDisabledDictItem_rejected() { |
||||
|
OpportunityStageTemplateDTO dto = validDto(); |
||||
|
dto.setNodes(List.of(nodeItem("OPP_STAGE_99", "已停用项"))); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.saveDraft(dto)) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_INVALID) |
||||
|
.hasMessageContaining("启用字典项"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("校验:「商机阶段」分组停用/不存在(启用项列表为空)→ 节点一律拒绝") |
||||
|
void saveDraft_stageGroupDisabled_rejected() { |
||||
|
when(dictQueryService.listEnabledItems(OpportunityRuleConstants.STAGE_DICT_GROUP_CODE)) |
||||
|
.thenReturn(List.of()); |
||||
|
OpportunityStageTemplateDTO dto = validDto(); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.saveDraft(dto)) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_INVALID); |
||||
|
} |
||||
|
|
||||
|
// ==================== 保存草稿三分支 + 节点规范化 ====================
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("新建草稿:编码顺延 + V1.0 + 草稿态;普通节点按序落库,末位系统补唯一固定节点") |
||||
|
void saveDraft_newTemplate_createsV1DraftWithFixedNode() { |
||||
|
OpportunityStageTemplateDTO dto = validDto(); |
||||
|
when(templateMapper.selectList(any())).thenReturn(List.of()); |
||||
|
stubInsertWithId(NEW_TPL_ID); |
||||
|
|
||||
|
service.saveDraft(dto); |
||||
|
|
||||
|
ArgumentCaptor<OpportunityStageTemplate> tplCaptor = ArgumentCaptor.forClass(OpportunityStageTemplate.class); |
||||
|
verify(templateMapper).insert(tplCaptor.capture()); |
||||
|
OpportunityStageTemplate saved = tplCaptor.getValue(); |
||||
|
assertThat(saved.getTemplateCode()).isEqualTo("OPP_STAGE_TPL_01"); |
||||
|
assertThat(saved.getVersionNo()).isEqualTo("V1.0"); |
||||
|
assertThat(saved.getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_DRAFT); |
||||
|
|
||||
|
// 节点全量替换:先删后插;2 个普通节点 + 末位固定节点
|
||||
|
verify(nodeMapper).delete(any()); |
||||
|
ArgumentCaptor<OpportunityStageNode> nodeCaptor = ArgumentCaptor.forClass(OpportunityStageNode.class); |
||||
|
verify(nodeMapper, times(3)).insert(nodeCaptor.capture()); |
||||
|
List<OpportunityStageNode> nodes = nodeCaptor.getAllValues(); |
||||
|
assertThat(nodes).extracting(OpportunityStageNode::getSeqNo).containsExactly(1, 2, 3); |
||||
|
assertThat(nodes.get(0).getStageDictCode()).isEqualTo("OPP_STAGE_01"); |
||||
|
assertThat(nodes.get(0).getIsFixed()).isEqualTo(OpportunityRuleConstants.FLAG_NO); |
||||
|
// 末位固定节点:字典值恒 OPP_STAGE_05、名称「已转项目」、isFixed=1
|
||||
|
OpportunityStageNode fixed = nodes.get(2); |
||||
|
assertThat(fixed.getStageDictCode()).isEqualTo(OpportunityRuleConstants.FIXED_STAGE_DICT_CODE); |
||||
|
assertThat(fixed.getCustomNodeName()).isEqualTo(OpportunityRuleConstants.FIXED_STAGE_NAME); |
||||
|
assertThat(fixed.getIsFixed()).isEqualTo(OpportunityRuleConstants.FLAG_YES); |
||||
|
// 所有商机范围不写部门子表(只做清空)
|
||||
|
verify(deptMapper).delete(any()); |
||||
|
verify(deptMapper, never()).insert(any(OpportunityStageTemplateDept.class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("新建指定部门草稿:适用部门逐行落子表(先删后插)") |
||||
|
void saveDraft_newDeptScopeTemplate_persistsDeptRows() { |
||||
|
OpportunityStageTemplateDTO dto = validDto(); |
||||
|
dto.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_DEPT); |
||||
|
dto.setDeptIds(List.of(DEPT_10, DEPT_20)); |
||||
|
when(templateMapper.selectList(any())).thenReturn(List.of()); |
||||
|
stubInsertWithId(NEW_TPL_ID); |
||||
|
|
||||
|
service.saveDraft(dto); |
||||
|
|
||||
|
verify(deptMapper).delete(any()); |
||||
|
verify(deptMapper, times(2)).insert(any(OpportunityStageTemplateDept.class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("编辑草稿:原位更新(编码/版本号/草稿态不变,只读字段防伪造),不插入新版本行") |
||||
|
void saveDraft_editDraft_updatesInPlace() { |
||||
|
OpportunityStageTemplateDTO dto = validDto(); |
||||
|
dto.setId(DRAFT_ID); |
||||
|
dto.setTemplateCode("FAKE_CODE"); // 前端伪造只读字段 → 忽略
|
||||
|
dto.setVersionNo("V9.9"); |
||||
|
when(templateMapper.selectById(DRAFT_ID)).thenReturn( |
||||
|
tplRow(DRAFT_ID, CODE_C1, "V1.1", OpportunityRuleConstants.RULE_STATUS_DRAFT)); |
||||
|
when(templateMapper.updateById(any(OpportunityStageTemplate.class))).thenReturn(1); |
||||
|
|
||||
|
service.saveDraft(dto); |
||||
|
|
||||
|
ArgumentCaptor<OpportunityStageTemplate> captor = ArgumentCaptor.forClass(OpportunityStageTemplate.class); |
||||
|
verify(templateMapper).updateById(captor.capture()); |
||||
|
OpportunityStageTemplate updated = captor.getValue(); |
||||
|
assertThat(updated.getId()).isEqualTo(DRAFT_ID); |
||||
|
assertThat(updated.getTemplateCode()).isEqualTo(CODE_C1); |
||||
|
assertThat(updated.getVersionNo()).isEqualTo("V1.1"); |
||||
|
assertThat(updated.getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_DRAFT); |
||||
|
verify(templateMapper, never()).insert(any(OpportunityStageTemplate.class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("编辑发布中版本:不动原行,生成新草稿(同编码、版本号 minor 顺延 V1.0→V1.1)") |
||||
|
void saveDraft_fromPublished_createsNewDraft() { |
||||
|
OpportunityStageTemplateDTO dto = validDto(); |
||||
|
dto.setId(PUB_ID); |
||||
|
OpportunityStageTemplate published = tplRow(PUB_ID, CODE_C1, "V1.0", OpportunityRuleConstants.RULE_STATUS_PUBLISHED); |
||||
|
when(templateMapper.selectById(PUB_ID)).thenReturn(published); |
||||
|
when(templateMapper.selectList(any())).thenReturn(List.of(published)); |
||||
|
stubInsertWithId(NEW_TPL_ID); |
||||
|
|
||||
|
service.saveDraft(dto); |
||||
|
|
||||
|
ArgumentCaptor<OpportunityStageTemplate> captor = ArgumentCaptor.forClass(OpportunityStageTemplate.class); |
||||
|
verify(templateMapper).insert(captor.capture()); |
||||
|
OpportunityStageTemplate draft = captor.getValue(); |
||||
|
assertThat(draft.getTemplateCode()).isEqualTo(CODE_C1); |
||||
|
assertThat(draft.getVersionNo()).isEqualTo("V1.1"); |
||||
|
assertThat(draft.getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_DRAFT); |
||||
|
// 原发布中行不动
|
||||
|
verify(templateMapper, never()).updateById(any(OpportunityStageTemplate.class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("编辑发布中版本但同编码已有草稿:拒绝 64016(进已有草稿编辑)") |
||||
|
void saveDraft_fromPublished_draftExists_rejected() { |
||||
|
OpportunityStageTemplateDTO dto = validDto(); |
||||
|
dto.setId(PUB_ID); |
||||
|
OpportunityStageTemplate published = tplRow(PUB_ID, CODE_C1, "V1.0", OpportunityRuleConstants.RULE_STATUS_PUBLISHED); |
||||
|
OpportunityStageTemplate draft = tplRow(DRAFT_ID, CODE_C1, "V1.1", OpportunityRuleConstants.RULE_STATUS_DRAFT); |
||||
|
when(templateMapper.selectById(PUB_ID)).thenReturn(published); |
||||
|
when(templateMapper.selectList(any())).thenReturn(List.of(published, draft)); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.saveDraft(dto)) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_DRAFT_EXISTS); |
||||
|
|
||||
|
verify(templateMapper, never()).insert(any(OpportunityStageTemplate.class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("编辑不存在的版本 → 拒绝 64014") |
||||
|
void saveDraft_notExist_rejected() { |
||||
|
OpportunityStageTemplateDTO dto = validDto(); |
||||
|
dto.setId(999L); |
||||
|
when(templateMapper.selectById(999L)).thenReturn(null); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.saveDraft(dto)) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_NOT_EXIST); |
||||
|
} |
||||
|
|
||||
|
// ==================== 保存并发布(状态机) ====================
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("发布校验:除固定节点外无普通节点 → 拒绝 64017,不触达落库") |
||||
|
void saveAndPublish_noNormalNodes_rejected() { |
||||
|
OpportunityStageTemplateDTO dto = validDto(); |
||||
|
dto.setNodes(List.of()); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.saveAndPublish(dto)) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_NODE_INVALID) |
||||
|
.hasMessageContaining("除固定节点外至少创建一个阶段节点"); |
||||
|
|
||||
|
verify(templateMapper, never()).insert(any(OpportunityStageTemplate.class)); |
||||
|
verify(templateMapper, never()).updateById(any(OpportunityStageTemplate.class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("新模板直接发布:落草稿后转发布中") |
||||
|
void saveAndPublish_newTemplate_published() { |
||||
|
OpportunityStageTemplateDTO dto = validDto(); |
||||
|
// selectList 序列:#1 编码生成、#2 顶替查询(同 code 无发布中)
|
||||
|
when(templateMapper.selectList(any())).thenReturn(List.of(), List.of()); |
||||
|
stubInsertWithId(NEW_TPL_ID); |
||||
|
when(templateMapper.updateById(any(OpportunityStageTemplate.class))).thenReturn(1); |
||||
|
|
||||
|
service.saveAndPublish(dto); |
||||
|
|
||||
|
verify(templateMapper).insert(any(OpportunityStageTemplate.class)); |
||||
|
ArgumentCaptor<OpportunityStageTemplate> captor = ArgumentCaptor.forClass(OpportunityStageTemplate.class); |
||||
|
verify(templateMapper).updateById(captor.capture()); |
||||
|
assertThat(captor.getValue().getId()).isEqualTo(NEW_TPL_ID); |
||||
|
assertThat(captor.getValue().getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_PUBLISHED); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("发布新版本顶替原发布中:V1.1 转停用、V1.2 转发布中(同 code 自我顶替)") |
||||
|
void saveAndPublish_replacesOldPublished() { |
||||
|
OpportunityStageTemplateDTO dto = validDto(); |
||||
|
dto.setId(PUB_ID); |
||||
|
OpportunityStageTemplate v1 = tplRow(PUB_ID, CODE_C1, "V1.1", OpportunityRuleConstants.RULE_STATUS_PUBLISHED); |
||||
|
when(templateMapper.selectById(PUB_ID)).thenReturn(v1); |
||||
|
// selectList 序列:#1 同 code 全版本(无草稿→生成 V1.2)、#2 同 code 原发布中(V1.1)
|
||||
|
when(templateMapper.selectList(any())).thenReturn(List.of(v1), List.of(v1)); |
||||
|
stubInsertWithId(NEW_TPL_ID); |
||||
|
when(templateMapper.updateById(any(OpportunityStageTemplate.class))).thenReturn(1); |
||||
|
|
||||
|
service.saveAndPublish(dto); |
||||
|
|
||||
|
ArgumentCaptor<OpportunityStageTemplate> insertCaptor = ArgumentCaptor.forClass(OpportunityStageTemplate.class); |
||||
|
verify(templateMapper).insert(insertCaptor.capture()); |
||||
|
assertThat(insertCaptor.getValue().getVersionNo()).isEqualTo("V1.2"); |
||||
|
// 两笔状态迁移:V1.1 → 停用;V1.2 → 发布中
|
||||
|
ArgumentCaptor<OpportunityStageTemplate> updateCaptor = ArgumentCaptor.forClass(OpportunityStageTemplate.class); |
||||
|
verify(templateMapper, times(2)).updateById(updateCaptor.capture()); |
||||
|
List<OpportunityStageTemplate> updates = updateCaptor.getAllValues(); |
||||
|
assertThat(updates.get(0).getId()).isEqualTo(PUB_ID); |
||||
|
assertThat(updates.get(0).getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_DISABLED); |
||||
|
assertThat(updates.get(1).getId()).isEqualTo(NEW_TPL_ID); |
||||
|
assertThat(updates.get(1).getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_PUBLISHED); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("发布新默认:原发布中默认自动取消默认(默认唯一是自动顶替,非拒绝)") |
||||
|
void saveAndPublish_newDefault_unsetsOldDefault() { |
||||
|
OpportunityStageTemplateDTO dto = validDto(); |
||||
|
dto.setIsDefault(OpportunityRuleConstants.FLAG_YES); |
||||
|
OpportunityStageTemplate oldDefault = tplRow(OLD_DEFAULT_ID, "OPP_STAGE_TPL_88", "V1.0", |
||||
|
OpportunityRuleConstants.RULE_STATUS_PUBLISHED); |
||||
|
oldDefault.setIsDefault(OpportunityRuleConstants.FLAG_YES); |
||||
|
// selectList 序列:#1 编码生成、#2 同 code 原发布中(无)、#3 发布中的旧默认(OLD_DEFAULT)
|
||||
|
when(templateMapper.selectList(any())).thenReturn(List.of(), List.of(), List.of(oldDefault)); |
||||
|
stubInsertWithId(NEW_TPL_ID); |
||||
|
when(templateMapper.updateById(any(OpportunityStageTemplate.class))).thenReturn(1); |
||||
|
|
||||
|
service.saveAndPublish(dto); |
||||
|
|
||||
|
ArgumentCaptor<OpportunityStageTemplate> updateCaptor = ArgumentCaptor.forClass(OpportunityStageTemplate.class); |
||||
|
verify(templateMapper, times(2)).updateById(updateCaptor.capture()); |
||||
|
List<OpportunityStageTemplate> updates = updateCaptor.getAllValues(); |
||||
|
assertThat(updates.get(0).getId()).isEqualTo(OLD_DEFAULT_ID); |
||||
|
assertThat(updates.get(0).getIsDefault()).isEqualTo(OpportunityRuleConstants.FLAG_NO); |
||||
|
assertThat(updates.get(1).getId()).isEqualTo(NEW_TPL_ID); |
||||
|
assertThat(updates.get(1).getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_PUBLISHED); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("草稿直接保存并发布:原位编辑后该草稿转发布中,不新建版本行") |
||||
|
void saveAndPublish_draftDirect_publishesDraftOnly() { |
||||
|
OpportunityStageTemplateDTO dto = validDto(); |
||||
|
dto.setId(DRAFT_ID); |
||||
|
when(templateMapper.selectById(DRAFT_ID)).thenReturn( |
||||
|
tplRow(DRAFT_ID, CODE_C1, "V1.1", OpportunityRuleConstants.RULE_STATUS_DRAFT)); |
||||
|
// publish 顶替查询:同 code 无其它发布中
|
||||
|
when(templateMapper.selectList(any())).thenReturn(List.of()); |
||||
|
when(templateMapper.updateById(any(OpportunityStageTemplate.class))).thenReturn(1); |
||||
|
|
||||
|
service.saveAndPublish(dto); |
||||
|
|
||||
|
verify(templateMapper, never()).insert(any(OpportunityStageTemplate.class)); |
||||
|
ArgumentCaptor<OpportunityStageTemplate> updateCaptor = ArgumentCaptor.forClass(OpportunityStageTemplate.class); |
||||
|
verify(templateMapper, times(2)).updateById(updateCaptor.capture()); |
||||
|
List<OpportunityStageTemplate> updates = updateCaptor.getAllValues(); |
||||
|
// #1 原位编辑(仍草稿)、#2 转发布中
|
||||
|
assertThat(updates.get(0).getId()).isEqualTo(DRAFT_ID); |
||||
|
assertThat(updates.get(0).getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_DRAFT); |
||||
|
assertThat(updates.get(1).getId()).isEqualTo(DRAFT_ID); |
||||
|
assertThat(updates.get(1).getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_PUBLISHED); |
||||
|
} |
||||
|
|
||||
|
// ==================== 停用 / 删除 / 复制(行操作矩阵) ====================
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("停用:发布中 → 已停用") |
||||
|
void disableTemplate_publishedToDisabled() { |
||||
|
when(templateMapper.selectById(PUB_ID)).thenReturn( |
||||
|
tplRow(PUB_ID, CODE_C1, "V1.0", OpportunityRuleConstants.RULE_STATUS_PUBLISHED)); |
||||
|
when(templateMapper.updateById(any(OpportunityStageTemplate.class))).thenReturn(1); |
||||
|
|
||||
|
service.disableTemplate(PUB_ID); |
||||
|
|
||||
|
ArgumentCaptor<OpportunityStageTemplate> captor = ArgumentCaptor.forClass(OpportunityStageTemplate.class); |
||||
|
verify(templateMapper).updateById(captor.capture()); |
||||
|
assertThat(captor.getValue().getId()).isEqualTo(PUB_ID); |
||||
|
assertThat(captor.getValue().getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_DISABLED); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("停用:非发布中(草稿)→ 拒绝 64013,不触达更新") |
||||
|
void disableTemplate_notPublished_rejected() { |
||||
|
when(templateMapper.selectById(DRAFT_ID)).thenReturn( |
||||
|
tplRow(DRAFT_ID, CODE_C1, "V1.1", OpportunityRuleConstants.RULE_STATUS_DRAFT)); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.disableTemplate(DRAFT_ID)) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_INVALID); |
||||
|
|
||||
|
verify(templateMapper, never()).updateById(any(OpportunityStageTemplate.class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("删除草稿:主表逻辑删 + 部门/节点子表级联硬删") |
||||
|
void deleteDraft_draftRemovedWithChildren() { |
||||
|
when(templateMapper.selectById(DRAFT_ID)).thenReturn( |
||||
|
tplRow(DRAFT_ID, CODE_C1, "V1.1", OpportunityRuleConstants.RULE_STATUS_DRAFT)); |
||||
|
|
||||
|
service.deleteDraft(DRAFT_ID); |
||||
|
|
||||
|
verify(templateMapper).deleteById(DRAFT_ID); |
||||
|
verify(deptMapper).delete(any()); |
||||
|
verify(nodeMapper).delete(any()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("删除:非草稿(发布中/停用不可删)→ 拒绝 64015") |
||||
|
void deleteDraft_notDraft_rejected() { |
||||
|
when(templateMapper.selectById(PUB_ID)).thenReturn( |
||||
|
tplRow(PUB_ID, CODE_C1, "V1.0", OpportunityRuleConstants.RULE_STATUS_PUBLISHED)); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.deleteDraft(PUB_ID)) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_NOT_DRAFT); |
||||
|
|
||||
|
verify(templateMapper, never()).deleteById(any()); |
||||
|
verify(deptMapper, never()).delete(any()); |
||||
|
verify(nodeMapper, never()).delete(any()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("复制:独立新模板(编码顺延 + V1.0 + 草稿),部门与节点随复制") |
||||
|
void copyTemplate_createsIndependentDraft() { |
||||
|
OpportunityStageTemplate source = tplRow(PUB_ID, CODE_C1, "V1.1", OpportunityRuleConstants.RULE_STATUS_PUBLISHED); |
||||
|
when(templateMapper.selectById(PUB_ID)).thenReturn(source); |
||||
|
// 编码顺延:现有最大 OPP_STAGE_TPL_01 → 新编码 OPP_STAGE_TPL_02
|
||||
|
when(templateMapper.selectList(any())).thenReturn(List.of(source)); |
||||
|
when(deptMapper.selectList(any())).thenReturn(List.of(deptRow(PUB_ID, DEPT_10))); |
||||
|
when(nodeMapper.selectList(any())).thenReturn(List.of( |
||||
|
nodeRow(7001L, PUB_ID, 1, "OPP_STAGE_01", OpportunityRuleConstants.FLAG_NO), |
||||
|
nodeRow(7002L, PUB_ID, 2, OpportunityRuleConstants.FIXED_STAGE_DICT_CODE, OpportunityRuleConstants.FLAG_YES))); |
||||
|
stubInsertWithId(COPY_ID); |
||||
|
|
||||
|
Long newId = service.copyTemplate(PUB_ID); |
||||
|
|
||||
|
assertThat(newId).isEqualTo(COPY_ID); |
||||
|
ArgumentCaptor<OpportunityStageTemplate> captor = ArgumentCaptor.forClass(OpportunityStageTemplate.class); |
||||
|
verify(templateMapper).insert(captor.capture()); |
||||
|
OpportunityStageTemplate copied = captor.getValue(); |
||||
|
assertThat(copied.getTemplateCode()).isEqualTo("OPP_STAGE_TPL_02"); |
||||
|
assertThat(copied.getVersionNo()).isEqualTo("V1.0"); |
||||
|
assertThat(copied.getStatus()).isEqualTo(OpportunityRuleConstants.RULE_STATUS_DRAFT); |
||||
|
// 部门与节点随复制(节点保序,含末位固定节点)
|
||||
|
verify(deptMapper).insert(any(OpportunityStageTemplateDept.class)); |
||||
|
ArgumentCaptor<OpportunityStageNode> nodeCaptor = ArgumentCaptor.forClass(OpportunityStageNode.class); |
||||
|
verify(nodeMapper, times(2)).insert(nodeCaptor.capture()); |
||||
|
assertThat(nodeCaptor.getAllValues()).extracting(OpportunityStageNode::getTemplateId) |
||||
|
.containsOnly(COPY_ID); |
||||
|
assertThat(nodeCaptor.getAllValues()).extracting(OpportunityStageNode::getSeqNo) |
||||
|
.containsExactly(1, 2); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("复制:版本不存在 → 拒绝 64014") |
||||
|
void copyTemplate_notExist_rejected() { |
||||
|
when(templateMapper.selectById(999L)).thenReturn(null); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.copyTemplate(999L)) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_NOT_EXIST); |
||||
|
} |
||||
|
|
||||
|
// ==================== 版本号生成(minor 顺延) ====================
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("版本号顺延:取全版本最大 (major, minor) 的 minor+1;无历史 → V1.0") |
||||
|
void nextVersionNo_minorBump() { |
||||
|
assertThat(OpportunityStageTemplateServiceImpl.nextVersionNo(List.of("V1.0", "V1.1", "V2.0"))).isEqualTo("V2.1"); |
||||
|
assertThat(OpportunityStageTemplateServiceImpl.nextVersionNo(List.of("V1.0"))).isEqualTo("V1.1"); |
||||
|
assertThat(OpportunityStageTemplateServiceImpl.nextVersionNo(List.of())).isEqualTo("V1.0"); |
||||
|
// 防御:非法版本号行忽略,不参与顺延
|
||||
|
assertThat(OpportunityStageTemplateServiceImpl.nextVersionNo(List.of("V1.0", "bad"))).isEqualTo("V1.1"); |
||||
|
} |
||||
|
|
||||
|
// ==================== 绑定匹配(商机创建时解析绑定版本) ====================
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("绑定匹配:无发布中模板 → null") |
||||
|
void resolveBindingVersion_noPublished_returnsNull() { |
||||
|
when(templateMapper.selectList(any())).thenReturn(List.of()); |
||||
|
|
||||
|
assertThat(service.resolveBindingVersion(DEPT_10)).isNull(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("绑定匹配:部门专用发布模板命中 → 优先于所有商机默认兜底") |
||||
|
void resolveBindingVersion_deptSpecificWinsOverDefault() { |
||||
|
OpportunityStageTemplate specific = tplRow(OTHER_TPL_ID, "OPP_STAGE_TPL_02", "V1.0", |
||||
|
OpportunityRuleConstants.RULE_STATUS_PUBLISHED); |
||||
|
specific.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_DEPT); |
||||
|
OpportunityStageTemplate defaultAll = tplRow(PUB_ID, CODE_C1, "V1.0", |
||||
|
OpportunityRuleConstants.RULE_STATUS_PUBLISHED); |
||||
|
defaultAll.setIsDefault(OpportunityRuleConstants.FLAG_YES); |
||||
|
when(templateMapper.selectList(any())).thenReturn(List.of(specific, defaultAll)); |
||||
|
when(deptMapper.selectList(any())).thenReturn(List.of(deptRow(OTHER_TPL_ID, DEPT_10))); |
||||
|
|
||||
|
assertThat(service.resolveBindingVersion(DEPT_10)).isEqualTo(specific); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("绑定匹配:多部门专用命中 → 取最近创建者(无部门覆盖唯一约束,绑定须确定)") |
||||
|
void resolveBindingVersion_multipleHits_latestCreatedWins() { |
||||
|
OpportunityStageTemplate older = tplRow(OTHER_TPL_ID, "OPP_STAGE_TPL_02", "V1.0", |
||||
|
OpportunityRuleConstants.RULE_STATUS_PUBLISHED); |
||||
|
older.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_DEPT); |
||||
|
older.setCreateTime(LocalDateTime.of(2026, 8, 1, 10, 0)); |
||||
|
OpportunityStageTemplate newer = tplRow(PUB_ID, "OPP_STAGE_TPL_03", "V1.0", |
||||
|
OpportunityRuleConstants.RULE_STATUS_PUBLISHED); |
||||
|
newer.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_DEPT); |
||||
|
newer.setCreateTime(LocalDateTime.of(2026, 8, 20, 10, 0)); |
||||
|
when(templateMapper.selectList(any())).thenReturn(List.of(older, newer)); |
||||
|
when(deptMapper.selectList(any())).thenReturn(List.of( |
||||
|
deptRow(OTHER_TPL_ID, DEPT_10), deptRow(PUB_ID, DEPT_10))); |
||||
|
|
||||
|
assertThat(service.resolveBindingVersion(DEPT_10)).isEqualTo(newer); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("绑定匹配:部门无专用命中 → 回落所有商机+默认模板") |
||||
|
void resolveBindingVersion_noDeptHit_fallsBackToDefault() { |
||||
|
OpportunityStageTemplate specific = tplRow(OTHER_TPL_ID, "OPP_STAGE_TPL_02", "V1.0", |
||||
|
OpportunityRuleConstants.RULE_STATUS_PUBLISHED); |
||||
|
specific.setApplyScope(OpportunityRuleConstants.APPLY_SCOPE_DEPT); |
||||
|
OpportunityStageTemplate defaultAll = tplRow(PUB_ID, CODE_C1, "V1.0", |
||||
|
OpportunityRuleConstants.RULE_STATUS_PUBLISHED); |
||||
|
defaultAll.setIsDefault(OpportunityRuleConstants.FLAG_YES); |
||||
|
when(templateMapper.selectList(any())).thenReturn(List.of(specific, defaultAll)); |
||||
|
when(deptMapper.selectList(any())).thenReturn(List.of()); |
||||
|
|
||||
|
assertThat(service.resolveBindingVersion(DEPT_10)).isEqualTo(defaultAll); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("绑定匹配:部门专用命中后不再回落默认;无默认兜底时返回 null") |
||||
|
void resolveBindingVersion_noDefaultFallback_returnsNull() { |
||||
|
OpportunityStageTemplate nonDefault = tplRow(PUB_ID, CODE_C1, "V1.0", |
||||
|
OpportunityRuleConstants.RULE_STATUS_PUBLISHED); |
||||
|
when(templateMapper.selectList(any())).thenReturn(List.of(nonDefault)); |
||||
|
|
||||
|
assertThat(service.resolveBindingVersion(DEPT_10)).isNull(); |
||||
|
} |
||||
|
|
||||
|
// ==================== 运行时查询缝(供商机模块消费) ====================
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("getNodeById:null 直接返回空,不查库") |
||||
|
void getNodeById_nullSafe() { |
||||
|
assertThat(service.getNodeById(null)).isNull(); |
||||
|
verify(nodeMapper, never()).selectById(any()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("getNodeById:委托节点 Mapper 按主键查询") |
||||
|
void getNodeById_delegatesToMapper() { |
||||
|
OpportunityStageNode node = nodeRow(7001L, PUB_ID, 1, "OPP_STAGE_01", OpportunityRuleConstants.FLAG_NO); |
||||
|
when(nodeMapper.selectById(7001L)).thenReturn(node); |
||||
|
|
||||
|
assertThat(service.getNodeById(7001L)).isEqualTo(node); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("listNodesOfVersion:null 返回空列表,不查库") |
||||
|
void listNodesOfVersion_nullSafe() { |
||||
|
assertThat(service.listNodesOfVersion(null)).isEmpty(); |
||||
|
verify(nodeMapper, never()).selectList(any()); |
||||
|
} |
||||
|
|
||||
|
// ==================== 查询(列表/详情 + 部门回显) ====================
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("分页列表:按版本维度返回,回显适用部门 ID 与名称") |
||||
|
void pageTemplates_fillsDeptNames() { |
||||
|
Page<OpportunityStageTemplate> page = new Page<>(1, 10); |
||||
|
page.setRecords(List.of(tplRow(PUB_ID, CODE_C1, "V1.0", OpportunityRuleConstants.RULE_STATUS_PUBLISHED))); |
||||
|
when(templateMapper.selectPage(any(), any())).thenReturn(page); |
||||
|
when(deptMapper.selectList(any())).thenReturn(List.of(deptRow(PUB_ID, DEPT_10))); |
||||
|
when(sysDeptService.listByIds(any())).thenReturn(List.of(sysDept(DEPT_10, "华东销售部"))); |
||||
|
|
||||
|
var result = service.pageTemplates(new OpportunityStageTemplatePageParam()); |
||||
|
|
||||
|
assertThat(result.getContent()).hasSize(1); |
||||
|
OpportunityStageTemplateDTO dto = result.getContent().get(0); |
||||
|
assertThat(dto.getTemplateCode()).isEqualTo(CODE_C1); |
||||
|
assertThat(dto.getDeptIds()).containsExactly(DEPT_10); |
||||
|
assertThat(dto.getDeptNames()).containsExactly("华东销售部"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("详情:不存在 → 拒绝 64014") |
||||
|
void getTemplateDetail_notExist_rejected() { |
||||
|
when(templateMapper.selectById(999L)).thenReturn(null); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.getTemplateDetail(999L)) |
||||
|
.isInstanceOf(BusinessErrorException.class) |
||||
|
.hasFieldOrPropertyWithValue("code", OpportunityRuleConstants.CODE_STAGE_TPL_NOT_EXIST); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("详情:主表字段 + 适用部门回显 + 有序节点(末位固定节点)") |
||||
|
void getTemplateDetail_fillsDeptsAndNodes() { |
||||
|
when(templateMapper.selectById(PUB_ID)).thenReturn( |
||||
|
tplRow(PUB_ID, CODE_C1, "V1.0", OpportunityRuleConstants.RULE_STATUS_PUBLISHED)); |
||||
|
when(deptMapper.selectList(any())).thenReturn(List.of(deptRow(PUB_ID, DEPT_10))); |
||||
|
when(sysDeptService.listByIds(any())).thenReturn(List.of(sysDept(DEPT_10, "华东销售部"))); |
||||
|
when(nodeMapper.selectList(any())).thenReturn(List.of( |
||||
|
nodeRow(7001L, PUB_ID, 1, "OPP_STAGE_01", OpportunityRuleConstants.FLAG_NO), |
||||
|
nodeRow(7002L, PUB_ID, 2, OpportunityRuleConstants.FIXED_STAGE_DICT_CODE, OpportunityRuleConstants.FLAG_YES))); |
||||
|
|
||||
|
OpportunityStageTemplateDTO dto = service.getTemplateDetail(PUB_ID); |
||||
|
|
||||
|
assertThat(dto.getTemplateCode()).isEqualTo(CODE_C1); |
||||
|
assertThat(dto.getDeptIds()).containsExactly(DEPT_10); |
||||
|
assertThat(dto.getDeptNames()).containsExactly("华东销售部"); |
||||
|
assertThat(dto.getNodes()).hasSize(2); |
||||
|
assertThat(dto.getNodes().get(1).getStageDictCode()) |
||||
|
.isEqualTo(OpportunityRuleConstants.FIXED_STAGE_DICT_CODE); |
||||
|
assertThat(dto.getNodes().get(1).getIsFixed()).isEqualTo(OpportunityRuleConstants.FLAG_YES); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue