Просмотр исходного кода

feat(dimension): P2-T1 指标多级细化混排 + P2-T2 评分口径文档化

P2-T1(指标多级细化/混排完整化):
- 实测确认 DimensionRegistryService.getTree(null) 已支持指标挂指标:
  指标节点以自身 id 入 idToNode,组装阶段按 parentId 经 idToNode.get 找父,
  父为指标节点时 attachChild 正常挂载(合并逻辑无需改动)。
- DimensionNode 新增 dimensionCode 字段(仅指标节点有值),getTree 对指标节点
  填充 ind.getDimensionCode()——指标挂指标时子指标继承父指标归属维度 code,
  保证子指标 dimensionCode 仍 ∈ 注册中心维度节点 code 集合(P1-T3 校验不破)。
- cfc-web indicators 管理页:树选择放开指标节点作父(disabled=false,维度/指标
  均可选),点击指标父节点时 dimensionCode 继承父指标归属,维度节点行为不变。
  契约测试(DimensionRegistryTreeTest)仅断言字段存在性,新字段不破坏。

P2-T2(评分口径文档化):
- 新建 docs/superpowers/specs/2026-11-05-dimension-scoring-convention.md:
  明确雷达/沙盘/指标三处共享 0-100 量纲 + P5/P25/P75/P95 百分位带 tier,逐条
  从代码核实换算逻辑(RadarChart 半径映射/P50 参考、EnergyService clamp 与
  五行乘性修正、NormCompareEngine 常模分带线性插值/待补不猜值)。
- DimensionCodeConstants 新增 SCORE_MIN/MAX、BAND_P5/25/75/95 共享常量并注释
  来源(未接入业务逻辑,零运行时行为变化)。
Agnes Agent 1 неделя назад
Родитель
Сommit
d73d333d1c

+ 20 - 0
cfc-backend/src/main/java/com/etotem/cfc/common/constant/DimensionCodeConstants.java

@@ -98,4 +98,24 @@ public class DimensionCodeConstants {
     public static final String LEGACY_WEALTH_ALTRUISM = "wealthAltruism";
     public static final String LEGACY_WEALTH_INCOME = "wealthIncome";
     public static final String LEGACY_WEALTH_FX = "wealthFx";
+
+    // ===== P2-T2 评分口径共享常量 =====
+    // 来源: docs/superpowers/specs/2026-11-05-dimension-scoring-convention.md
+    // 统一量纲 0-100(雷达 / 沙盘能量 / 指标归一化 三处共享同一量纲):
+    //   - 雷达(0-100): RadarChart.vue r = outerR × clamp(score/100,0,1),分值文本 Math.round(score)+'%'
+    //   - 沙盘能量(0-100%): EnergyService 各子维公式 clamp(...,0,100)
+    //   - 指标归一化: NormCompareEngine 按百分位带 P5/P25/P75/P95 判 偏低/正常/偏高
+    public static final int SCORE_MIN = 0;
+    public static final int SCORE_MAX = 100;
+
+    // 百分位带断点(NormCompareEngine.applyBandFromNorm / applyBandFromPopulation):
+    //   v < P5      → band=veryLow, status=偏低
+    //   P5 ≤ v < P25→ band=low,     status=偏低
+    //   P25 ≤ v ≤ P75 → band=mid,   status=正常
+    //   P75 < v ≤ P95 → band=high,  status=偏高
+    //   v > P95     → band=veryHigh,status=偏高
+    public static final int BAND_P5 = 5;
+    public static final int BAND_P25 = 25;
+    public static final int BAND_P75 = 75;
+    public static final int BAND_P95 = 95;
 }

+ 8 - 1
cfc-backend/src/main/java/com/etotem/cfc/dto/DimensionNode.java

@@ -6,7 +6,8 @@ import java.util.List;
  * 统一维度树节点(维度节点 ∪ 指标节点混排)。
  *
  * <p>结构见设计文档 §5.1:{id, code, name, parentId, nodeKind, sourceSystem,
- * legacyAlias, wuxingRuleRef, children?}。</p>
+ * legacyAlias, wuxingRuleRef, children?};P2-T1 起指标节点额外带 dimensionCode
+ * (指标归属维度 code,供指标挂指标时继承归属,null=通用)。</p>
  */
 public class DimensionNode {
 
@@ -18,6 +19,10 @@ public class DimensionNode {
     private String sourceSystem;
     private String legacyAlias;
     private String wuxingRuleRef;
+
+    /** 指标节点的归属维度 code(仅 nodeKind=indicator 有值;维度节点为 null)。P2-T1。 */
+    private String dimensionCode;
+
     private List<DimensionNode> children;
 
     public DimensionNode() {
@@ -39,6 +44,8 @@ public class DimensionNode {
     public void setLegacyAlias(String legacyAlias) { this.legacyAlias = legacyAlias; }
     public String getWuxingRuleRef() { return wuxingRuleRef; }
     public void setWuxingRuleRef(String wuxingRuleRef) { this.wuxingRuleRef = wuxingRuleRef; }
+    public String getDimensionCode() { return dimensionCode; }
+    public void setDimensionCode(String dimensionCode) { this.dimensionCode = dimensionCode; }
     public List<DimensionNode> getChildren() { return children; }
     public void setChildren(List<DimensionNode> children) { this.children = children; }
 }

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DimensionRegistryService.java

@@ -81,6 +81,9 @@ public class DimensionRegistryService {
             n.setNodeKind("indicator");
             n.setSourceSystem("indicator");
             n.setLegacyAlias(ind.getLegacyAlias());
+            // P2-T1:指标节点暴露归属维度 code(指标挂指标时子指标继承该归属,
+            // 保证子指标 dimensionCode 仍 ∈ 注册中心维度节点 code 集合)
+            n.setDimensionCode(ind.getDimensionCode());
             idToNode.putIfAbsent(n.getId(), n);
         }
 

+ 12 - 3
cfc-web/src/views/admin/indicators/index.vue

@@ -245,8 +245,11 @@ export default {
           id: n.id,
           code: n.code,
           label: n.name || n.code || '',
-          // P1-T3 仅允许挂接到维度节点(root/sub);指标节点禁选(P2 再放开子树挂接)
-          disabled: n.nodeKind === 'indicator',
+          nodeKind: n.nodeKind || '',
+          // 指标节点也暴露归属维度 code(P2-T1:指标挂指标时子指标继承该归属)
+          dimensionCode: n.dimensionCode || '',
+          // P2-T1:允许挂到任意维度节点(root/sub)或指标节点(多级细化),全部可选作父
+          disabled: false,
           children: this._mapDimNodes(n.children || [])
         }
       })
@@ -262,7 +265,13 @@ export default {
     // 树节点点击:设置挂接节点(parentId=节点id,dimensionCode=节点code)
     onDimensionNodeClick(data, node) {
       this.form.parentId = data.id
-      this.form.dimensionCode = data.code
+      if (data.nodeKind === 'indicator') {
+        // P2-T1:指标挂指标 → 子指标继承父指标的归属维度 code(保持 dimensionCode 合法)
+        this.form.dimensionCode = data.dimensionCode || ''
+      } else {
+        // 维度节点(root/sub) → 归属维度 code 即节点 code
+        this.form.dimensionCode = data.code
+      }
       this.currentTreeNodeId = data.id
       var labels = []
       var p = node

+ 128 - 0
docs/superpowers/specs/2026-11-05-dimension-scoring-convention.md

@@ -0,0 +1,128 @@
+# 维度评分口径约定(P2-T2)
+
+> 状态:评审稿 · 归属:统一维度体系(2026-11-03-unified-dimension-system-design.md §9 P2-T2)
+> 目的:**文档化**雷达 / 沙盘能量 / 指标归一化 三处共享的 percentile / tier / 归一化口径,消除歧义。
+> 原则:本文件**不改变任何运行时行为**(纯文档 + 可选共享常量,常量未接入业务逻辑)。所有换算逻辑以代码实际实现为准,逐条核实并注明出处。
+
+---
+
+## 1. 共享口径(三处统一)
+
+**量纲**:所有维度得分统一落在 **0–100** 闭区间(`0`=最弱/最差,`100`=最强/最优)。
+
+**统一离散化(percentile 带 / tier)**:以人群百分位断点 **P5 / P25 / P75 / P95** 划分五档(tier)。该断点是三处共享的**唯一** tier 分段依据:
+
+| 数值区间(相对常模/人群) | band(档) | status(语义) |
+|---|---|---|
+| `v < P5` | `veryLow` | 偏低(异常) |
+| `P5 ≤ v < P25` | `low` | 偏低(异常) |
+| `P25 ≤ v ≤ P75` | `mid` | 正常 |
+| `P75 < v ≤ P95` | `high` | 偏高(异常) |
+| `v > P95` | `veryHigh` | 偏高(异常) |
+
+**归一化公式**:指标原始值 → 百分位,采用常模断点 **线性插值**:
+
+```
+percentile(v) =
+    P≤5            , v ≤ P5
+    P≥95           , v ≥ P95
+    P[ p_i + (v - p_i)/(p_{i+1} - p_i) × (p_{i+1} - p_i) ] , p_i ≤ v ≤ p_{i+1}
+```
+
+其中断点序列 `(p_0..p_4) = (P5, P25, P50, P75, P95)`,对应百分位 `(5, 25, 50, 75, 95)`。
+> 注:上式第二行在 `p_{i+1} - p_i > 0` 时即 `cents[i] + (v - p_i)/span × (cents[i+1] - cents[i])`;`span ≤ 0` 时退化为该断点对应百分位。
+
+**常量来源**:`com.etotem.cfc.common.constant.DimensionCodeConstants`(P2-T2 新增)
+`SCORE_MIN=0`、`SCORE_MAX=100`、`BAND_P5=5`、`BAND_P25=25`、`BAND_P75=75`、`BAND_P95=95`。
+
+---
+
+## 2. 子系统 A —— 雷达(RadarChart)
+
+**代码**:`cfc-frontend/components/RadarChart.vue`(uni-app / Vue2);后端取数 `DimensionScoreServiceImpl.calcPopulationScore`。
+
+**实际换算**(从代码核实,非推测):
+- 分值输入 `scores[]`(0–100,与 `dimensions[]` 按索引对齐)。
+- 无效值归一:`_safeScore(v)` —— 非 number / `NaN` / `null` 一律按 `0`(防 Canvas `NaN` 静默失败)。
+- 顶点半径:`r = outerR × clamp(score/100, 0, 1)`(即 0–100 线性映射到 0–100% 半径)。
+- 分值文本:`Math.round(score) + '%'`。
+- 网格层级:`[0.25, 0.5, 0.75, 1.0]`(对应 25/50/75/100 分参考线)。
+- 参考系列(同一 0–100 量纲):
+  - **人群位置** `populationScores[]` = 常模 **P50**(`DimensionScoreServiceImpl.calcPopulationScore`:`cognitive_norms.percentile_50` / `health_norm_reference.percentile_50`;无成员/无常模返回 `null`,画布按 0 处理)。
+  - **家庭平均** `avgScores[]` / `familyScores[]` = 家庭平均分。
+- **tier 文本**:RadarChart **当前不输出** tier 文本;其 0–100 分与 §1 档位按同一断点可互相映射(展示层如需标签,应复用 P5/P25/P75/P95 断点,勿另设阈值)。
+
+**结论**:雷达把 0–100 分直接画为半径占比;tier 语义通过数值隐含(数值 = 百分位等价量纲)。
+
+---
+
+## 3. 子系统 B —— 沙盘能量(Energy)
+
+**代码**:`cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java`。
+
+**实际换算**(从代码核实,非推测):
+- 语义:计算每个家庭成员(主账号+成员)的 **身 / 心 / 智 / 行 / 富** 五维能量值,**0–100%**(类注释 line 27)。
+- 各子维公式 → `clamp(..., 0, 100)` 收口。代表公式:
+  - 富·利他贡献:`clamp(20 + (直接邀请人数-1) × 15, 0, 100)`(1 人=20 分,每多 1 人 +15,封顶 100)。
+  - 富·财务积累:`clamp(cf_balance.total_earned / 50, 0, 100)`(5000 CF = 100 分)。
+  - 行动·习惯/打卡:`clamp(连续打卡天数 × 100 / 参考天数, 0, 100)` 及加权合成。
+- **五行调节(在 0–100 基础上乘系数,仍 clamp [0,100])**:
+  - 增益:`boost = (上游-50)/100 × 10`(仅当 `上游>60 && 下游<80` 触发)。
+  - 衰减/透支/克制:`penalty = 0.5 + 0.5 × (自身/100)`(身<40 → 财富 × penalty;行>75 且身<40 → 身 × penalty 等)。
+- 最终能量比 = 该维度能量值(0–100%)。
+
+**结论**:沙盘以 0–100 量化五维能量;tier 通过数值隐含(同 §1 断点)。五行调节是 0–100 量纲上的乘性修正,不改变量纲。
+
+---
+
+## 4. 子系统 C —— 指标归一化(Indicator)
+
+**代码**:
+- 单一写者:`NormalizeIndicatorPipeline.processReport` / `snapshotFoodIndices`(原始值 + `numeric_value` + `ref_range_snapshot` 幂等写入 `indicator_values`)。
+- 常模比对:`attribution/NormCompareEngine.applyBandFromNorm` / `applyBandFromPopulation` / `roughPercentile`。
+- 模型:`attribution/model/IndicatorReading`(band / status / percentile 常量)。
+
+**实际换算**(从代码核实,非推测):
+- 常模命中(`p5/p95` 完整断点存在):
+  - `v < p5` → `veryLow` / 偏低
+  - `v < p25` → `low` / 偏低
+  - `v ≤ p75` → `mid` / 正常
+  - `v ≤ p95` → `high` / 偏高
+  - `v > p95` → `veryHigh` / 偏高
+  - percentile 文本:`roughPercentile` 线性插值(`P≤5` / `P≥95` / 插值 `P<n>`)。
+- 菌群 `populationLevel`(如 `"35%"`)优先作为人群百分位:`≤5 / ≤25 / ≤75 / ≤95` 同上分带,`percentile = "P" + round(pop)`。
+- **常模缺失 → `status="待补"`,`band=unknown`,不猜值**(§8 共享知识·常模口径)。
+
+**结论**:指标显式使用 §1 的 P5/P25/P75/P95 分带与线性插值归一化,是最直接落地 tier 的子系统。
+
+---
+
+## 5. 三处一致性校验
+
+| 维度 | 量纲 | 是否显式 tier | tier 断点 | 归一化方式 |
+|---|---|---|---|---|
+| 雷达 | 0–100 | 否(数值隐含) | P5/P25/P75/P95 | `clamp(score/100,0,1)` → 半径;参考=P50 |
+| 沙盘能量 | 0–100% | 否(数值隐含) | P5/P25/P75/P95 | 子维公式 clamp(0,100) + 五行乘性修正 |
+| 指标 | 0–100 百分位 | 是(band/status) | P5/P25/P75/P95 | 常模断点线性插值 → 百分位带 |
+
+**共享同一口径**:三者同用 0–100 量纲,且 tier 均以 P5/P25/P75/P95 断点为准(指标显式实现,雷达/沙盘数值隐含、可按同断点映射)。**消除歧义点**:任何子系统不得另设 tier 阈值(如 优秀/良好 自定 80/60 分界),如需标签须复用 §1 断点。
+
+---
+
+## 6. 不变式(红线)
+
+- 本文件**不改变**任何取分 / 归一化 / 五行计算逻辑;仅文档化 + 新增未接入的共享常量。
+- 常模缺失(指标)→ `待补`,不猜值;缺失成员/常模(雷达人群位置)→ `null`(画布按 0)。
+- 所有分数 clamp 到 `[0, 100]`;`NaN`/`null` 归一为 0(RadarChart `_safeScore`)。
+
+## 7. 代码出处速查
+
+| 事实 | 出处 |
+|---|---|
+| 0–100 → 半径、null→0、`%` 文本、网格层级 | `cfc-frontend/components/RadarChart.vue`(`_safeScore`、`_drawScoreArea`、`_drawScoreText`、`_drawGrid`) |
+| 人群位置 = 常模 P50 | `cfc-backend/.../service/impl/DimensionScoreServiceImpl.java#calcPopulationScore` |
+| 五维能量 0–100%、clamp、五行 boost/penalty | `cfc-backend/.../service/EnergyService.java` |
+| 指标常模分带 + 线性插值 + populationLevel 回退 + 待补 | `cfc-backend/.../service/attribution/NormCompareEngine.java`(`applyBandFromNorm`/`applyBandFromPopulation`/`roughPercentile`) |
+| band/status 常量(veryLow/low/mid/high/veryHigh、待补/偏低/正常/偏高) | `cfc-backend/.../service/attribution/model/IndicatorReading.java` |
+| 指标单一写者(原始值+参考范围快照) | `cfc-backend/.../service/NormalizeIndicatorPipeline.java` |
+| 共享常量(SCORE_MIN/MAX、BAND_P5/25/75/95) | `cfc-backend/.../common/constant/DimensionCodeConstants.java`(P2-T2) |