From a11fbc6efd3c06863395fea0c96f7b652652b908 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 30 Jun 2026 21:25:14 +0700 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D0=B7=D0=B0=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D1=8C=20=D0=B3=D1=80=D0=B0=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=20=D1=80=D0=B0=D1=81=D1=85=D0=BE=D0=B4=D0=BE=D0=B2=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=B0=D1=88=D0=B1=D0=BE=D1=80=D0=B4=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Учитывать списания из журнала баланса и не-пополнения; при отсутствии истории показывать оценку по активным VPS. Год по умолчанию — текущий. Co-authored-by: Cursor --- apps/web/src/components/domain/charts.tsx | 111 +++++++++++-- apps/web/src/lib/chart-analytics.test.ts | 82 +++++++++ apps/web/src/lib/chart-analytics.ts | 192 +++++++++++++++++++--- apps/web/src/routes/_auth/dashboard.tsx | 3 + 4 files changed, 354 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/lib/chart-analytics.test.ts diff --git a/apps/web/src/components/domain/charts.tsx b/apps/web/src/components/domain/charts.tsx index d2c21ac..814cdd7 100644 --- a/apps/web/src/components/domain/charts.tsx +++ b/apps/web/src/components/domain/charts.tsx @@ -30,11 +30,13 @@ import { useMemo, useState } from 'react' import { SelectField } from '@/components/select-field' import { aggregatePaymentsByMonthYear, + aggregateDashboardExpensesByMonthYear, availablePaymentYears, + availableExpenseYears, type PaymentChartFilter, } from '@/lib/chart-analytics' -import type { Vps, Provider, Payment, Settings, RatesData, ServerProject } from '@/types/entities' +import type { Vps, Provider, Payment, Settings, RatesData, ServerProject, BalanceLedgerRow } from '@/types/entities' import { canonicalPaymentType, convertCurrency, @@ -216,9 +218,10 @@ function DashboardMonthlyBarChart({ }) { const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase() const years = useMemo(() => availablePaymentYears(payments), [payments]) - const [year, setYear] = useState(() => years[0] ?? new Date().getFullYear()) + const currentYear = new Date().getFullYear() + const [year, setYear] = useState(currentYear) - const effectiveYear = years.includes(year) ? year : (years[0] ?? year) + const effectiveYear = years.includes(year) ? year : currentYear const data = useMemo( () => aggregatePaymentsByMonthYear(payments, effectiveYear, settings, ratesData, paymentFilter), @@ -302,20 +305,106 @@ export function DashboardPaymentsChart(props: { ) } -export function DashboardExpensesChart(props: { +export function DashboardExpensesChart({ + payments, + balanceLedger, + vps, + providers, + settings, + ratesData, + className, +}: { payments: Payment[] + balanceLedger: BalanceLedgerRow[] + vps: Vps[] + providers: Provider[] settings: Settings[] ratesData: RatesData | null className?: string }) { + const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase() + const years = useMemo( + () => availableExpenseYears(payments, balanceLedger), + [payments, balanceLedger], + ) + const currentYear = new Date().getFullYear() + const [year, setYear] = useState(currentYear) + + const effectiveYear = years.includes(year) ? year : currentYear + + const { rows: data, mode } = useMemo( + () => + aggregateDashboardExpensesByMonthYear( + payments, + balanceLedger, + vps, + providers, + effectiveYear, + settings, + ratesData, + ), + [payments, balanceLedger, vps, providers, effectiveYear, settings, ratesData], + ) + + const chartConfig: ChartConfig = useMemo( + () => ({ + amount: { label: 'Расходы', color: 'var(--chart-1)' }, + }), + [], + ) + + const hasData = data.some((row) => row.amount > 0) + const description = + mode === 'estimate' ? 'Оценка по активным VPS за текущий год' : 'Последние 12 мес' + return ( - + + + Расходы + {description} + +
+ + { + if (v) setYear(Number(v)) + }} + options={years.map((y) => ({ value: String(y), label: String(y) }))} + /> +
+
+
+ + {!hasData ? ( + + ) : ( + + + + + + formatCurrency(Number(v), baseCurrency)} /> + } + /> + + + + )} + +
) } diff --git a/apps/web/src/lib/chart-analytics.test.ts b/apps/web/src/lib/chart-analytics.test.ts new file mode 100644 index 0000000..d11b163 --- /dev/null +++ b/apps/web/src/lib/chart-analytics.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' + +import { + aggregateDashboardExpensesByMonthYear, + aggregatePaymentsByMonthYear, + isExpensePayment, +} from './chart-analytics' +import type { Payment, Settings, Vps } from '@/types/entities' + +const settings: Settings[] = [{ id: 's1', baseCurrency: 'RUB' }] + +const activeVps: Vps = { + id: 'v1', + ip: '1.2.3.4', + providerId: 'pr1', + providerAccountId: 'a1', + vcpu: 1, + ramGb: 1, + diskGb: 10, + status: 'active', + tariffType: 'monthly', + currency: 'RUB', + dailyRate: null, + monthlyRate: 1500, + createdAt: '2026-01-01', +} + +describe('chart-analytics', () => { + it('treats topups as income, not expense', () => { + expect(isExpensePayment('provider_balance_topup')).toBe(false) + expect(isExpensePayment('direct_vps_payment')).toBe(true) + }) + + it('aggregates topups only in payments chart', () => { + const payments: Payment[] = [ + { + id: 'p1', + type: 'provider_balance_topup', + date: '2026-03-15', + amount: 1000, + currency: 'RUB', + providerAccountId: 'a1', + vpsId: null, + note: '', + }, + ] + + const all = aggregatePaymentsByMonthYear(payments, 2026, settings, null, 'all') + const expenseOnly = aggregatePaymentsByMonthYear(payments, 2026, settings, null, 'expense') + + expect(all[2]?.amount).toBe(1000) + expect(expenseOnly[2]?.amount).toBe(0) + }) + + it('falls back to VPS burn estimate when no expense records exist', () => { + const payments: Payment[] = [ + { + id: 'p1', + type: 'provider_balance_topup', + date: '2026-03-15', + amount: 1000, + currency: 'RUB', + providerAccountId: 'a1', + vpsId: null, + note: '', + }, + ] + + const { rows, mode } = aggregateDashboardExpensesByMonthYear( + payments, + [], + [activeVps], + [], + 2026, + settings, + null, + ) + + expect(mode).toBe('estimate') + expect(rows[2]?.amount).toBe(1500) + }) +}) diff --git a/apps/web/src/lib/chart-analytics.ts b/apps/web/src/lib/chart-analytics.ts index d026b41..ec318e3 100644 --- a/apps/web/src/lib/chart-analytics.ts +++ b/apps/web/src/lib/chart-analytics.ts @@ -1,11 +1,21 @@ -import type { Payment, Settings, RatesData } from '@/types/entities' -import { canonicalPaymentType, convertCurrency, monthKey, toIsoCurrency } from '@/lib/format' +import type { + Payment, + Settings, + RatesData, + Vps, + Provider, + BalanceLedgerRow, +} from '@/types/entities' +import { + canonicalPaymentType, + convertCurrency, + convertVpsMonthlyBurnToBase, + monthKey, + toIsoCurrency, +} from '@/lib/format' +import { providerByIdMap } from '@/lib/billmanager' -export const EXPENSE_PAYMENT_TYPES = new Set([ - 'direct_vps_payment', - 'daily_debit', - 'monthly_debit', -]) +const INCOME_PAYMENT_TYPES = new Set(['provider_balance_topup']) const MONTH_SHORT_RU = [ 'янв', @@ -24,38 +34,35 @@ const MONTH_SHORT_RU = [ export type PaymentChartFilter = 'all' | 'expense' +export type DashboardExpenseChartMode = 'actual' | 'estimate' + export function formatMonthShortRu(monthIndex: number): string { return MONTH_SHORT_RU[monthIndex] ?? '' } export function isExpensePayment(type: string): boolean { - return EXPENSE_PAYMENT_TYPES.has(canonicalPaymentType(type)) + return !INCOME_PAYMENT_TYPES.has(canonicalPaymentType(type)) } -export function availablePaymentYears(payments: Payment[]): number[] { - const years = new Set() - for (const p of payments) { - const key = monthKey(p.date) - if (!key) continue - const year = Number(key.slice(0, 4)) - if (Number.isFinite(year)) years.add(year) - } - years.add(new Date().getFullYear()) - return Array.from(years).sort((a, b) => b - a) +function yearFromDateString(dateString: string): number | null { + const key = monthKey(dateString) + if (!key) return null + const year = Number(key.slice(0, 4)) + return Number.isFinite(year) ? year : null } -export function aggregatePaymentsByMonthYear( +function monthBucketsFromPayments( payments: Payment[], year: number, settings: Settings[], ratesData: RatesData | null, - filter: PaymentChartFilter = 'all', -): { month: string; amount: number }[] { + includePayment: (type: string) => boolean, +): number[] { const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase() const byMonth = Array.from({ length: 12 }, () => 0) for (const p of payments) { - if (filter === 'expense' && !isExpensePayment(p.type)) continue + if (!includePayment(p.type)) continue const date = new Date(p.date) if (Number.isNaN(date.getTime()) || date.getFullYear() !== year) continue const converted = convertCurrency( @@ -67,8 +74,147 @@ export function aggregatePaymentsByMonthYear( byMonth[date.getMonth()]! += converted } - return byMonth.map((amount, index) => ({ + return byMonth +} + +function monthBucketsFromLedgerDebits( + balanceLedger: BalanceLedgerRow[], + year: number, + settings: Settings[], + ratesData: RatesData | null, +): number[] { + const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase() + const byMonth = Array.from({ length: 12 }, () => 0) + + for (const row of balanceLedger) { + if (row.direction !== 'debit') continue + const date = new Date(row.date) + if (Number.isNaN(date.getTime()) || date.getFullYear() !== year) continue + const converted = convertCurrency( + Number(row.amount), + toIsoCurrency(row.currency ?? baseCurrency), + baseCurrency, + ratesData, + ) + byMonth[date.getMonth()]! += converted + } + + return byMonth +} + +function mergeMonthBuckets(...sources: number[][]): number[] { + return Array.from({ length: 12 }, (_, index) => + sources.reduce((sum, buckets) => sum + (buckets[index] ?? 0), 0), + ) +} + +function bucketsToRows(buckets: number[]): { month: string; amount: number }[] { + return buckets.map((amount, index) => ({ month: formatMonthShortRu(index), amount: Math.round(amount), })) } + +export function availablePaymentYears(payments: Payment[]): number[] { + const years = new Set() + for (const p of payments) { + const year = yearFromDateString(p.date) + if (year != null) years.add(year) + } + years.add(new Date().getFullYear()) + return Array.from(years).sort((a, b) => b - a) +} + +export function availableExpenseYears( + payments: Payment[], + balanceLedger: BalanceLedgerRow[], +): number[] { + const years = new Set() + + for (const p of payments) { + if (!isExpensePayment(p.type)) continue + const year = yearFromDateString(p.date) + if (year != null) years.add(year) + } + + for (const row of balanceLedger) { + if (row.direction !== 'debit') continue + const year = yearFromDateString(row.date) + if (year != null) years.add(year) + } + + years.add(new Date().getFullYear()) + return Array.from(years).sort((a, b) => b - a) +} + +export function aggregatePaymentsByMonthYear( + payments: Payment[], + year: number, + settings: Settings[], + ratesData: RatesData | null, + filter: PaymentChartFilter = 'all', +): { month: string; amount: number }[] { + const includePayment = filter === 'expense' ? isExpensePayment : () => true + return bucketsToRows( + monthBucketsFromPayments(payments, year, settings, ratesData, includePayment), + ) +} + +export function aggregateEstimatedVpsBurnByMonthYear( + vps: Vps[], + providers: Provider[], + year: number, + settings: Settings[], + ratesData: RatesData | null, +): { month: string; amount: number }[] { + const providerById = providerByIdMap(providers) + const monthlyBurn = vps + .filter((item) => item.status === 'active') + .reduce( + (sum, item) => + sum + + convertVpsMonthlyBurnToBase(item, providerById.get(item.providerId), settings, ratesData), + 0, + ) + + const now = new Date() + const rounded = Math.round(monthlyBurn) + + return Array.from({ length: 12 }, (_, index) => ({ + month: formatMonthShortRu(index), + amount: year === now.getFullYear() && index <= now.getMonth() ? rounded : 0, + })) +} + +export function aggregateDashboardExpensesByMonthYear( + payments: Payment[], + balanceLedger: BalanceLedgerRow[], + vps: Vps[], + providers: Provider[], + year: number, + settings: Settings[], + ratesData: RatesData | null, +): { rows: { month: string; amount: number }[]; mode: DashboardExpenseChartMode } { + const combined = mergeMonthBuckets( + monthBucketsFromPayments(payments, year, settings, ratesData, isExpensePayment), + monthBucketsFromLedgerDebits(balanceLedger, year, settings, ratesData), + ) + const rows = bucketsToRows(combined) + + if (rows.some((row) => row.amount > 0)) { + return { rows, mode: 'actual' } + } + + const estimated = aggregateEstimatedVpsBurnByMonthYear( + vps, + providers, + year, + settings, + ratesData, + ) + if (estimated.some((row) => row.amount > 0)) { + return { rows: estimated, mode: 'estimate' } + } + + return { rows, mode: 'actual' } +} diff --git a/apps/web/src/routes/_auth/dashboard.tsx b/apps/web/src/routes/_auth/dashboard.tsx index fa783de..3e71014 100644 --- a/apps/web/src/routes/_auth/dashboard.tsx +++ b/apps/web/src/routes/_auth/dashboard.tsx @@ -298,6 +298,9 @@ function DashboardPage() { />