diff --git a/apps/api/src/services/dashboard-stats.ts b/apps/api/src/services/dashboard-stats.ts index 502f23c..af8860f 100644 --- a/apps/api/src/services/dashboard-stats.ts +++ b/apps/api/src/services/dashboard-stats.ts @@ -1,12 +1,14 @@ import { getSnapshot } from '@cfdm/db/repositories/snapshot' +import { countExpiringWithin7Days, countInventoryIssues } from '@cfdm/shared/utils/inventory-health' +import { accountBalanceApi } from '@cfdm/shared/utils/account-balance' const STALE_SYNC_HOURS = 48 function vpsBurnRate(v: { - status: string - tariffType: string - dailyRate: number | null - monthlyRate: number | null + status?: string | null + tariffType?: string | null + dailyRate?: number | string | null + monthlyRate?: number | string | null }): number { if (v.status !== 'active') return 0 const monthly = Number(v.monthlyRate || 0) @@ -43,9 +45,6 @@ export interface DashboardStats { export function computeDashboardStats(): DashboardStats { const snap = getSnapshot() const now = new Date() - const in7Days = new Date(now) - in7Days.setDate(in7Days.getDate() + 7) - const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const activeVps = snap.vps.filter((v) => v.status === 'active') const monthlyBurnEstimate = activeVps.reduce((acc, v) => acc + vpsBurnRate(v), 0) @@ -57,7 +56,9 @@ export function computeDashboardStats(): DashboardStats { const burnByAccount = new Map() for (const v of activeVps) { const burn = vpsBurnRate(v) - burnByAccount.set(v.providerAccountId, (burnByAccount.get(v.providerAccountId) ?? 0) + burn) + const accountId = v.providerAccountId + if (!accountId) continue + burnByAccount.set(accountId, (burnByAccount.get(accountId) ?? 0) + burn) } let minRunwayDays: number | null = null @@ -69,12 +70,17 @@ export function computeDashboardStats(): DashboardStats { if (minRunwayDays == null || days < minRunwayDays) minRunwayDays = days } - const expiringWithin7Days = activeVps.filter((v) => { - if (!v.paidUntil) return false - const d = new Date(v.paidUntil) - if (Number.isNaN(d.getTime())) return false - return d >= todayStart && d <= in7Days - }).length + const expiringWithin7Days = countExpiringWithin7Days( + { + vps: snap.vps, + providerAccounts: snap.providerAccounts, + providers: snap.providers, + payments: snap.payments, + balanceLedger: snap.balanceLedger, + syncLog: snap.syncLog, + }, + now, + ) const providerById = new Map(snap.providers.map((p) => [p.id, p])) const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000 @@ -91,46 +97,18 @@ export function computeDashboardStats(): DashboardStats { const lowBalanceAccountCount = snap.providerAccounts.filter((a) => { const threshold = Number(a.balanceAlertBelow ?? 0) if (!Number.isFinite(threshold) || threshold <= 0) return false - const balance = Number(a.balanceApi ?? 0) - return balance < threshold + const balance = accountBalanceApi(a) + return balance != null && balance < threshold }).length - const noRateCount = activeVps.filter((v) => { - 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 - }).length - - const paidOverdueCount = activeVps.filter((v) => { - if (!v.paidUntil) return false - const d = new Date(v.paidUntil) - if (Number.isNaN(d.getTime())) return false - return d < todayStart - }).length - - const balanceMismatchCount = snap.providerAccounts.filter((a) => { - const apiBalance = a.balanceApi != null ? Number(a.balanceApi) : null - if (apiBalance == null || !Number.isFinite(apiBalance)) return false - const rows = snap.balanceLedger.filter((r) => r.providerAccountId === a.id) - if (rows.length === 0) return false - const credits = rows.filter((r) => r.direction === 'credit').reduce((acc, r) => acc + Number(r.amount || 0), 0) - const debits = rows.filter((r) => r.direction === 'debit').reduce((acc, r) => acc + Number(r.amount || 0), 0) - const ledger = credits - debits - if (!Number.isFinite(ledger)) return false - const diff = Math.abs(apiBalance - ledger) - const tol = Math.max(10, Math.abs(apiBalance) * 0.05) - return diff > tol - }).length - - let issuesCount = 0 - if (noRateCount > 0) issuesCount++ - if (paidOverdueCount > 0) issuesCount++ - if (expiringWithin7Days > 0) issuesCount++ - if (staleSyncAccountCount > 0) issuesCount++ - if (lowBalanceAccountCount > 0) issuesCount++ - if (balanceMismatchCount > 0) issuesCount++ + const issuesCount = countInventoryIssues({ + vps: snap.vps, + providerAccounts: snap.providerAccounts, + providers: snap.providers, + payments: snap.payments, + balanceLedger: snap.balanceLedger, + syncLog: snap.syncLog, + }) let lastGlobalSyncAt: string | null = null for (const row of snap.syncLog) { diff --git a/apps/web/src/components/health-mode-banner.tsx b/apps/web/src/components/health-mode-banner.tsx index 39c404c..a184293 100644 --- a/apps/web/src/components/health-mode-banner.tsx +++ b/apps/web/src/components/health-mode-banner.tsx @@ -6,7 +6,9 @@ import { Button } from '@cfdm/ui/components/button' const HEALTH_LABELS: Record = { 'no-rate': 'Нет ставки или валюты', 'paid-overdue': 'Просрочена оплата (оценка)', + 'expiring-soon': 'Истекает в течение 7 дней', 'stale-sync': 'Нет успешного синка > 48 ч', + 'low-balance': 'Низкий баланс аккаунта', 'balance-mismatch': 'Баланс API и ledger расходятся', } diff --git a/apps/web/src/lib/account.ts b/apps/web/src/lib/account.ts index 7b559b1..6ce9371 100644 --- a/apps/web/src/lib/account.ts +++ b/apps/web/src/lib/account.ts @@ -1,23 +1 @@ -/** Баланс API аккаунта (поддержка camelCase из API и snake_case в типах). */ -export function accountBalanceApi(account: { - balance_api?: number | null - balanceApi?: number | null -}): number | null { - const raw = account.balance_api ?? account.balanceApi - if (raw == null) return null - const n = Number(raw) - return Number.isFinite(n) ? n : null -} - -export function accountBalanceCurrency(account: { - balance_currency?: string - balanceCurrency?: string - currency?: string -}): string { - return ( - account.balance_currency ?? - account.balanceCurrency ?? - account.currency ?? - 'RUB' - ) -} +export { accountBalanceApi, accountBalanceCurrency } from '@cfdm/shared/utils/account-balance' diff --git a/apps/web/src/lib/inventory-health.ts b/apps/web/src/lib/inventory-health.ts index 6726814..b0ed9d4 100644 --- a/apps/web/src/lib/inventory-health.ts +++ b/apps/web/src/lib/inventory-health.ts @@ -1,174 +1,17 @@ -import type { - Vps, - ProviderAccount, - Provider, - Payment, - BalanceLedgerRow, - SyncLogRow, - SyncSummary, -} from '@/types/entities' -import { getPaidUntilDate } from './paid-until' +import type { SyncSummary } from '@/types/entities' -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 -} - -import { accountBalanceApi } from '@/lib/account' - -export function accountHasApiLedgerMismatch( - account: ProviderAccount, - balanceLedger: BalanceLedgerRow[], -): boolean { - const apiBalance = accountBalanceApi(account) - if (apiBalance == null) 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 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 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 { + STALE_SYNC_HOURS, + accountHasApiLedgerMismatch, + lastOkSyncFinishedAt, + computeInventoryHealth, + countInventoryIssues, + countExpiringWithin7Days, + getStaleSyncAccountIds, + getBalanceMismatchAccountIds, + type InventoryIssue, + type InventoryHealthInput, +} from '@cfdm/shared/utils/inventory-health' export function formatSyncSummaryLine(summary: SyncSummary | null | undefined): string { if (!summary || typeof summary !== 'object') return '' diff --git a/apps/web/src/lib/paid-until.ts b/apps/web/src/lib/paid-until.ts index 3908bea..a38f013 100644 --- a/apps/web/src/lib/paid-until.ts +++ b/apps/web/src/lib/paid-until.ts @@ -1,72 +1,8 @@ -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 -} +export { + getPaidUntilDate, + type PaidUntilContext, + type PaidUntilVps, + type PaidUntilAccount, + type PaidUntilPayment, + type PaidUntilLedgerRow, +} from '@cfdm/shared/utils/paid-until' diff --git a/apps/web/src/routes/_auth/accounts.tsx b/apps/web/src/routes/_auth/accounts.tsx index ceed17c..8d96ff6 100644 --- a/apps/web/src/routes/_auth/accounts.tsx +++ b/apps/web/src/routes/_auth/accounts.tsx @@ -206,6 +206,8 @@ function AccountsPage() { } else if (health === 'balance-mismatch') { const ids = new Set(getBalanceMismatchAccountIds(snapshot.providerAccounts, snapshot.balanceLedger)) result = accounts.filter((a) => ids.has(a.id)) + } else if (health === 'low-balance') { + result = accounts.filter((a) => getAccountHealthFlags(a, healthCtx).includes('low-balance')) } } return applyAccountFilters(result, filters, snapshot?.providers ?? [], healthCtx) diff --git a/apps/web/src/routes/_auth/dashboard.tsx b/apps/web/src/routes/_auth/dashboard.tsx index e61e991..0dbd2e5 100644 --- a/apps/web/src/routes/_auth/dashboard.tsx +++ b/apps/web/src/routes/_auth/dashboard.tsx @@ -259,13 +259,13 @@ function DashboardPage() { внимание ) : undefined, - onClick: () => navigate({ to: '/vps', search: { health: 'paid-overdue' } }), + onClick: () => navigate({ to: '/vps', search: { health: 'expiring-soon' } }), }, { label: 'Проблемы', - value: stats?.issuesCount ?? issues.length, + value: issues.length, icon: , - variant: (stats?.issuesCount ?? issues.length) > 0 ? 'destructive' : 'default', + variant: issues.length > 0 ? 'destructive' : 'default', }, ]} /> @@ -318,7 +318,7 @@ function DashboardPage() { i.key} diff --git a/apps/web/src/routes/_auth/vps.tsx b/apps/web/src/routes/_auth/vps.tsx index 999fac0..e25e62b 100644 --- a/apps/web/src/routes/_auth/vps.tsx +++ b/apps/web/src/routes/_auth/vps.tsx @@ -225,6 +225,15 @@ function VpsPage() { const d = getPaidUntilDate(v, ctx) return d != null && d < todayStart }) + } else if (health === 'expiring-soon') { + const in7Days = new Date(now) + in7Days.setDate(in7Days.getDate() + 7) + rows = rows.filter((v) => { + if (v.status !== 'active') return false + const d = getPaidUntilDate(v, ctx) + if (d == null) return false + return d >= todayStart && d <= in7Days + }) } return rows }, [snapshot, filters, health]) diff --git a/packages/shared/src/utils/account-balance.ts b/packages/shared/src/utils/account-balance.ts new file mode 100644 index 0000000..fda5351 --- /dev/null +++ b/packages/shared/src/utils/account-balance.ts @@ -0,0 +1,18 @@ +/** Баланс API аккаунта (camelCase из API и snake_case в типах). */ +export function accountBalanceApi(account: { + balance_api?: number | null + balanceApi?: number | null +}): number | null { + const raw = account.balance_api ?? account.balanceApi + if (raw == null) return null + const n = Number(raw) + return Number.isFinite(n) ? n : null +} + +export function accountBalanceCurrency(account: { + balance_currency?: string + balanceCurrency?: string + currency?: string +}): string { + return account.balance_currency ?? account.balanceCurrency ?? account.currency ?? 'RUB' +} diff --git a/packages/shared/src/utils/inventory-health.ts b/packages/shared/src/utils/inventory-health.ts new file mode 100644 index 0000000..b92fcbb --- /dev/null +++ b/packages/shared/src/utils/inventory-health.ts @@ -0,0 +1,248 @@ +import { accountBalanceApi } from './account-balance.js' +import { getPaidUntilDate, type PaidUntilContext } from './paid-until.js' + +export const STALE_SYNC_HOURS = 48 + +export interface HealthProvider { + id: string + apiType?: string | null + apiBaseUrl?: string | null +} + +export interface HealthProviderAccount { + id: string + providerId: string + balance_api?: number | null + balanceApi?: number | null + balance_currency?: string | null + balanceCurrency?: string | null + currency?: string | null + apiCredentialsSet?: boolean + balanceAlertBelow?: number | null +} + +export interface HealthBalanceLedgerRow { + providerAccountId?: string | null + direction?: string | null + amount?: number | string | null + currency?: string | null +} + +export interface HealthSyncLogRow { + accountId: string + status?: string | null + finishedAt?: string | null +} + +export interface InventoryIssue { + key: string + title: string + count: number + to: string + hint?: string +} + +export interface InventoryHealthInput extends PaidUntilContext { + providerAccounts: HealthProviderAccount[] + providers?: HealthProvider[] + balanceLedger: HealthBalanceLedgerRow[] + syncLog?: HealthSyncLogRow[] +} + +function ledgerRowsInAccountCurrency( + account: HealthProviderAccount, + balanceLedger: HealthBalanceLedgerRow[], +): HealthBalanceLedgerRow[] { + const cur = (account.balance_currency || account.balanceCurrency || 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: HealthProviderAccount, + balanceLedger: HealthBalanceLedgerRow[], +): 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: HealthProviderAccount, + balanceLedger: HealthBalanceLedgerRow[], +): boolean { + const apiBalance = accountBalanceApi(account) + if (apiBalance == null) 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 diff = Math.abs(apiBalance - ledger) + const tol = Math.max(10, Math.abs(apiBalance) * 0.05) + return diff > tol +} + +export function lastOkSyncFinishedAt( + accountId: string, + syncLog: HealthSyncLogRow[] = [], +): 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 function countExpiringWithin7Days(input: InventoryHealthInput, now = new Date()): number { + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const in7Days = new Date(now) + in7Days.setDate(in7Days.getDate() + 7) + const ctx = { ...input, now } + return input.vps.filter((v) => { + if (v.status !== 'active') return false + const d = getPaidUntilDate(v, ctx) + if (d == null) return false + return d >= todayStart && d <= in7Days + }).length +} + +export function computeInventoryHealth(input: InventoryHealthInput): InventoryIssue[] { + const { vps, providerAccounts, providers = [], 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 in7Days = new Date(now) + in7Days.setDate(in7Days.getDate() + 7) + const ctx = { ...input, now } + + const issues: InventoryIssue[] = [] + + 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 expiringSoon = vps.filter((v) => { + if (v.status !== 'active') return false + const d = getPaidUntilDate(v, ctx) + if (d == null) return false + return d >= todayStart && d <= in7Days + }) + if (expiringSoon.length) { + issues.push({ + key: 'expiring-soon', + title: 'Истекает в течение 7 дней', + count: expiringSoon.length, + to: '/vps?health=expiring-soon', + }) + } + + 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 lowBalanceAccounts = providerAccounts.filter((a) => { + const threshold = Number(a.balanceAlertBelow ?? 0) + if (!Number.isFinite(threshold) || threshold <= 0) return false + const balance = accountBalanceApi(a) + return balance != null && balance < threshold + }) + if (lowBalanceAccounts.length) { + issues.push({ + key: 'low-balance', + title: 'Низкий баланс аккаунта', + count: lowBalanceAccounts.length, + to: '/accounts?health=low-balance', + }) + } + + 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 countInventoryIssues(input: InventoryHealthInput): number { + return computeInventoryHealth(input).length +} + +export function getStaleSyncAccountIds( + providerAccounts: HealthProviderAccount[], + providers: HealthProvider[], + syncLog: HealthSyncLogRow[] = [], + 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: HealthProviderAccount[], + balanceLedger: HealthBalanceLedgerRow[], +): string[] { + return providerAccounts.filter((a) => accountHasApiLedgerMismatch(a, balanceLedger)).map((a) => a.id) +} diff --git a/packages/shared/src/utils/paid-until.ts b/packages/shared/src/utils/paid-until.ts new file mode 100644 index 0000000..4b5e668 --- /dev/null +++ b/packages/shared/src/utils/paid-until.ts @@ -0,0 +1,105 @@ +import { accountBalanceApi } from './account-balance.js' + +export interface PaidUntilVps { + id: string + status?: string | null + providerAccountId?: string | null + tariffType?: string | null + dailyRate?: number | string | null + monthlyRate?: number | string | null + paidUntil?: string | null + currency?: string | null +} + +export interface PaidUntilAccount { + id: string + billingMode?: string | null + balance_api?: number | null + balanceApi?: number | null +} + +export interface PaidUntilPayment { + vpsId?: string | null + type?: string | null + amount?: number | string | null +} + +export interface PaidUntilLedgerRow { + providerAccountId?: string | null + direction?: string | null + amount?: number | string | null +} + +export interface PaidUntilContext { + vps: PaidUntilVps[] + providerAccounts: PaidUntilAccount[] + payments: PaidUntilPayment[] + balanceLedger: PaidUntilLedgerRow[] + now?: Date +} + +function getAccountBalance( + accountId: string, + providerAccounts: PaidUntilAccount[], + balanceLedger: PaidUntilLedgerRow[], +): number { + const account = providerAccounts.find((a) => a.id === accountId) + const apiBalance = account ? accountBalanceApi(account) : null + if (apiBalance != null) return apiBalance + 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 function getPaidUntilDate(item: PaidUntilVps, 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 +}