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标底等级判定(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包位置拍板:`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真实 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