fix(dashboard): унифицировать расчёт проблем инвентаря между API и UI
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Счётчик issuesCount и таблица «Проблемы» теперь используют общую логику в @cfdm/shared; добавлены категории «истекает 7 дн» и «низкий баланс». Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<string, number>()
|
||||
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) {
|
||||
|
||||
@@ -6,7 +6,9 @@ import { Button } from '@cfdm/ui/components/button'
|
||||
const HEALTH_LABELS: Record<string, string> = {
|
||||
'no-rate': 'Нет ставки или валюты',
|
||||
'paid-overdue': 'Просрочена оплата (оценка)',
|
||||
'expiring-soon': 'Истекает в течение 7 дней',
|
||||
'stale-sync': 'Нет успешного синка > 48 ч',
|
||||
'low-balance': 'Низкий баланс аккаунта',
|
||||
'balance-mismatch': 'Баланс API и ledger расходятся',
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -259,13 +259,13 @@ function DashboardPage() {
|
||||
внимание
|
||||
</Badge>
|
||||
) : 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: <HashIcon className="size-4" />,
|
||||
variant: (stats?.issuesCount ?? issues.length) > 0 ? 'destructive' : 'default',
|
||||
variant: issues.length > 0 ? 'destructive' : 'default',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -318,7 +318,7 @@ function DashboardPage() {
|
||||
<TabsContent value="issues" className="mt-0">
|
||||
<DataGridCard
|
||||
title="Здоровье инвентаря"
|
||||
description="Ставка, просрочка оплаты, устаревший синк, расхождения баланса"
|
||||
description="Ставка, оплата, синк, баланс аккаунтов"
|
||||
columns={columnDefFromDataGrid(issueColumns)}
|
||||
data={issues}
|
||||
rowId={(i) => i.key}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user