diff --git a/.scratch/crm-project/API-SUMMARY.md b/.scratch/crm-project/API-SUMMARY.md index e3445ea..1ad9b41 100644 --- a/.scratch/crm-project/API-SUMMARY.md +++ b/.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. 状态 / 值域附录 diff --git a/.scratch/crm-project/tickets/13-projectrule-port.md b/.scratch/crm-project/tickets/13-projectrule-port.md index 2e6ce15..d939aac 100644 --- a/.scratch/crm-project/tickets/13-projectrule-port.md +++ b/.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 + 测试绿 diff --git a/.scratch/crm-project/tickets/README.md b/.scratch/crm-project/tickets/README.md index 6664316..4b03f79 100644 --- a/.scratch/crm-project/tickets/README.md +++ b/.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" 形态取尾段)。 - (后续各票拍板追加于此) diff --git a/crm-project/src/main/java/com/crm/project/query/impl/ProjectQuerySupport.java b/crm-project/src/main/java/com/crm/project/query/impl/ProjectQuerySupport.java index e8d7bb3..7b600d2 100644 --- a/crm-project/src/main/java/com/crm/project/query/impl/ProjectQuerySupport.java +++ b/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; *

共用同一数据集语义(对齐商机 D-15 纪律:列表与看板不各造一套过滤口径)。 * scope=manage 不加 owner 条件——@DataScope 拦截器按 {@code crm_project} 实体注解 * 自动注入部门天花板;scope=mine 显式 {@code owner_user_id=当前用户}。

+ * + *

阶段名回退链(票 13):**ProjectRule 别名优先 → 字典名(project_stage 分组)→ + * 枚举名兵底**(spec §4/grill#9)。

*/ @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 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 stageAliasMap() { + Map 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 dictStageNames) { + return stageNameOf(stage, dictStageNames, stageAliasMap()); + } + + /** 同上(调用方已持有别名字典时的重载,看板列头批量用) */ + public String stageNameOf(Integer stage, Map dictStageNames, + Map 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; diff --git a/crm-project/src/main/java/com/crm/project/service/impl/ProjectDetailServiceImpl.java b/crm-project/src/main/java/com/crm/project/service/impl/ProjectDetailServiceImpl.java index 9cfc3c8..5feaf08 100644 --- a/crm-project/src/main/java/com/crm/project/service/impl/ProjectDetailServiceImpl.java +++ b/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); diff --git a/crm-project/src/main/java/com/crm/project/sub/ProjectStageTabService.java b/crm-project/src/main/java/com/crm/project/sub/ProjectStageTabService.java index 2d48278..0ba6381 100644 --- a/crm-project/src/main/java/com/crm/project/sub/ProjectStageTabService.java +++ b/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 走乙)。 * - *

标底等级判定(A+~SQ)接入点 = {@code bidBaseTypeName},票 13 经 - * {@code ProjectRuleQueryPort.loadBidConfig} 接入,本服务只留字段位。

+ *

标底等级判定(A+~SQ,票 13):{@code bidBaseTypeName} 经 + * {@code ProjectRuleQueryPort.loadBidConfig} 取等级名(name);未配置回退字典 code 本身。

*/ @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 必填"); diff --git a/crm-project/src/test/java/com/crm/project/query/impl/ProjectListBoardH2IntegrationTest.java b/crm-project/src/test/java/com/crm/project/query/impl/ProjectListBoardH2IntegrationTest.java index 96f050c..4bdde24 100644 --- a/crm-project/src/test/java/com/crm/project/query/impl/ProjectListBoardH2IntegrationTest.java +++ b/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() { diff --git a/crm-project/src/test/java/com/crm/project/service/impl/ProjectCreateDetailH2IntegrationTest.java b/crm-project/src/test/java/com/crm/project/service/impl/ProjectCreateDetailH2IntegrationTest.java index 630a02f..35c187e 100644 --- a/crm-project/src/test/java/com/crm/project/service/impl/ProjectCreateDetailH2IntegrationTest.java +++ b/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 diff --git a/crm-project/src/test/java/com/crm/project/sub/ProjectTabsH2IntegrationTest.java b/crm-project/src/test/java/com/crm/project/sub/ProjectTabsH2IntegrationTest.java index 2990bcf..765fc3a 100644 --- a/crm-project/src/test/java/com/crm/project/sub/ProjectTabsH2IntegrationTest.java +++ b/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"); diff --git a/crm-rule/src/main/java/com/crm/rule/port/inbound/BidConfigDTO.java b/crm-rule/src/main/java/com/crm/rule/port/inbound/BidConfigDTO.java new file mode 100644 index 0000000..b081689 --- /dev/null +++ b/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) { +} diff --git a/crm-rule/src/main/java/com/crm/rule/port/inbound/ConflictPullDTO.java b/crm-rule/src/main/java/com/crm/rule/port/inbound/ConflictPullDTO.java new file mode 100644 index 0000000..202087f --- /dev/null +++ b/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-out,spec §8) + * + *

含「启用维度集 + 命中阈值 + 触发条件」签名形态(spin-out 前置拍板): + * {@code enabledDimensions}/{@code hitThreshold} 为**签名预留字段**——已建 + * {@code conflictPullJson} 只有拉群 5 行 + 2 触发开关,无「匹配维度选择」配置层 + * (原型 A7-3-4 有、落表砂掉);本期解析恒空/恒 1,spin-out 前需先补配置列。

+ * + * @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 rows, + boolean autoTriggerMultiParty, + boolean autoTriggerRemote, + List enabledDimensions, + Integer hitThreshold) { + + /** 拉群行 */ + public record Row(String rowKey, String pullRule, Integer required) { + } +} diff --git a/crm-rule/src/main/java/com/crm/rule/port/inbound/PoolMemberDTO.java b/crm-rule/src/main/java/com/crm/rule/port/inbound/PoolMemberDTO.java new file mode 100644 index 0000000..912cc73 --- /dev/null +++ b/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) { +} diff --git a/crm-rule/src/main/java/com/crm/rule/port/inbound/ProjectRuleQueryPort.java b/crm-rule/src/main/java/com/crm/rule/port/inbound/ProjectRuleQueryPort.java new file mode 100644 index 0000000..a0d41bc --- /dev/null +++ b/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=1;spec §6,票 13) + * + *

包位置拍板:`com.crm.rule.port.inbound`——crm-rule **声明并自实现**(对称 + * `com.crm.customer.port.CustomerCatalogPort` 的 inbound 形态:契约住产权方、消费方 + * (crm-project)依赖注入;与 `port.outbound`(crm-rule 声明、消费方 adapter 实现)方向相反)。 + * DTO 拍板:随包 record,住本包(不暴露内部 {@code ProjectRuleDTO},契约面稳定)。

+ * + *

五区块消费边界(spec §6):①阶段别名 ✅本期(看板列头/详情阶段名,别名优先、空回退 + * 字典名);⑤标底等级判定 ✅本期(A+~SQ;分成%计算 spin-out,字段留 DTO 不消费); + * ②拉群 / ③冲突拉群 / ④阶段提醒 ⚠️spin-out——签名预留、实现留桩(本口为纯读, + * 「桩」指消费侧逻辑不做;本口如实返回配置投影,含 {@code ConflictPullDTO} 的 + * 「启用维度集 + 命中阈值 + 触发条件」签名形态)。

+ */ +public interface ProjectRuleQueryPort { + + /** ① 阶段别名配置(stageCode = project_stage 值字符串 0~6;alias 空回退字典名) */ + List loadStageAlias(); + + /** + * ⑤ 标底配置(等级判定本期消费点:取 {@code name} 作等级名;分成字段留 DTO 不消费)。 + * + * @param bidTypeKey 标底类型键(A+/A-/B+/B-/C/N/H/X/SQ) + * @return 命中的标底配置;未配置返回 null + */ + BidConfigDTO loadBidConfig(String bidTypeKey); + + /** ② 项目拉群人员(spin-out:依赖企微群;签名预留) */ + List loadPoolMembers(); + + /** ③ 冲突拉群规则(spin-out:检测留桩+拉群 spin-out;DTO 含维度集/阈值签名形态) */ + ConflictPullDTO loadConflictPull(); + + /** ④ 阶段提醒(spin-out:依赖通知推送;签名预留) */ + List loadStageRemind(); +} diff --git a/crm-rule/src/main/java/com/crm/rule/port/inbound/StageAliasDTO.java b/crm-rule/src/main/java/com/crm/rule/port/inbound/StageAliasDTO.java new file mode 100644 index 0000000..6cd2e9c --- /dev/null +++ b/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) { +} diff --git a/crm-rule/src/main/java/com/crm/rule/port/inbound/StageRemindDTO.java b/crm-rule/src/main/java/com/crm/rule/port/inbound/StageRemindDTO.java new file mode 100644 index 0000000..9ee0bf0 --- /dev/null +++ b/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) { +} diff --git a/crm-rule/src/main/java/com/crm/rule/port/inbound/impl/ProjectRuleQueryPortImpl.java b/crm-rule/src/main/java/com/crm/rule/port/inbound/impl/ProjectRuleQueryPortImpl.java new file mode 100644 index 0000000..8faed80 --- /dev/null +++ b/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 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 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 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 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; + } +} diff --git a/crm-rule/src/test/java/com/crm/rule/port/inbound/ProjectRuleQueryPortImplTest.java b/crm-rule/src/test/java/com/crm/rule/port/inbound/ProjectRuleQueryPortImplTest.java new file mode 100644 index 0000000..88851d4 --- /dev/null +++ b/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 形态拍板的功能面钉子。 + * + *

真实 ProjectRuleServiceImpl JSON 解析链 + mock mapper:验证 ①⑤真读、 + * ②③④纯读签名、ConflictPullDTO 维度集/阈值签名形态、未初始化防御。

+ */ +@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 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(); + } +}