feat(material): /api/mtrl 注解 ypt/health_tier 并支持 ypt 排序与分组

This commit is contained in:
zty 2026-07-10 02:38:10 -04:00
parent 7027a7748f
commit f5ec58031a
1 changed files with 60 additions and 33 deletions

View File

@ -9,6 +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, computeYpt, Tier } from '../common/material-ranking'
// const PREDICT_FIELD: ((keyof PublicMaterial) | (keyof SelfMaterial))[] = [
// 'methanal', 'methanal_min_be', 'methanal_be_area', 'methanal_be_roc',
@ -21,6 +22,47 @@ import moment from 'moment'
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, 恒升序; 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 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])
list.sort((a, b) => {
if (group) {
const d = tierRank(a.health_tier) - tierRank(b.health_tier)
if (d !== 0) return d
}
const k = sk ?? 'ypt'
const av = a[k], bv = b[k]
// 升序null 排最后
if (av === null || av === undefined) return bv === null || bv === undefined ? 0 : 1
if (bv === null || bv === undefined) return -1
return av === bv ? 0 : av > bv ? 1 : -1
})
}
if (query.size) {
const page = query.page ?? 1
list = list.slice((page - 1) * query.size, page * query.size)
}
return list
}
// 环保建材展示公开免认证E0 优先,不足补 E1最多 8 条
materialRouter.get('/eco', async ctx => {
const take = 8
@ -198,7 +240,7 @@ materialRouter.get(
]
},
sort_key: {
enum: ['name', 'category', 'brand', 'factory', 'spec', 'eco_level', 'health_level', 'sort_order', 'methanal_be_area', 'tvoc_be_area', 'benzene_be_area', 'toluene_be_area', 'p_xylene_be_area', 'creator_name', 'created_at', 'updated_at']
enum: ['name', 'category', 'brand', 'factory', 'spec', 'eco_level', 'health_level', 'sort_order', 'methanal_be_area', 'tvoc_be_area', 'benzene_be_area', 'toluene_be_area', 'p_xylene_be_area', 'creator_name', 'created_at', 'updated_at', 'ypt']
},
sort_val: {
enum: ['asc', 'desc']
@ -210,6 +252,10 @@ materialRouter.get(
size: {
type: 'number',
minimum: 1
},
group_by_tier: {
type: 'number',
enum: [0, 1]
}
},
additionalProperties: false
@ -217,16 +263,20 @@ materialRouter.get(
}, { coerceTypes: true }),
async ctx => {
const acctCert = ctx.state.acct_cert as AcctCert
const { type, collected, ...query } = ctx.state.query
// group_by_tier 不是 Prisma 列,需从传给 dynSearch 的 query 中剔除,否则 findMany 报未知字段
const { type, collected, group_by_tier, ...query } = ctx.state.query
let materials: AnyObj[] = []
// 查询公共材料
let pubMtrlQty = 0
if (!type || type == DATA_TYPE.PUBLIC) {
const conditions: AnyObj = dynSearch(query, {
fuzzyFields: ['material_id', 'name', 'category', 'brand', 'factory', 'spec', 'creator_name']
})
delete conditions.take; delete conditions.skip
if (!query.sort_key) {
conditions.orderBy = [{ sort_order: 'asc' }, { updated_at: 'desc' }]
} else if (query.sort_key === 'ypt') {
// ypt 是内存计算字段,非 Prisma 列,不能下推为 orderBy
delete conditions.orderBy
}
if (!isNone(collected) && !acctCert.is_admin) {
if (!conditions.where) {
@ -243,11 +293,9 @@ materialRouter.get(
}
}
const pubMtrls = await db.publicMaterial.findMany(conditions as never)
pubMtrlQty = pubMtrls.length
materials.push(...pubMtrls)
}
// 查询自建材料
let selfMtrlQty = 0
if (!type || type == DATA_TYPE.PRIVATE) {
delete query.display
delete query.from
@ -257,40 +305,19 @@ materialRouter.get(
const selfConditions: AnyObj = dynSearch(query, {
fuzzyFields: ['material_id', 'name', 'category', 'brand', 'factory', 'spec', 'creator_name']
})
delete selfConditions.take; delete selfConditions.skip
if (!query.sort_key) {
selfConditions.orderBy = [{ sort_order: 'asc' }, { updated_at: 'desc' }]
} else if (query.sort_key === 'ypt') {
// ypt 是内存计算字段,非 Prisma 列,不能下推为 orderBy
delete selfConditions.orderBy
}
const selfMtrls = await db.selfMaterial.findMany(selfConditions as never)
selfMtrlQty = selfMtrls.length
materials.push(...selfMtrls)
}
// 排序和分页
if (pubMtrlQty && selfMtrlQty) {
const sk = query.sort_key
const sv = query.sort_val == 'asc' ? 0 : 1
materials.sort((a, b) => {
const keys = sk ? [sk] : ['sort_order', 'updated_at']
for (const k of keys) {
const av = !sv ? a[k] : b[k]
const bv = sv ? a[k] : b[k]
const dir = sk ? (sv ? -1 : 1) : (k === 'updated_at' ? -1 : 1)
if (av && bv) {
if (av != bv) return av > bv ? dir : -dir
} else if (av && !bv) {
return dir
} else if (!av && bv) {
return -dir
}
}
return 0
})
if (query.size) {
const page = query.page ?? 1
const size = query.size
materials = materials.slice((page - 1) * size, page * size)
}
}
ctx.body = materials
// 统一:注解 ypt/health_tier + 排序(含 ypt/分组) + 分页
const classify = await loadTierClassifier()
ctx.body = rankAndPaginate(materials, { ...query, group_by_tier }, classify)
}
)