17 changed files with 412 additions and 13 deletions
@ -0,0 +1,15 @@ |
|||
package com.crm.rule.port.inbound; |
|||
|
|||
/** |
|||
* 标底认定配置行(⑤,{@link ProjectRuleQueryPort#loadBidConfig(String)}) |
|||
* |
|||
* @param bidTypeKey 标底类型键(A+/A-/B+/B-/C/N/H/X/SQ) |
|||
* @param name 等级名(本期等级判定消费点) |
|||
* @param remark 备注 |
|||
* @param bidOwnerPct 投标商务分成%(分成计算 spin-out,留 DTO 不消费) |
|||
* @param winOwnerPct 赢单负责人分成%(spin-out 同上) |
|||
* @param applyType 申请类型:AFTER_PUBLISH / IN_ADVANCE |
|||
*/ |
|||
public record BidConfigDTO(String bidTypeKey, String name, String remark, |
|||
Integer bidOwnerPct, Integer winOwnerPct, String applyType) { |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
package com.crm.rule.port.inbound; |
|||
|
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 冲突拉群规则(③,spin-out:检测留桩 + 拉群 spin-out,spec §8) |
|||
* |
|||
* <p>含「启用维度集 + 命中阈值 + 触发条件」签名形态(spin-out 前置拍板): |
|||
* {@code enabledDimensions}/{@code hitThreshold} 为**签名预留字段**——已建 |
|||
* {@code conflictPullJson} 只有拉群 5 行 + 2 触发开关,无「匹配维度选择」配置层 |
|||
* (原型 A7-3-4 有、落表砂掉);本期解析恒空/恒 1,spin-out 前需先补配置列。</p> |
|||
* |
|||
* @param rows 拉群 5 行骨架(ALL_PARTIES/FILING_CLERK/DEPT_LEADERS/REMOTE_CONTACT/VP_MARKETING) |
|||
* @param autoTriggerMultiParty 多方报备自动触发开关(1/0) |
|||
* @param autoTriggerRemote 异地报备自动触发开关(1/0) |
|||
* @param enabledDimensions 启用匹配维度集(签名预留:项目名称/地点/品牌/联系人/时间戳;本期恒空集) |
|||
* @param hitThreshold 命中阈值(签名预留:命中≥N 项视为冲突;本期恒 1 占位) |
|||
*/ |
|||
public record ConflictPullDTO(List<Row> rows, |
|||
boolean autoTriggerMultiParty, |
|||
boolean autoTriggerRemote, |
|||
List<String> enabledDimensions, |
|||
Integer hitThreshold) { |
|||
|
|||
/** 拉群行 */ |
|||
public record Row(String rowKey, String pullRule, Integer required) { |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
package com.crm.rule.port.inbound; |
|||
|
|||
/** |
|||
* 项目拉群人员行(②,spin-out 依赖企微群;签名预留) |
|||
* |
|||
* @param roleKey 角色键(ProjectRuleConstants.POOL_ROLE_* 七角色) |
|||
* @param enabled 是否启用(1/0) |
|||
* @param remark 备注 |
|||
*/ |
|||
public record PoolMemberDTO(String roleKey, Integer enabled, String remark) { |
|||
} |
|||
@ -0,0 +1,40 @@ |
|||
package com.crm.rule.port.inbound; |
|||
|
|||
import java.util.List; |
|||
|
|||
/** |
|||
* ProjectRule 消费 inbound port(纯读,写方=crm-rule 单例行 id=1;spec §6,票 13) |
|||
* |
|||
* <p>包位置拍板:`com.crm.rule.port.inbound`——crm-rule **声明并自实现**(对称 |
|||
* `com.crm.customer.port.CustomerCatalogPort` 的 inbound 形态:契约住产权方、消费方 |
|||
* (crm-project)依赖注入;与 `port.outbound`(crm-rule 声明、消费方 adapter 实现)方向相反)。 |
|||
* DTO 拍板:随包 record,住本包(不暴露内部 {@code ProjectRuleDTO},契约面稳定)。</p> |
|||
* |
|||
* <p>五区块消费边界(spec §6):①阶段别名 ✅本期(看板列头/详情阶段名,别名优先、空回退 |
|||
* 字典名);⑤标底等级判定 ✅本期(A+~SQ;分成%计算 spin-out,字段留 DTO 不消费); |
|||
* ②拉群 / ③冲突拉群 / ④阶段提醒 ⚠️spin-out——签名预留、实现留桩(本口为纯读, |
|||
* 「桩」指消费侧逻辑不做;本口如实返回配置投影,含 {@code ConflictPullDTO} 的 |
|||
* 「启用维度集 + 命中阈值 + 触发条件」签名形态)。</p> |
|||
*/ |
|||
public interface ProjectRuleQueryPort { |
|||
|
|||
/** ① 阶段别名配置(stageCode = project_stage 值字符串 0~6;alias 空回退字典名) */ |
|||
List<StageAliasDTO> loadStageAlias(); |
|||
|
|||
/** |
|||
* ⑤ 标底配置(等级判定本期消费点:取 {@code name} 作等级名;分成字段留 DTO 不消费)。 |
|||
* |
|||
* @param bidTypeKey 标底类型键(A+/A-/B+/B-/C/N/H/X/SQ) |
|||
* @return 命中的标底配置;未配置返回 null |
|||
*/ |
|||
BidConfigDTO loadBidConfig(String bidTypeKey); |
|||
|
|||
/** ② 项目拉群人员(spin-out:依赖企微群;签名预留) */ |
|||
List<PoolMemberDTO> loadPoolMembers(); |
|||
|
|||
/** ③ 冲突拉群规则(spin-out:检测留桩+拉群 spin-out;DTO 含维度集/阈值签名形态) */ |
|||
ConflictPullDTO loadConflictPull(); |
|||
|
|||
/** ④ 阶段提醒(spin-out:依赖通知推送;签名预留) */ |
|||
List<StageRemindDTO> loadStageRemind(); |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
package com.crm.rule.port.inbound; |
|||
|
|||
/** |
|||
* 阶段别名行(①,{@link ProjectRuleQueryPort#loadStageAlias()}) |
|||
* |
|||
* @param stageCode 阶段键(project_stage 值字符串 0~6) |
|||
* @param stageName 阶段名(配置冗余回显) |
|||
* @param alias 别名(看板列头/详情阶段名优先取;空回退字典名) |
|||
* @param remark 备注 |
|||
*/ |
|||
public record StageAliasDTO(String stageCode, String stageName, String alias, String remark) { |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
package com.crm.rule.port.inbound; |
|||
|
|||
/** |
|||
* 阶段提醒规则行(④,spin-out 依赖通知推送;签名预留) |
|||
* |
|||
* @param stageCode 阶段键 |
|||
* @param scene 提醒场景 |
|||
* @param triggerCondition 触发条件 |
|||
* @param notifyWay 通知方式 |
|||
* @param enabled 是否启用(1/0) |
|||
*/ |
|||
public record StageRemindDTO(String stageCode, String scene, |
|||
String triggerCondition, String notifyWay, Integer enabled) { |
|||
} |
|||
@ -0,0 +1,88 @@ |
|||
package com.crm.rule.port.inbound.impl; |
|||
|
|||
import cn.hutool.core.util.StrUtil; |
|||
import com.crm.rule.domain.dto.ProjectRuleDTO; |
|||
import com.crm.rule.port.inbound.BidConfigDTO; |
|||
import com.crm.rule.port.inbound.ConflictPullDTO; |
|||
import com.crm.rule.port.inbound.PoolMemberDTO; |
|||
import com.crm.rule.port.inbound.ProjectRuleQueryPort; |
|||
import com.crm.rule.port.inbound.StageAliasDTO; |
|||
import com.crm.rule.port.inbound.StageRemindDTO; |
|||
import com.crm.rule.service.IProjectRuleService; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.util.List; |
|||
import java.util.Optional; |
|||
|
|||
/** |
|||
* {@link ProjectRuleQueryPort} 实现:委托 {@link IProjectRuleService#current()} |
|||
* (单例行 id=1 聚合读,未初始化防御为空区块),重整为 port 契约 DTO。纯读、无写。 |
|||
*/ |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
public class ProjectRuleQueryPortImpl implements ProjectRuleQueryPort { |
|||
|
|||
/** 冲突匹配命中阈值占位(签名预留:配置列补齐前恒 1,仅防 NPE,消费侧 spin-out 不读) */ |
|||
private static final int HIT_THRESHOLD_PLACEHOLDER = 1; |
|||
|
|||
private final IProjectRuleService projectRuleService; |
|||
|
|||
@Override |
|||
public List<StageAliasDTO> loadStageAlias() { |
|||
return Optional.ofNullable(projectRuleService.current().getStageAliases()).orElse(List.of()) |
|||
.stream().map(item -> new StageAliasDTO(item.getStageCode(), item.getStageName(), |
|||
item.getAlias(), item.getRemark())) |
|||
.toList(); |
|||
} |
|||
|
|||
@Override |
|||
public BidConfigDTO loadBidConfig(String bidTypeKey) { |
|||
if (StrUtil.isBlank(bidTypeKey)) { |
|||
return null; |
|||
} |
|||
return Optional.ofNullable(projectRuleService.current().getBidConfigs()).orElse(List.of()) |
|||
.stream().filter(item -> bidTypeKey.equals(item.getBidTypeKey())) |
|||
.findFirst() |
|||
.map(item -> new BidConfigDTO(item.getBidTypeKey(), item.getName(), item.getRemark(), |
|||
item.getBidOwnerPct(), item.getWinOwnerPct(), item.getApplyType())) |
|||
.orElse(null); |
|||
} |
|||
|
|||
@Override |
|||
public List<PoolMemberDTO> loadPoolMembers() { |
|||
return Optional.ofNullable(projectRuleService.current().getPoolMembers()).orElse(List.of()) |
|||
.stream().map(item -> new PoolMemberDTO(item.getRoleKey(), item.getEnabled(), |
|||
item.getRemark())) |
|||
.toList(); |
|||
} |
|||
|
|||
@Override |
|||
public ConflictPullDTO loadConflictPull() { |
|||
ProjectRuleDTO.ConflictPullConfig config = projectRuleService.current().getConflictPull(); |
|||
if (config == null) { |
|||
return new ConflictPullDTO(List.of(), false, false, List.of(), HIT_THRESHOLD_PLACEHOLDER); |
|||
} |
|||
List<ConflictPullDTO.Row> rows = Optional.ofNullable(config.getRows()).orElse(List.of()) |
|||
.stream().map(row -> new ConflictPullDTO.Row(row.getRowKey(), row.getPullRule(), |
|||
row.getRequired())) |
|||
.toList(); |
|||
// 维度集签名预留:conflictPullJson 无该配置层,恒空集(spin-out 前置补列)
|
|||
return new ConflictPullDTO(rows, |
|||
intFlag(config.getAutoTriggerMultiParty()), |
|||
intFlag(config.getAutoTriggerRemote()), |
|||
List.of(), HIT_THRESHOLD_PLACEHOLDER); |
|||
} |
|||
|
|||
@Override |
|||
public List<StageRemindDTO> loadStageRemind() { |
|||
return Optional.ofNullable(projectRuleService.current().getStageReminds()).orElse(List.of()) |
|||
.stream().map(item -> new StageRemindDTO(item.getStageCode(), item.getScene(), |
|||
item.getTriggerCondition(), item.getNotifyWay(), item.getEnabled())) |
|||
.toList(); |
|||
} |
|||
|
|||
private boolean intFlag(Integer value) { |
|||
return value != null && value == 1; |
|||
} |
|||
} |
|||
@ -0,0 +1,100 @@ |
|||
package com.crm.rule.port.inbound; |
|||
|
|||
import com.crm.rule.constant.ProjectRuleConstants; |
|||
import com.crm.rule.port.inbound.impl.ProjectRuleQueryPortImpl; |
|||
import com.crm.rule.service.impl.ProjectRuleServiceImpl; |
|||
import com.crm.rule.mapper.ProjectRuleMapper; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
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.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
|
|||
import java.util.List; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
|
|||
/** |
|||
* ProjectRuleQueryPort 实现规格验证(票 13):包位置/DTO 形态拍板的功能面钉子。 |
|||
* |
|||
* <p>真实 ProjectRuleServiceImpl JSON 解析链 + mock mapper:验证 ①⑤真读、 |
|||
* ②③④纯读签名、ConflictPullDTO 维度集/阈值签名形态、未初始化防御。</p> |
|||
*/ |
|||
@DisplayName("ProjectRuleQueryPort(票 13,纯读)") |
|||
@ExtendWith(MockitoExtension.class) |
|||
class ProjectRuleQueryPortImplTest { |
|||
|
|||
@Mock |
|||
private ProjectRuleMapper ruleMapper; |
|||
|
|||
private ProjectRuleQueryPortImpl port; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
ProjectRuleServiceImpl ruleService = |
|||
new ProjectRuleServiceImpl(ruleMapper, new ObjectMapper()); |
|||
port = new ProjectRuleQueryPortImpl(ruleService); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("①⑤:阶段别名与标底配置真读(JSON 解析链);loadBidConfig 按 bidTypeKey 命中") |
|||
void loadsAliasAndBidConfig() { |
|||
com.crm.rule.domain.entity.ProjectRule row = |
|||
new com.crm.rule.domain.entity.ProjectRule(); |
|||
row.setStageAliasJson( |
|||
"[{\"stageCode\":\"0\",\"stageName\":\"冲突处理\",\"alias\":\"报备冲突\",\"remark\":\"\"}," |
|||
+ "{\"stageCode\":\"1\",\"stageName\":\"方案设计\",\"alias\":\"\",\"remark\":\"\"}]"); |
|||
row.setBidConfigJson( |
|||
"[{\"bidTypeKey\":\"A+\",\"name\":\"A+级标底\",\"remark\":\"\",\"bidOwnerPct\":30," |
|||
+ "\"winOwnerPct\":20,\"applyType\":\"" + ProjectRuleConstants.BID_APPLY_IN_ADVANCE + "\"}," |
|||
+ "{\"bidTypeKey\":\"SQ\",\"name\":\"SQ级标底\",\"remark\":\"\",\"bidOwnerPct\":0," |
|||
+ "\"winOwnerPct\":0,\"applyType\":\"" + ProjectRuleConstants.BID_APPLY_AFTER_PUBLISH + "\"}]"); |
|||
org.mockito.Mockito.when(ruleMapper.selectById(ProjectRuleConstants.SINGLETON_ID)).thenReturn(row); |
|||
|
|||
// ①:别名真读;空别名照原样透出(消费侧回退字典名)
|
|||
List<StageAliasDTO> alias = port.loadStageAlias(); |
|||
assertThat(alias).hasSize(2); |
|||
assertThat(alias.get(0).alias()).isEqualTo("报备冲突"); |
|||
assertThat(alias.get(1).alias()).isEmpty(); |
|||
|
|||
// ⑤:等级判定消费点(按 bidTypeKey 命中取 name;分成字段留 DTO)
|
|||
BidConfigDTO aPlus = port.loadBidConfig("A+"); |
|||
assertThat(aPlus).isNotNull(); |
|||
assertThat(aPlus.name()).isEqualTo("A+级标底"); |
|||
assertThat(aPlus.bidOwnerPct()).isEqualTo(30); |
|||
assertThat(port.loadBidConfig("ZZ")).isNull(); |
|||
assertThat(port.loadBidConfig(null)).isNull(); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("③:ConflictPullDTO 含维度集+阈值签名形态(配置缺层时空集/占位)+ 2 触发开关真读") |
|||
void conflictPullSignatureShape() { |
|||
com.crm.rule.domain.entity.ProjectRule row = |
|||
new com.crm.rule.domain.entity.ProjectRule(); |
|||
row.setConflictPullJson("{\"rows\":[{\"rowKey\":\"ALL_PARTIES\",\"pullRule\":\"全员群\",\"required\":1}]," |
|||
+ "\"autoTriggerMultiParty\":1,\"autoTriggerRemote\":0}"); |
|||
org.mockito.Mockito.when(ruleMapper.selectById(ProjectRuleConstants.SINGLETON_ID)).thenReturn(row); |
|||
|
|||
ConflictPullDTO dto = port.loadConflictPull(); |
|||
assertThat(dto.rows()).hasSize(1); |
|||
assertThat(dto.rows().get(0).rowKey()).isEqualTo("ALL_PARTIES"); |
|||
assertThat(dto.autoTriggerMultiParty()).isTrue(); |
|||
assertThat(dto.autoTriggerRemote()).isFalse(); |
|||
// 签名预留:维度集空集、阈值占位(配置层 spin-out 前置补列)
|
|||
assertThat(dto.enabledDimensions()).isEmpty(); |
|||
assertThat(dto.hitThreshold()).isEqualTo(1); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("未初始化防御:行缺失 → 全空投影不抛错(②③④桩纯读语义)") |
|||
void missingRowDefensive() { |
|||
org.mockito.Mockito.when(ruleMapper.selectById(ProjectRuleConstants.SINGLETON_ID)).thenReturn(null); |
|||
assertThat(port.loadStageAlias()).isEmpty(); |
|||
assertThat(port.loadPoolMembers()).isEmpty(); |
|||
assertThat(port.loadStageRemind()).isEmpty(); |
|||
assertThat(port.loadConflictPull().rows()).isEmpty(); |
|||
assertThat(port.loadBidConfig("A+")).isNull(); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue