From dc8a595d0ad5085e767d758ae6c72b58ea75dc42 Mon Sep 17 00:00:00 2001 From: imbytecat Date: Mon, 11 May 2026 23:38:37 +0800 Subject: [PATCH] =?UTF-8?q?refactor(domain):=20=E9=9B=86=E4=B8=AD=E7=94=B5?= =?UTF-8?q?=E6=B1=A0=E4=B8=9A=E5=8A=A1=E5=B8=B8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/domain/battery.constants.ts | 63 +++++++++++ src/domain/battery.test.ts | 37 ++++--- src/domain/battery.ts | 186 +++++++++++++++++++++----------- 3 files changed, 211 insertions(+), 75 deletions(-) create mode 100644 src/domain/battery.constants.ts diff --git a/src/domain/battery.constants.ts b/src/domain/battery.constants.ts new file mode 100644 index 0000000..ef780b4 --- /dev/null +++ b/src/domain/battery.constants.ts @@ -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 diff --git a/src/domain/battery.test.ts b/src/domain/battery.test.ts index 2a706e0..b5cbdf0 100644 --- a/src/domain/battery.test.ts +++ b/src/domain/battery.test.ts @@ -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') }) }) diff --git a/src/domain/battery.ts b/src/domain/battery.ts index 579f83d..6bad090 100644 --- a/src/domain/battery.ts +++ b/src/domain/battery.ts @@ -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 +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 +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(items: FleetUnit[], getKey: (item: FleetUnit) => T) { return Object.entries( - items.reduce>((acc, item) => { + items.reduce>((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(items: FleetUnit[], getKey: (item: FleetU ) } +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: 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>((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]