Files
vps-tracker/packages/shared/src/utils/inventory-health.ts
T
DenozordecandCursor 9be50aa03b
Docker / build (push) Has been cancelled
feat(api, web): интеграция хостера 4VPS.SU — синк VPS, баланса и тарифов
Добавлен адаптер 4vps, обобщён pipeline синхронизации через ProviderAdapter и обновлён UI для Panel ID и API Key.

Co-authored-by: Cursor <[email protected]>
2026-06-29 01:13:50 +07:00

250 lines
8.1 KiB
TypeScript

import { accountBalanceApi } from './account-balance.js'
import { isSyncApiType } from '../contracts/provider.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 syncAccounts = providerAccounts.filter((a) => {
const p = providerById.get(a.providerId)
return isSyncApiType(p?.apiType) && Boolean((p?.apiBaseUrl || '').trim()) && a.apiCredentialsSet
})
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
const staleAccounts = syncAccounts.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 syncAccounts = providerAccounts.filter((a) => {
const p = providerById.get(a.providerId)
return isSyncApiType(p?.apiType) && Boolean((p?.apiBaseUrl || '').trim()) && a.apiCredentialsSet
})
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
return syncAccounts
.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)
}