Browse Source

ticket13: ProjectRuleQueryPort inbound(crm-rule 声明自实现,①⑤真读 ②③④签名预留桩)+ 看板列头/详情阶段名别名优先 + 标底等级判定接入

master
luoweijian 10 hours ago
parent
commit
e5bbde8286
  1. 6
      .scratch/crm-project/API-SUMMARY.md
  2. 10
      .scratch/crm-project/tickets/13-projectrule-port.md
  3. 1
      .scratch/crm-project/tickets/README.md
  4. 33
      crm-project/src/main/java/com/crm/project/query/impl/ProjectQuerySupport.java
  5. 13
      crm-project/src/main/java/com/crm/project/service/impl/ProjectDetailServiceImpl.java
  6. 18
      crm-project/src/main/java/com/crm/project/sub/ProjectStageTabService.java
  7. 20
      crm-project/src/test/java/com/crm/project/query/impl/ProjectListBoardH2IntegrationTest.java
  8. 6
      crm-project/src/test/java/com/crm/project/service/impl/ProjectCreateDetailH2IntegrationTest.java
  9. 10
      crm-project/src/test/java/com/crm/project/sub/ProjectTabsH2IntegrationTest.java
  10. 15
      crm-rule/src/main/java/com/crm/rule/port/inbound/BidConfigDTO.java
  11. 28
      crm-rule/src/main/java/com/crm/rule/port/inbound/ConflictPullDTO.java
  12. 11
      crm-rule/src/main/java/com/crm/rule/port/inbound/PoolMemberDTO.java
  13. 40
      crm-rule/src/main/java/com/crm/rule/port/inbound/ProjectRuleQueryPort.java
  14. 12
      crm-rule/src/main/java/com/crm/rule/port/inbound/StageAliasDTO.java
  15. 14
      crm-rule/src/main/java/com/crm/rule/port/inbound/StageRemindDTO.java
  16. 88
      crm-rule/src/main/java/com/crm/rule/port/inbound/impl/ProjectRuleQueryPortImpl.java
  17. 100
      crm-rule/src/test/java/com/crm/rule/port/inbound/ProjectRuleQueryPortImplTest.java

6
.scratch/crm-project/API-SUMMARY.md

@ -104,7 +104,11 @@
- **用户移交(A7-1)**:`ProjectUserTransferPortImpl`(`com.crm.project.transfer`,实现 crm-base `UserTransferPort`,`domain()="project"`)——查 `crm_project`、刷 owner 三列(owner_user_id/owner_name_snapshot/owner_dept_id);crm-auth `UserTransferService` 经 Spring 多态注册表自动重挂,**零改动**。原 crm-opportunity 侧共表实现(`opp_status=5` 切域)已删除;商机域移交(`OpportunityUserTransferPortImpl` 查 opportunity)不受影响。
- **客户换负责人同步**:`ProjectOwnerSyncPortImpl`(`com.crm.project.port.inbound.impl`,`@Primary` 实现 crm-customer `ProjectOwnerSyncPort`)——customer_id 命中项目批量刷 owner 三列,返回真实影响行数;crm-customer 侧 Noop 桩已退役。
(后续票:2.8 ProjectRule 消费(票 13)……随票追加于此)
### 2.8 ProjectRule 消费(票 13,域内 port、零 HTTP 端点)
- `ProjectRuleQueryPort`(`com.crm.rule.port.inbound`,crm-rule 声明并自实现):①阶段别名(看板列头/详情阶段名,**别名优先→字典名→枚举名**回退链已接);⑤标底等级判定(`/project/bidding` 的 `bidBaseTypeName`,未配置回退 code 本身);②③④签名预留纯读桩(拉群/冲突拉群/阶段提醒,消费侧 spin-out)。
- 阶段别名配置入口:A7 后台管理/业务规则/项目规则(crm-rule 既有端点,`A7` 分区文档)。
## 3. 状态 / 值域附录

10
.scratch/crm-project/tickets/13-projectrule-port.md

@ -6,8 +6,8 @@
**Status:** ready-for-agent
- [ ] 包位置 + DTO 形态拍板并留痕 README Decisions
- [ ] 看板列头/详情阶段名别名优先、空回退字典名(改造 04 的回退版)
- [ ] 标底等级判定可用(A+~SQ)
- [ ] ②③④桩返回约定语义,纯读无写
- [ ] 通用纪律收工项:文档沉淀、BOM 扫描、mvn compile + 测试绿
- [x] 包位置 + DTO 形态拍板并留痕 README Decisions
- [x] 看板列头/详情阶段名别名优先、空回退字典名(改造 04 的回退版)
- [x] 标底等级判定可用(A+~SQ)
- [x] ②③④桩返回约定语义,纯读无写
- [x] 通用纪律收工项:文档沉淀、BOM 扫描、mvn compile + 测试绿

1
.scratch/crm-project/tickets/README.md

@ -39,4 +39,5 @@ Frontier 推进:01(无阻塞可立即开);02 之后 03/04/05/09/10/11/12
- 2026-09-10 票 02 拍板:**项目动态落域内 `project_oplog` 单表 + `ProjectOplogWriter` 统一写入器**(spec §4「crm-log 投影」落地口径)——crm-log 平台审计表 `sys_log_operation` 无 biz 维度、无法按项目过滤投影;域内单表对称 opportunity_oplog 先例,写端点另挂 `@OpLog` 落平台全局审计 crm-log。票 10 读侧按此投影。
- 2026-09-10 票 02 拍板:**深 tag 页面目录** = 项目管理(看板视图)/项目管理(分屏视图)/项目管理(列表视图)/我的项目/项目详情/项目建档。原型树 v29 一行索引未展开详情页;详情/建档 tag 对应原型 a5-1-2-1 页签详情与看板/列表页新建入口。
- 2026-09-10 票 02 拍板:错误码段 **68xxx**(auth 61/file 62/dict 63/rule 64/lead 65/opp 66/customer 67 之后顺延);商机侧 66008(卡不存在)在建档入口翻译为 68007(异常不跨 seam)。
- 2026-09-10 票 13 拍板:**ProjectRuleQueryPort 包位置 = `com.crm.rule.port.inbound`**(crm-rule 声明并自实现,对称 CustomerCatalogPort 的 inbound 形态;与 port.outbound「消费方 adapter 实现」方向相反),DTO 随包 record(StageAliasDTO/BidConfigDTO/PoolMemberDTO/ConflictPullDTO/StageRemindDTO),不暴露内部 ProjectRuleDTO。ConflictPullDTO 含「启用维度集+命中阈值+触发条件」签名形态——维度集/阈值为签名预留(conflictPullJson 无该配置层,解析恒空集/占位 1,spin-out 前置补列)。stageCode 取 project_stage 值字符串("0"~"6",容错 "STAGE_0" 形态取尾段)。
- (后续各票拍板追加于此)

33
crm-project/src/main/java/com/crm/project/query/impl/ProjectQuerySupport.java

@ -14,6 +14,7 @@ import com.crm.project.domain.enums.BidResult;
import com.crm.project.domain.enums.FilingStatus;
import com.crm.project.domain.enums.ProjectStage;
import com.crm.project.domain.enums.ProjectStatus;
import com.crm.rule.port.inbound.ProjectRuleQueryPort;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@ -28,6 +29,9 @@ import java.util.stream.Collectors;
* <p>共用同一数据集语义对齐商机 D-15 纪律列表与看板不各造一套过滤口径
* scope=manage 不加 owner 条件@DataScope 拦截器按 {@code crm_project} 实体注解
* 自动注入部门天花板scope=mine 显式 {@code owner_user_id=当前用户}</p>
*
* <p>阶段名回退链 13**ProjectRule 别名优先 字典名project_stage 分组
* 枚举名兵底**spec §4/grill#9</p>
*/
@Component
@RequiredArgsConstructor
@ -35,6 +39,7 @@ public class ProjectQuerySupport {
private final DictQueryService dictQueryService;
private final CustomerCatalogPort customerCatalog;
private final ProjectRuleQueryPort projectRuleQueryPort;
/** scope 校验 + mine 过滤(manage 交由 @DataScope 拦截器注入部门天花板) */
public LambdaQueryWrapper<CrmProject> scopeWrapper(String scope, Long currentUserId) {
@ -80,12 +85,38 @@ public class ProjectQuerySupport {
return dictQueryService.getNames(ProjectConstants.DICT_GROUP_PROJECT_STAGE, codes);
}
/** 阶段名:字典名(project_stage 分组)→ 枚举名兜底(别名优先接入点归票 13 ProjectRuleQueryPort) */
/** 阶段别名映射(ProjectRule ①,stageCode → alias,空别名不进映射;票 13) */
public Map<String, String> stageAliasMap() {
Map<String, String> map = new java.util.HashMap<>();
for (com.crm.rule.port.inbound.StageAliasDTO item : projectRuleQueryPort.loadStageAlias()) {
if (StrUtil.isBlank(item.alias()) || StrUtil.isBlank(item.stageCode())) {
continue;
}
// stageCode 容错两种配置形态:"0" / "STAGE_0",取尾段数字
String code = item.stageCode().contains("_")
? item.stageCode().substring(item.stageCode().lastIndexOf('_') + 1)
: item.stageCode();
map.putIfAbsent(code, item.alias());
}
return map;
}
/** 阶段名回退链(票 13):别名优先 → 字典名 → 枚举名兵底 */
public String stageNameOf(Integer stage, Map<String, String> dictStageNames) {
return stageNameOf(stage, dictStageNames, stageAliasMap());
}
/** 同上(调用方已持有别名字典时的重载,看板列头批量用) */
public String stageNameOf(Integer stage, Map<String, String> dictStageNames,
Map<String, String> aliasMap) {
if (stage == null) {
return null;
}
String code = String.valueOf(stage);
String alias = aliasMap.get(code);
if (StrUtil.isNotBlank(alias)) {
return alias;
}
String dictName = dictStageNames.get(code);
if (StrUtil.isNotBlank(dictName)) {
return dictName;

13
crm-project/src/main/java/com/crm/project/service/impl/ProjectDetailServiceImpl.java

@ -16,6 +16,7 @@ import com.crm.project.domain.enums.ProjectStage;
import com.crm.project.domain.enums.ProjectStatus;
import com.crm.project.mapper.CrmProjectMapper;
import com.crm.project.service.IProjectDetailService;
import com.crm.rule.port.inbound.ProjectRuleQueryPort;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@ -38,6 +39,7 @@ public class ProjectDetailServiceImpl implements IProjectDetailService {
private final CrmProjectMapper projectMapper;
private final DictQueryService dictQueryService;
private final CustomerCatalogPort customerCatalog;
private final ProjectRuleQueryPort projectRuleQueryPort;
@Override
public ProjectDTO getDetail(Long id) {
@ -65,12 +67,21 @@ public class ProjectDetailServiceImpl implements IProjectDetailService {
dto.setBidResultName(bid == null ? null : bid.getLabel());
}
/** 阶段名:字典名(project_stage 分组)→ 枚举名兜底(别名优先接入点归票 13 ProjectRuleQueryPort) */
/** 阶段名回退链(票 13):别名优先 → 字典名(project_stage 分组)→ 枚举名兵底 */
private String stageNameOf(Integer stage) {
if (stage == null) {
return null;
}
String code = String.valueOf(stage);
// 别名优先(ProjectRule ①,票 13)
String alias = projectRuleQueryPort.loadStageAlias().stream()
.filter(item -> code.equals(item.stageCode()))
.map(com.crm.rule.port.inbound.StageAliasDTO::alias)
.filter(StrUtil::isNotBlank)
.findFirst().orElse(null);
if (alias != null) {
return alias;
}
String dictName = dictQueryService
.getNames(ProjectConstants.DICT_GROUP_PROJECT_STAGE, Set.of(code))
.get(code);

18
crm-project/src/main/java/com/crm/project/sub/ProjectStageTabService.java

@ -10,6 +10,8 @@ import com.crm.project.domain.dto.ProjectSchemeDTO;
import com.crm.project.domain.entity.CrmProject;
import com.crm.project.domain.enums.BidResult;
import com.crm.project.mapper.CrmProjectMapper;
import com.crm.rule.port.inbound.BidConfigDTO;
import com.crm.rule.port.inbound.ProjectRuleQueryPort;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@ -17,14 +19,15 @@ import org.springframework.stereotype.Component;
* 阶段专属 Tab 读侧 11四个读端点全读 crm_project 主表扩展列标量
* **零阶段子表**spec §2.3/grill#11 走乙
*
* <p>标底等级判定A+~SQ接入点 = {@code bidBaseTypeName} 13
* {@code ProjectRuleQueryPort.loadBidConfig} 接入本服务只留字段位</p>
* <p>标底等级判定A+~SQ 13{@code bidBaseTypeName}
* {@code ProjectRuleQueryPort.loadBidConfig} 取等级名name未配置回退字典 code 本身</p>
*/
@Component
@RequiredArgsConstructor
public class ProjectStageTabService {
private final CrmProjectMapper projectMapper;
private final ProjectRuleQueryPort projectRuleQueryPort;
/** 方案设计(概算金额/方案版本/设计任务状态) */
public ProjectSchemeDTO scheme(Long projectId) {
@ -56,6 +59,7 @@ public class ProjectStageTabService {
dto.setBidBudget(project.getBidBudget());
dto.setBrandControl(project.getBrandControl());
dto.setBidBaseType(project.getBidBaseType());
dto.setBidBaseTypeName(bidGradeName(project.getBidBaseType()));
dto.setAllocRatio(project.getAllocRatio());
dto.setBidBond(project.getBidBond());
dto.setBidFileId(project.getBidFileId());
@ -79,6 +83,16 @@ public class ProjectStageTabService {
return dto;
}
/** 标底等级判定(⑤,票 13):等级名取配置 name;未配置回退 code 本身 */
private String bidGradeName(String bidBaseType) {
if (bidBaseType == null || bidBaseType.isBlank()) {
return null;
}
BidConfigDTO config = projectRuleQueryPort.loadBidConfig(bidBaseType);
return config == null || config.name() == null || config.name().isBlank()
? bidBaseType : config.name();
}
private CrmProject requireProject(Long projectId) {
if (projectId == null) {
throw new BusinessErrorException(ProjectConstants.CODE_PROJECT_INVALID, "项目 id 必填");

20
crm-project/src/test/java/com/crm/project/query/impl/ProjectListBoardH2IntegrationTest.java

@ -68,6 +68,8 @@ class ProjectListBoardH2IntegrationTest {
private final CustomerCatalogPort customerCatalog = Mockito.mock(CustomerCatalogPort.class);
private final DictQueryService dictQueryService = Mockito.mock(DictQueryService.class);
private final com.crm.rule.port.inbound.ProjectRuleQueryPort projectRuleQueryPort =
Mockito.mock(com.crm.rule.port.inbound.ProjectRuleQueryPort.class);
@BeforeAll
static void bootstrap() throws Exception {
@ -118,7 +120,9 @@ class ProjectListBoardH2IntegrationTest {
Mockito.when(dictQueryService.getNames(any(), anySet())).thenReturn(Map.of());
Mockito.when(customerCatalog.requireLive(any()))
.thenReturn(new CustomerBrief(1L, "C-001", "甲客户", "ENTERPRISE", "李四"));
ProjectQuerySupport support = new ProjectQuerySupport(dictQueryService, customerCatalog);
Mockito.when(projectRuleQueryPort.loadStageAlias()).thenReturn(List.of());
ProjectQuerySupport support = new ProjectQuerySupport(dictQueryService, customerCatalog,
projectRuleQueryPort);
listQuery = new ProjectViewQueryImpl(projectMapper, support);
boardQuery = new ProjectBoardQueryImpl(projectMapper, support);
}
@ -220,6 +224,20 @@ class ProjectListBoardH2IntegrationTest {
assertThat(stage6.getCards().get(0).getBidResult()).isEqualTo(2); // 输单
}
@Test
@DisplayName("board:列头别名优先(票 13)——ProjectRule 配置别名覆盖字典/枚举名")
void board_aliasPriority() {
Mockito.when(projectRuleQueryPort.loadStageAlias()).thenReturn(List.of(
new com.crm.rule.port.inbound.StageAliasDTO("0", "冲突处理", "报备冲突中心", "")));
ProjectBoardDTO board = boardQuery.board("manage");
assertThat(board.getColumns().get(0).getStageName()).isEqualTo("报备冲突中心");
assertThat(board.getColumns().get(1).getStageName()).isEqualTo("方案设计"); // 未配别名回退
Mockito.when(projectRuleQueryPort.loadStageAlias()).thenReturn(List.of());
ProjectBoardDTO boardNoAlias = boardQuery.board("manage");
assertThat(boardNoAlias.getColumns().get(0).getStageName()).isEqualTo("冲突处理");
}
@Test
@DisplayName("board:mine 只见本人项目(P1 一条在 stage0)")
void board_mineScope() {

6
crm-project/src/test/java/com/crm/project/service/impl/ProjectCreateDetailH2IntegrationTest.java

@ -88,6 +88,8 @@ class ProjectCreateDetailH2IntegrationTest {
private final ProjectOwnerSnapshotResolver ownerResolver =
Mockito.mock(ProjectOwnerSnapshotResolver.class);
private final DictQueryService dictQueryService = Mockito.mock(DictQueryService.class);
private final com.crm.rule.port.inbound.ProjectRuleQueryPort projectRuleQueryPort =
Mockito.mock(com.crm.rule.port.inbound.ProjectRuleQueryPort.class);
@BeforeAll
static void bootstrap() throws Exception {
@ -152,7 +154,9 @@ class ProjectCreateDetailH2IntegrationTest {
ProjectOplogWriter writer = new ProjectOplogWriter(oplogMapper);
createService = new ProjectCreateServiceImpl(projectMapper, customerCatalog,
schemeCardService, detector, ownerResolver, writer);
detailService = new ProjectDetailServiceImpl(projectMapper, dictQueryService, customerCatalog);
Mockito.when(projectRuleQueryPort.loadStageAlias()).thenReturn(java.util.List.of());
detailService = new ProjectDetailServiceImpl(projectMapper, dictQueryService, customerCatalog,
projectRuleQueryPort);
}
@AfterEach

10
crm-project/src/test/java/com/crm/project/sub/ProjectTabsH2IntegrationTest.java

@ -87,6 +87,8 @@ class ProjectTabsH2IntegrationTest {
private final CustomerCatalogPort customerCatalog = Mockito.mock(CustomerCatalogPort.class);
private final ProjectOwnerSnapshotResolver ownerResolver =
Mockito.mock(ProjectOwnerSnapshotResolver.class);
private final com.crm.rule.port.inbound.ProjectRuleQueryPort projectRuleQueryPort =
Mockito.mock(com.crm.rule.port.inbound.ProjectRuleQueryPort.class);
@BeforeAll
static void bootstrap() throws Exception {
@ -149,7 +151,12 @@ class ProjectTabsH2IntegrationTest {
new ProjectOplogWriter(oplogMapper), ownerResolver);
attachmentService = new ProjectAttachmentService(attachmentMapper, projectMapper);
customerTimelineService = new ProjectCustomerTimelineTabService(projectMapper, oplogMapper, customerCatalog);
stageTabService = new ProjectStageTabService(projectMapper);
Mockito.when(projectRuleQueryPort.loadBidConfig("A+")).thenReturn(
new com.crm.rule.port.inbound.BidConfigDTO("A+", "A级标底", null, 30, 20,
com.crm.rule.constant.ProjectRuleConstants.BID_APPLY_IN_ADVANCE));
Mockito.when(projectRuleQueryPort.loadBidConfig(Mockito.argThat(k ->
k != null && !"A+".equals(k)))).thenReturn(null);
stageTabService = new ProjectStageTabService(projectMapper, projectRuleQueryPort);
}
@AfterEach
@ -305,6 +312,7 @@ class ProjectTabsH2IntegrationTest {
ProjectBiddingDTO bidding = stageTabService.bidding(p.getId());
assertThat(bidding.getBidBaseType()).isEqualTo("A+");
assertThat(bidding.getBidBaseTypeName()).isEqualTo("A级标底"); // 票 13 等级判定
assertThat(bidding.getBidderName()).isEqualTo("华东机电");
assertThat(bidding.getBidFileId()).isEqualTo("file-1001");

15
crm-rule/src/main/java/com/crm/rule/port/inbound/BidConfigDTO.java

@ -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) {
}

28
crm-rule/src/main/java/com/crm/rule/port/inbound/ConflictPullDTO.java

@ -0,0 +1,28 @@
package com.crm.rule.port.inbound;
import java.util.List;
/**
* 冲突拉群规则spin-out检测留桩 + 拉群 spin-outspec §8
*
* <p>启用维度集 + 命中阈值 + 触发条件签名形态spin-out 前置拍板
* {@code enabledDimensions}/{@code hitThreshold} **签名预留字段**已建
* {@code conflictPullJson} 只有拉群 5 + 2 触发开关匹配维度选择配置层
* 原型 A7-3-4 落表砂掉本期解析恒空/ 1spin-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) {
}
}

11
crm-rule/src/main/java/com/crm/rule/port/inbound/PoolMemberDTO.java

@ -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) {
}

40
crm-rule/src/main/java/com/crm/rule/port/inbound/ProjectRuleQueryPort.java

@ -0,0 +1,40 @@
package com.crm.rule.port.inbound;
import java.util.List;
/**
* ProjectRule 消费 inbound port纯读写方=crm-rule 单例行 id=1spec §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();
}

12
crm-rule/src/main/java/com/crm/rule/port/inbound/StageAliasDTO.java

@ -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) {
}

14
crm-rule/src/main/java/com/crm/rule/port/inbound/StageRemindDTO.java

@ -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) {
}

88
crm-rule/src/main/java/com/crm/rule/port/inbound/impl/ProjectRuleQueryPortImpl.java

@ -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;
}
}

100
crm-rule/src/test/java/com/crm/rule/port/inbound/ProjectRuleQueryPortImplTest.java

@ -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…
Cancel
Save