Enhance database management and synchronization features

- Added new backup and restore functionality for JSON and SQLite database formats.
- Implemented notification settings for low balance alerts and sync digests in the settings page.
- Updated database schema to include new columns for balance alert thresholds and notification preferences.
- Refactored synchronization logic to provide detailed summaries and improved error handling.
- Enhanced user interface for managing accounts and settings, including new input fields for balance alerts.
- Improved sync log display to include summary information for better tracking of synchronization results.
This commit is contained in:
Denozordec
2026-03-20 23:11:58 +07:00
parent 994dde2314
commit 196c5be532
20 changed files with 1342 additions and 130 deletions
+61
View File
@@ -176,3 +176,64 @@ export async function sendTelegramTestNotification() {
const res = await fetchApi('/api/settings/telegram/test', { method: 'POST' })
return res
}
export async function downloadBackupJsonBlob() {
const url = `${API_BASE}/api/backup/json`
const res = await fetch(url)
if (!res.ok) {
let message = res.statusText || 'Ошибка выгрузки'
try {
const data = await res.json()
if (data?.error) message = data.error
} catch {
/* ignore */
}
throw new Error(message)
}
return res.blob()
}
export async function downloadBackupDatabaseBlob() {
const url = `${API_BASE}/api/backup/database`
const res = await fetch(url)
if (!res.ok) {
let message = res.statusText || 'Ошибка выгрузки'
try {
const data = await res.json()
if (data?.error) message = data.error
} catch {
/* ignore */
}
throw new Error(message)
}
return res.blob()
}
export async function importBackupJson(payload) {
return fetchApi('/api/backup/json', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function importBackupDatabaseBuffer(buffer) {
const url = `${API_BASE}/api/backup/database`
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: buffer,
})
if (!res.ok) {
let message = res.statusText || 'Ошибка восстановления'
try {
const data = await res.json()
if (data?.error) message = data.error
} catch {
/* ignore */
}
const err = new Error(message)
err.status = res.status
throw err
}
return res.json()
}
+184
View File
@@ -0,0 +1,184 @@
import { getPaidUntilDate } from './paid-until'
const STALE_SYNC_HOURS = 48
function ledgerBalanceInCurrency(account, balanceLedger) {
const cur = (account.balance_currency || account.currency || '').trim()
const rows = balanceLedger.filter((row) => row.providerAccountId === account.id)
const filtered = cur
? rows.filter((row) => !row.currency || row.currency === cur)
: rows
const credits = filtered
.filter((row) => row.direction === 'credit')
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
const debits = filtered
.filter((row) => row.direction === 'debit')
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
return credits - debits
}
export function lastOkSyncFinishedAt(accountId, syncLog) {
const rows = (syncLog || []).filter(
(r) => r.accountId === accountId && r.status === 'ok' && r.finishedAt,
)
let best = null
for (const r of rows) {
const t = new Date(r.finishedAt).getTime()
if (!Number.isNaN(t) && (!best || t > best)) best = t
}
return best
}
/**
* @param {{
* vps: object[],
* providerAccounts: object[],
* payments: object[],
* balanceLedger: object[],
* syncLog?: object[],
* }} input
*/
export function computeInventoryHealth(input) {
const { vps, providerAccounts, payments, balanceLedger, syncLog = [] } = input
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const ctx = { vps, providerAccounts, payments, balanceLedger, now }
/** @type {{ key: string, title: string, count: number, to: string, hint?: string }[]} */
const issues = []
const noProject = vps.filter((v) => v.status === 'active' && !(v.project || '').trim())
if (noProject.length) {
issues.push({
key: 'no-project',
title: 'Активные VPS без проекта',
count: noProject.length,
to: '/vps?health=no-project',
})
}
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 && d < todayStart
})
if (paidOverdue.length) {
issues.push({
key: 'paid-overdue',
title: 'Просрочена оплата (оценка)',
count: paidOverdue.length,
to: '/vps?health=paid-overdue',
})
}
const bmAccounts = providerAccounts.filter(
(a) => a.apiType === 'billmanager' && (a.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) => {
if (a.balance_api == null || !Number.isFinite(Number(a.balance_api))) return false
const ledger = ledgerBalanceInCurrency(a, balanceLedger)
if (!Number.isFinite(ledger)) return false
const api = Number(a.balance_api)
const diff = Math.abs(api - ledger)
const tol = Math.max(10, Math.abs(api) * 0.05)
return diff > tol
})
if (mismatchAccounts.length) {
issues.push({
key: 'balance-mismatch',
title: 'Баланс API и ledger расходятся',
count: mismatchAccounts.length,
to: '/accounts?health=balance-mismatch',
})
}
return issues
}
/**
* @param {object[]} providerAccounts
* @param {object[]} syncLog
*/
export function getStaleSyncAccountIds(providerAccounts, syncLog, now = new Date()) {
const bmAccounts = providerAccounts.filter(
(a) => a.apiType === 'billmanager' && (a.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)
}
/**
* @param {object[]} providerAccounts
* @param {object[]} balanceLedger
*/
export function getBalanceMismatchAccountIds(providerAccounts, balanceLedger) {
return providerAccounts
.filter((a) => {
if (a.balance_api == null || !Number.isFinite(Number(a.balance_api))) return false
const ledger = ledgerBalanceInCurrency(a, balanceLedger)
if (!Number.isFinite(ledger)) return false
const api = Number(a.balance_api)
const diff = Math.abs(api - ledger)
const tol = Math.max(10, Math.abs(api) * 0.05)
return diff > tol
})
.map((a) => a.id)
}
/**
* @param {object|null} summary
*/
export function formatSyncSummaryLine(summary) {
if (!summary || typeof summary !== 'object') return ''
if (summary.error) return String(summary.error)
const parts = []
if (summary.added?.length) parts.push(`+${summary.added.length} VPS`)
if (summary.updated?.length) parts.push(`~${summary.updated.length} изм.`)
if (summary.paymentsAdded) parts.push(`+${summary.paymentsAdded} платежей`)
if (summary.tariffsOnly && summary.tariffsCount != null) parts.push(`тарифы: ${summary.tariffsCount}`)
if (parts.length) return parts.join(', ')
if (summary.vpsCount != null || summary.paymentsCount != null) {
return `синк: VPS ${summary.vpsCount ?? 0}, платежи ${summary.paymentsCount ?? 0}`
}
return ''
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Дата «оплачено до» для VPS (как на дашборде).
*/
function getAccountBalance(accountId, providerAccounts, balanceLedger) {
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((row) => row.direction === 'credit')
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
const debits = ledgerRows
.filter((row) => row.direction === 'debit')
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
return credits - debits
}
/**
* @param {object} item - VPS
* @param {{ vps: object[], providerAccounts: object[], payments: object[], balanceLedger: object[], now?: Date }} ctx
* @returns {Date|null}
*/
export function getPaidUntilDate(item, ctx) {
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 &&
(() => {
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const diffMs = paidUntilFromApi - today
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
}
+13
View File
@@ -340,3 +340,16 @@ export function downloadTextFile(fileName, content) {
anchor.click()
URL.revokeObjectURL(url)
}
/**
* @param {string} fileName
* @param {Blob} blob
*/
export function downloadBlob(fileName, blob) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.click()
URL.revokeObjectURL(url)
}