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

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

Co-authored-by: Cursor <[email protected]>
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
@@ -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
}