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 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 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 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 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(items: FleetUnit[], getKey: (item: FleetUnit) => T) { return Object.entries( items.reduce>((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) { 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>((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 = 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), } }