From 71d0091457718e638cfa1b8c2dfef4d78d145e5e Mon Sep 17 00:00:00 2001 From: kkfluous Date: Fri, 7 Aug 2026 14:41:29 +0800 Subject: [PATCH] refactor: add hydrogen station drilldown --- src/modules/energy/HydrogenDaily.tsx | 120 ++++++++++++++++-- src/modules/energy/HydrogenOverview.tsx | 35 ++++- src/modules/energy/api.ts | 2 + .../energy/hydrogen-drill-context.test.ts | 58 +++++++++ src/modules/energy/hydrogen-drill-context.ts | 104 +++++++++++++++ src/modules/energy/types.ts | 3 + src/server/routes/energy/index.ts | 31 +++-- src/server/routes/energy/query.test.ts | 21 +++ src/server/routes/energy/query.ts | 12 ++ 9 files changed, 361 insertions(+), 25 deletions(-) create mode 100644 src/modules/energy/hydrogen-drill-context.test.ts create mode 100644 src/modules/energy/hydrogen-drill-context.ts create mode 100644 src/server/routes/energy/query.test.ts create mode 100644 src/server/routes/energy/query.ts diff --git a/src/modules/energy/HydrogenDaily.tsx b/src/modules/energy/HydrogenDaily.tsx index 11d0fac..fdc3c53 100644 --- a/src/modules/energy/HydrogenDaily.tsx +++ b/src/modules/energy/HydrogenDaily.tsx @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useState } from 'react'; -import { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Building2, ChevronRight, Fuel, Plug, TrendingUp, Truck, X } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts'; import TrendBadge from './TrendBadge'; @@ -7,6 +7,11 @@ import { fetchHydrogenDaily } from './api'; import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types'; import RotatingFooterHint from '../../components/RotatingFooterHint'; import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface'; +import { + buildHydrogenDrillUrl, + parseHydrogenDrillContext, + type HydrogenDrillContext, +} from './hydrogen-drill-context'; const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [ { id: 'thisWeek', label: '本周' }, @@ -44,35 +49,54 @@ function normalizeRange(start: string, end: string): { start: string; end: strin } export default function HydrogenDaily() { - const [pick, setPick] = useState('last15'); - const [dateRange, setDateRange] = useState(() => getQuickRange('last15')); - const [customer, setCustomer] = useState('lingniu'); + const [drillContext, setDrillContext] = useState(() => ( + parseHydrogenDrillContext(window.location.search) + )); + const [pick, setPick] = useState(() => ( + drillContext.startDate && drillContext.endDate ? 'custom' : 'last15' + )); + const [dateRange, setDateRange] = useState(() => ( + drillContext.startDate && drillContext.endDate + ? { start: drillContext.startDate, end: drillContext.endDate } + : getQuickRange('last15') + )); + const [customer, setCustomer] = useState(drillContext.vehicleScope); const [expanded, setExpanded] = useState>(new Set()); const [rows, setRows] = useState(null); const [error, setError] = useState(null); const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]); + const selectedStationId = drillContext.level === 'station' ? drillContext.stationId : undefined; + const selectedStationName = drillContext.level === 'station' + ? drillContext.stationName || `站点 #${drillContext.stationId}` + : undefined; + + const commitDrillContext = useCallback((next: HydrogenDrillContext, mode: 'push' | 'replace') => { + const url = buildHydrogenDrillUrl(window.location, next); + window.history[mode === 'push' ? 'pushState' : 'replaceState'](null, '', url); + setDrillContext(next); + }, []); useEffect(() => { let cancelled = false; setError(null); const query = pick === 'custom' - ? { startDate: effectiveRange.start, endDate: effectiveRange.end } - : { range: pick }; + ? { startDate: effectiveRange.start, endDate: effectiveRange.end, stationId: selectedStationId } + : { range: pick, stationId: selectedStationId }; fetchHydrogenDaily(query, customer) .then(r => { if (!cancelled) setRows(r); }) .catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); }); return () => { cancelled = true; }; - }, [pick, customer, effectiveRange.start, effectiveRange.end]); + }, [pick, customer, effectiveRange.start, effectiveRange.end, selectedStationId]); // 柱图:按日期升序,用于"从左到右时间流" const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]); const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0); const activeDays = (rows ?? []).filter(r => r.totalKg > 0).length; const stationCount = useMemo(() => { - const names = new Set(); - (rows ?? []).forEach(r => r.stations.forEach(s => names.add(s.name))); - return names.size; + const ids = new Set(); + (rows ?? []).forEach(r => r.stations.forEach(s => ids.add(s.stationId))); + return ids.size; }, [rows]); const avgKg = activeDays > 0 ? totalKg / activeDays : 0; const scopeLabel = pick === 'custom' @@ -92,16 +116,65 @@ export default function HydrogenDaily() { }); const applyQuickPick = (nextPick: DateQuickPick) => { + const nextRange = getQuickRange(nextPick); setPick(nextPick); - setDateRange(getQuickRange(nextPick)); + setDateRange(nextRange); + commitDrillContext({ + ...drillContext, + vehicleScope: customer, + startDate: nextRange.start, + endDate: nextRange.end, + }, 'replace'); }; const updateDateRange = (field: 'start' | 'end', value: string) => { if (!value) return; + const nextRange = { ...dateRange, [field]: value }; setPick('custom'); - setDateRange(prev => ({ ...prev, [field]: value })); + setDateRange(nextRange); + const normalized = normalizeRange(nextRange.start, nextRange.end); + commitDrillContext({ + ...drillContext, + vehicleScope: customer, + startDate: normalized.start, + endDate: normalized.end, + }, 'replace'); }; + const updateCustomer = (next: CustomerType) => { + setCustomer(next); + commitDrillContext({ + ...drillContext, + vehicleScope: next, + startDate: effectiveRange.start, + endDate: effectiveRange.end, + }, 'replace'); + }; + + const clearStation = () => { + commitDrillContext({ + level: 'overview', + year: drillContext.year, + vehicleScope: customer, + startDate: effectiveRange.start, + endDate: effectiveRange.end, + }, 'replace'); + }; + + useEffect(() => { + const handlePopState = () => { + const next = parseHydrogenDrillContext(window.location.search); + setDrillContext(next); + setCustomer(next.vehicleScope); + if (next.startDate && next.endDate) { + setPick('custom'); + setDateRange({ start: next.startDate, end: next.endDate }); + } + }; + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); + }, []); + return (
@@ -158,7 +231,7 @@ export default function HydrogenDaily() { {(['lingniu', 'external'] as const).map(c => (
+ {selectedStationName && ( +
+ +
+
{selectedStationName}
+
站点每日明细 · {rangeText}
+
+ +
+ )} +
diff --git a/src/modules/energy/HydrogenOverview.tsx b/src/modules/energy/HydrogenOverview.tsx index 80070dc..6157612 100644 --- a/src/modules/energy/HydrogenOverview.tsx +++ b/src/modules/energy/HydrogenOverview.tsx @@ -2,10 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend, } from 'recharts'; -import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw, Gauge, AlertTriangle, Building2 } from 'lucide-react'; +import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw, Gauge, AlertTriangle, Building2, ChevronRight } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api'; import RotatingFooterHint from '../../components/RotatingFooterHint'; +import { buildHydrogenDrillUrl } from './hydrogen-drill-context'; const REGION_COLORS = [ '#3b82f6', '#22d3ee', '#a855f7', '#f59e0b', @@ -155,6 +156,26 @@ export default function HydrogenOverview() { const top5Share = top5.reduce((sum, item) => sum + item.kg, 0) / Math.max(1, k.yearKg) * 100; const profitYield = k.yearRevenue > 0 ? k.yearProfit / k.yearRevenue * 100 : 0; const stationAvgKg = stations.length > 0 ? k.yearKg / stations.length : 0; + const openStation = (station: typeof stations[number]) => { + const now = new Date(); + const endDate = activeYear === now.getFullYear() + ? `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` + : `${activeYear}-12-31`; + const url = buildHydrogenDrillUrl( + { pathname: window.location.pathname, search: window.location.search, hash: '#hydrogen' }, + { + level: 'station', + year: activeYear, + stationId: station.id, + stationName: station.name, + vehicleScope: 'lingniu', + startDate: `${activeYear}-01-01`, + endDate, + }, + ); + window.history.pushState(null, '', url); + window.dispatchEvent(new HashChangeEvent('hashchange')); + }; // 月度收支组合数据(推算"年内每月"图) const monthlyDual = monthly.map(m => ({ @@ -497,7 +518,17 @@ export default function HydrogenOverview() { return ( {i + 1} - {s.name} + + + {kgFmt.value}{kgFmt.unit} diff --git a/src/modules/energy/api.ts b/src/modules/energy/api.ts index 3885ff8..2898103 100644 --- a/src/modules/energy/api.ts +++ b/src/modules/energy/api.ts @@ -31,6 +31,7 @@ export interface HydrogenDailyQuery { range?: DateQuickPick; startDate?: string; endDate?: string; + stationId?: number; } export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise { @@ -38,6 +39,7 @@ export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: Customer if (query.range) q.set('range', query.range); if (query.startDate) q.set('startDate', query.startDate); if (query.endDate) q.set('endDate', query.endDate); + if (query.stationId !== undefined) q.set('stationId', String(query.stationId)); return fetchJson(`${BASE}/hydrogen/daily?${q.toString()}`); } diff --git a/src/modules/energy/hydrogen-drill-context.test.ts b/src/modules/energy/hydrogen-drill-context.test.ts new file mode 100644 index 0000000..810ec1b --- /dev/null +++ b/src/modules/energy/hydrogen-drill-context.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildHydrogenDrillUrl, + parseHydrogenDrillContext, +} from './hydrogen-drill-context.js'; + +test('parses a station drill context including station zero', () => { + assert.deepEqual( + parseHydrogenDrillContext('?hydrogenLevel=station&hydrogenStation=0&hydrogenStationName=%E6%9C%AA%E5%85%B3%E8%81%94%E7%AB%99%E7%82%B9&hydrogenScope=lingniu&hydrogenStart=2026-08-01&hydrogenEnd=2026-08-07'), + { + level: 'station', + stationId: 0, + stationName: '未关联站点', + vehicleScope: 'lingniu', + startDate: '2026-08-01', + endDate: '2026-08-07', + }, + ); +}); + +test('requires a valid entity for station and customer levels', () => { + assert.equal(parseHydrogenDrillContext('?hydrogenLevel=station&hydrogenStation=-1').level, 'overview'); + assert.equal(parseHydrogenDrillContext('?hydrogenLevel=customer').level, 'overview'); +}); + +test('builds a namespaced station URL and preserves unrelated parameters', () => { + const url = buildHydrogenDrillUrl( + { pathname: '/energy', search: '?company=5', hash: '#hydrogen' }, + { + level: 'station', + year: 2026, + stationId: 18, + stationName: '测试加氢站', + vehicleScope: 'lingniu', + startDate: '2026-08-01', + endDate: '2026-08-07', + }, + ); + + assert.equal( + url, + '/energy?company=5&hydrogenLevel=station&hydrogenYear=2026&hydrogenStation=18&hydrogenStationName=%E6%B5%8B%E8%AF%95%E5%8A%A0%E6%B0%A2%E7%AB%99&hydrogenScope=lingniu&hydrogenStart=2026-08-01&hydrogenEnd=2026-08-07#hydrogen', + ); +}); + +test('clears station parameters when returning to overview', () => { + const url = buildHydrogenDrillUrl( + { + pathname: '/energy', + search: '?hydrogenLevel=station&hydrogenStation=18&hydrogenStationName=test&company=5', + hash: '#hydrogen', + }, + { level: 'overview', vehicleScope: 'lingniu' }, + ); + + assert.equal(url, '/energy?company=5&hydrogenScope=lingniu#hydrogen'); +}); diff --git a/src/modules/energy/hydrogen-drill-context.ts b/src/modules/energy/hydrogen-drill-context.ts new file mode 100644 index 0000000..0afcf25 --- /dev/null +++ b/src/modules/energy/hydrogen-drill-context.ts @@ -0,0 +1,104 @@ +import type { CustomerType } from './types'; + +export type HydrogenDrillLevel = 'overview' | 'station' | 'customer'; + +export interface HydrogenDrillContext { + level: HydrogenDrillLevel; + year?: number; + stationId?: number; + stationName?: string; + customerName?: string; + vehicleScope: CustomerType; + startDate?: string; + endDate?: string; +} + +interface LocationParts { + pathname: string; + search: string; + hash: string; +} + +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +function validDate(value: string | null): string | undefined { + if (!value || !DATE_PATTERN.test(value)) return undefined; + const timestamp = Date.parse(`${value}T00:00:00+08:00`); + return Number.isFinite(timestamp) ? value : undefined; +} + +function positiveInteger(value: string | null): number | undefined { + if (!value || !/^\d+$/.test(value)) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; +} + +function stationId(value: string | null): number | undefined { + if (!value || !/^\d+$/.test(value)) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +export function parseHydrogenDrillContext(search: string): HydrogenDrillContext { + const params = new URLSearchParams(search); + const requestedLevel = params.get('hydrogenLevel'); + const parsedStationId = stationId(params.get('hydrogenStation')); + const parsedStationName = params.get('hydrogenStationName')?.trim() || undefined; + const customerName = params.get('hydrogenCustomer')?.trim() || undefined; + const context: HydrogenDrillContext = { + level: requestedLevel === 'station' && parsedStationId !== undefined + ? 'station' + : requestedLevel === 'customer' && customerName + ? 'customer' + : 'overview', + vehicleScope: params.get('hydrogenScope') === 'external' ? 'external' : 'lingniu', + }; + + const year = positiveInteger(params.get('hydrogenYear')); + if (year && year >= 2000 && year <= 2100) context.year = year; + if (context.level === 'station') { + context.stationId = parsedStationId; + if (parsedStationName) context.stationName = parsedStationName; + } + if (context.level === 'customer') context.customerName = customerName; + const startDate = validDate(params.get('hydrogenStart')); + const endDate = validDate(params.get('hydrogenEnd')); + if (startDate) context.startDate = startDate; + if (endDate) context.endDate = endDate; + return context; +} + +export function buildHydrogenDrillUrl( + location: LocationParts, + context: HydrogenDrillContext, +): string { + const params = new URLSearchParams(location.search); + for (const key of [ + 'hydrogenLevel', + 'hydrogenYear', + 'hydrogenStation', + 'hydrogenStationName', + 'hydrogenCustomer', + 'hydrogenScope', + 'hydrogenStart', + 'hydrogenEnd', + ]) { + params.delete(key); + } + + if (context.level !== 'overview') params.set('hydrogenLevel', context.level); + if (context.year) params.set('hydrogenYear', String(context.year)); + if (context.level === 'station' && context.stationId !== undefined) { + params.set('hydrogenStation', String(context.stationId)); + if (context.stationName) params.set('hydrogenStationName', context.stationName); + } + if (context.level === 'customer' && context.customerName) { + params.set('hydrogenCustomer', context.customerName); + } + params.set('hydrogenScope', context.vehicleScope); + if (context.startDate) params.set('hydrogenStart', context.startDate); + if (context.endDate) params.set('hydrogenEnd', context.endDate); + + const query = params.toString(); + return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`; +} diff --git a/src/modules/energy/types.ts b/src/modules/energy/types.ts index a1c27c2..99a2342 100644 --- a/src/modules/energy/types.ts +++ b/src/modules/energy/types.ts @@ -22,6 +22,7 @@ export interface HydrogenKpi { } export interface HydrogenStationTop { + id: number; rank: number; name: string; kg: number; @@ -52,6 +53,7 @@ export interface HydrogenCustomerRow { } export interface HydrogenStationFull { + id: number; name: string; kg: number; revenue: number; @@ -60,6 +62,7 @@ export interface HydrogenStationFull { } export interface HydrogenStationRow { + stationId: number; name: string; pricePerKg: number; kg: number; diff --git a/src/server/routes/energy/index.ts b/src/server/routes/energy/index.ts index 976f0c4..4b37b18 100644 --- a/src/server/routes/energy/index.ts +++ b/src/server/routes/energy/index.ts @@ -3,6 +3,11 @@ import type { RowDataPacket } from 'mysql2'; import pool from '../../db.js'; import hydrogenPool from '../../hydrogen-db.js'; import { cached } from './cache.js'; +import { + parseHydrogenCustomerKind, + parseHydrogenStationId, + type HydrogenCustomerKind, +} from './query.js'; import type { AuthUser } from '../../auth/types.js'; import { canAccessEnergy } from '../../auth/types.js'; @@ -27,10 +32,8 @@ const HYDROGEN_BASE_WHERE = `del_flag = '0'`; const HYDROGEN_BASE_WHERE_B = `b.del_flag = '0'`; const ELECTRIC_LOCAL = `charging_start_time`; -type CustomerKind = 'external' | 'lingniu' | 'all'; - // 新账本 hydrogen_fuel_ledger 当前只承载羚牛车辆订单;外部车辆数据源待接入。 -function customerClause(customer: CustomerKind): string { +function customerClause(customer: HydrogenCustomerKind): string { if (customer === 'external') return '1=0'; if (customer === 'lingniu') return '1=1'; return '1=1'; @@ -229,6 +232,7 @@ app.get('/hydrogen/overview', async (c) => { ); const top5KgSum = kpi.yearKg || 1; const top5 = top5Rows.map((r, i) => ({ + id: Number(r.id) || 0, rank: i + 1, name: r.name as string, kg: Number(r.kg) || 0, @@ -256,6 +260,7 @@ app.get('/hydrogen/overview', async (c) => { const stationKgSum = stationFullRows.reduce((s, r) => s + (Number(r.kg) || 0), 0) || 1; const stationRevSum = stationFullRows.reduce((s, r) => s + (Number(r.revenue) || 0), 0) || 1; const stations = stationFullRows.map(r => ({ + id: Number(r.id) || 0, name: r.name as string, kg: Number(r.kg) || 0, revenue: Number(r.revenue) || 0, @@ -370,17 +375,24 @@ app.get('/hydrogen/overview', async (c) => { app.get('/hydrogen/daily', async (c) => { const range = (c.req.query('range') || 'last15') as Range; const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate')); - const customer = (c.req.query('customer') || 'external') as CustomerKind; + const customer = parseHydrogenCustomerKind(c.req.query('customer')); + const stationId = parseHydrogenStationId(c.req.query('stationId')); const force = c.req.query('force') === '1'; - const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}`, async () => { + const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}&station=${stationId ?? 'all'}`, async () => { - const where = [ + const whereParts = [ HYDROGEN_BASE_WHERE_B, `b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`, dateRangeClause(`b.${HYDROGEN_LOCAL}`), customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'), - ].join(' AND '); + ]; + const whereParams: Array = [dateRange.start, dateRange.end]; + if (stationId !== null) { + whereParts.push('COALESCE(b.station_id, 0) = ?'); + whereParams.push(stationId); + } + const where = whereParts.join(' AND '); // 站点级聚合(每日 × 每站)。前端组装成 day → stations // 站点名 fallback:站点主数据 → 账本冗余站点名 → 未关联站点 @@ -399,7 +411,7 @@ app.get('/hydrogen/daily', async (c) => { WHERE ${where} GROUP BY d, COALESCE(b.station_id, 0) ORDER BY d DESC, kg DESC`, - [dateRange.start, dateRange.end], + whereParams, ); // 站点环比:同站点上一条记录的 kg @@ -459,6 +471,7 @@ app.get('/hydrogen/daily', async (c) => { customerType: customer, stations: info ? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({ + stationId: s.stationId, name: s.name, pricePerKg: Math.round(s.pricePerKg * 100) / 100, kg: Math.round(s.kg * 100) / 100, @@ -571,7 +584,7 @@ app.get('/electric/overview', async (c) => { // 缺失日期补零 // ========================================================= app.get('/electric/monthly', async (c) => { - const customer = (c.req.query('customer') || 'lingniu') as CustomerKind; + const customer = parseHydrogenCustomerKind(c.req.query('customer')); const range = (c.req.query('range') || 'last15') as Range; const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate')); const force = c.req.query('force') === '1'; diff --git a/src/server/routes/energy/query.test.ts b/src/server/routes/energy/query.test.ts new file mode 100644 index 0000000..1618bd9 --- /dev/null +++ b/src/server/routes/energy/query.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + parseHydrogenCustomerKind, + parseHydrogenStationId, +} from './query.js'; + +test('parses only non-negative integer station ids', () => { + assert.equal(parseHydrogenStationId('0'), 0); + assert.equal(parseHydrogenStationId('18'), 18); + assert.equal(parseHydrogenStationId('-1'), null); + assert.equal(parseHydrogenStationId('1 OR 1=1'), null); + assert.equal(parseHydrogenStationId(undefined), null); +}); + +test('defaults unknown customer scopes to lingniu', () => { + assert.equal(parseHydrogenCustomerKind('external'), 'external'); + assert.equal(parseHydrogenCustomerKind('all'), 'all'); + assert.equal(parseHydrogenCustomerKind('invalid'), 'lingniu'); + assert.equal(parseHydrogenCustomerKind(undefined), 'lingniu'); +}); diff --git a/src/server/routes/energy/query.ts b/src/server/routes/energy/query.ts new file mode 100644 index 0000000..5f7f342 --- /dev/null +++ b/src/server/routes/energy/query.ts @@ -0,0 +1,12 @@ +export type HydrogenCustomerKind = 'external' | 'lingniu' | 'all'; + +export function parseHydrogenCustomerKind(value: string | undefined): HydrogenCustomerKind { + if (value === 'external' || value === 'all') return value; + return 'lingniu'; +} + +export function parseHydrogenStationId(value: string | undefined): number | null { + if (value == null || !/^\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null; +}