refactor(material): 健康档定格到 health_level, 去掉实时 health_tier
- 新增一次性迁移脚本 migrate-health-level.ts: 按当前全库三分位写入 health_level(覆盖手动值, 公共材料补齐) - rankAndPaginate 改为只注解 ypt(排序用), 分组改按持久化 health_level; 移除 loadTierClassifier - annotate -> annotateYpt; 单测同步更新
This commit is contained in:
parent
fb033ada28
commit
3da1fb1282
|
|
@ -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
|
||||
* 运行(线上 iapips2):npx 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())
|
||||
|
|
@ -58,13 +58,10 @@ export function buildTierClassifier(all: BeAreaSource[]): (ypt: number | null) =
|
|||
}
|
||||
}
|
||||
|
||||
// 给材料数组挂上 ypt 与 health_tier
|
||||
export function annotate<T extends BeAreaSource>(
|
||||
materials: T[],
|
||||
classify: (ypt: number | null) => Tier | null
|
||||
): (T & { ypt: number | null; health_tier: Tier | null })[] {
|
||||
return materials.map(m => {
|
||||
const ypt = computeYpt(m)
|
||||
return { ...m, ypt, health_tier: classify(ypt) }
|
||||
})
|
||||
// 给材料数组挂上 ypt(用于排序)。健康档已改为持久化到 health_level 字段,
|
||||
// 由一次性迁移脚本 (scripts/migrate-health-level.ts) 写入,运行时不再实时分档。
|
||||
export function annotateYpt<T extends BeAreaSource>(
|
||||
materials: T[]
|
||||
): (T & { ypt: number | null })[] {
|
||||
return materials.map(m => ({ ...m, ypt: computeYpt(m) }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { ID_PREFIX, DATA_TYPE, ERR_CODE } from '../common/constants'
|
|||
import authz from '../middlewares/authz'
|
||||
import Service from '../common/service'
|
||||
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))[] = [
|
||||
// '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()
|
||||
materialRouter.prefix('/api/mtrl')
|
||||
|
||||
const BE_AREA_SELECT = {
|
||||
methanal_be_area: true, tvoc_be_area: true, benzene_be_area: true, toluene_be_area: true, p_xylene_be_area: true
|
||||
} as const
|
||||
|
||||
// 查全库(公共+自建)be_area,建三分位分类器
|
||||
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)
|
||||
// 注解 ypt(用于排序)+ 排序(ypt / *_be_area / 其它字段, 方向随 sort_val)
|
||||
// + 分组(group_by_tier 时先按持久化的 health_level 分档 A→B→C→(空),档序固定不随方向翻转) + 分页
|
||||
// 健康档已定格到 health_level 字段(由 scripts/migrate-health-level.ts 一次性写入),运行时不再实时分档。
|
||||
function rankAndPaginate(materials: AnyObj[], query: AnyObj): AnyObj[] {
|
||||
let list: AnyObj[] = annotateYpt(materials as never)
|
||||
const sk = query.sort_key as string | undefined
|
||||
const group = query.group_by_tier == 1 || query.group_by_tier === true
|
||||
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
|
||||
list.sort((a, b) => {
|
||||
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
|
||||
}
|
||||
const k = sk ?? 'ypt'
|
||||
|
|
@ -317,8 +305,7 @@ materialRouter.get(
|
|||
materials.push(...selfMtrls)
|
||||
}
|
||||
// 统一:注解 ypt/health_tier + 排序(含 ypt/分组) + 分页
|
||||
const classify = await loadTierClassifier()
|
||||
ctx.body = rankAndPaginate(materials, { ...query, group_by_tier }, classify)
|
||||
ctx.body = rankAndPaginate(materials, { ...query, group_by_tier })
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -1145,8 +1132,7 @@ materialRouter.get(
|
|||
mtrl.collectors = []
|
||||
})
|
||||
}
|
||||
const classify = await loadTierClassifier()
|
||||
ctx.body = rankAndPaginate(materials, { ...query, group_by_tier }, classify)
|
||||
ctx.body = rankAndPaginate(materials, { ...query, group_by_tier })
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -1880,8 +1866,7 @@ materialRouter.get(
|
|||
delete conditions.orderBy
|
||||
}
|
||||
const selfMaterials = await db.selfMaterial.findMany(conditions as never)
|
||||
const classify = await loadTierClassifier()
|
||||
ctx.body = rankAndPaginate(selfMaterials, { ...query, group_by_tier }, classify)
|
||||
ctx.body = rankAndPaginate(selfMaterials, { ...query, group_by_tier })
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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) => ({
|
||||
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', () => {
|
||||
it('挂上 ypt 与 health_tier', () => {
|
||||
describe('annotateYpt', () => {
|
||||
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 classify = buildTierClassifier(all)
|
||||
const out = annotate(all, classify)
|
||||
expect(out[0].health_tier).toBe('A')
|
||||
const out = annotateYpt(all)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue