refactor(repo): переход на pnpm monorepo с shadcn/ui и Fastify+Drizzle
Frontend:
- apps/web (Vite+TS, TanStack Router/Query, shadcn/ui @cfdm/ui base-nova)
- 10 страниц в routes/_auth/, Recharts через shadcn Chart, lucide-react
- формы на RHF + Zod (FormSheet/FormField)
- удалены Tabler, Chart.js, react-router-dom
Backend (параллельный трек):
- apps/api (Fastify 5 + Drizzle + better-sqlite3)
- packages/db: Drizzle-схема и repositories по сущностям
- packages/shared: Zod-контракты
- роуты с валидацией и единым форматом ошибок { error: { code, message } }
- sync/backup — заглушки 501 (billmanager-адаптеры переносятся отдельно)
- legacy Express оставлен как runtime по умолчанию (RUNTIME=express)
Infra:
- Dockerfile multi-stage под pnpm workspaces
- .dockerignore и docker-compose обновлены под monorepo
Rules:
- удалены нерелевантные правила (rust, cloudflare, server/frontend-conventions)
- project-structure.mdc и AGENTS.md переписаны под monorepo
- frontend-shadcn.mdc, shadcn-ui-production.mdc, sqlite.mdc обновлены
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
import type {
|
||||
DataSnapshot,
|
||||
RatesData,
|
||||
Settings,
|
||||
Vps,
|
||||
Provider,
|
||||
ProviderAccount,
|
||||
Payment,
|
||||
BalanceLedgerRow,
|
||||
} from '@/types/entities'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? ''
|
||||
|
||||
export class ApiError extends Error {
|
||||
status?: number
|
||||
constructor(message: string, status?: number) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `${API_BASE}${path.startsWith('/') ? path : `/${path}`}`
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
...options,
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = res.statusText || 'API error'
|
||||
try {
|
||||
const data = (await res.json()) as { error?: string }
|
||||
if (data?.error) message = data.error
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new ApiError(message, res.status)
|
||||
}
|
||||
if (res.status === 204) return null as T
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
export type CollectionName =
|
||||
| 'vps'
|
||||
| 'providers'
|
||||
| 'providerAccounts'
|
||||
| 'payments'
|
||||
| 'balanceLedger'
|
||||
| 'settings'
|
||||
|
||||
const COLLECTION_PATHS: Record<CollectionName, string> = {
|
||||
vps: '/api/vps',
|
||||
providers: '/api/providers',
|
||||
providerAccounts: '/api/provider-accounts',
|
||||
payments: '/api/payments',
|
||||
balanceLedger: '/api/balance-ledger',
|
||||
settings: '/api/settings',
|
||||
}
|
||||
|
||||
function uid(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
|
||||
return `id-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
}
|
||||
|
||||
export const api = {
|
||||
fetchData: () => fetchApi<DataSnapshot>('/api/data'),
|
||||
fetchCollection: <T>(name: CollectionName) => fetchApi<T[]>(COLLECTION_PATHS[name]),
|
||||
|
||||
create: <T extends { id?: string }>(name: CollectionName, record: T) =>
|
||||
fetchApi<T[]>(COLLECTION_PATHS[name], {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...record, id: record.id || uid() }),
|
||||
}),
|
||||
|
||||
update: <T>(name: CollectionName, id: string, patch: Partial<T>) =>
|
||||
fetchApi<T[]>(`${COLLECTION_PATHS[name]}/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(patch),
|
||||
}),
|
||||
|
||||
remove: <T>(name: CollectionName, id: string) =>
|
||||
fetchApi<T[]>(`${COLLECTION_PATHS[name]}/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
|
||||
bulkUpdateVps: (ids: string[], action: string, value: unknown) =>
|
||||
fetchApi('/api/vps/bulk', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ ids, action, value }),
|
||||
}),
|
||||
|
||||
syncAccount: (accountId: string, opts: Record<string, unknown> = {}) =>
|
||||
fetchApi(`/api/sync/${encodeURIComponent(accountId)}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(opts),
|
||||
}),
|
||||
|
||||
fetchAccountBalance: (accountId: string) =>
|
||||
fetchApi<{ balance: number; currency: string }>(
|
||||
`/api/sync/${encodeURIComponent(accountId)}/balance`,
|
||||
),
|
||||
|
||||
testConnection: (apiBaseUrl: string, apiCredentials: string) =>
|
||||
fetchApi('/api/sync/test-connection', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ apiBaseUrl, apiCredentials }),
|
||||
}),
|
||||
|
||||
fetchSyncStatus: () => fetchApi('/api/sync/status'),
|
||||
sendTelegramTest: () =>
|
||||
fetchApi('/api/settings/telegram/test', { method: 'POST' }),
|
||||
|
||||
fetchProjectSuggestions: (q = '', limit = 25) => {
|
||||
const params = new URLSearchParams()
|
||||
if (q) params.set('q', q)
|
||||
params.set('limit', String(limit))
|
||||
return fetchApi<string[]>(`/api/projects/suggest?${params.toString()}`)
|
||||
},
|
||||
|
||||
downloadBackupJson: async (): Promise<Blob> => {
|
||||
const res = await fetch(`${API_BASE}/api/backup/json`)
|
||||
if (!res.ok) throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status)
|
||||
return res.blob()
|
||||
},
|
||||
|
||||
downloadBackupDatabase: async (): Promise<Blob> => {
|
||||
const res = await fetch(`${API_BASE}/api/backup/database`)
|
||||
if (!res.ok) throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status)
|
||||
return res.blob()
|
||||
},
|
||||
|
||||
importBackupJson: (payload: unknown) =>
|
||||
fetchApi('/api/backup/json', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
|
||||
importBackupDatabase: async (buffer: ArrayBuffer) => {
|
||||
const res = await fetch(`${API_BASE}/api/backup/database`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: buffer,
|
||||
})
|
||||
if (!res.ok) throw new ApiError(res.statusText || 'Ошибка восстановления', res.status)
|
||||
return res.json()
|
||||
},
|
||||
}
|
||||
|
||||
export type {
|
||||
DataSnapshot,
|
||||
RatesData,
|
||||
Settings,
|
||||
Vps,
|
||||
Provider,
|
||||
ProviderAccount,
|
||||
Payment,
|
||||
BalanceLedgerRow,
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ProviderAccount, Provider } from '@/types/entities'
|
||||
|
||||
export function providerByIdMap(providers: Provider[]): Map<string, Provider> {
|
||||
return new Map(providers.map((p) => [p.id, p]))
|
||||
}
|
||||
|
||||
export function billmanagerSyncableAccounts(
|
||||
providerAccounts: ProviderAccount[],
|
||||
providers: Provider[],
|
||||
): ProviderAccount[] {
|
||||
const pmap = providerByIdMap(providers)
|
||||
return providerAccounts.filter((a) => {
|
||||
const p = pmap.get(a.providerId)
|
||||
return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet
|
||||
})
|
||||
}
|
||||
|
||||
export function accountBillmanagerUiReady(
|
||||
account: ProviderAccount,
|
||||
provider?: Provider | null,
|
||||
): boolean {
|
||||
return (
|
||||
provider?.apiType === 'billmanager' &&
|
||||
Boolean((provider.apiBaseUrl || '').trim()) &&
|
||||
Boolean(account.apiCredentialsSet)
|
||||
)
|
||||
}
|
||||
|
||||
export function accountUsesBillmanagerBalanceApi(
|
||||
account: ProviderAccount,
|
||||
provider?: Provider | null,
|
||||
): boolean {
|
||||
return provider?.apiType === 'billmanager' && account.balance_api != null
|
||||
}
|
||||
|
||||
export function accountSelectLabel(
|
||||
account: ProviderAccount,
|
||||
providerById: Map<string, Provider>,
|
||||
scopedProviderId?: string,
|
||||
): string {
|
||||
const name = account.name?.trim() || '—'
|
||||
if (scopedProviderId) return name
|
||||
const providerName = providerById.get(account.providerId)?.name ?? '—'
|
||||
return `${providerName} / ${name}`
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import type { Settings, RatesData, Vps, Provider } from '@/types/entities'
|
||||
|
||||
export function uid(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
|
||||
return `id-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
}
|
||||
|
||||
export function normalizeWebsiteUrl(website?: string): string {
|
||||
if (!website) return ''
|
||||
if (website.startsWith('http://') || website.startsWith('https://')) return website
|
||||
return `https://${website}`
|
||||
}
|
||||
|
||||
export function faviconUrlFromWebsite(website?: string): string {
|
||||
const normalized = normalizeWebsiteUrl(website)
|
||||
if (!normalized) return ''
|
||||
try {
|
||||
const { hostname } = new URL(normalized)
|
||||
return `https://www.google.com/s2/favicons?domain=${hostname}&sz=32`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const COUNTRY_CODE_BY_NAME: Record<string, string> = {
|
||||
germany: 'DE', netherlands: 'NL', russia: 'RU', usa: 'US', 'united states': 'US',
|
||||
ukraine: 'UA', poland: 'PL', france: 'FR', spain: 'ES', italy: 'IT',
|
||||
estonia: 'EE', finland: 'FI', sweden: 'SE', norway: 'NO', latvia: 'LV',
|
||||
lithuania: 'LT', czechia: 'CZ', czech: 'CZ', singapore: 'SG', japan: 'JP',
|
||||
canada: 'CA', brazil: 'BR', turkey: 'TR', georgia: 'GE', kazakhstan: 'KZ',
|
||||
}
|
||||
|
||||
export function getCountryFlagEmoji(country?: string): string {
|
||||
if (!country) return '🌐'
|
||||
const code = COUNTRY_CODE_BY_NAME[country.trim().toLowerCase()]
|
||||
if (!code) return '🌐'
|
||||
return code.toUpperCase().split('').map((c) => String.fromCodePoint(127397 + c.charCodeAt(0))).join('')
|
||||
}
|
||||
|
||||
const PAYMENT_TYPE_LABELS: Record<string, string> = {
|
||||
direct_vps_payment: 'Прямой платеж за VPS',
|
||||
provider_balance_topup: 'Пополнение баланса хостера',
|
||||
daily_debit: 'Ежедневное списание',
|
||||
monthly_debit: 'Ежемесячное списание',
|
||||
}
|
||||
|
||||
export function paymentTypeLabel(type: string): string {
|
||||
return PAYMENT_TYPE_LABELS[type] ?? type
|
||||
}
|
||||
|
||||
const VPS_STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Активен', paused: 'Приостановлен', archived: 'Архив',
|
||||
}
|
||||
|
||||
export function vpsStatusLabel(status: string): string {
|
||||
return VPS_STATUS_LABELS[status] ?? status
|
||||
}
|
||||
|
||||
const BILLING_MODE_LABELS: Record<string, string> = {
|
||||
daily: 'Ежедневно', monthly: 'Ежемесячно',
|
||||
}
|
||||
|
||||
export function billingModeLabel(mode: string): string {
|
||||
return BILLING_MODE_LABELS[mode] ?? mode
|
||||
}
|
||||
|
||||
const TARIFF_TYPE_LABELS: Record<string, string> = {
|
||||
daily: 'Суточный', monthly: 'Месячный',
|
||||
}
|
||||
|
||||
export function tariffTypeLabel(type: string): string {
|
||||
return TARIFF_TYPE_LABELS[type] ?? type
|
||||
}
|
||||
|
||||
const CURRENCY_SYMBOL_MAP: Record<string, string> = {
|
||||
'€': 'EUR', '$': 'USD', '₽': 'RUB', '£': 'GBP', '¥': 'JPY', '₴': 'UAH', '₸': 'KZT',
|
||||
}
|
||||
|
||||
export function toIsoCurrency(currency?: string | null): string {
|
||||
if (!currency || typeof currency !== 'string') return 'USD'
|
||||
const trimmed = currency.trim()
|
||||
if (CURRENCY_SYMBOL_MAP[trimmed]) return CURRENCY_SYMBOL_MAP[trimmed]
|
||||
const upper = trimmed.toUpperCase()
|
||||
if (upper === 'RUR') return 'RUB'
|
||||
if (trimmed.length === 3 && /^[A-Z]{3}$/i.test(trimmed)) return upper
|
||||
return 'USD'
|
||||
}
|
||||
|
||||
export function effectiveVpsTariffCurrency(vps: Vps, provider?: Provider | null): string {
|
||||
const provRaw = (provider?.baseCurrency || '').trim()
|
||||
if (provRaw) return toIsoCurrency(provRaw)
|
||||
const ownRaw = (vps?.currency || '').trim()
|
||||
if (ownRaw) return toIsoCurrency(ownRaw)
|
||||
return 'RUB'
|
||||
}
|
||||
|
||||
export function formatCurrency(amount: number, currency = 'USD'): string {
|
||||
const safeAmount = Number.isFinite(Number(amount)) ? Number(amount) : 0
|
||||
const isoCurrency = toIsoCurrency(currency)
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency', currency: isoCurrency, minimumFractionDigits: 2,
|
||||
}).format(safeAmount)
|
||||
}
|
||||
|
||||
export function parseProviderFxRate(raw: unknown): number {
|
||||
if (raw == null) return NaN
|
||||
const s = String(raw).trim()
|
||||
if (!s) return NaN
|
||||
if (s.toLowerCase() === 'auto') return NaN
|
||||
const n = Number(s.replace(',', '.'))
|
||||
if (!Number.isFinite(n) || n <= 0) return NaN
|
||||
return n
|
||||
}
|
||||
|
||||
export function normalizeRatesPayload(payload: unknown): RatesData | null {
|
||||
if (!payload || typeof payload !== 'object') return null
|
||||
const p = payload as Record<string, unknown>
|
||||
if (p.base && p.rates && typeof p.rates === 'object') return p as unknown as RatesData
|
||||
const valute = p.Valute as Record<string, { CharCode?: string; Value?: number; Nominal?: number }> | undefined
|
||||
if (valute && typeof valute === 'object') {
|
||||
const rates: Record<string, number> = {}
|
||||
for (const v of Object.values(valute)) {
|
||||
const code = v?.CharCode
|
||||
const val = Number(v?.Value)
|
||||
const nom = Number(v?.Nominal) || 1
|
||||
if (typeof code === 'string' && code.length === 3 && Number.isFinite(val) && val > 0 && nom > 0) {
|
||||
rates[code] = nom / val
|
||||
}
|
||||
}
|
||||
if (Object.keys(rates).length === 0) return null
|
||||
return { base: 'RUB', rates, date: typeof p.Date === 'string' ? p.Date : '' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function convertCurrency(
|
||||
amount: number,
|
||||
fromCurrency: string,
|
||||
toCurrency: string,
|
||||
ratesData: RatesData | null,
|
||||
): number {
|
||||
const safeAmount = Number(amount)
|
||||
if (!Number.isFinite(safeAmount)) return 0
|
||||
const from = toIsoCurrency(fromCurrency)
|
||||
const to = toIsoCurrency(toCurrency)
|
||||
if (!from || !to || from === to) return safeAmount
|
||||
if (!ratesData || !ratesData.rates || !ratesData.base) return safeAmount
|
||||
const apiBase = ratesData.base.toUpperCase()
|
||||
const rates: Record<string, number> = { ...ratesData.rates, [apiBase]: 1 }
|
||||
const rateFrom = rates[from]
|
||||
const rateTo = rates[to]
|
||||
if (!Number.isFinite(rateFrom) || rateFrom <= 0 || !Number.isFinite(rateTo) || rateTo <= 0) {
|
||||
return safeAmount
|
||||
}
|
||||
return (safeAmount / rateFrom) * rateTo
|
||||
}
|
||||
|
||||
export function formatInBaseCurrency(
|
||||
amount: number,
|
||||
currency: string,
|
||||
appSettings: Settings[] | Settings | null | undefined,
|
||||
ratesData: RatesData | null,
|
||||
): string {
|
||||
const settings: Partial<Settings> = Array.isArray(appSettings)
|
||||
? (appSettings[0] ?? {})
|
||||
: (appSettings ?? {})
|
||||
const baseCurrency = settings.baseCurrency || 'RUB'
|
||||
const autoConvert = settings.autoConvert !== false
|
||||
if (!autoConvert) return formatCurrency(amount, currency)
|
||||
const converted = convertCurrency(amount, currency, baseCurrency, ratesData)
|
||||
return formatCurrency(converted, baseCurrency)
|
||||
}
|
||||
|
||||
export interface ConvertedWithProvider {
|
||||
value: number
|
||||
currency: string
|
||||
source: 'native' | 'provider' | 'global' | 'no-rates'
|
||||
}
|
||||
|
||||
export function convertWithProviderRate(
|
||||
amount: number,
|
||||
currency: string,
|
||||
provider: Provider | null | undefined,
|
||||
appSettings: Settings[] | Settings | null,
|
||||
ratesData: RatesData | null,
|
||||
): ConvertedWithProvider {
|
||||
const safeAmount = Number(amount)
|
||||
const settings: Partial<Settings> = Array.isArray(appSettings)
|
||||
? (appSettings[0] ?? {})
|
||||
: (appSettings ?? {})
|
||||
const appBase = (settings.baseCurrency || 'RUB').toUpperCase()
|
||||
if (!Number.isFinite(safeAmount)) return { value: 0, currency: appBase, source: 'global' }
|
||||
const fromCurrency = toIsoCurrency(currency || appBase)
|
||||
if (fromCurrency === appBase) return { value: safeAmount, currency: appBase, source: 'native' }
|
||||
|
||||
const usdRate = parseProviderFxRate(provider?.usdRate)
|
||||
const eurRate = parseProviderFxRate(provider?.eurRate)
|
||||
if (fromCurrency === 'USD' && Number.isFinite(usdRate)) {
|
||||
return { value: safeAmount * usdRate, currency: appBase, source: 'provider' }
|
||||
}
|
||||
if (fromCurrency === 'EUR' && Number.isFinite(eurRate)) {
|
||||
return { value: safeAmount * eurRate, currency: appBase, source: 'provider' }
|
||||
}
|
||||
if (!ratesData || !ratesData.rates || !ratesData.base) {
|
||||
return { value: safeAmount, currency: fromCurrency, source: 'no-rates' }
|
||||
}
|
||||
const converted = convertCurrency(safeAmount, fromCurrency, appBase, ratesData)
|
||||
const oneConverted = convertCurrency(1, fromCurrency, appBase, ratesData)
|
||||
const globalRatesWork = Number.isFinite(oneConverted) && Math.abs(oneConverted - 1) > 1e-8
|
||||
return { value: converted, currency: appBase, source: globalRatesWork ? 'global' : 'no-rates' }
|
||||
}
|
||||
|
||||
export function formatInProviderCurrency(
|
||||
amount: number,
|
||||
currency: string,
|
||||
provider: Provider | null | undefined,
|
||||
appSettings: Settings[] | Settings | null,
|
||||
ratesData: RatesData | null,
|
||||
): string {
|
||||
const converted = convertWithProviderRate(amount, currency, provider, appSettings, ratesData)
|
||||
return formatCurrency(converted.value, converted.currency)
|
||||
}
|
||||
|
||||
export function monthKey(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function toCsv(rows: Record<string, unknown>[]): string {
|
||||
if (!rows.length) return ''
|
||||
const headers = Object.keys(rows[0])
|
||||
const escapeValue = (value: unknown) => {
|
||||
const str = `${value ?? ''}`
|
||||
if (str.includes('"') || str.includes(',') || str.includes('\n')) {
|
||||
return `"${str.replaceAll('"', '""')}"`
|
||||
}
|
||||
return str
|
||||
}
|
||||
const lines = [headers.join(',')]
|
||||
for (const row of rows) {
|
||||
lines.push(headers.map((h) => escapeValue(row[h])).join(','))
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function downloadTextFile(fileName: string, content: string): void {
|
||||
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = fileName
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export function downloadBlob(fileName: string, blob: Blob): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = fileName
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import type {
|
||||
Vps,
|
||||
ProviderAccount,
|
||||
Provider,
|
||||
Payment,
|
||||
BalanceLedgerRow,
|
||||
SyncLogRow,
|
||||
SyncSummary,
|
||||
} from '@/types/entities'
|
||||
import { getPaidUntilDate } from './paid-until'
|
||||
|
||||
const STALE_SYNC_HOURS = 48
|
||||
|
||||
function ledgerRowsInAccountCurrency(
|
||||
account: ProviderAccount,
|
||||
balanceLedger: BalanceLedgerRow[],
|
||||
): BalanceLedgerRow[] {
|
||||
const cur = (account.balance_currency || account.currency || '').trim()
|
||||
const rows = balanceLedger.filter((row) => row.providerAccountId === account.id)
|
||||
if (!cur) return rows
|
||||
return rows.filter((row) => !row.currency || row.currency === cur)
|
||||
}
|
||||
|
||||
function ledgerBalanceInCurrency(
|
||||
account: ProviderAccount,
|
||||
balanceLedger: BalanceLedgerRow[],
|
||||
): number {
|
||||
const filtered = ledgerRowsInAccountCurrency(account, balanceLedger)
|
||||
const credits = filtered.filter((r) => r.direction === 'credit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
|
||||
const debits = filtered.filter((r) => r.direction === 'debit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
|
||||
return credits - debits
|
||||
}
|
||||
|
||||
export function accountHasApiLedgerMismatch(
|
||||
account: ProviderAccount,
|
||||
balanceLedger: BalanceLedgerRow[],
|
||||
): boolean {
|
||||
if (account.balance_api == null || !Number.isFinite(Number(account.balance_api))) return false
|
||||
const rows = ledgerRowsInAccountCurrency(account, balanceLedger)
|
||||
if (rows.length === 0) return false
|
||||
const ledger = ledgerBalanceInCurrency(account, balanceLedger)
|
||||
if (!Number.isFinite(ledger)) return false
|
||||
const apiBalance = Number(account.balance_api)
|
||||
const diff = Math.abs(apiBalance - ledger)
|
||||
const tol = Math.max(10, Math.abs(apiBalance) * 0.05)
|
||||
return diff > tol
|
||||
}
|
||||
|
||||
export function lastOkSyncFinishedAt(
|
||||
accountId: string,
|
||||
syncLog: SyncLogRow[] = [],
|
||||
): number | null {
|
||||
const rows = syncLog.filter((r) => r.accountId === accountId && r.status === 'ok' && r.finishedAt)
|
||||
let best: number | null = null
|
||||
for (const r of rows) {
|
||||
const t = new Date(r.finishedAt as string).getTime()
|
||||
if (!Number.isNaN(t) && (best == null || t > best)) best = t
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export interface InventoryIssue {
|
||||
key: string
|
||||
title: string
|
||||
count: number
|
||||
to: string
|
||||
hint?: string
|
||||
}
|
||||
|
||||
export interface InventoryHealthInput {
|
||||
vps: Vps[]
|
||||
providerAccounts: ProviderAccount[]
|
||||
providers?: Provider[]
|
||||
payments: Payment[]
|
||||
balanceLedger: BalanceLedgerRow[]
|
||||
syncLog?: SyncLogRow[]
|
||||
}
|
||||
|
||||
export function computeInventoryHealth(input: InventoryHealthInput): InventoryIssue[] {
|
||||
const { vps, providerAccounts, providers = [], payments, balanceLedger, syncLog = [] } = input
|
||||
const providerById = new Map(providers.map((p) => [p.id, p]))
|
||||
const now = new Date()
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const ctx = { vps, providerAccounts, payments, balanceLedger, now }
|
||||
|
||||
const issues: InventoryIssue[] = []
|
||||
|
||||
const noProject = vps.filter((v) => v.status === 'active' && !(v.project || '').trim())
|
||||
if (noProject.length) {
|
||||
issues.push({ key: 'no-project', title: 'Активные VPS без проекта', count: noProject.length, to: '/vps?health=no-project' })
|
||||
}
|
||||
|
||||
const noRate = vps.filter((v) => {
|
||||
if (v.status !== 'active') return false
|
||||
const dr = Number(v.dailyRate || 0)
|
||||
const mr = Number(v.monthlyRate || 0)
|
||||
const noMoney = (!Number.isFinite(dr) || dr <= 0) && (!Number.isFinite(mr) || mr <= 0)
|
||||
const noCur = !(v.currency || '').trim()
|
||||
return noMoney || noCur
|
||||
})
|
||||
if (noRate.length) {
|
||||
issues.push({ key: 'no-rate', title: 'Нет ставки или валюты', count: noRate.length, to: '/vps?health=no-rate' })
|
||||
}
|
||||
|
||||
const paidOverdue = vps.filter((v) => {
|
||||
if (v.status !== 'active') return false
|
||||
const d = getPaidUntilDate(v, ctx)
|
||||
return d != null && d < todayStart
|
||||
})
|
||||
if (paidOverdue.length) {
|
||||
issues.push({ key: 'paid-overdue', title: 'Просрочена оплата (оценка)', count: paidOverdue.length, to: '/vps?health=paid-overdue' })
|
||||
}
|
||||
|
||||
const bmAccounts = providerAccounts.filter((a) => {
|
||||
const p = providerById.get(a.providerId)
|
||||
return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet
|
||||
})
|
||||
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
|
||||
const staleAccounts = bmAccounts.filter((a) => {
|
||||
const t = lastOkSyncFinishedAt(a.id, syncLog)
|
||||
if (t == null) return true
|
||||
return now.getTime() - t > staleMs
|
||||
})
|
||||
if (staleAccounts.length) {
|
||||
issues.push({
|
||||
key: 'stale-sync',
|
||||
title: `Нет успешного синка > ${STALE_SYNC_HOURS} ч`,
|
||||
count: staleAccounts.length,
|
||||
to: '/accounts?health=stale-sync',
|
||||
hint: 'Проверьте API и журнал синхронизации',
|
||||
})
|
||||
}
|
||||
|
||||
const mismatchAccounts = providerAccounts.filter((a) => accountHasApiLedgerMismatch(a, balanceLedger))
|
||||
if (mismatchAccounts.length) {
|
||||
issues.push({
|
||||
key: 'balance-mismatch',
|
||||
title: 'Баланс API и ledger расходятся',
|
||||
count: mismatchAccounts.length,
|
||||
to: '/accounts?health=balance-mismatch',
|
||||
hint: 'Считается только если в журнале «Баланс и списания» есть движения по аккаунту',
|
||||
})
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
export function getStaleSyncAccountIds(
|
||||
providerAccounts: ProviderAccount[],
|
||||
providers: Provider[],
|
||||
syncLog: SyncLogRow[] = [],
|
||||
now = new Date(),
|
||||
): string[] {
|
||||
const providerById = new Map(providers.map((p) => [p.id, p]))
|
||||
const bmAccounts = providerAccounts.filter((a) => {
|
||||
const p = providerById.get(a.providerId)
|
||||
return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet
|
||||
})
|
||||
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
|
||||
return bmAccounts
|
||||
.filter((a) => {
|
||||
const t = lastOkSyncFinishedAt(a.id, syncLog)
|
||||
if (t == null) return true
|
||||
return now.getTime() - t > staleMs
|
||||
})
|
||||
.map((a) => a.id)
|
||||
}
|
||||
|
||||
export function getBalanceMismatchAccountIds(
|
||||
providerAccounts: ProviderAccount[],
|
||||
balanceLedger: BalanceLedgerRow[],
|
||||
): string[] {
|
||||
return providerAccounts.filter((a) => accountHasApiLedgerMismatch(a, balanceLedger)).map((a) => a.id)
|
||||
}
|
||||
|
||||
export function formatSyncSummaryLine(summary: SyncSummary | null | undefined): string {
|
||||
if (!summary || typeof summary !== 'object') return ''
|
||||
if (summary.error) return String(summary.error)
|
||||
const parts: string[] = []
|
||||
if (summary.added?.length) parts.push(`+${summary.added.length} VPS`)
|
||||
if (summary.updated?.length) parts.push(`~${summary.updated.length} изм.`)
|
||||
if (summary.paymentsAdded) parts.push(`+${summary.paymentsAdded} платежей`)
|
||||
if (summary.tariffsOnly && summary.tariffsCount != null) parts.push(`тарифы: ${summary.tariffsCount}`)
|
||||
if (parts.length) return parts.join(', ')
|
||||
if (summary.vpsCount != null || summary.paymentsCount != null) {
|
||||
return `синк: VPS ${summary.vpsCount ?? 0}, платежи ${summary.paymentsCount ?? 0}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { HTMLInputProps } from '@/types/dom'
|
||||
|
||||
export const noBrowserSuggestProps: HTMLInputProps = Object.freeze({
|
||||
autoComplete: 'off',
|
||||
'data-lpignore': 'true',
|
||||
'data-1p-ignore': 'true',
|
||||
'data-bwignore': 'true',
|
||||
'data-form-type': 'other',
|
||||
} as HTMLInputProps)
|
||||
|
||||
export const passwordCredentialInputProps: HTMLInputProps = Object.freeze({
|
||||
autoComplete: 'new-password',
|
||||
} as HTMLInputProps)
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Vps, ProviderAccount, Payment, BalanceLedgerRow } from '@/types/entities'
|
||||
|
||||
function getAccountBalance(
|
||||
accountId: string,
|
||||
providerAccounts: ProviderAccount[],
|
||||
balanceLedger: BalanceLedgerRow[],
|
||||
): number {
|
||||
const account = providerAccounts.find((a) => a.id === accountId)
|
||||
if (account?.balance_api != null && Number.isFinite(Number(account.balance_api))) {
|
||||
return Number(account.balance_api)
|
||||
}
|
||||
const ledgerRows = balanceLedger.filter((row) => row.providerAccountId === accountId)
|
||||
const credits = ledgerRows.filter((r) => r.direction === 'credit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
|
||||
const debits = ledgerRows.filter((r) => r.direction === 'debit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
|
||||
return credits - debits
|
||||
}
|
||||
|
||||
export interface PaidUntilContext {
|
||||
vps: Vps[]
|
||||
providerAccounts: ProviderAccount[]
|
||||
payments: Payment[]
|
||||
balanceLedger: BalanceLedgerRow[]
|
||||
now?: Date
|
||||
}
|
||||
|
||||
export function getPaidUntilDate(item: Vps, ctx: PaidUntilContext): Date | null {
|
||||
const { vps, providerAccounts, payments, balanceLedger, now = new Date() } = ctx
|
||||
if (item.status !== 'active') return null
|
||||
const account = providerAccounts.find((a) => a.id === item.providerAccountId)
|
||||
const tariffType = item.tariffType || (Number(item.dailyRate || 0) > 0 ? 'daily' : 'monthly')
|
||||
const isDailyBilling = tariffType === 'daily' || account?.billingMode === 'daily'
|
||||
|
||||
const paidUntilFromApi = item.paidUntil
|
||||
? (() => {
|
||||
const d = new Date(item.paidUntil)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
})()
|
||||
: null
|
||||
|
||||
const isPaidUntilNextDay =
|
||||
paidUntilFromApi != null &&
|
||||
(() => {
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const diffMs = paidUntilFromApi.getTime() - today.getTime()
|
||||
const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000))
|
||||
return diffDays >= 0 && diffDays <= 2
|
||||
})()
|
||||
|
||||
const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay
|
||||
if (!shouldCalculateFromBalance && paidUntilFromApi) return paidUntilFromApi
|
||||
|
||||
const dailyRate = Number(item.dailyRate || 0)
|
||||
const monthlyRate = Number(item.monthlyRate || 0)
|
||||
const burnRate = tariffType === 'daily' ? dailyRate : monthlyRate / 30
|
||||
if (!Number.isFinite(burnRate) || burnRate <= 0) return paidUntilFromApi
|
||||
|
||||
const accountBalance = getAccountBalance(item.providerAccountId, providerAccounts, balanceLedger)
|
||||
const activeInAccount = vps.filter(
|
||||
(v) => v.providerAccountId === item.providerAccountId && v.status === 'active',
|
||||
).length
|
||||
const allocatedBalance = activeInAccount > 0 ? Math.max(0, accountBalance) / activeInAccount : 0
|
||||
const directPayments = payments
|
||||
.filter((p) => p.vpsId === item.id && p.type === 'direct_vps_payment')
|
||||
.reduce((acc, p) => acc + Number(p.amount || 0), 0)
|
||||
const funds = directPayments + allocatedBalance
|
||||
const coveredDays = Math.floor(funds / burnRate)
|
||||
if (!Number.isFinite(coveredDays) || coveredDays <= 0) return paidUntilFromApi
|
||||
|
||||
const paidUntil = new Date(now)
|
||||
paidUntil.setDate(paidUntil.getDate() + coveredDays)
|
||||
return paidUntil
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
createRouter as tanstackCreateRouter,
|
||||
rootRouteId,
|
||||
} from '@tanstack/react-router'
|
||||
|
||||
import { routeTree } from '../routeTree.gen'
|
||||
import { queryClient } from './queryClient'
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: ReturnType<typeof createRouter>
|
||||
}
|
||||
}
|
||||
|
||||
export function createRouter(opts?: { context?: { queryClient: QueryClient } }) {
|
||||
return tanstackCreateRouter({
|
||||
routeTree,
|
||||
context: opts?.context ?? { queryClient },
|
||||
defaultPreload: 'intent',
|
||||
scrollRestoration: true,
|
||||
})
|
||||
}
|
||||
|
||||
export { rootRouteId }
|
||||
@@ -0,0 +1,92 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const vpsStatusSchema = z.enum(['active', 'paused', 'archived'])
|
||||
export const tariffTypeSchema = z.enum(['daily', 'monthly'])
|
||||
export const billingModeSchema = z.enum(['daily', 'monthly'])
|
||||
export const paymentTypeSchema = z.enum([
|
||||
'direct_vps_payment',
|
||||
'provider_balance_topup',
|
||||
'daily_debit',
|
||||
'monthly_debit',
|
||||
])
|
||||
export const ledgerDirectionSchema = z.enum(['credit', 'debit'])
|
||||
export const apiTypeSchema = z.enum(['billmanager', 'none'])
|
||||
|
||||
export const providerSchema = z.object({
|
||||
id: z.string().min(1).optional(),
|
||||
name: z.string().min(1, 'Название обязательно'),
|
||||
website: z.string().url('Невалидный URL').or(z.literal('')).optional(),
|
||||
apiType: apiTypeSchema,
|
||||
apiBaseUrl: z.string().optional().default(''),
|
||||
baseCurrency: z.string().min(1).default('RUB'),
|
||||
usdRate: z.string().optional().default(''),
|
||||
eurRate: z.string().optional().default(''),
|
||||
supportPhone: z.string().optional().default(''),
|
||||
supportUrl: z.string().optional().default(''),
|
||||
notes: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
export const providerAccountSchema = z.object({
|
||||
id: z.string().min(1).optional(),
|
||||
providerId: z.string().min(1, 'Выберите хостера'),
|
||||
name: z.string().min(1, 'Название обязательно'),
|
||||
login: z.string().optional().default(''),
|
||||
apiCredentials: z.string().optional().default(''),
|
||||
billingMode: billingModeSchema.default('monthly'),
|
||||
notes: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
export const vpsSchema = z.object({
|
||||
id: z.string().min(1).optional(),
|
||||
ip: z.string().min(1, 'IP обязателен'),
|
||||
dns: z.string().optional().default(''),
|
||||
providerId: z.string().min(1, 'Выберите хостера'),
|
||||
providerAccountId: z.string().min(1, 'Выберите аккаунт'),
|
||||
vcpu: z.coerce.number().int().min(0).default(1),
|
||||
ramGb: z.coerce.number().min(0).default(1),
|
||||
diskGb: z.coerce.number().min(0).default(10),
|
||||
status: vpsStatusSchema.default('active'),
|
||||
tariffType: tariffTypeSchema.default('monthly'),
|
||||
currency: z.string().min(1, 'Валюта обязательна').default('RUB'),
|
||||
monthlyRate: z.coerce.number().min(0).default(0),
|
||||
dailyRate: z.coerce.number().min(0).default(0),
|
||||
paidUntil: z.string().optional().default(''),
|
||||
project: z.string().optional().default(''),
|
||||
notes: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
export const paymentSchema = z.object({
|
||||
id: z.string().min(1).optional(),
|
||||
type: paymentTypeSchema,
|
||||
date: z.string().min(1, 'Дата обязательна'),
|
||||
amount: z.coerce.number().min(0, 'Сумма должна быть ≥ 0'),
|
||||
currency: z.string().min(1).default('RUB'),
|
||||
providerAccountId: z.string().min(1, 'Выберите аккаунт'),
|
||||
note: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
export const balanceLedgerSchema = z.object({
|
||||
providerAccountId: z.string().min(1, 'Выберите аккаунт'),
|
||||
direction: ledgerDirectionSchema,
|
||||
amount: z.coerce.number().min(0, 'Сумма должна быть ≥ 0'),
|
||||
currency: z.string().min(1).default('RUB'),
|
||||
date: z.string().min(1, 'Дата обязательна'),
|
||||
note: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
export const settingsSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
baseCurrency: z.string().min(1).default('RUB'),
|
||||
ratesUrl: z.string().url('Невалидный URL').or(z.literal('')).optional(),
|
||||
autoConvert: z.boolean().default(true),
|
||||
syncEnabled: z.boolean().optional().default(true),
|
||||
telegramChatId: z.string().optional().default(''),
|
||||
telegramBotToken: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
export type ProviderFormValues = z.infer<typeof providerSchema>
|
||||
export type ProviderAccountFormValues = z.infer<typeof providerAccountSchema>
|
||||
export type VpsFormValues = z.infer<typeof vpsSchema>
|
||||
export type PaymentFormValues = z.infer<typeof paymentSchema>
|
||||
export type BalanceLedgerFormValues = z.infer<typeof balanceLedgerSchema>
|
||||
export type SettingsFormValues = z.infer<typeof settingsSchema>
|
||||
Reference in New Issue
Block a user