476 lines
16 KiB
TypeScript
476 lines
16 KiB
TypeScript
import { z } from 'zod'
|
|
import {
|
|
DEVICE_STATUS,
|
|
type DeviceStatus,
|
|
EVENT_SEVERITY,
|
|
fromMysqlBoolean,
|
|
POWER_STATUS,
|
|
type PowerStatus,
|
|
SOH_THRESHOLDS,
|
|
} from './battery.constants'
|
|
|
|
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 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(),
|
|
userId: z.number().int(),
|
|
mac: z.string(),
|
|
devModel: z.string(),
|
|
devName: z.string(),
|
|
isLowPower: z.boolean(),
|
|
powerStatus: powerStatusSchema,
|
|
power: z.number().int().min(0).max(100),
|
|
createTime: z.string(),
|
|
remark: z.string().nullable(),
|
|
})
|
|
export type BatteryInfo = z.infer<typeof batteryInfoSchema>
|
|
|
|
export const fleetUnitSchema = z.object({
|
|
id: z.string(),
|
|
displayName: z.string(),
|
|
batch: z.string(),
|
|
firmware: z.string(),
|
|
cycles: z.number().int(),
|
|
soh: z.number().nullable(),
|
|
sohSource: z.union([z.literal('prediction'), z.literal('unavailable')]),
|
|
soh30d: z.number().nullable(),
|
|
soh60d: z.number().nullable(),
|
|
soh90d: z.number().nullable(),
|
|
temperature: z.number(),
|
|
riskScore: z.number().int(),
|
|
chargeEfficiency: z.number(),
|
|
status: deviceStatusSchema,
|
|
riskFactors: z.array(z.string()),
|
|
})
|
|
export type FleetUnit = z.infer<typeof fleetUnitSchema>
|
|
|
|
export const sohPointSchema = z.object({ month: z.string(), value: z.number() })
|
|
export const sohResponseSchema = z.object({
|
|
history: z.array(sohPointSchema),
|
|
forecast: z.array(sohPointSchema),
|
|
})
|
|
|
|
export const eventItemSchema = z.object({
|
|
time: z.string(),
|
|
title: z.string(),
|
|
detail: z.string(),
|
|
severity: z.union([z.literal(EVENT_SEVERITY.HIGH), z.literal(EVENT_SEVERITY.MEDIUM), z.literal(EVENT_SEVERITY.LOW)]),
|
|
})
|
|
|
|
export const strategyItemSchema = z.object({
|
|
name: z.string(),
|
|
impact: z.string(),
|
|
scope: z.string(),
|
|
eta: z.string(),
|
|
})
|
|
|
|
export const summaryResponseSchema = z.object({
|
|
totalDevices: z.number().int(),
|
|
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().nullable() })),
|
|
riskFactorCounts: z.array(z.object({ factor: z.string(), 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(),
|
|
})
|
|
|
|
export const dashboardSnapshotSchema = z.object({
|
|
devices: z.array(fleetUnitSchema),
|
|
soh: sohResponseSchema,
|
|
events: z.array(eventItemSchema),
|
|
strategies: z.array(strategyItemSchema),
|
|
summary: summaryResponseSchema,
|
|
})
|
|
export type DashboardSnapshot = z.infer<typeof dashboardSnapshotSchema>
|
|
|
|
export const batteriesResponseSchema = z.object({
|
|
updatedAt: z.string(),
|
|
total: z.number().int(),
|
|
lowPower: z.number().int(),
|
|
charging: z.number().int(),
|
|
items: z.array(batteryInfoSchema),
|
|
nextCursor: z.string().nullable(),
|
|
})
|
|
export type BatteriesResponse = z.infer<typeof batteriesResponseSchema>
|
|
|
|
export type BatteriesPageSummary = {
|
|
total?: number
|
|
lowPower?: number
|
|
charging?: number
|
|
}
|
|
|
|
export type BatteryPrediction = {
|
|
mac: string
|
|
nowSoh: number
|
|
monthSoh: number
|
|
trmonthSoh: number
|
|
riskScore: number | null
|
|
riskLevel: string | null
|
|
status: string | null
|
|
modelName: string | null
|
|
cyclesUsed: number | null
|
|
updatedAt: string | null
|
|
}
|
|
|
|
const round1 = (value: number) => Math.round(value * 10) / 10
|
|
|
|
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
|
|
|
|
const pad2 = (value: number) => value.toString().padStart(2, '0')
|
|
|
|
const formatDateTime = (date: Date) =>
|
|
`${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`
|
|
|
|
export function getDeviceStatus(soh: number): DeviceStatus {
|
|
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 {
|
|
const riskText = `${prediction.riskLevel ?? ''} ${prediction.status ?? ''}`.toLowerCase()
|
|
|
|
if (
|
|
riskText.includes('high') ||
|
|
riskText.includes('danger') ||
|
|
riskText.includes('危险') ||
|
|
riskText.includes('高')
|
|
) {
|
|
return DEVICE_STATUS.WARNING
|
|
}
|
|
if (
|
|
riskText.includes('medium') ||
|
|
riskText.includes('warning') ||
|
|
riskText.includes('关注') ||
|
|
riskText.includes('中')
|
|
) {
|
|
return DEVICE_STATUS.WATCH
|
|
}
|
|
|
|
if (prediction.riskScore !== null) {
|
|
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 === POWER_STATUS.CHARGING || value === POWER_STATUS.FULL) return value
|
|
return POWER_STATUS.NOT_CHARGING
|
|
}
|
|
|
|
export function normalizeLowPower(value: string | boolean): boolean {
|
|
return fromMysqlBoolean(value)
|
|
}
|
|
|
|
export type BatteryInfoSourceRow = {
|
|
id: number
|
|
userId: number
|
|
mac: string
|
|
devModel: string
|
|
devName: string
|
|
isLowPower: string | boolean
|
|
powerStatus: number
|
|
power: number
|
|
createTime: Date | string
|
|
remark: string | null
|
|
}
|
|
|
|
export function toBatteryInfo(row: BatteryInfoSourceRow): BatteryInfo {
|
|
return {
|
|
id: row.id,
|
|
userId: row.userId,
|
|
mac: row.mac,
|
|
devModel: row.devModel,
|
|
devName: row.devName,
|
|
isLowPower: normalizeLowPower(row.isLowPower),
|
|
powerStatus: normalizePowerStatus(row.powerStatus),
|
|
power: row.power,
|
|
createTime: row.createTime instanceof Date ? row.createTime.toISOString() : String(row.createTime),
|
|
remark: row.remark,
|
|
}
|
|
}
|
|
|
|
export function createBatteriesResponse(
|
|
items: BatteryInfo[],
|
|
now = new Date(),
|
|
summary: BatteriesPageSummary = {},
|
|
nextCursor: string | null = null,
|
|
): BatteriesResponse {
|
|
return {
|
|
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 === POWER_STATUS.CHARGING).length,
|
|
items,
|
|
nextCursor,
|
|
}
|
|
}
|
|
|
|
function toFleetUnit(item: BatteryInfo, prediction?: BatteryPrediction): FleetUnit {
|
|
const hasPrediction = Boolean(prediction)
|
|
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 <= SOH_THRESHOLDS.LOW_POWER) riskFactors.push('低电量')
|
|
if (item.powerStatus === POWER_STATUS.CHARGING) riskFactors.push('充电中')
|
|
if (!hasPrediction) riskFactors.push('健康预测不可用')
|
|
if (prediction && status === DEVICE_STATUS.WARNING) riskFactors.push('衰减加速')
|
|
if (item.remark?.includes('v3.7')) riskFactors.push('旧固件')
|
|
if (prediction?.riskLevel) riskFactors.push(`预测风险:${prediction.riskLevel}`)
|
|
|
|
const soh30d = prediction ? round1(clamp(prediction.monthSoh, 0, 100)) : null
|
|
const soh90d = prediction ? round1(clamp(prediction.trmonthSoh, 0, 100)) : null
|
|
const soh60d = null
|
|
const temperature = 0
|
|
const chargeEfficiency = 0
|
|
const fallbackRiskScore =
|
|
(item.isLowPower || item.power <= SOH_THRESHOLDS.LOW_POWER ? 60 : 0) +
|
|
(item.powerStatus === POWER_STATUS.CHARGING ? 20 : 0)
|
|
const riskScore = Math.round(clamp(prediction?.riskScore ?? fallbackRiskScore, 0, 100))
|
|
|
|
return {
|
|
id: item.mac,
|
|
displayName: item.devName || item.mac,
|
|
batch: item.devModel,
|
|
firmware: item.remark ?? '未提供',
|
|
cycles: prediction?.cyclesUsed ?? 0,
|
|
soh,
|
|
sohSource: prediction ? 'prediction' : 'unavailable',
|
|
soh30d,
|
|
soh60d,
|
|
soh90d,
|
|
temperature,
|
|
riskScore,
|
|
chargeEfficiency,
|
|
status,
|
|
riskFactors,
|
|
}
|
|
}
|
|
|
|
function createSohResponse(devices: FleetUnit[]) {
|
|
const predictedDevices = devices.filter((unit) => unit.soh !== null)
|
|
if (predictedDevices.length === 0) return { history: [], forecast: [] }
|
|
|
|
const avgNow = averageNullable(predictedDevices.map((unit) => unit.soh))
|
|
const avgMonth = averageNullable(predictedDevices.map((unit) => unit.soh30d))
|
|
const avgTrmonth = averageNullable(predictedDevices.map((unit) => unit.soh90d))
|
|
|
|
const forecast = [
|
|
avgNow === null ? null : { month: '当前', value: round1(clamp(avgNow, 0, 100)) },
|
|
avgMonth === null ? null : { month: '30天', value: round1(clamp(avgMonth, 0, 100)) },
|
|
avgTrmonth === null ? null : { month: '90天', value: round1(clamp(avgTrmonth, 0, 100)) },
|
|
].filter((point): point is { month: string; value: number } => point !== null)
|
|
|
|
return {
|
|
history: [],
|
|
forecast,
|
|
}
|
|
}
|
|
|
|
function summarizeBy<T extends string>(items: FleetUnit[], getKey: (item: FleetUnit) => T) {
|
|
return Object.entries(
|
|
items.reduce<Record<string, { sum: number; valueCount: number; count: number }>>((acc, item) => {
|
|
const key = getKey(item)
|
|
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
|
|
}, {}),
|
|
)
|
|
}
|
|
|
|
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: null,
|
|
avgSoh30d: null,
|
|
avgSoh90d: null,
|
|
warningCount: 0,
|
|
watchCount: 0,
|
|
healthyCount: 0,
|
|
batchPerformance: [],
|
|
riskFactorCounts: [],
|
|
firmwareHealth: [],
|
|
updatedAt: formatDateTime(now),
|
|
executiveSummary: '当前没有可用于电池健康分析的真实设备记录。',
|
|
}
|
|
}
|
|
|
|
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: 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: 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) {
|
|
acc[factor] = (acc[factor] ?? 0) + 1
|
|
}
|
|
return acc
|
|
}, {}),
|
|
)
|
|
.map(([factor, count]) => ({ factor, count }))
|
|
.sort((a, b) => b.count - a.count)
|
|
const weakestModel = batchPerformance.at(-1)?.batch ?? '当前设备型号'
|
|
const weakestRemark = firmwareHealth.at(-1)?.firmware ?? '未提供备注'
|
|
const predictedDevices = devices.filter((unit) => unit.soh !== null).length
|
|
const missingPredictionDevices = totalDevices - predictedDevices
|
|
|
|
return {
|
|
totalDevices,
|
|
avgSoh: avgSoh === null ? null : round1(avgSoh),
|
|
avgSoh30d: avgSoh30d === null ? null : round1(avgSoh30d),
|
|
avgSoh90d: avgSoh90d === null ? null : round1(avgSoh90d),
|
|
warningCount,
|
|
watchCount,
|
|
healthyCount,
|
|
batchPerformance,
|
|
riskFactorCounts,
|
|
firmwareHealth,
|
|
updatedAt: formatDateTime(now),
|
|
executiveSummary:
|
|
avgSoh === null
|
|
? '当前健康预测暂不可用,系统仍会展示设备电量、充电状态与低电量风险。请稍后复查或联系管理员。'
|
|
: `当前共有 ${predictedDevices} 台设备具备健康预测,${missingPredictionDevices} 台设备暂无预测结果。建议重点关注 ${weakestModel} 型号与 ${weakestRemark} 备注设备,优先处理低电量和充电中的设备,并在下次同步后复查未来 30/90 天健康趋势。`,
|
|
}
|
|
}
|
|
|
|
function createEvents(devices: FleetUnit[], now: Date) {
|
|
if (devices.length === 0) return []
|
|
|
|
const predictedDevices = devices.filter((unit) => unit.soh !== null)
|
|
const warningDevices = devices.filter((unit) => unit.status === DEVICE_STATUS.WARNING)
|
|
const missingPredictionDevices = devices.length - predictedDevices.length
|
|
|
|
return [
|
|
{
|
|
time: formatDateTime(now),
|
|
title: '风险快照',
|
|
detail: `本次概览包含 ${devices.length} 台设备,其中 ${predictedDevices.length} 台具备健康预测,${warningDevices.length} 台处于预警状态。`,
|
|
severity: warningDevices.length > 0 ? EVENT_SEVERITY.HIGH : EVENT_SEVERITY.LOW,
|
|
},
|
|
{
|
|
time: formatDateTime(now),
|
|
title: '预测可用性快照',
|
|
detail:
|
|
missingPredictionDevices > 0
|
|
? `当前有 ${missingPredictionDevices} 台设备暂无健康预测,相关趋势将暂不展示。`
|
|
: '当前所有设备均已具备健康预测,可继续观察趋势变化。',
|
|
severity: missingPredictionDevices > 0 ? EVENT_SEVERITY.MEDIUM : EVENT_SEVERITY.LOW,
|
|
},
|
|
] satisfies DashboardSnapshot['events']
|
|
}
|
|
|
|
function createStrategies(devices: FleetUnit[]) {
|
|
if (devices.length === 0) return []
|
|
|
|
const warningDevices = devices.filter((unit) => unit.status === DEVICE_STATUS.WARNING)
|
|
const powerAttentionDevices = devices.filter(
|
|
(unit) => unit.riskFactors.includes('充电中') || unit.riskFactors.includes('低电量'),
|
|
)
|
|
const missingPredictionDevices = devices.filter((unit) => unit.soh === null)
|
|
|
|
return [
|
|
{
|
|
name: '优先处理预警设备',
|
|
impact: `当前有 ${warningDevices.length} 台设备处于预警状态,建议先复核供电、连接与预测结果。`,
|
|
scope: warningDevices.length > 0 ? `${warningDevices.length} 台预警设备` : '当前设备',
|
|
eta: '本次巡检周期内',
|
|
},
|
|
{
|
|
name: '提升预测覆盖',
|
|
impact:
|
|
missingPredictionDevices.length > 0
|
|
? `当前有 ${missingPredictionDevices.length} 台设备暂无健康预测,建议在下次同步后复查。`
|
|
: `当前已有 ${devices.length} 台设备具备预测结果,可继续观察健康变化。`,
|
|
scope:
|
|
powerAttentionDevices.length > 0
|
|
? `${powerAttentionDevices.length} 台充电中或低电量设备`
|
|
: missingPredictionDevices.length > 0
|
|
? `${missingPredictionDevices.length} 台缺失预测设备`
|
|
: '当前设备',
|
|
eta: '下次同步后复查',
|
|
},
|
|
] satisfies DashboardSnapshot['strategies']
|
|
}
|
|
|
|
export function createDashboardSnapshot(
|
|
items: BatteryInfo[],
|
|
now = new Date(),
|
|
predictions: ReadonlyMap<string, BatteryPrediction> = new Map(),
|
|
): DashboardSnapshot {
|
|
const devices = items.map((item) => toFleetUnit(item, predictions.get(item.mac)))
|
|
|
|
return {
|
|
devices,
|
|
soh: createSohResponse(devices),
|
|
events: createEvents(devices, now),
|
|
strategies: createStrategies(devices),
|
|
summary: createSummary(devices, now),
|
|
}
|
|
}
|