refactor(material): 健康档定格到 health_level, 去掉实时 health_tier

- 新增一次性迁移脚本 migrate-health-level.ts: 按当前全库三分位写入 health_level(覆盖手动值, 公共材料补齐)
- rankAndPaginate 改为只注解 ypt(排序用), 分组改按持久化 health_level; 移除 loadTierClassifier
- annotate -> annotateYpt; 单测同步更新
This commit is contained in:
zty 2026-07-12 21:48:08 -04:00
parent fb033ada28
commit 3da1fb1282
4 changed files with 83 additions and 42 deletions

View File

@ -0,0 +1,56 @@
/**
* Ypt health_level
*
* - + 线
* - A/B/C be_area null"—"
* - health_level
* -
*
* npx dotenv -e .env.development -- npx ts-node scripts/migrate-health-level.ts
* 线 iapips2npx dotenv -e .env -- npx ts-node scripts/migrate-health-level.ts
* DATABASE_URL dotenv
*/
import { PrismaClient } from '@prisma/client'
import { computeYpt, buildTierClassifier } from '../src/common/material-ranking'
const db = new PrismaClient()
const BE_SELECT = {
methanal_be_area: true,
tvoc_be_area: true,
benzene_be_area: true,
toluene_be_area: true,
p_xylene_be_area: true
} as const
async function main() {
// SelfMaterial 主键是复合键 [material_id, creator_id],需一并取出
const [pub, self] = await Promise.all([
db.publicMaterial.findMany({ select: { material_id: true, ...BE_SELECT } }),
db.selfMaterial.findMany({ select: { material_id: true, creator_id: true, ...BE_SELECT } })
])
const classify = buildTierClassifier([...pub, ...self])
const dist: Record<string, number> = { A: 0, B: 0, C: 0, 'null': 0 }
for (const m of pub) {
const tier = classify(computeYpt(m))
dist[tier ?? 'null']++
await db.publicMaterial.update({ where: { material_id: m.material_id }, data: { health_level: tier } })
}
for (const m of self) {
const tier = classify(computeYpt(m))
dist[tier ?? 'null']++
await db.selfMaterial.updateMany({ where: { material_id: m.material_id, creator_id: m.creator_id }, data: { health_level: tier } })
}
console.log(`migrate-health-level done: public=${pub.length}, self=${self.length}`)
console.log(`分档分布 -> A:${dist.A} B:${dist.B} C:${dist.C} 缺值(null):${dist['null']}`)
}
main()
.catch(e => {
console.error('migrate failed:', e)
process.exitCode = 1
})
.finally(() => db.$disconnect())

View File

@ -58,13 +58,10 @@ export function buildTierClassifier(all: BeAreaSource[]): (ypt: number | null) =
} }
} }
// 给材料数组挂上 ypt 与 health_tier // 给材料数组挂上 ypt用于排序。健康档已改为持久化到 health_level 字段,
export function annotate<T extends BeAreaSource>( // 由一次性迁移脚本 (scripts/migrate-health-level.ts) 写入,运行时不再实时分档。
materials: T[], export function annotateYpt<T extends BeAreaSource>(
classify: (ypt: number | null) => Tier | null materials: T[]
): (T & { ypt: number | null; health_tier: Tier | null })[] { ): (T & { ypt: number | null })[] {
return materials.map(m => { return materials.map(m => ({ ...m, ypt: computeYpt(m) }))
const ypt = computeYpt(m)
return { ...m, ypt, health_tier: classify(ypt) }
})
} }

View File

@ -9,7 +9,7 @@ import { ID_PREFIX, DATA_TYPE, ERR_CODE } from '../common/constants'
import authz from '../middlewares/authz' import authz from '../middlewares/authz'
import Service from '../common/service' import Service from '../common/service'
import moment from 'moment' import moment from 'moment'
import { annotate, buildTierClassifier, Tier } from '../common/material-ranking' import { annotateYpt } from '../common/material-ranking'
// const PREDICT_FIELD: ((keyof PublicMaterial) | (keyof SelfMaterial))[] = [ // const PREDICT_FIELD: ((keyof PublicMaterial) | (keyof SelfMaterial))[] = [
// 'methanal', 'methanal_min_be', 'methanal_be_area', 'methanal_be_roc', // 'methanal', 'methanal_min_be', 'methanal_be_area', 'methanal_be_roc',
@ -22,31 +22,19 @@ import { annotate, buildTierClassifier, Tier } from '../common/material-ranking'
const materialRouter = new Router() const materialRouter = new Router()
materialRouter.prefix('/api/mtrl') materialRouter.prefix('/api/mtrl')
const BE_AREA_SELECT = { // 注解 ypt用于排序+ 排序(ypt / *_be_area / 其它字段, 方向随 sort_val)
methanal_be_area: true, tvoc_be_area: true, benzene_be_area: true, toluene_be_area: true, p_xylene_be_area: true // + 分组(group_by_tier 时先按持久化的 health_level 分档 A→B→C→(空),档序固定不随方向翻转) + 分页
} as const // 健康档已定格到 health_level 字段(由 scripts/migrate-health-level.ts 一次性写入),运行时不再实时分档。
function rankAndPaginate(materials: AnyObj[], query: AnyObj): AnyObj[] {
// 查全库(公共+自建be_area建三分位分类器 let list: AnyObj[] = annotateYpt(materials as never)
async function loadTierClassifier(): Promise<(ypt: number | null) => Tier | null> {
const [pub, self] = await Promise.all([
db.publicMaterial.findMany({ select: BE_AREA_SELECT }),
db.selfMaterial.findMany({ select: BE_AREA_SELECT })
])
return buildTierClassifier([...pub, ...self])
}
// 注解 + 排序(ypt / *_be_area, 方向随 sort_val; group_by_tier 时先按档,档序固定不随方向翻转) + 分页
const TIER_ORDER: Record<string, number> = { A: 0, B: 1, C: 2 }
function rankAndPaginate(materials: AnyObj[], query: AnyObj, classify: (ypt: number | null) => Tier | null): AnyObj[] {
let list: AnyObj[] = annotate(materials as never, classify)
const sk = query.sort_key as string | undefined const sk = query.sort_key as string | undefined
const group = query.group_by_tier == 1 || query.group_by_tier === true const group = query.group_by_tier == 1 || query.group_by_tier === true
if (sk || group) { if (sk || group) {
const tierRank = (t: Tier | null) => (t === null ? 3 : TIER_ORDER[t]) const levelRank = (l: unknown) => (l === 'A' ? 0 : l === 'B' ? 1 : l === 'C' ? 2 : 3)
const dir = query.sort_val === 'desc' ? -1 : 1 const dir = query.sort_val === 'desc' ? -1 : 1
list.sort((a, b) => { list.sort((a, b) => {
if (group) { if (group) {
const d = tierRank(a.health_tier) - tierRank(b.health_tier) const d = levelRank(a.health_level) - levelRank(b.health_level)
if (d !== 0) return d if (d !== 0) return d
} }
const k = sk ?? 'ypt' const k = sk ?? 'ypt'
@ -317,8 +305,7 @@ materialRouter.get(
materials.push(...selfMtrls) materials.push(...selfMtrls)
} }
// 统一:注解 ypt/health_tier + 排序(含 ypt/分组) + 分页 // 统一:注解 ypt/health_tier + 排序(含 ypt/分组) + 分页
const classify = await loadTierClassifier() ctx.body = rankAndPaginate(materials, { ...query, group_by_tier })
ctx.body = rankAndPaginate(materials, { ...query, group_by_tier }, classify)
} }
) )
@ -1145,8 +1132,7 @@ materialRouter.get(
mtrl.collectors = [] mtrl.collectors = []
}) })
} }
const classify = await loadTierClassifier() ctx.body = rankAndPaginate(materials, { ...query, group_by_tier })
ctx.body = rankAndPaginate(materials, { ...query, group_by_tier }, classify)
} }
) )
@ -1880,8 +1866,7 @@ materialRouter.get(
delete conditions.orderBy delete conditions.orderBy
} }
const selfMaterials = await db.selfMaterial.findMany(conditions as never) const selfMaterials = await db.selfMaterial.findMany(conditions as never)
const classify = await loadTierClassifier() ctx.body = rankAndPaginate(selfMaterials, { ...query, group_by_tier })
ctx.body = rankAndPaginate(selfMaterials, { ...query, group_by_tier }, classify)
} }
) )

View File

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { computeYpt, buildTierClassifier, annotate } from '../src/common/material-ranking' import { computeYpt, buildTierClassifier, annotateYpt } from '../src/common/material-ranking'
const M = (methanal: number|null, tvoc: number|null, benzene: number|null, toluene: number|null, pxylene: number|null) => ({ const M = (methanal: number|null, tvoc: number|null, benzene: number|null, toluene: number|null, pxylene: number|null) => ({
methanal_be_area: methanal, tvoc_be_area: tvoc, benzene_be_area: benzene, toluene_be_area: toluene, p_xylene_be_area: pxylene methanal_be_area: methanal, tvoc_be_area: tvoc, benzene_be_area: benzene, toluene_be_area: toluene, p_xylene_be_area: pxylene
@ -42,13 +42,16 @@ describe('buildTierClassifier', () => {
}) })
}) })
describe('annotate', () => { describe('annotateYpt', () => {
it('挂上 ypt 与 health_tier', () => { it('只挂上 ypt健康档不再实时计算已定格到 health_level', () => {
const all = [M(1,0,0,0,0), M(2,0,0,0,0), M(3,0,0,0,0)] const all = [M(1,0,0,0,0), M(2,0,0,0,0), M(3,0,0,0,0)]
const classify = buildTierClassifier(all) const out = annotateYpt(all)
const out = annotate(all, classify)
expect(out[0].health_tier).toBe('A')
expect(out[0].ypt).toBeCloseTo(1.8, 6) expect(out[0].ypt).toBeCloseTo(1.8, 6)
expect(out[2].health_tier).toBe('C') expect(out[2].ypt).toBeCloseTo(5.4, 6)
expect(out[0]).not.toHaveProperty('health_tier')
})
it('缺值材料 ypt=null', () => {
const out = annotateYpt([M(1, null, 0, 0, 0)])
expect(out[0].ypt).toBeNull()
}) })
}) })