feat(domain): 新增电池领域模型与聚合逻辑
This commit is contained in:
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<typeof powerStatusSchema>
|
||||
|
||||
export const deviceStatusSchema = z.union([z.literal('健康'), z.literal('关注'), z.literal('预警')])
|
||||
export type DeviceStatus = z.infer<typeof deviceStatusSchema>
|
||||
|
||||
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(),
|
||||
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<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('高'), 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<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),
|
||||
})
|
||||
export type BatteriesResponse = z.infer<typeof batteriesResponseSchema>
|
||||
|
||||
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<T extends string>(items: FleetUnit[], getKey: (item: FleetUnit) => T) {
|
||||
return Object.entries(
|
||||
items.reduce<Record<string, { sum: number; count: number }>>((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<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 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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user