refactor(domain): 集中电池业务常量

This commit is contained in:
2026-05-11 23:38:37 +08:00
parent a8e3cf5f4b
commit dc8a595d0a
3 changed files with 211 additions and 75 deletions
+63
View File
@@ -0,0 +1,63 @@
export const POWER_STATUS = {
NOT_CHARGING: 0,
CHARGING: 1,
FULL: 2,
} as const
export type PowerStatus = (typeof POWER_STATUS)[keyof typeof POWER_STATUS]
export const POWER_STATUS_VALUES = [POWER_STATUS.NOT_CHARGING, POWER_STATUS.CHARGING, POWER_STATUS.FULL] as const
export const BATTERY_LIST_SORT = {
CREATED_AT_DESC: 'createdAtDesc',
CREATED_AT_ASC: 'createdAtAsc',
POWER_DESC: 'powerDesc',
POWER_ASC: 'powerAsc',
} as const
export type BatteryListSort = (typeof BATTERY_LIST_SORT)[keyof typeof BATTERY_LIST_SORT]
export const BATTERY_LIST_SORT_VALUES = [
BATTERY_LIST_SORT.CREATED_AT_DESC,
BATTERY_LIST_SORT.CREATED_AT_ASC,
BATTERY_LIST_SORT.POWER_DESC,
BATTERY_LIST_SORT.POWER_ASC,
] as const
export const MYSQL_BOOLEAN = {
TRUE: 'true',
FALSE: 'false',
} as const
export function toMysqlBoolean(value: boolean) {
return value ? MYSQL_BOOLEAN.TRUE : MYSQL_BOOLEAN.FALSE
}
export function fromMysqlBoolean(value: string | boolean) {
if (typeof value === 'boolean') return value
return value.toLowerCase() === MYSQL_BOOLEAN.TRUE
}
export const DEVICE_STATUS = {
HEALTHY: '健康',
WATCH: '关注',
WARNING: '预警',
} as const
export type DeviceStatus = (typeof DEVICE_STATUS)[keyof typeof DEVICE_STATUS]
export const EVENT_SEVERITY = {
HIGH: '高',
MEDIUM: '中',
LOW: '低',
} as const
export type EventSeverity = (typeof EVENT_SEVERITY)[keyof typeof EVENT_SEVERITY]
export const SOH_THRESHOLDS = {
WARNING: 85,
WATCH: 90,
LOW_POWER: 20,
HIGH_RISK_SCORE: 70,
WATCH_RISK_SCORE: 40,
} as const
+25 -12
View File
@@ -1,5 +1,13 @@
import { describe, expect, test } from 'bun:test'
import { createBatteriesResponse, createDashboardSnapshot, getDeviceStatus, toBatteryInfo } from './battery'
import {
createBatteriesResponse,
createDashboardSnapshot,
DEVICE_STATUS,
getDeviceStatus,
MYSQL_BOOLEAN,
POWER_STATUS,
toBatteryInfo,
} from './battery'
const rows = [
{
@@ -8,8 +16,8 @@ const rows = [
mac: 'RING-A03',
devModel: '2401-A',
devName: 'RING-A03',
isLowPower: 'false',
powerStatus: 2,
isLowPower: MYSQL_BOOLEAN.FALSE,
powerStatus: POWER_STATUS.FULL,
power: 94,
createTime: new Date('2026-05-10T23:00:00.000Z'),
remark: 'v3.8.2',
@@ -20,8 +28,8 @@ const rows = [
mac: 'RING-B11',
devModel: '2402-B',
devName: 'RING-B11',
isLowPower: 'true',
powerStatus: 1,
isLowPower: MYSQL_BOOLEAN.TRUE,
powerStatus: POWER_STATUS.CHARGING,
power: 84,
createTime: '2026-05-10 22:00:00',
remark: null,
@@ -30,9 +38,9 @@ const rows = [
describe('battery domain', () => {
test('preserves legacy SOH status thresholds', () => {
expect(getDeviceStatus(91)).toBe('健康')
expect(getDeviceStatus(90)).toBe('关注')
expect(getDeviceStatus(85)).toBe('预警')
expect(getDeviceStatus(91)).toBe(DEVICE_STATUS.HEALTHY)
expect(getDeviceStatus(90)).toBe(DEVICE_STATUS.WATCH)
expect(getDeviceStatus(85)).toBe(DEVICE_STATUS.WARNING)
})
test('builds batteries response counters from records', () => {
@@ -54,10 +62,15 @@ describe('battery domain', () => {
expect(snapshot.devices).toHaveLength(2)
expect(snapshot.devices.every((device) => device.sohSource === 'unavailable')).toBe(true)
expect(snapshot.devices.every((device) => device.soh === 0)).toBe(true)
expect(snapshot.soh.history).toHaveLength(12)
expect(snapshot.soh.forecast).toHaveLength(4)
expect(snapshot.devices.every((device) => device.soh === null)).toBe(true)
expect(snapshot.devices.every((device) => device.soh30d === null)).toBe(true)
expect(snapshot.devices.every((device) => device.soh90d === null)).toBe(true)
expect(snapshot.soh.history).toHaveLength(0)
expect(snapshot.soh.forecast).toHaveLength(0)
expect(snapshot.summary.totalDevices).toBe(snapshot.devices.length)
expect(snapshot.summary.avgSoh).toBeNull()
expect(snapshot.summary.avgSoh30d).toBeNull()
expect(snapshot.summary.avgSoh90d).toBeNull()
expect(snapshot.summary.warningCount + snapshot.summary.watchCount + snapshot.summary.healthyCount).toBe(
snapshot.devices.length,
)
@@ -93,7 +106,7 @@ describe('battery domain', () => {
expect(predicted?.sohSource).toBe('prediction')
expect(predicted?.soh30d).toBe(58)
expect(predicted?.soh90d).toBe(52)
expect(predicted?.status).toBe('预警')
expect(predicted?.status).toBe(DEVICE_STATUS.WARNING)
expect(predicted?.firmware).toBe('XGBoost')
})
})
+122 -62
View File
@@ -1,10 +1,42 @@
import { z } from 'zod'
import {
DEVICE_STATUS,
type DeviceStatus,
EVENT_SEVERITY,
fromMysqlBoolean,
POWER_STATUS,
type PowerStatus,
SOH_THRESHOLDS,
} from './battery.constants'
export const powerStatusSchema = z.union([z.literal(0), z.literal(1), z.literal(2)])
export type PowerStatus = z.infer<typeof powerStatusSchema>
export {
BATTERY_LIST_SORT,
BATTERY_LIST_SORT_VALUES,
type BatteryListSort,
DEVICE_STATUS,
type DeviceStatus,
EVENT_SEVERITY,
type EventSeverity,
fromMysqlBoolean,
MYSQL_BOOLEAN,
POWER_STATUS,
POWER_STATUS_VALUES,
type PowerStatus,
SOH_THRESHOLDS,
toMysqlBoolean,
} from './battery.constants'
export const deviceStatusSchema = z.union([z.literal('健康'), z.literal('关注'), z.literal('预警')])
export type DeviceStatus = z.infer<typeof deviceStatusSchema>
export const powerStatusSchema = z.union([
z.literal(POWER_STATUS.NOT_CHARGING),
z.literal(POWER_STATUS.CHARGING),
z.literal(POWER_STATUS.FULL),
])
export const deviceStatusSchema = z.union([
z.literal(DEVICE_STATUS.HEALTHY),
z.literal(DEVICE_STATUS.WATCH),
z.literal(DEVICE_STATUS.WARNING),
])
export const batteryInfoSchema = z.object({
id: z.number().int(),
@@ -25,11 +57,11 @@ export const fleetUnitSchema = z.object({
batch: z.string(),
firmware: z.string(),
cycles: z.number().int(),
soh: z.number(),
soh: z.number().nullable(),
sohSource: z.union([z.literal('prediction'), z.literal('unavailable')]),
soh30d: z.number(),
soh60d: z.number(),
soh90d: z.number(),
soh30d: z.number().nullable(),
soh60d: z.number().nullable(),
soh90d: z.number().nullable(),
temperature: z.number(),
riskScore: z.number().int(),
chargeEfficiency: z.number(),
@@ -48,7 +80,7 @@ export const eventItemSchema = z.object({
time: z.string(),
title: z.string(),
detail: z.string(),
severity: z.union([z.literal('高'), z.literal('中'), z.literal('低')]),
severity: z.union([z.literal(EVENT_SEVERITY.HIGH), z.literal(EVENT_SEVERITY.MEDIUM), z.literal(EVENT_SEVERITY.LOW)]),
})
export const strategyItemSchema = z.object({
@@ -60,15 +92,15 @@ export const strategyItemSchema = z.object({
export const summaryResponseSchema = z.object({
totalDevices: z.number().int(),
avgSoh: z.number(),
avgSoh30d: z.number(),
avgSoh90d: z.number(),
avgSoh: z.number().nullable(),
avgSoh30d: z.number().nullable(),
avgSoh90d: z.number().nullable(),
warningCount: z.number().int(),
watchCount: z.number().int(),
healthyCount: z.number().int(),
batchPerformance: z.array(z.object({ batch: z.string(), avgSoh: z.number() })),
batchPerformance: z.array(z.object({ batch: z.string(), avgSoh: z.number().nullable() })),
riskFactorCounts: z.array(z.object({ factor: z.string(), count: z.number().int() })),
firmwareHealth: z.array(z.object({ firmware: z.string(), avgSoh: z.number(), count: z.number().int() })),
firmwareHealth: z.array(z.object({ firmware: z.string(), avgSoh: z.number().nullable(), count: z.number().int() })),
updatedAt: z.string(),
executiveSummary: z.string(),
})
@@ -131,9 +163,9 @@ const formatEventTime = (date: Date) =>
`${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`
export function getDeviceStatus(soh: number): DeviceStatus {
if (soh <= 85) return '预警'
if (soh <= 90) return '关注'
return '健康'
if (soh <= SOH_THRESHOLDS.WARNING) return DEVICE_STATUS.WARNING
if (soh <= SOH_THRESHOLDS.WATCH) return DEVICE_STATUS.WATCH
return DEVICE_STATUS.HEALTHY
}
function getDeviceStatusByRisk(prediction: BatteryPrediction): DeviceStatus {
@@ -145,7 +177,7 @@ function getDeviceStatusByRisk(prediction: BatteryPrediction): DeviceStatus {
riskText.includes('危险') ||
riskText.includes('高')
) {
return '预警'
return DEVICE_STATUS.WARNING
}
if (
riskText.includes('medium') ||
@@ -153,25 +185,24 @@ function getDeviceStatusByRisk(prediction: BatteryPrediction): DeviceStatus {
riskText.includes('关注') ||
riskText.includes('中')
) {
return '关注'
return DEVICE_STATUS.WATCH
}
if (prediction.riskScore !== null) {
if (prediction.riskScore >= 70) return '预警'
if (prediction.riskScore >= 40) return '关注'
if (prediction.riskScore >= SOH_THRESHOLDS.HIGH_RISK_SCORE) return DEVICE_STATUS.WARNING
if (prediction.riskScore >= SOH_THRESHOLDS.WATCH_RISK_SCORE) return DEVICE_STATUS.WATCH
}
return getDeviceStatus(prediction.nowSoh)
}
export function normalizePowerStatus(value: number): PowerStatus {
if (value === 1 || value === 2) return value
return 0
if (value === POWER_STATUS.CHARGING || value === POWER_STATUS.FULL) return value
return POWER_STATUS.NOT_CHARGING
}
export function normalizeLowPower(value: string | boolean): boolean {
if (typeof value === 'boolean') return value
return value.toLowerCase() === 'true'
return fromMysqlBoolean(value)
}
export type BatteryInfoSourceRow = {
@@ -212,7 +243,7 @@ export function createBatteriesResponse(
updatedAt: now.toISOString(),
total: summary.total ?? items.length,
lowPower: summary.lowPower ?? items.filter((item) => item.isLowPower).length,
charging: summary.charging ?? items.filter((item) => item.powerStatus === 1).length,
charging: summary.charging ?? items.filter((item) => item.powerStatus === POWER_STATUS.CHARGING).length,
items,
nextCursor,
}
@@ -220,21 +251,25 @@ export function createBatteriesResponse(
function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPrediction): FleetUnit {
const hasPrediction = Boolean(prediction)
const soh = prediction ? round1(clamp(prediction.nowSoh, 0, 100)) : 0
const status = prediction ? getDeviceStatusByRisk(prediction) : item.isLowPower || item.power <= 20 ? '关注' : '健康'
const soh = prediction ? round1(clamp(prediction.nowSoh, 0, 100)) : null
const status = prediction
? getDeviceStatusByRisk(prediction)
: item.isLowPower || item.power <= SOH_THRESHOLDS.LOW_POWER
? DEVICE_STATUS.WATCH
: DEVICE_STATUS.HEALTHY
const riskFactors: string[] = []
if (item.isLowPower || item.power <= 20) riskFactors.push('低电量')
if (item.powerStatus === 1) riskFactors.push('充电中')
if (item.isLowPower || item.power <= SOH_THRESHOLDS.LOW_POWER) riskFactors.push('低电量')
if (item.powerStatus === POWER_STATUS.CHARGING) riskFactors.push('充电中')
if (!hasPrediction) riskFactors.push('SoH预测不可用')
if (prediction && status === '预警') riskFactors.push('衰减加速')
if (prediction && status === DEVICE_STATUS.WARNING) riskFactors.push('衰减加速')
if (item.remark?.includes('v3.7')) riskFactors.push('旧固件')
if (prediction?.riskLevel) riskFactors.push(`AI风险:${prediction.riskLevel}`)
const thermalPressure = index % 3
const soh30d = prediction ? round1(clamp(prediction.monthSoh, 0, 100)) : 0
const soh90d = prediction ? round1(clamp(prediction.trmonthSoh, 0, 100)) : 0
const soh60d = prediction ? round1((soh30d + soh90d) / 2) : 0
const soh30d = prediction ? round1(clamp(prediction.monthSoh, 0, 100)) : null
const soh90d = prediction ? round1(clamp(prediction.trmonthSoh, 0, 100)) : null
const soh60d = soh30d !== null && soh90d !== null ? round1((soh30d + soh90d) / 2) : null
const temperature = round1(29.5 + thermalPressure * 2.1 + (item.isLowPower ? 1.4 : 0))
const chargeEfficiency = round1(clamp(91 + item.power / 12 - riskFactors.length * 1.8, 80, 98))
const riskScore = Math.round(
@@ -249,7 +284,7 @@ function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPredi
id: item.devName || item.mac,
batch: item.devModel,
firmware: prediction?.modelName ?? item.remark ?? 'unknown',
cycles: 120 + index * 17 + Math.round((100 - soh) * 2.2),
cycles: 120 + index * 17 + Math.round((100 - (soh ?? item.power)) * 2.2),
soh,
sohSource: prediction ? 'prediction' : 'unavailable',
soh30d,
@@ -264,10 +299,12 @@ function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPredi
}
function createSohResponse(devices: FleetUnit[], now: Date) {
if (devices.length === 0) return { history: [], forecast: [] }
const predictedDevices = devices.filter((unit) => unit.soh !== null)
if (predictedDevices.length === 0) return { history: [], forecast: [] }
const avgSoh = devices.reduce((sum, unit) => sum + unit.soh, 0) / devices.length
const monthlyDrop = 0.45 + devices.reduce((sum, unit) => sum + unit.riskScore, 0) / devices.length / 160
const avgSoh = averageNullable(predictedDevices.map((unit) => unit.soh)) ?? 0
const monthlyDrop =
0.45 + predictedDevices.reduce((sum, unit) => sum + unit.riskScore, 0) / predictedDevices.length / 160
const history = Array.from({ length: 12 }, (_, index) => {
const monthOffset = index - 11
@@ -287,10 +324,13 @@ function createSohResponse(devices: FleetUnit[], now: Date) {
function summarizeBy<T extends string>(items: FleetUnit[], getKey: (item: FleetUnit) => T) {
return Object.entries(
items.reduce<Record<string, { sum: number; count: number }>>((acc, item) => {
items.reduce<Record<string, { sum: number; valueCount: number; count: number }>>((acc, item) => {
const key = getKey(item)
const entry = acc[key] ?? { sum: 0, count: 0 }
const entry = acc[key] ?? { sum: 0, valueCount: 0, count: 0 }
if (item.soh !== null) {
entry.sum += item.soh
entry.valueCount += 1
}
entry.count += 1
acc[key] = entry
return acc
@@ -298,15 +338,22 @@ function summarizeBy<T extends string>(items: FleetUnit[], getKey: (item: FleetU
)
}
function averageNullable(values: Array<number | null>) {
const available = values.filter((value) => value !== null)
if (available.length === 0) return null
return available.reduce((sum, value) => sum + value, 0) / available.length
}
function createSummary(devices: FleetUnit[], now: Date) {
const totalDevices = devices.length
if (totalDevices === 0) {
return {
totalDevices,
avgSoh: 0,
avgSoh30d: 0,
avgSoh90d: 0,
avgSoh: null,
avgSoh30d: null,
avgSoh90d: null,
warningCount: 0,
watchCount: 0,
healthyCount: 0,
@@ -318,18 +365,22 @@ function createSummary(devices: FleetUnit[], now: Date) {
}
}
const avgSoh = devices.reduce((sum, unit) => sum + unit.soh, 0) / totalDevices
const avgSoh30d = devices.reduce((sum, unit) => sum + unit.soh30d, 0) / totalDevices
const avgSoh90d = devices.reduce((sum, unit) => sum + unit.soh90d, 0) / totalDevices
const warningCount = devices.filter((unit) => unit.status === '预警').length
const watchCount = devices.filter((unit) => unit.status === '关注').length
const healthyCount = devices.filter((unit) => unit.status === '健康').length
const avgSoh = averageNullable(devices.map((unit) => unit.soh))
const avgSoh30d = averageNullable(devices.map((unit) => unit.soh30d))
const avgSoh90d = averageNullable(devices.map((unit) => unit.soh90d))
const warningCount = devices.filter((unit) => unit.status === DEVICE_STATUS.WARNING).length
const watchCount = devices.filter((unit) => unit.status === DEVICE_STATUS.WATCH).length
const healthyCount = devices.filter((unit) => unit.status === DEVICE_STATUS.HEALTHY).length
const batchPerformance = summarizeBy(devices, (unit) => unit.batch)
.map(([batch, data]) => ({ batch, avgSoh: round1(data.sum / data.count) }))
.sort((a, b) => b.avgSoh - a.avgSoh)
.map(([batch, data]) => ({ batch, avgSoh: data.valueCount > 0 ? round1(data.sum / data.valueCount) : null }))
.sort((a, b) => (b.avgSoh ?? -1) - (a.avgSoh ?? -1))
const firmwareHealth = summarizeBy(devices, (unit) => unit.firmware)
.map(([firmware, data]) => ({ firmware, avgSoh: round1(data.sum / data.count), count: data.count }))
.sort((a, b) => b.avgSoh - a.avgSoh)
.map(([firmware, data]) => ({
firmware,
avgSoh: data.valueCount > 0 ? round1(data.sum / data.valueCount) : null,
count: data.count,
}))
.sort((a, b) => (b.avgSoh ?? -1) - (a.avgSoh ?? -1))
const riskFactorCounts = Object.entries(
devices.reduce<Record<string, number>>((acc, unit) => {
for (const factor of unit.riskFactors) {
@@ -345,9 +396,9 @@ function createSummary(devices: FleetUnit[], now: Date) {
return {
totalDevices,
avgSoh: round1(avgSoh),
avgSoh30d: round1(avgSoh30d),
avgSoh90d: round1(avgSoh90d),
avgSoh: avgSoh === null ? null : round1(avgSoh),
avgSoh30d: avgSoh30d === null ? null : round1(avgSoh30d),
avgSoh90d: avgSoh90d === null ? null : round1(avgSoh90d),
warningCount,
watchCount,
healthyCount,
@@ -355,7 +406,10 @@ function createSummary(devices: FleetUnit[], now: Date) {
riskFactorCounts,
firmwareHealth,
updatedAt: formatDateTime(now),
executiveSummary: `当前电池健康度总体可控,重点风险集中在 ${weakestBatch} 批次与 ${weakestFirmware} 固件设备。建议优先跟踪低电量、充电状态与未来 90 天 SoH 变化。`,
executiveSummary:
avgSoh === null
? '当前 AI SoH 预测不可用,页面仅展示 MySQL 采集电量、充电状态与低电量风险。请检查预测服务配置或历史数据量。'
: `当前电池健康度总体可控,重点风险集中在 ${weakestBatch} 批次与 ${weakestFirmware} 固件设备。建议优先跟踪低电量、充电状态与未来 90 天 SoH 变化。`,
}
}
@@ -371,14 +425,20 @@ function createEvents(devices: FleetUnit[], now: Date) {
{
time: formatEventTime(addHours(now, -2)),
title: `${first.id} 进入重点观察队列`,
detail: `${first.id} 当前 SoH 为 ${first.soh.toFixed(1)}%,综合风险评分 ${first.riskScore}`,
severity: first.status === '预警' ? '高' : '中',
detail:
first.soh === null
? `${first.id} 当前 SoH 预测不可用,综合风险评分 ${first.riskScore}`
: `${first.id} 当前 SoH 为 ${first.soh.toFixed(1)}%,综合风险评分 ${first.riskScore}`,
severity: first.status === DEVICE_STATUS.WARNING ? EVENT_SEVERITY.HIGH : EVENT_SEVERITY.MEDIUM,
},
{
time: formatEventTime(addHours(now, -5)),
title: `${second.batch} 批次健康度趋势更新`,
detail: `${second.batch} 批次未来 90 天预测 SoH 为 ${second.soh90d.toFixed(1)}%。`,
severity: second.status === '健康' ? '低' : '中',
detail:
second.soh90d === null
? `${second.batch} 批次未来 90 天 SoH 预测不可用。`
: `${second.batch} 批次未来 90 天预测 SoH 为 ${second.soh90d.toFixed(1)}%。`,
severity: second.status === DEVICE_STATUS.HEALTHY ? EVENT_SEVERITY.LOW : EVENT_SEVERITY.MEDIUM,
},
] satisfies DashboardSnapshot['events']
}
@@ -386,7 +446,7 @@ function createEvents(devices: FleetUnit[], now: Date) {
function createStrategies(devices: FleetUnit[]) {
if (devices.length === 0) return []
const warningDevices = devices.filter((unit) => unit.status === '预警')
const warningDevices = devices.filter((unit) => unit.status === DEVICE_STATUS.WARNING)
const chargingDevices = devices.filter((unit) => unit.riskFactors.includes('充电中'))
const first = devices.slice().sort((a, b) => b.riskScore - a.riskScore)[0]