From 29e70fea9a2b9586b0d34087176cd488d1982c11 Mon Sep 17 00:00:00 2001 From: imbytecat Date: Mon, 11 May 2026 22:39:05 +0800 Subject: [PATCH] =?UTF-8?q?refactor(domain):=20=E6=A0=87=E6=B3=A8=20SoH=20?= =?UTF-8?q?=E6=9D=A5=E6=BA=90=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/domain/battery.test.ts | 6 +++++- src/domain/battery.ts | 43 +++++++++++++++++++++++++------------- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/src/domain/battery.test.ts b/src/domain/battery.test.ts index 7258f53..2a706e0 100644 --- a/src/domain/battery.test.ts +++ b/src/domain/battery.test.ts @@ -44,14 +44,17 @@ describe('battery domain', () => { expect(response.total).toBe(items.length) expect(response.lowPower).toBe(1) expect(response.charging).toBe(1) + expect(response.nextCursor).toBeNull() expect(response.items[0]?.createTime).toBe('2026-05-10T23:00:00.000Z') }) - test('creates old dashboard aggregate shape from deterministic records', () => { + test('creates dashboard aggregate shape without using power as fake SOH', () => { const now = new Date('2026-05-11T00:00:00.000Z') const snapshot = createDashboardSnapshot(rows.map(toBatteryInfo), now) 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.summary.totalDevices).toBe(snapshot.devices.length) @@ -87,6 +90,7 @@ describe('battery domain', () => { const predicted = snapshot.devices.find((device) => device.id === 'RING-A03') expect(predicted?.soh).toBe(60) + expect(predicted?.sohSource).toBe('prediction') expect(predicted?.soh30d).toBe(58) expect(predicted?.soh90d).toBe(52) expect(predicted?.status).toBe('预警') diff --git a/src/domain/battery.ts b/src/domain/battery.ts index 8b4b80e..b03943d 100644 --- a/src/domain/battery.ts +++ b/src/domain/battery.ts @@ -26,6 +26,7 @@ export const fleetUnitSchema = z.object({ firmware: z.string(), cycles: z.number().int(), soh: z.number(), + sohSource: z.union([z.literal('prediction'), z.literal('unavailable')]), soh30d: z.number(), soh60d: z.number(), soh90d: z.number(), @@ -87,9 +88,16 @@ export const batteriesResponseSchema = z.object({ 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 @@ -194,39 +202,43 @@ export function toBatteryInfo(row: BatteryInfoSourceRow): BatteryInfo { } } -export function createBatteriesResponse(items: BatteryInfo[], now = new Date()): BatteriesResponse { +export function createBatteriesResponse( + items: BatteryInfo[], + now = new Date(), + summary: BatteriesPageSummary = {}, + nextCursor: string | null = null, +): BatteriesResponse { return { updatedAt: now.toISOString(), - total: items.length, - lowPower: items.filter((item) => item.isLowPower).length, - charging: items.filter((item) => item.powerStatus === 1).length, + total: summary.total ?? items.length, + lowPower: summary.lowPower ?? items.filter((item) => item.isLowPower).length, + charging: summary.charging ?? items.filter((item) => item.powerStatus === 1).length, items, + nextCursor, } } function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPrediction): FleetUnit { - const soh = prediction?.nowSoh ?? item.power - const status = prediction ? getDeviceStatusByRisk(prediction) : getDeviceStatus(soh) + 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 riskFactors: string[] = [] if (item.isLowPower || item.power <= 20) riskFactors.push('低电量') if (item.powerStatus === 1) riskFactors.push('充电中') - if (status === '预警') riskFactors.push('衰减加速') + if (!hasPrediction) riskFactors.push('SoH预测不可用') + if (prediction && status === '预警') 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)) - : round1(clamp(soh - 0.8 - thermalPressure * 0.25, 0, 100)) - const soh90d = prediction - ? round1(clamp(prediction.trmonthSoh, 0, 100)) - : round1(clamp(soh - 2.8 - thermalPressure * 0.45, 0, 100)) - const soh60d = prediction ? round1((soh30d + soh90d) / 2) : round1(clamp(soh - 1.7 - thermalPressure * 0.35, 0, 100)) + 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 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(prediction?.riskScore ?? 12 + (100 - soh) * 1.45 + riskFactors.length * 8 + thermalPressure * 4, 8, 96), + clamp(prediction?.riskScore ?? 18 + riskFactors.length * 10 + thermalPressure * 4 + (item.isLowPower ? 18 : 0), 8, 96), ) return { @@ -235,6 +247,7 @@ function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPredi firmware: prediction?.modelName ?? item.remark ?? 'unknown', cycles: 120 + index * 17 + Math.round((100 - soh) * 2.2), soh, + sohSource: prediction ? 'prediction' : 'unavailable', soh30d, soh60d, soh90d,