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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user