refactor(repo): переход на pnpm monorepo с shadcn/ui и Fastify+Drizzle
Frontend:
- apps/web (Vite+TS, TanStack Router/Query, shadcn/ui @cfdm/ui base-nova)
- 10 страниц в routes/_auth/, Recharts через shadcn Chart, lucide-react
- формы на RHF + Zod (FormSheet/FormField)
- удалены Tabler, Chart.js, react-router-dom
Backend (параллельный трек):
- apps/api (Fastify 5 + Drizzle + better-sqlite3)
- packages/db: Drizzle-схема и repositories по сущностям
- packages/shared: Zod-контракты
- роуты с валидацией и единым форматом ошибок { error: { code, message } }
- sync/backup — заглушки 501 (billmanager-адаптеры переносятся отдельно)
- legacy Express оставлен как runtime по умолчанию (RUNTIME=express)
Infra:
- Dockerfile multi-stage под pnpm workspaces
- .dockerignore и docker-compose обновлены под monorepo
Rules:
- удалены нерелевантные правила (rust, cloudflare, server/frontend-conventions)
- project-structure.mdc и AGENTS.md переписаны под monorepo
- frontend-shadcn.mdc, shadcn-ui-production.mdc, sqlite.mdc обновлены
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* BILLmanager 6 API HTTP client
|
||||
* @see https://www.ispsystem.com/docs/b6c/developer-section/working-with-api/guide-to-ispsystem-software-api
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} baseUrl - e.g. https://bill.example.com:1500/billmgr
|
||||
* @param {string} authinfo - username:password
|
||||
* @param {string} func - API function name (vds, payment, dedic)
|
||||
* @param {Record<string, string>} [params] - additional query params
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function billmanagerRequest(baseUrl, authinfo, func, params = {}) {
|
||||
const url = new URL(baseUrl)
|
||||
url.searchParams.set('authinfo', authinfo)
|
||||
url.searchParams.set('out', 'bjson')
|
||||
url.searchParams.set('func', func)
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v != null && v !== '') url.searchParams.set(k, String(v))
|
||||
}
|
||||
const res = await fetch(url.toString(), { method: 'GET' })
|
||||
if (!res.ok) {
|
||||
throw new Error(`BILLmanager API HTTP ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
if (data.error) {
|
||||
throw new Error(data.error.msg || data.error.$t || 'BILLmanager API error')
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* BILLmanager 6 API adapter
|
||||
* @see https://www.ispsystem.com/docs/b6c/developer-section/working-with-api/guide-to-ispsystem-software-api
|
||||
* @see https://www.ispsystem.com/docs/b6c/developer-section/billmanager-api/virtual-private-servers-vds
|
||||
* @see https://www.ispsystem.com/docs/b6c/developer-section/billmanager-api/payments-payment
|
||||
*/
|
||||
|
||||
export { testConnection, fetchVds, fetchDashboardInfo, fetchPayments, fetchVdsOrderPricelist, fetchVdsOrderPricelistAllDatacenters } from './operations.js'
|
||||
export { syncFromBillmanager } from './sync.js'
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* BILLmanager API response → vps-tracker model mappers
|
||||
*/
|
||||
|
||||
import { parsePricelist } from './parsers.js'
|
||||
|
||||
/** BILLmanager VDS status: 1=ordered, 2=active, 3=suspended, 4=deleted, 5=processing */
|
||||
const VDS_STATUS_MAP = {
|
||||
1: 'active',
|
||||
2: 'active',
|
||||
3: 'paused',
|
||||
4: 'archived',
|
||||
5: 'active',
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} item - BILLmanager vds item
|
||||
* @returns {object} vps-tracker vps shape
|
||||
*/
|
||||
export function mapVdsToVps(item, providerId, providerAccountId) {
|
||||
const status = VDS_STATUS_MAP[Number(item.item_status_orig ?? item.item_status)] ?? 'active'
|
||||
// cost: "100.00 RUB / Месяц" — приоритет для ежемесячного платежа; item_cost — запасной
|
||||
const costStr = String(item.cost || '').replace(/[^\d.-]/g, '')
|
||||
const cost = parseFloat(costStr) || parseFloat(item.item_cost) || 0
|
||||
const createdate = item.createdate || ''
|
||||
const expiredate = item.real_expiredate || item.expiredate || ''
|
||||
const ip = (item.ip || '').trim()
|
||||
const domain = (item.domain || '').trim()
|
||||
const datacenter = (item.datacentername || item.datacenter || '').trim()
|
||||
const ostempl = (item.ostempl || '').trim()
|
||||
const currency = (item.currency_str || 'RUB').toString().trim() || 'RUB'
|
||||
|
||||
const pricelist = item.pricelist || item.tariff || item.plan || ''
|
||||
const parsed = parsePricelist(pricelist)
|
||||
|
||||
return {
|
||||
externalId: String(item.id || ''),
|
||||
ip: ip || domain || `bm-${item.id}`,
|
||||
dns: domain || ip || '',
|
||||
ipv6: '',
|
||||
additionalIps: [],
|
||||
providerId,
|
||||
providerAccountId,
|
||||
country: '',
|
||||
city: '',
|
||||
datacenter,
|
||||
os: ostempl,
|
||||
vcpu: parsed.vcpu || 0,
|
||||
ramGb: parsed.ramGb || 0,
|
||||
diskGb: parsed.diskGb || 0,
|
||||
diskType: parsed.diskType || 'NVMe',
|
||||
virtualization: parsed.virtualization || 'KVM',
|
||||
bandwidthTb: 0,
|
||||
sshPort: 22,
|
||||
rootUser: 'root',
|
||||
purpose: '',
|
||||
environment: 'prod',
|
||||
project: '',
|
||||
monitoringEnabled: false,
|
||||
backupEnabled: false,
|
||||
status,
|
||||
tariffType: 'monthly',
|
||||
currency: currency || 'RUB',
|
||||
dailyRate: null,
|
||||
monthlyRate: cost || null,
|
||||
createdAt: createdate ? createdate.slice(0, 10) : new Date().toISOString().slice(0, 10),
|
||||
paidUntil: expiredate ? expiredate.slice(0, 10) : '',
|
||||
notes: `bm-${item.id}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} item - BILLmanager payment item
|
||||
* @returns {object|null} vps-tracker payment shape or null if not credited
|
||||
*/
|
||||
export function mapPaymentToPayment(item, providerAccountId) {
|
||||
const rawAmount = item.subaccountamount_iso || item.paymethodamount_iso || '0'
|
||||
const amount = parseFloat(String(rawAmount).replace(/[^\d.-]/g, '')) || 0
|
||||
const createDate = item.create_date || item.createdate || ''
|
||||
const dateStr = createDate ? String(createDate).slice(0, 10) : new Date().toISOString().slice(0, 10)
|
||||
// status_orig / real_status = "4" (credited), item.status может быть "Зачислен"
|
||||
const statusNum = Number(item.status_orig ?? item.real_status ?? item.status)
|
||||
if (statusNum !== 4) return null
|
||||
return {
|
||||
externalId: String(item.id || ''),
|
||||
type: 'provider_balance_topup',
|
||||
date: dateStr,
|
||||
amount,
|
||||
currency: (String(item.subaccountamount_iso || item.paymethodamount_iso || '').match(/([A-Z]{3})\b/) || [])[1] || 'USD',
|
||||
providerAccountId,
|
||||
vpsId: null,
|
||||
note: `BILLmanager #${item.number || item.id}`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* BILLmanager API operations — fetch VDS, payments, dashboard, tariffs
|
||||
*/
|
||||
|
||||
import { billmanagerRequest } from './client.js'
|
||||
import { extractList, elemToObject, parseTariffDesc, parseDatacenterName, extractTariflist } from './parsers.js'
|
||||
|
||||
/**
|
||||
* @param {string} baseUrl
|
||||
* @param {string} authinfo
|
||||
* @returns {Promise<object[]>} list of vps objects (raw BILLmanager format)
|
||||
*/
|
||||
export async function fetchVds(baseUrl, authinfo) {
|
||||
const data = await billmanagerRequest(baseUrl, authinfo, 'vds')
|
||||
const elems = extractList(data, 'vds')
|
||||
return elems.map((e) => elemToObject(e))
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить текущий баланс аккаунта (dashboard.info)
|
||||
* @param {string} baseUrl - e.g. https://billing.example.com/billmgr
|
||||
* @param {string} authinfo - username:password
|
||||
* @param {object} [opts] - { fallbackCurrency } — валюта аккаунта, если API не возвращает
|
||||
* @returns {Promise<{ balance: number, currency: string, enoughmoneyto?: string, realbalance?: string }>}
|
||||
*/
|
||||
export async function fetchDashboardInfo(baseUrl, authinfo, opts = {}) {
|
||||
const data = await billmanagerRequest(baseUrl, authinfo, 'dashboard.info', {
|
||||
dashboard: 'info',
|
||||
sfrom: 'ajax',
|
||||
})
|
||||
const elems = extractList(data, 'dashboard') || (Array.isArray(data.elem) ? data.elem : [])
|
||||
const item = elems.length > 0 ? elemToObject(elems[0]) : {}
|
||||
const balanceStr = String(item.realbalance || item.balance || item.available || '0')
|
||||
const amount = parseFloat(balanceStr.replace(/[^\d.,-]/g, '').replace(',', '.')) || 0
|
||||
let currency = (balanceStr.match(/([A-Z]{3})\b/) || [])[1]
|
||||
if (!currency) {
|
||||
if (balanceStr.includes('€') || balanceStr.includes('EUR')) currency = 'EUR'
|
||||
else if (balanceStr.includes('$') || balanceStr.includes('USD')) currency = 'USD'
|
||||
else if (balanceStr.includes('₽') || balanceStr.includes('RUB')) currency = 'RUB'
|
||||
else if (balanceStr.includes('£')) currency = 'GBP'
|
||||
else currency = opts.fallbackCurrency || 'RUB'
|
||||
}
|
||||
return {
|
||||
balance: amount,
|
||||
currency: currency || opts.fallbackCurrency || 'RUB',
|
||||
enoughmoneyto: item.enoughmoneyto || '',
|
||||
realbalance: item.realbalance || '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} baseUrl - e.g. https://billing.example.com/billmgr
|
||||
* @param {string} authinfo - username:password
|
||||
* @param {object} [opts] - { createdatestart, createdateend } — опционально, не все API поддерживают
|
||||
* @returns {Promise<object[]>} list of payment objects
|
||||
*/
|
||||
export async function fetchPayments(baseUrl, authinfo, opts = {}) {
|
||||
const params = {}
|
||||
if (opts.createdatestart) params.createdatestart = opts.createdatestart
|
||||
if (opts.createdateend) params.createdateend = opts.createdateend
|
||||
if (opts.createdate === 'other') params.createdate = 'other'
|
||||
if (opts.filter === 'on') params.filter = 'on'
|
||||
if (opts.status != null) params.status = String(opts.status)
|
||||
const data = await billmanagerRequest(baseUrl, authinfo, 'payment', params)
|
||||
const elems = extractList(data, 'payment')
|
||||
return elems.map((e) => elemToObject(e))
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить список активных тарифов для заказа VDS (vds.order) для одного датацентра
|
||||
* @param {string} baseUrl
|
||||
* @param {string} authinfo
|
||||
* @param {object} [opts] - { plid, period, datacenter }
|
||||
* @returns {Promise<{ tariffItems: object[], slist: object }>}
|
||||
*/
|
||||
export async function fetchVdsOrderPricelist(baseUrl, authinfo, opts = {}) {
|
||||
const params = {
|
||||
plid: opts.plid || '',
|
||||
sfrom: 'ajax',
|
||||
}
|
||||
if (opts.period) params.period = opts.period
|
||||
if (opts.datacenter) params.datacenter = opts.datacenter
|
||||
|
||||
const data = await billmanagerRequest(baseUrl, authinfo, 'vds.order', params)
|
||||
|
||||
const tariflist = extractTariflist(data)
|
||||
const slist = data?.list?.slist ?? data?.slist ?? {}
|
||||
if (tariflist.length === 0) return { tariffItems: [], slist }
|
||||
|
||||
const tariffItems = tariflist.map((rawItem) => {
|
||||
const item = elemToObject(rawItem)
|
||||
const parsed = parseTariffDesc(item.desc || '')
|
||||
const descClean = (item.desc || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
const name = parsed.name || parsed.cpuModel || item.price?.split(' ')[0] || '—'
|
||||
return {
|
||||
externalId: String(item.pricelist || ''),
|
||||
name,
|
||||
desc: descClean,
|
||||
vcpu: parsed.vcpu,
|
||||
ramGb: parsed.ramGb,
|
||||
diskGb: parsed.diskGb,
|
||||
diskType: parsed.diskType,
|
||||
virtualization: parsed.virtualization,
|
||||
channel: parsed.channel,
|
||||
location: parsed.location,
|
||||
cpuModel: parsed.cpuModel,
|
||||
orderAvailable: (item.order_available || '').toLowerCase() === 'on',
|
||||
price: item.price || '',
|
||||
}
|
||||
})
|
||||
|
||||
return { tariffItems, slist }
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить тарифы по всем датацентрам — отдельный запрос на каждый ДЦ.
|
||||
* @param {string} baseUrl
|
||||
* @param {string} authinfo
|
||||
* @returns {Promise<{ tariffItems: object[], slist: object }>}
|
||||
*/
|
||||
export async function fetchVdsOrderPricelistAllDatacenters(baseUrl, authinfo) {
|
||||
const initial = await fetchVdsOrderPricelist(baseUrl, authinfo)
|
||||
const slist = initial.slist || {}
|
||||
const datacenters = Array.isArray(slist.datacenter) ? slist.datacenter : []
|
||||
|
||||
if (datacenters.length === 0) {
|
||||
return { tariffItems: initial.tariffItems, slist }
|
||||
}
|
||||
|
||||
const allTariffItems = []
|
||||
|
||||
for (let i = 0; i < datacenters.length; i++) {
|
||||
const dc = datacenters[i]
|
||||
const dcKey = String(dc.k ?? dc.key ?? '')
|
||||
const dcName = String(dc.v ?? dc.value ?? dc.name ?? '')
|
||||
const { country, location } = parseDatacenterName(dcName)
|
||||
|
||||
const result = i === 0 ? initial : await fetchVdsOrderPricelist(baseUrl, authinfo, { datacenter: dcKey })
|
||||
for (const t of result.tariffItems) {
|
||||
allTariffItems.push({
|
||||
...t,
|
||||
datacenterKey: dcKey,
|
||||
datacenterName: dcName,
|
||||
country,
|
||||
location: location || dcName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { tariffItems: allTariffItems, slist }
|
||||
}
|
||||
|
||||
/**
|
||||
* Test API connection — запрос списка VDS
|
||||
* @param {string} baseUrl
|
||||
* @param {string} authinfo - username:password
|
||||
* @returns {Promise<{ ok: boolean, error?: string, vdsCount?: number }>}
|
||||
*/
|
||||
export async function testConnection(baseUrl, authinfo) {
|
||||
if (!baseUrl?.trim() || !authinfo?.trim()) {
|
||||
return { ok: false, error: 'Укажите URL и учётные данные' }
|
||||
}
|
||||
try {
|
||||
const items = await fetchVds(baseUrl.trim(), authinfo.trim())
|
||||
return { ok: true, vdsCount: items?.length ?? 0 }
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message || 'Ошибка подключения' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* BILLmanager API response parsers
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse BILLmanager JSON list response.
|
||||
* bjson: { elem: [ {...}, {...} ] } или { data: { elem: [...] } }
|
||||
* xml/json: { doc: { vds: { elem: [...] } } }
|
||||
*/
|
||||
export function extractList(data, key) {
|
||||
if (Array.isArray(data.elem)) return data.elem
|
||||
if (data.data?.elem) return Array.isArray(data.data.elem) ? data.data.elem : [data.data.elem]
|
||||
const doc = data.doc || data
|
||||
let list = doc[key]
|
||||
if (!list) return []
|
||||
if (Array.isArray(list)) return list
|
||||
if (list.elem) {
|
||||
const elems = Array.isArray(list.elem) ? list.elem : [list.elem]
|
||||
return elems
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract flat object from BILLmanager elem (can be array of { $name, $t } or already flat object)
|
||||
*/
|
||||
export function elemToObject(elem) {
|
||||
if (!elem) return {}
|
||||
if (Array.isArray(elem)) {
|
||||
const obj = {}
|
||||
for (const e of elem) {
|
||||
const name = e.$name || e.name
|
||||
const val = e.$t ?? e.$ ?? e
|
||||
if (name) obj[name] = typeof val === 'object' && val !== null ? (val.$t ?? val.$ ?? JSON.stringify(val)) : val
|
||||
}
|
||||
return obj
|
||||
}
|
||||
return typeof elem === 'object' ? elem : {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse pricelist string: "KVM SSD Start (1 CPU/768 MB RAM/7 GB SSD)"
|
||||
* @returns {{ vcpu: number, ramGb: number, diskGb: number, diskType: string, virtualization: string }}
|
||||
*/
|
||||
export function parsePricelist(pricelist) {
|
||||
const s = String(pricelist || '')
|
||||
let vcpu = 0
|
||||
let ramGb = 0
|
||||
let diskGb = 0
|
||||
let diskType = 'NVMe'
|
||||
let virtualization = 'KVM'
|
||||
|
||||
const cpuMatch = s.match(/(\d+)\s*(?:CPU|СPU)/i)
|
||||
if (cpuMatch) vcpu = parseInt(cpuMatch[1], 10) || 0
|
||||
|
||||
const ramMbMatch = s.match(/(\d+)\s*MB\s*RAM/i)
|
||||
if (ramMbMatch) ramGb = Math.max(1, Math.round((parseInt(ramMbMatch[1], 10) || 0) / 1024))
|
||||
else {
|
||||
const ramGbMatch = s.match(/(\d+)\s*GB\s*RAM/i)
|
||||
if (ramGbMatch) ramGb = parseInt(ramGbMatch[1], 10) || 0
|
||||
}
|
||||
|
||||
const diskMatch = s.match(/(\d+)\s*GB\s*(SSD|NVMe|HDD)/i)
|
||||
if (diskMatch) {
|
||||
diskGb = parseInt(diskMatch[1], 10) || 0
|
||||
diskType = diskMatch[2] || 'NVMe'
|
||||
}
|
||||
|
||||
if (/\bKVM\b/i.test(s)) virtualization = 'KVM'
|
||||
else if (/\bOpenVZ\b/i.test(s)) virtualization = 'OpenVZ'
|
||||
else if (/\bLXC\b/i.test(s)) virtualization = 'LXC'
|
||||
|
||||
return { vcpu, ramGb, diskGb, diskType, virtualization }
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит описание тарифа из vds.order.pricelist (desc)
|
||||
* Поддерживает два формата: текстовый (Firstbyte) и HTML (Selectel и др.)
|
||||
* @param {string} desc - HTML или текстовое описание тарифа
|
||||
* @returns {{ name: string, vcpu: number, ramGb: number, diskGb: number, diskType: string, virtualization: string, channel: string, location: string, cpuModel: string }}
|
||||
*/
|
||||
export function parseTariffDesc(desc) {
|
||||
const s = String(desc || '')
|
||||
let name = ''
|
||||
let vcpu = 0
|
||||
let ramGb = 0
|
||||
let diskGb = 0
|
||||
let diskType = 'SSD'
|
||||
let virtualization = 'KVM'
|
||||
let channel = ''
|
||||
let location = ''
|
||||
let cpuModel = ''
|
||||
|
||||
const firstLine = s.split(/\r?\n|<br\s*\/?>/i)[0]?.trim() || ''
|
||||
name = firstLine.replace(/<[^>]+>/g, '').trim()
|
||||
|
||||
// Формат 1: "Процессор: 1 ядро", "Память: 1 GB", "Диск: 20 GB SSD", "Канал: 200Mb/s"
|
||||
const cpuMatch = s.match(/Процессор:\s*(\d+)\s*(?:ядро|ядра|ядер)/i)
|
||||
if (cpuMatch) vcpu = parseInt(cpuMatch[1], 10) || 0
|
||||
|
||||
const ramMbMatch = s.match(/Память:\s*(\d+)\s*MB/i)
|
||||
if (ramMbMatch) ramGb = Math.max(0.5, Math.round((parseInt(ramMbMatch[1], 10) || 0) / 1024 * 10) / 10)
|
||||
else {
|
||||
const ramGbMatch = s.match(/Память:\s*(\d+)\s*GB/i)
|
||||
if (ramGbMatch) ramGb = parseInt(ramGbMatch[1], 10) || 0
|
||||
}
|
||||
|
||||
const diskMatch = s.match(/Диск:\s*(\d+)\s*GB\s*(SSD|SAS|HDD|NVMe)/i)
|
||||
if (diskMatch) {
|
||||
diskGb = parseInt(diskMatch[1], 10) || 0
|
||||
diskType = diskMatch[2] || 'SSD'
|
||||
}
|
||||
|
||||
const channelMatch = s.match(/Канал:\s*(\d+Mb\/s)/i)
|
||||
if (channelMatch) channel = channelMatch[1]
|
||||
|
||||
if (/\bKVM\b/i.test(s)) virtualization = 'KVM'
|
||||
else if (/\bOpenVZ\b/i.test(s)) virtualization = 'OpenVZ'
|
||||
else if (/\bLXC\b/i.test(s)) virtualization = 'LXC'
|
||||
|
||||
// Формат 2 (HTML): "Публичная сеть: 250 Мбит/с", "Локация: Москва, Россия", "Процессор: Ryzen 7 5800X", "NVMe накопитель"
|
||||
if (!channel) {
|
||||
const netMatch = s.match(/(?:Публичная сеть|Канал)[:\s]*\*?\*?(\d+)\s*Мбит/i)
|
||||
if (netMatch) channel = `${netMatch[1]} Мбит/с`
|
||||
}
|
||||
if (!location) {
|
||||
const locMatch = s.match(/Локация[:\s]*([^;]+)/i)
|
||||
if (locMatch) location = locMatch[1].replace(/<[^>]+>/g, '').trim()
|
||||
}
|
||||
if (!cpuModel) {
|
||||
const procMatch = s.match(/Процессор[:\s]*([^;]+?)(?:\s+до\s|$)/i)
|
||||
if (procMatch) cpuModel = procMatch[1].replace(/<[^>]+>/g, '').trim()
|
||||
}
|
||||
if (!diskType || diskType === 'SSD') {
|
||||
if (/\bNVMe\b/i.test(s)) diskType = 'NVMe'
|
||||
else if (/\bSAS\b/i.test(s)) diskType = 'SAS'
|
||||
else if (/\bHDD\b/i.test(s)) diskType = 'HDD'
|
||||
else if (/\bSSD\b/i.test(s)) diskType = 'SSD'
|
||||
}
|
||||
|
||||
return { name, vcpu, ramGb, diskGb, diskType, virtualization, channel, location, cpuModel }
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит название датацентра для извлечения страны и локации
|
||||
* @param {string} dcName - название ДЦ, напр. "1 Датацентр Россия, Москва", "[DE] Франкфурт", "Франция"
|
||||
* @returns {{ country: string, location: string }}
|
||||
*/
|
||||
export function parseDatacenterName(dcName) {
|
||||
const s = String(dcName || '').trim()
|
||||
if (!s) return { country: '', location: '' }
|
||||
|
||||
const COUNTRY_CODE_MAP = {
|
||||
DE: 'Германия',
|
||||
FI: 'Финляндия',
|
||||
RU: 'Россия',
|
||||
FR: 'Франция',
|
||||
GB: 'Великобритания',
|
||||
NL: 'Нидерланды',
|
||||
US: 'США',
|
||||
SE: 'Швеция',
|
||||
NO: 'Норвегия',
|
||||
BE: 'Бельгия',
|
||||
CH: 'Швейцария',
|
||||
CZ: 'Чехия',
|
||||
CA: 'Канада',
|
||||
LV: 'Латвия',
|
||||
LT: 'Литва',
|
||||
EE: 'Эстония',
|
||||
PL: 'Польша',
|
||||
IT: 'Италия',
|
||||
DK: 'Дания',
|
||||
AU: 'Австралия',
|
||||
ES: 'Испания',
|
||||
SG: 'Сингапур',
|
||||
}
|
||||
|
||||
// "[DE] Франкфурт | AMD EPYC" -> country: DE/Германия, location: Франкфурт
|
||||
const codeMatch = s.match(/\[([A-Z]{2})\]\s*([^|]+)/)
|
||||
if (codeMatch) {
|
||||
const code = codeMatch[1]
|
||||
const loc = codeMatch[2].trim()
|
||||
return { country: COUNTRY_CODE_MAP[code] || code, location: loc }
|
||||
}
|
||||
|
||||
// "N Датацентр Страна, Город" или "Датацентр Страна, Город"
|
||||
const dcMatch = s.match(/(?:\d+\s+)?Датацентр\s+([^,]+),\s*(.+)/i)
|
||||
if (dcMatch) {
|
||||
return { country: dcMatch[1].trim(), location: dcMatch[2].trim() }
|
||||
}
|
||||
|
||||
// "Страна, Город" (без слова Датацентр)
|
||||
const commaMatch = s.match(/^([^,]+),\s*(.+)$/)
|
||||
if (commaMatch) {
|
||||
return { country: commaMatch[1].trim(), location: commaMatch[2].trim() }
|
||||
}
|
||||
|
||||
// Только страна: "Франция", "Россия", "Германия", "Чехия" и т.д.
|
||||
const countryOnly = [
|
||||
'Россия', 'Чехия', 'Нидерланды', 'Франция', 'Великобритания', 'Германия',
|
||||
'Финляндия', 'Швеция', 'Норвегия', 'Бельгия', 'Швейцария', 'Канада',
|
||||
'Латвия', 'Литва', 'Эстония', 'Польша', 'Италия', 'Дания', 'Австралия',
|
||||
'Испания', 'Сингапур', 'США', 'Азия', 'Европа',
|
||||
]
|
||||
for (const c of countryOnly) {
|
||||
if (s === c || s.startsWith(c + ',') || s.startsWith(c + ' ')) {
|
||||
const rest = s.slice(c.length).replace(/^[,\s]+/, '')
|
||||
return { country: c, location: rest }
|
||||
}
|
||||
}
|
||||
|
||||
// "ММТС-9", "Adman", "Европа DC1", "Москва DC3" — по ключевым словам
|
||||
if (/ММТС|Adman|Москва/i.test(s)) {
|
||||
return { country: 'Россия', location: s }
|
||||
}
|
||||
if (/Европа/i.test(s)) {
|
||||
return { country: 'Европа', location: s.replace(/Европа\s*/i, '').trim() || s }
|
||||
}
|
||||
|
||||
// "США | Ryzen 9 9950X" — страна до |
|
||||
const pipeMatch = s.match(/^([^|]+)\s*\|/)
|
||||
if (pipeMatch) {
|
||||
const part = pipeMatch[1].trim()
|
||||
for (const c of countryOnly) {
|
||||
if (part.includes(c)) return { country: c, location: '' }
|
||||
}
|
||||
return { country: part, location: '' }
|
||||
}
|
||||
|
||||
return { country: s, location: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлечь tariflist из ответа vds.order (поддержка разных форматов BILLmanager)
|
||||
* @param {object} data - сырой ответ API
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function extractTariflist(data) {
|
||||
if (!data) return []
|
||||
const listNode = data.list ?? data.doc?.list ?? data.doc
|
||||
if (!listNode) return []
|
||||
|
||||
// tariflist / tarifflist / pricelist — разные варианты названия
|
||||
let list = listNode.tariflist ?? listNode.tarifflist ?? listNode.pricelist
|
||||
if (Array.isArray(list)) return list
|
||||
|
||||
// elem-формат (как в vds, payment)
|
||||
const elems = listNode.elem
|
||||
if (elems) return Array.isArray(elems) ? elems : [elems]
|
||||
|
||||
return []
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Sync BILLmanager data into vps-tracker DB
|
||||
*/
|
||||
|
||||
import { fetchVds, fetchPayments, fetchDashboardInfo, fetchVdsOrderPricelistAllDatacenters } from './operations.js'
|
||||
import { mapVdsToVps, mapPaymentToPayment } from './mappers.js'
|
||||
|
||||
/**
|
||||
* Sync BILLmanager data into vps-tracker DB
|
||||
* @param {object} account - provider_account с apiCredentials и apiBaseUrl (URL с хостера или уже подставленный)
|
||||
* @param {object} db - getDb() wrapper
|
||||
* @param {object} [opts] - { paymentDaysBack, skipTariffs, skipVpsPayments }
|
||||
* @returns {{ vpsCount: number, paymentsCount: number, tariffsCount: number, balance?: object }}
|
||||
*/
|
||||
export async function syncFromBillmanager(account, db, opts = {}) {
|
||||
const { skipTariffs = false, skipVpsPayments = false } = opts
|
||||
const { apiBaseUrl, apiCredentials, providerId, id: accountId } = account
|
||||
if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) {
|
||||
throw new Error('API URL and credentials are required')
|
||||
}
|
||||
const authinfo = apiCredentials.trim()
|
||||
|
||||
const fetchVpsPayments = !skipVpsPayments
|
||||
const fetchTariffs = !skipTariffs
|
||||
|
||||
const [vdsItems, paymentItems, dashboardInfo, tariffResult] = await Promise.all([
|
||||
fetchVpsPayments ? fetchVds(apiBaseUrl, authinfo) : [],
|
||||
fetchVpsPayments ? fetchPayments(apiBaseUrl, authinfo, {}) : [],
|
||||
fetchVpsPayments ? fetchDashboardInfo(apiBaseUrl, authinfo, { fallbackCurrency: account.currency }).catch(() => null) : null,
|
||||
fetchTariffs ? fetchVdsOrderPricelistAllDatacenters(apiBaseUrl, authinfo).catch((err) => {
|
||||
console.warn('fetchVdsOrderPricelistAllDatacenters failed:', err.message)
|
||||
return { tariffItems: [], slist: {} }
|
||||
}) : { tariffItems: [], slist: {} },
|
||||
])
|
||||
const { tariffItems = [], slist = {} } = tariffResult || {}
|
||||
|
||||
let vpsCount = 0
|
||||
/** @type {{ added: { id: string, label: string }[], updated: { id: string, label: string, fields: string[] }[], paymentsAdded: number }} */
|
||||
const syncSummary = { added: [], updated: [], paymentsAdded: 0 }
|
||||
if (fetchVpsPayments) {
|
||||
const vpsInsertSql = `INSERT INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, projectId, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
const vpsUpdateSql = `UPDATE vps SET ip=?, ipv6=?, additionalIps=?, dns=?, country=?, city=?, datacenter=?, os=?, status=?, tariffType=?, currency=?, dailyRate=?, monthlyRate=?, paidUntil=?, notes=?
|
||||
WHERE id=?`
|
||||
|
||||
const SYNC_UPDATE_FIELDS = ['country', 'city', 'datacenter', 'os', 'notes', 'status', 'tariffType', 'currency', 'dailyRate', 'monthlyRate', 'paidUntil']
|
||||
|
||||
const normVal = (v) => {
|
||||
if (v == null || v === '') return ''
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
|
||||
return String(v)
|
||||
}
|
||||
|
||||
for (const item of vdsItems) {
|
||||
const vps = mapVdsToVps(item, providerId, accountId)
|
||||
const id = `vps-bm-${accountId}-${vps.externalId}`
|
||||
const additionalIps = JSON.stringify(vps.additionalIps || [])
|
||||
const dailyRate = vps.dailyRate
|
||||
const monthlyRate = vps.monthlyRate
|
||||
const paidUntil = vps.paidUntil || ''
|
||||
const notes = vps.notes ? `${vps.notes} [bm-${vps.externalId}]` : `bm-${vps.externalId}`
|
||||
|
||||
const existing = db.prepare('SELECT * FROM vps WHERE providerAccountId = ? AND (ip = ? OR notes LIKE ?)').get(accountId, vps.ip, `%bm-${vps.externalId}%`)
|
||||
if (existing) {
|
||||
let userOverrides = []
|
||||
try {
|
||||
userOverrides = existing.userOverrides ? JSON.parse(existing.userOverrides) : []
|
||||
} catch {
|
||||
userOverrides = []
|
||||
}
|
||||
const merged = {
|
||||
ip: vps.ip,
|
||||
ipv6: vps.ipv6,
|
||||
additionalIps,
|
||||
dns: vps.dns,
|
||||
country: vps.country,
|
||||
city: vps.city,
|
||||
datacenter: vps.datacenter,
|
||||
os: vps.os,
|
||||
status: vps.status,
|
||||
tariffType: vps.tariffType,
|
||||
currency: vps.currency,
|
||||
dailyRate,
|
||||
monthlyRate,
|
||||
paidUntil,
|
||||
notes,
|
||||
}
|
||||
for (const f of SYNC_UPDATE_FIELDS) {
|
||||
if (userOverrides.includes(f)) {
|
||||
merged[f] = existing[f]
|
||||
}
|
||||
}
|
||||
const compareFields = ['ip', 'ipv6', 'dns', ...SYNC_UPDATE_FIELDS]
|
||||
const changedFields = compareFields.filter(
|
||||
(f) => normVal(merged[f]) !== normVal(existing[f]),
|
||||
)
|
||||
if (changedFields.length > 0) {
|
||||
const label = merged.dns || merged.ip || existing.id
|
||||
syncSummary.updated.push({ id: existing.id, label, fields: changedFields })
|
||||
}
|
||||
db.run(vpsUpdateSql,
|
||||
merged.ip,
|
||||
merged.ipv6,
|
||||
merged.additionalIps,
|
||||
merged.dns,
|
||||
merged.country,
|
||||
merged.city,
|
||||
merged.datacenter,
|
||||
merged.os,
|
||||
merged.status,
|
||||
merged.tariffType,
|
||||
merged.currency,
|
||||
merged.dailyRate,
|
||||
merged.monthlyRate,
|
||||
merged.paidUntil,
|
||||
merged.notes,
|
||||
existing.id,
|
||||
)
|
||||
} else {
|
||||
const label = vps.dns || vps.ip || id
|
||||
syncSummary.added.push({ id, label })
|
||||
db.run(vpsInsertSql,
|
||||
id,
|
||||
vps.ip,
|
||||
vps.ipv6,
|
||||
additionalIps,
|
||||
vps.dns,
|
||||
vps.providerId,
|
||||
vps.providerAccountId,
|
||||
vps.country,
|
||||
vps.city,
|
||||
vps.datacenter,
|
||||
vps.os,
|
||||
vps.vcpu,
|
||||
vps.ramGb,
|
||||
vps.diskGb,
|
||||
vps.diskType,
|
||||
vps.virtualization,
|
||||
vps.bandwidthTb,
|
||||
vps.sshPort,
|
||||
vps.rootUser,
|
||||
vps.purpose,
|
||||
vps.environment,
|
||||
vps.project,
|
||||
null,
|
||||
vps.monitoringEnabled ? 1 : 0,
|
||||
vps.backupEnabled ? 1 : 0,
|
||||
vps.status,
|
||||
vps.tariffType,
|
||||
vps.currency,
|
||||
dailyRate,
|
||||
monthlyRate,
|
||||
vps.createdAt,
|
||||
paidUntil,
|
||||
notes,
|
||||
'[]',
|
||||
)
|
||||
}
|
||||
vpsCount++
|
||||
}
|
||||
}
|
||||
|
||||
let paymentsCount = 0
|
||||
if (fetchVpsPayments) {
|
||||
const existingPayments = new Set(
|
||||
db.prepare('SELECT note FROM payments WHERE providerAccountId = ?').all(accountId).map((r) => r.note),
|
||||
)
|
||||
const paymentInsertSql = `INSERT INTO payments (id, type, date, amount, currency, providerAccountId, vpsId, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
for (const item of paymentItems) {
|
||||
const payment = mapPaymentToPayment(item, accountId)
|
||||
if (!payment || payment.amount <= 0) continue
|
||||
const note = payment.note
|
||||
if (existingPayments.has(note)) continue
|
||||
const id = `pay-bm-${accountId}-${payment.externalId}`
|
||||
db.run(paymentInsertSql, id, payment.type, payment.date, payment.amount, payment.currency, payment.providerAccountId, payment.vpsId, note)
|
||||
existingPayments.add(note)
|
||||
paymentsCount++
|
||||
syncSummary.paymentsAdded += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (fetchVpsPayments && dashboardInfo) {
|
||||
db.run(
|
||||
'UPDATE provider_accounts SET balance_api=?, balance_currency=?, balance_updated_at=?, enoughmoneyto=? WHERE id=?',
|
||||
dashboardInfo.balance,
|
||||
dashboardInfo.currency || 'RUB',
|
||||
new Date().toISOString(),
|
||||
dashboardInfo.enoughmoneyto || '',
|
||||
accountId,
|
||||
)
|
||||
}
|
||||
|
||||
let tariffsCount = 0
|
||||
let newTariffs = []
|
||||
if (fetchTariffs) {
|
||||
const existingTariffIds = new Set(
|
||||
db.prepare('SELECT id FROM active_tariffs WHERE providerAccountId = ?').all(accountId).map((r) => r.id),
|
||||
)
|
||||
const syncedAt = new Date().toISOString()
|
||||
db.run('DELETE FROM active_tariffs WHERE providerAccountId = ?', accountId)
|
||||
const tariffInsertSql = `INSERT INTO active_tariffs (id, providerAccountId, providerId, externalId, datacenterKey, datacenterName, name, desc, vcpu, ramGb, diskGb, diskType, virtualization, channel, location, country, cpuModel, orderAvailable, price, syncedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
for (const t of tariffItems) {
|
||||
const dcKey = t.datacenterKey ?? ''
|
||||
const dcName = t.datacenterName ?? ''
|
||||
const id = dcKey ? `tariff-bm-${accountId}-${t.externalId}-${dcKey}` : `tariff-bm-${accountId}-${t.externalId}`
|
||||
if (!existingTariffIds.has(id)) {
|
||||
newTariffs.push({ name: t.name || '', price: t.price || '', providerId })
|
||||
}
|
||||
db.run(tariffInsertSql,
|
||||
id,
|
||||
accountId,
|
||||
providerId,
|
||||
t.externalId,
|
||||
dcKey,
|
||||
dcName,
|
||||
t.name || '',
|
||||
t.desc || '',
|
||||
t.vcpu || 0,
|
||||
t.ramGb || 0,
|
||||
t.diskGb || 0,
|
||||
t.diskType || 'SSD',
|
||||
t.virtualization || 'KVM',
|
||||
t.channel || '',
|
||||
t.location || '',
|
||||
t.country || '',
|
||||
t.cpuModel || '',
|
||||
t.orderAvailable ? 1 : 0,
|
||||
t.price || '',
|
||||
syncedAt,
|
||||
)
|
||||
tariffsCount++
|
||||
}
|
||||
|
||||
if (Object.keys(slist).length > 0) {
|
||||
const datacenters = Array.isArray(slist.datacenter) ? JSON.stringify(slist.datacenter) : '[]'
|
||||
const periods = Array.isArray(slist.period) ? JSON.stringify(slist.period) : '[]'
|
||||
db.run(
|
||||
`INSERT OR REPLACE INTO tariff_sync_options (providerAccountId, datacenters, periods, syncedAt) VALUES (?, ?, ?, ?)`,
|
||||
accountId,
|
||||
datacenters,
|
||||
periods,
|
||||
syncedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!fetchVpsPayments) {
|
||||
syncSummary.tariffsOnly = true
|
||||
}
|
||||
return { vpsCount, paymentsCount, tariffsCount, newTariffs, balance: dashboardInfo, syncSummary }
|
||||
}
|
||||
Reference in New Issue
Block a user