# 材料健康分级与 Yp 排序 —— 设计文档 日期:2026-07-10 分支:`feature/source-charts` ## 1. 背景与目标 材料库(公共 + 自建)目前只按手动 `sort_order` 排序,用户无法按"环保程度"挑选材料。本功能让材料能: 1. 按**健康等级**分成 A/B/C 三档 2. 按**单个污染物的 Yp 值**(平衡释放量范围,越小越环保)升序排序 —— 可在每档内排,也可全库排 3. 按**综合值 Ypt**(五种污染物 Yp 的加权和)升序排序 **术语对照(已核对源码)**: - **Yp = `*_be_area`(平衡释放量范围)**。材料表单里甲醛栏标注「平衡释放量范围(Yp)」。 - Yp甲醛=`methanal_be_area`、YpTVOC=`tvoc_be_area`、Yp苯=`benzene_be_area`、Yp甲苯=`toluene_be_area`、Yp二甲苯=`p_xylene_be_area` - **健康等级 = `health_level`**,原为自建材料手动维护的 A/B/C 字段;本功能后**不再用于分组**(见 §2)。 **成功标准**:材料库/选材界面能按"排序依据 × 是否分组"两个控件排序与分组展示,同一材料在任何界面看到的健康档一致。 ## 2. 核心计算逻辑 ### 2.1 Ypt 综合值 每个材料一个标量,越小越环保: ``` Ypt = methanal_be_area×1.8 + tvoc_be_area×0.25 + benzene_be_area×1 + toluene_be_area×0.45 + p_xylene_be_area×0.38 ``` **缺值规则**:5 个 `*_be_area` 中**任一为空** → 该材料 `ypt = null`、`health_tier = null`,不参与分档,排在所有材料最后。 ### 2.2 健康档 A/B/C —— 全库三分位 - 取**全库所有"数据完整"材料**(公共 + 自建合一),按 Ypt 升序 - 前 1/3 → **A**(最健康)、中 1/3 → **B**、后 1/3 → **C** - 边界按**材料个数**切(非按 Ypt 数值区间):n 个完整材料,边界索引为 `⌈n/3⌉`、`⌈2n/3⌉` - 相同 Ypt 落**同一档**、不强拆(并列时以第一次跨越边界的 Ypt 为准) - **实时计算,不落库**(分位随库存变化)。现有 `health_level` 手动字段保留但**功能上弃用**(不再用于分组) - **全库口径**:即使在"项目选材"等子集场景,`health_tier` 也按全库 Ypt 分布来定,不在子集内部重新分位 —— 保证一致性 ## 3. 后端设计 ### 3.1 新增共享工具 `src/common/material-ranking.ts` 纯函数,无 HTTP 依赖,便于单测: - `POLL_WEIGHTS`:`{ methanal: 1.8, tvoc: 0.25, benzene: 1, toluene: 0.45, p_xylene: 0.38 }` - `computeYpt(m): number | null` —— 按 §2.1,任一 be_area 缺失返回 `null` - `buildTierClassifier(): Promise<(ypt: number|null) => 'A'|'B'|'C'|null>` - 查全库所有公共+自建材料的 5 个 `*_be_area` 列(只取这几列,轻量) - 对每条算 Ypt,过滤掉 null,升序,按 §2.2 定出两个边界值 - 返回分类器:`ypt==null → null`;否则按边界返回 A/B/C - `annotate(materials, classifier): void` —— 给每条材料原地挂上 `ypt`、`health_tier` ### 3.2 三个列表接口接入 均调用上述工具,逻辑不各自重写: 1. `/api/mtrl`(材料库,`controllers/material.ts` 列表处理器) 2. `/api/proj/space/mtrl`(项目选材,`controllers/project.ts`) 3. `/api/proj/temp/:type/space/mtrl`(模板选材,`controllers/template.ts`) ### 3.3 接口参数扩展 - `sort_key` 枚举增加 `ypt`;单污染物 Yp 排序复用已有的 `methanal_be_area` 等枚举值 - 新增 `group_by_tier`:`0 | 1`(默认 0) - 响应:每条材料新增 `ypt: number|null`、`health_tier: 'A'|'B'|'C'|null` ### 3.4 排序语义 - `group_by_tier=0`:全库按 `sort_key` 升序;该排序值为空的材料排最后 - `group_by_tier=1`:先按档 **A → B → C → (缺值 null)**,**档内**再按 `sort_key` 升序 - **分页**照常在整个排好序的列表上切片(`page`/`size`);由前端在档位变化处插分区标题,无需一次拉全量 ### 3.5 必要的小重构 现有 `/api/mtrl` 仅在"公共+自建都有结果"时才走内存排序+切片;只有一种类型时直接返回 Prisma 排序结果。因 `ypt`/`health_tier` 是计算值、Prisma 无法排序,需让 **"注解 → 排序 → 切片"在所有情况统一走内存路径**。改动限定在列表处理器内部,不影响其它逻辑。 ## 4. 前端设计 用户端(iapip-web)与管理端(iapip-ms)是两个独立 app,前端代码各写一份;后端已扛计算重活,前端仅负责控件与展示。 ### 4.1 两个控件(材料列表顶部) - **「排序依据」下拉**:综合 Ypt(默认)/ 甲醛 Yp / TVOC Yp / 苯 Yp / 甲苯 Yp / 二甲苯 Yp - 映射 `sort_key ∈ {ypt, methanal_be_area, tvoc_be_area, benzene_be_area, toluene_be_area, p_xylene_be_area}`,恒 `sort_val=asc` - **「按健康等级分组」开关** → `group_by_tier` 0/1 ### 4.2 展示 - 每个材料卡片加**健康档标签**:A=绿 / B=黄 / C=红 / 缺值=灰"—"。不分组时也显示 - 分组开时:渲染 **A / B / C /「数据不全」** 分区标题,档内按所选依据升序 - **默认视图**:综合 Ypt 升序、不分组、带档标签 ### 4.3 落地范围 - 材料库(用户端 + 管理端):完整两控件 + 分组 + 标签 - 项目 / 模板选材:档标签 + 排序依据下拉(分组开关可选,视界面空间) ## 5. 边界情况 - 完整数据材料 < 3 个:按 `⌈n/3⌉`、`⌈2n/3⌉` 切,档位可能缺字母(正常,不报错) - 相同 Ypt:落同一档,不强拆 - 缺值材料:标签"—",分组时进「数据不全」区,恒排最后 - 全库无任何完整材料:分类器对所有输入返回 null(全部"数据不全") - 性能:`buildTierClassifier` 每次请求多一次只取 5 列的全库查询,材料量级上千条,可忽略 ## 6. 测试 - `computeYpt`、`buildTierClassifier` 为纯函数,加单元测试覆盖: - Ypt 公式数值正确性 - 任一 be_area 缺失 → null - 三分位边界(n 可整除 / 不可整除)、并列同档、小 n(1/2 个) - 后端当前无测试框架,随本功能引入轻量框架(vitest),仅测这两个纯函数 - 排序/分组的接口行为通过手动验证(三接口 + 两控件组合) ## 7. 非目标(YAGNI) - 不做降序 / 自定义权重配置(权重按公式固定) - 不迁移或清理历史 `health_level` 手动数据(仅停用于分组) - 不做档位持久化 / 缓存(实时算,量级不大)