fix(dashboard): унифицировать расчёт проблем инвентаря между API и UI
Docker / build (push) Has been cancelled

Счётчик issuesCount и таблица «Проблемы» теперь используют общую логику в @cfdm/shared; добавлены категории «истекает 7 дн» и «низкий баланс».

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-29 00:24:49 +07:00
co-authored by Cursor
parent 31a4798d78
commit 2c66d0c0ee
11 changed files with 440 additions and 321 deletions
+30 -52
View File
@@ -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
View File
@@ -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'
+13 -170
View File
@@ -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 ''
+8 -72
View File
@@ -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'
+2
View File
@@ -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)
+4 -4
View File
@@ -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}
+9
View File
@@ -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)
}
+105
View File
@@ -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
}