refactor(domain): 集中电池业务常量
This commit is contained in:
@@ -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
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
+123
-63
@@ -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 }
|
||||
entry.sum += item.soh
|
||||
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]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user