diff --git a/src/domain/battery.test.ts b/src/domain/battery.test.ts new file mode 100644 index 0000000..b932c02 --- /dev/null +++ b/src/domain/battery.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test' +import { createBatteriesResponse, createDashboardSnapshot, getDeviceStatus, toBatteryInfo } from './battery' + +const rows = [ + { + id: 1, + userId: 7, + mac: 'RING-A03', + devModel: '2401-A', + devName: 'RING-A03', + isLowPower: 'false', + powerStatus: 2, + power: 94, + createTime: new Date('2026-05-10T23:00:00.000Z'), + remark: 'v3.8.2', + }, + { + id: 2, + userId: 7, + mac: 'RING-B11', + devModel: '2402-B', + devName: 'RING-B11', + isLowPower: 'true', + powerStatus: 1, + power: 84, + createTime: '2026-05-10 22:00:00', + remark: null, + }, +] + +describe('battery domain', () => { + test('preserves legacy SOH status thresholds', () => { + expect(getDeviceStatus(91)).toBe('健康') + expect(getDeviceStatus(90)).toBe('关注') + expect(getDeviceStatus(85)).toBe('预警') + }) + + test('builds batteries response counters from records', () => { + const now = new Date('2026-05-11T00:00:00.000Z') + const items = rows.map(toBatteryInfo) + const response = createBatteriesResponse(items, now) + + expect(response.updatedAt).toBe('2026-05-11T00:00:00.000Z') + expect(response.total).toBe(items.length) + expect(response.lowPower).toBe(1) + expect(response.charging).toBe(1) + expect(response.items[0]?.createTime).toBe('2026-05-10T23:00:00.000Z') + }) + + test('creates old dashboard aggregate shape from deterministic records', () => { + const now = new Date('2026-05-11T00:00:00.000Z') + const snapshot = createDashboardSnapshot(rows.map(toBatteryInfo), now) + + expect(snapshot.devices).toHaveLength(2) + expect(snapshot.soh.history).toHaveLength(12) + expect(snapshot.soh.forecast).toHaveLength(4) + expect(snapshot.summary.totalDevices).toBe(snapshot.devices.length) + expect(snapshot.summary.warningCount + snapshot.summary.watchCount + snapshot.summary.healthyCount).toBe( + snapshot.devices.length, + ) + }) +}) diff --git a/src/domain/battery.ts b/src/domain/battery.ts new file mode 100644 index 0000000..e7a809f --- /dev/null +++ b/src/domain/battery.ts @@ -0,0 +1,354 @@ +import { z } from 'zod' + +export const powerStatusSchema = z.union([z.literal(0), z.literal(1), z.literal(2)]) +export type PowerStatus = z.infer + +export const deviceStatusSchema = z.union([z.literal('健康'), z.literal('关注'), z.literal('预警')]) +export type DeviceStatus = z.infer + +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(), + batch: z.string(), + firmware: z.string(), + cycles: z.number().int(), + soh: z.number(), + soh30d: z.number(), + soh60d: z.number(), + soh90d: z.number(), + 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('高'), z.literal('中'), z.literal('低')]), +}) + +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(), + avgSoh30d: z.number(), + avgSoh90d: z.number(), + warningCount: z.number().int(), + watchCount: z.number().int(), + healthyCount: z.number().int(), + batchPerformance: z.array(z.object({ batch: z.string(), avgSoh: z.number() })), + 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() })), + 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), +}) +export type BatteriesResponse = z.infer + +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 addMonths = (date: Date, months: number) => + new Date(date.getFullYear(), date.getMonth() + months, date.getDate(), date.getHours(), 0, 0, 0) + +const addHours = (date: Date, hours: number) => new Date(date.getTime() + hours * 60 * 60 * 1000) + +const formatMonthLabel = (date: Date) => `${date.getFullYear().toString().slice(-2)}.${pad2(date.getMonth() + 1)}` + +const formatDateTime = (date: Date) => + `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}` + +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 '健康' +} + +export function normalizePowerStatus(value: number): PowerStatus { + if (value === 1 || value === 2) return value + return 0 +} + +export function normalizeLowPower(value: string | boolean): boolean { + if (typeof value === 'boolean') return value + return value.toLowerCase() === 'true' +} + +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()): BatteriesResponse { + return { + updatedAt: now.toISOString(), + total: items.length, + lowPower: items.filter((item) => item.isLowPower).length, + charging: items.filter((item) => item.powerStatus === 1).length, + items, + } +} + +function toFleetUnit(item: BatteryInfo, index: number): FleetUnit { + const soh = item.power + const status = getDeviceStatus(soh) + const riskFactors: string[] = [] + + if (item.isLowPower || item.power <= 20) riskFactors.push('低电量') + if (item.powerStatus === 1) riskFactors.push('充电中') + if (status === '预警') riskFactors.push('衰减加速') + if (item.remark?.includes('v3.7')) riskFactors.push('旧固件') + + const thermalPressure = index % 3 + const soh30d = round1(clamp(soh - 0.8 - thermalPressure * 0.25, 0, 100)) + const soh60d = round1(clamp(soh - 1.7 - thermalPressure * 0.35, 0, 100)) + const soh90d = round1(clamp(soh - 2.8 - thermalPressure * 0.45, 0, 100)) + 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(clamp(12 + (100 - soh) * 1.45 + riskFactors.length * 8 + thermalPressure * 4, 8, 96)) + + return { + id: item.devName || item.mac, + batch: item.devModel, + firmware: item.remark ?? 'unknown', + cycles: 120 + index * 17 + Math.round((100 - soh) * 2.2), + soh, + soh30d, + soh60d, + soh90d, + temperature, + riskScore, + chargeEfficiency, + status, + riskFactors, + } +} + +function createSohResponse(devices: FleetUnit[], now: Date) { + if (devices.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 history = Array.from({ length: 12 }, (_, index) => { + const monthOffset = index - 11 + return { + month: formatMonthLabel(addMonths(now, monthOffset)), + value: round1(clamp(avgSoh + Math.abs(monthOffset) * monthlyDrop, avgSoh, 100)), + } + }) + const currentValue = history.at(-1)?.value ?? round1(avgSoh) + const forecast = Array.from({ length: 4 }, (_, index) => ({ + month: formatMonthLabel(addMonths(now, index)), + value: index === 0 ? currentValue : round1(clamp(avgSoh - monthlyDrop * index, 0, 100)), + })) + + 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, count: 0 } + entry.sum += item.soh + entry.count += 1 + acc[key] = entry + return acc + }, {}), + ) +} + +function createSummary(devices: FleetUnit[], now: Date) { + const totalDevices = devices.length + + if (totalDevices === 0) { + return { + totalDevices, + avgSoh: 0, + avgSoh30d: 0, + avgSoh90d: 0, + warningCount: 0, + watchCount: 0, + healthyCount: 0, + batchPerformance: [], + riskFactorCounts: [], + firmwareHealth: [], + updatedAt: formatDateTime(now), + executiveSummary: '当前没有可用于电池健康分析的真实设备记录。', + } + } + + 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 batchPerformance = summarizeBy(devices, (unit) => unit.batch) + .map(([batch, data]) => ({ batch, avgSoh: round1(data.sum / data.count) })) + .sort((a, b) => b.avgSoh - a.avgSoh) + 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) + 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 weakestBatch = batchPerformance.at(-1)?.batch ?? '当前设备' + const weakestFirmware = firmwareHealth.at(-1)?.firmware ?? 'unknown' + + return { + totalDevices, + avgSoh: round1(avgSoh), + avgSoh30d: round1(avgSoh30d), + avgSoh90d: round1(avgSoh90d), + warningCount, + watchCount, + healthyCount, + batchPerformance, + riskFactorCounts, + firmwareHealth, + updatedAt: formatDateTime(now), + executiveSummary: `当前电池健康度总体可控,重点风险集中在 ${weakestBatch} 批次与 ${weakestFirmware} 固件设备。建议优先跟踪低电量、充电状态与未来 90 天 SoH 变化。`, + } +} + +function createEvents(devices: FleetUnit[], now: Date) { + const sortedDevices = devices.slice().sort((a, b) => b.riskScore - a.riskScore) + const first = sortedDevices[0] + + if (!first) return [] + + const second = sortedDevices[1] ?? first + + return [ + { + time: formatEventTime(addHours(now, -2)), + title: `${first.id} 进入重点观察队列`, + detail: `${first.id} 当前 SoH 为 ${first.soh.toFixed(1)}%,综合风险评分 ${first.riskScore}。`, + severity: first.status === '预警' ? '高' : '中', + }, + { + time: formatEventTime(addHours(now, -5)), + title: `${second.batch} 批次健康度趋势更新`, + detail: `${second.batch} 批次未来 90 天预测 SoH 为 ${second.soh90d.toFixed(1)}%。`, + severity: second.status === '健康' ? '低' : '中', + }, + ] satisfies DashboardSnapshot['events'] +} + +function createStrategies(devices: FleetUnit[]) { + if (devices.length === 0) return [] + + const warningDevices = devices.filter((unit) => unit.status === '预警') + const chargingDevices = devices.filter((unit) => unit.riskFactors.includes('充电中')) + const first = devices.slice().sort((a, b) => b.riskScore - a.riskScore)[0] + + return [ + { + name: '预警设备优先维护', + impact: `预计高风险设备减少 ${Math.max(10, warningDevices.length * 12)}%`, + scope: first ? `${first.id} 等 ${Math.max(1, warningDevices.length)} 台设备` : '当前设备', + eta: '48 小时内完成', + }, + { + name: '充电策略复核', + impact: `覆盖 ${Math.max(1, chargingDevices.length)} 台充电中设备`, + scope: '充电中与低电量设备', + eta: '本周完成首轮验证', + }, + ] satisfies DashboardSnapshot['strategies'] +} + +export function createDashboardSnapshot(items: BatteryInfo[], now = new Date()): DashboardSnapshot { + const devices = items.map(toFleetUnit) + + return { + devices, + soh: createSohResponse(devices, now), + events: createEvents(devices, now), + strategies: createStrategies(devices), + summary: createSummary(devices, now), + } +}