feat(billmanager): профиль FirstByte — ДЦ, оплата и specs VPS
Docker / build (push) Failing after 19s
Docker / build (push) Failing after 19s
Парсинг страны/города и paidUntil из list vds; CPU/RAM/диск из vds.order по pricelist_id, иначе vds.edit. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi, afterEach } from 'vitest'
|
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||||
|
|
||||||
import { elemToObject, extractList } from './parsers.js'
|
import { elemToObject, extractList, parseDatacenterName } from './parsers.js'
|
||||||
import { mapVdsToVps } from './mappers.js'
|
import { mapVdsToVps } from './mappers.js'
|
||||||
import {
|
import {
|
||||||
DEFAULT_PROFILE,
|
DEFAULT_PROFILE,
|
||||||
@@ -9,6 +9,10 @@ import {
|
|||||||
waicoreOverrides,
|
waicoreOverrides,
|
||||||
} from './profiles/index.js'
|
} from './profiles/index.js'
|
||||||
import { fetchVds, mapVdsWithProfile } from './operations.js'
|
import { fetchVds, mapVdsWithProfile } from './operations.js'
|
||||||
|
import {
|
||||||
|
enrichMappedVpsFromTariffs,
|
||||||
|
parseSpecsFromVdsEdit,
|
||||||
|
} from './vds-specs.js'
|
||||||
|
|
||||||
/** Minimal Waicore-style bjson (no credentials / addon noise). */
|
/** Minimal Waicore-style bjson (no credentials / addon noise). */
|
||||||
const WAICORE_VDS_FIXTURE = {
|
const WAICORE_VDS_FIXTURE = {
|
||||||
@@ -33,6 +37,44 @@ const WAICORE_VDS_FIXTURE = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FIRSTBYTE_VDS_FIXTURE = {
|
||||||
|
func: 'vds',
|
||||||
|
elem: [
|
||||||
|
{
|
||||||
|
id: '4478979',
|
||||||
|
ip: '45.95.202.221',
|
||||||
|
domain: 'auth.shnt.top',
|
||||||
|
expiredate: '2026-10-15',
|
||||||
|
real_expiredate: '2026-10-15',
|
||||||
|
billdaily: 'off',
|
||||||
|
datacentername: '1 Датацентр Россия, Москва',
|
||||||
|
ostempl: 'Debian-13-amd64',
|
||||||
|
pricelist: 'MSK-KVM-SSD-START',
|
||||||
|
pricelist_id: '5228',
|
||||||
|
currency_str: 'RUB',
|
||||||
|
createdate: '2026-07-15',
|
||||||
|
item_status_orig: '2',
|
||||||
|
cost: '75.00 RUB / Месяц',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4208964',
|
||||||
|
ip: '193.168.227.234',
|
||||||
|
domain: 'vt.shnt.top',
|
||||||
|
expiredate: 'Ежедневное списание',
|
||||||
|
real_expiredate: '2026-07-20',
|
||||||
|
billdaily: 'on',
|
||||||
|
datacentername: '1 Датацентр Россия, Москва',
|
||||||
|
ostempl: 'Debian-13-amd64',
|
||||||
|
pricelist: 'MSK-KVM-SAS-1',
|
||||||
|
pricelist_id: '564',
|
||||||
|
currency_str: 'RUB',
|
||||||
|
createdate: '2026-04-21',
|
||||||
|
item_status_orig: '2',
|
||||||
|
cost: '129.00 RUB / Месяц',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
describe('billmanager profiles', () => {
|
describe('billmanager profiles', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
@@ -53,6 +95,13 @@ describe('billmanager profiles', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('resolve: firstbyte hostname → firstbyte profile', () => {
|
||||||
|
const p = resolveBillmanagerProfile('https://my.firstbyte.ru/')
|
||||||
|
expect(p.id).toBe('firstbyte')
|
||||||
|
expect(p.funcs.listVds).toBe('vds')
|
||||||
|
expect(p.map.enrichVds).toBeTypeOf('function')
|
||||||
|
})
|
||||||
|
|
||||||
it('resolve: unknown hoster → default', () => {
|
it('resolve: unknown hoster → default', () => {
|
||||||
const p = resolveBillmanagerProfile('https://bill.hoster.ru/')
|
const p = resolveBillmanagerProfile('https://bill.hoster.ru/')
|
||||||
expect(p.id).toBe('default')
|
expect(p.id).toBe('default')
|
||||||
@@ -67,6 +116,13 @@ describe('billmanager profiles', () => {
|
|||||||
expect(merged.map.vds).toBe(DEFAULT_PROFILE.map.vds)
|
expect(merged.map.vds).toBe(DEFAULT_PROFILE.map.vds)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('parseDatacenterName: FirstByte «1 Датацентр Россия, Москва»', () => {
|
||||||
|
expect(parseDatacenterName('1 Датацентр Россия, Москва')).toEqual({
|
||||||
|
country: 'Россия',
|
||||||
|
location: 'Москва',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('Waicore fixture elem → MappedVps fields', () => {
|
it('Waicore fixture elem → MappedVps fields', () => {
|
||||||
const profile = resolveBillmanagerProfile('https://my.waicore.com/')
|
const profile = resolveBillmanagerProfile('https://my.waicore.com/')
|
||||||
const elems = extractList(WAICORE_VDS_FIXTURE, profile.extract.listVdsKey)
|
const elems = extractList(WAICORE_VDS_FIXTURE, profile.extract.listVdsKey)
|
||||||
@@ -81,6 +137,82 @@ describe('billmanager profiles', () => {
|
|||||||
expect(vps.status).toBe('active')
|
expect(vps.status).toBe('active')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FirstByte: country/city + paidUntil for monthly and daily', () => {
|
||||||
|
const profile = resolveBillmanagerProfile('https://bill.firstbyte.ru/')
|
||||||
|
const elems = extractList(FIRSTBYTE_VDS_FIXTURE, profile.extract.listVdsKey)
|
||||||
|
expect(elems).toHaveLength(2)
|
||||||
|
|
||||||
|
const monthly = mapVdsWithProfile(
|
||||||
|
profile,
|
||||||
|
elemToObject(elems[0]!),
|
||||||
|
'prov-fb',
|
||||||
|
'acc-fb',
|
||||||
|
)
|
||||||
|
expect(monthly.country).toBe('Россия')
|
||||||
|
expect(monthly.city).toBe('Москва')
|
||||||
|
expect(monthly.datacenter).toBe('1 Датацентр Россия, Москва')
|
||||||
|
expect(monthly.paidUntil).toBe('2026-10-15')
|
||||||
|
expect(monthly.tariffType).toBe('monthly')
|
||||||
|
expect(monthly.diskType).toBe('SSD')
|
||||||
|
expect(monthly.virtualization).toBe('KVM')
|
||||||
|
expect(monthly.vcpu).toBe(0)
|
||||||
|
|
||||||
|
const daily = mapVdsWithProfile(
|
||||||
|
profile,
|
||||||
|
elemToObject(elems[1]!),
|
||||||
|
'prov-fb',
|
||||||
|
'acc-fb',
|
||||||
|
)
|
||||||
|
expect(daily.country).toBe('Россия')
|
||||||
|
expect(daily.city).toBe('Москва')
|
||||||
|
expect(daily.paidUntil).toBe('2026-07-20')
|
||||||
|
expect(daily.tariffType).toBe('daily')
|
||||||
|
expect(daily.diskType).toBe('SAS')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FirstByte: specs from vds.order by pricelist_id', () => {
|
||||||
|
const profile = resolveBillmanagerProfile('https://my.firstbyte.ru/')
|
||||||
|
expect(profile.options?.fetchVdsEditForSpecs).toBe(true)
|
||||||
|
|
||||||
|
const item = elemToObject(FIRSTBYTE_VDS_FIXTURE.elem[0]!)
|
||||||
|
let mapped = mapVdsWithProfile(profile, item, 'p', 'a')
|
||||||
|
mapped = enrichMappedVpsFromTariffs(item, mapped, [
|
||||||
|
{
|
||||||
|
externalId: '5228',
|
||||||
|
name: 'START',
|
||||||
|
desc: 'START<br/>Процессор: 1 ядро; Память: 1 GB; Диск: 15 GB SSD',
|
||||||
|
vcpu: 1,
|
||||||
|
ramGb: 1,
|
||||||
|
diskGb: 15,
|
||||||
|
diskType: 'SSD',
|
||||||
|
virtualization: 'KVM',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(mapped.vcpu).toBe(1)
|
||||||
|
expect(mapped.ramGb).toBe(1)
|
||||||
|
expect(mapped.diskGb).toBe(15)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parseSpecsFromVdsEdit reads addon labels', () => {
|
||||||
|
const specs = parseSpecsFromVdsEdit({
|
||||||
|
model: {
|
||||||
|
addon_11: '2',
|
||||||
|
addon_12: '2048',
|
||||||
|
addon_13: '40',
|
||||||
|
pricelist: 'MSK-KVM-SSD-START',
|
||||||
|
},
|
||||||
|
messages: [
|
||||||
|
{ name: 'addon_11', msg: 'Количество ядер процессора' },
|
||||||
|
{ name: 'addon_12', msg: 'Память (MB)' },
|
||||||
|
{ name: 'addon_13', msg: 'Диск SSD (GB)' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(specs.vcpu).toBe(2)
|
||||||
|
expect(specs.ramGb).toBe(2)
|
||||||
|
expect(specs.diskGb).toBe(40)
|
||||||
|
expect(specs.diskType).toBe('SSD')
|
||||||
|
})
|
||||||
|
|
||||||
it('fetchVds for waicore URL uses func=vds.vps', async () => {
|
it('fetchVds for waicore URL uses func=vds.vps', async () => {
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
|
|||||||
@@ -44,3 +44,10 @@ export const myHosterOverrides: BillmanagerProfileOverrides = {
|
|||||||
`resolveBillmanagerProfile(apiBaseUrl)` → `merge(DEFAULT, override)` или `DEFAULT`.
|
`resolveBillmanagerProfile(apiBaseUrl)` → `merge(DEFAULT, override)` или `DEFAULT`.
|
||||||
|
|
||||||
Sync и operations **не** содержат `if (hoster)` — только профиль.
|
Sync и operations **не** содержат `if (hoster)` — только профиль.
|
||||||
|
|
||||||
|
## Готовые профили
|
||||||
|
|
||||||
|
| id | Match | Что переопределяет |
|
||||||
|
|----|-------|-------------------|
|
||||||
|
| `waicore` | `waicore.com` / keyword | `funcs.listVds = vds.vps` |
|
||||||
|
| `firstbyte` | `firstbyte.ru`, `firstbyte.club`, `1byte.ru` | `enrichVds`: страна/город, `paidUntil` ISO, `billdaily`→daily, diskType/KVM из имени; `options.fetchVdsEditForSpecs`; sync: specs из `vds.order` по `pricelist_id`, иначе `vds.edit` |
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { parseDatacenterName } from '../parsers.js'
|
||||||
|
import type { MappedVps } from '../mappers.js'
|
||||||
|
import type { BillmanagerProfileOverrides } from './types.js'
|
||||||
|
|
||||||
|
/** YYYY-MM-DD only — skip labels like «Ежедневное списание». */
|
||||||
|
function pickPaidUntilDate(item: Record<string, string>): string {
|
||||||
|
for (const key of ['real_expiredate', 'expiredate'] as const) {
|
||||||
|
const raw = String(item[key] ?? '').trim()
|
||||||
|
const m = raw.match(/^(\d{4}-\d{2}-\d{2})/)
|
||||||
|
if (m) return m[1]!
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disk / virt hints from FirstByte tariff codes (MSK-KVM-SSD-START, KVM-SAS-1). */
|
||||||
|
export function inferFirstbyteHardwareHints(item: Record<string, string>): {
|
||||||
|
diskType?: string
|
||||||
|
virtualization?: string
|
||||||
|
} {
|
||||||
|
const hay = `${item.pricelist || ''} ${item.intname || ''} ${item.name || ''}`
|
||||||
|
let diskType: string | undefined
|
||||||
|
let virtualization: string | undefined
|
||||||
|
if (/\bNVMe\b/i.test(hay)) diskType = 'NVMe'
|
||||||
|
else if (/\bSAS\b/i.test(hay)) diskType = 'SAS'
|
||||||
|
else if (/\bHDD\b/i.test(hay)) diskType = 'HDD'
|
||||||
|
else if (/\bSSD\b/i.test(hay)) diskType = 'SSD'
|
||||||
|
if (/\bKVM\b/i.test(hay)) virtualization = 'KVM'
|
||||||
|
else if (/\bOpenVZ\b/i.test(hay)) virtualization = 'OpenVZ'
|
||||||
|
else if (/\bLXC\b/i.test(hay)) virtualization = 'LXC'
|
||||||
|
return { diskType, virtualization }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FirstByte — standard func=vds, but list has no CPU/RAM/disk numbers.
|
||||||
|
*
|
||||||
|
* Sync additionally fills specs via:
|
||||||
|
* 1) vds.order match by pricelist_id (`enrichMappedVpsFromTariffs`)
|
||||||
|
* 2) func=vds.edit&elid= (`options.fetchVdsEditForSpecs`)
|
||||||
|
*
|
||||||
|
* This enrich: geo, paidUntil, daily billing, diskType/KVM from tariff name.
|
||||||
|
*/
|
||||||
|
export function enrichFirstbyteVds(
|
||||||
|
item: Record<string, string>,
|
||||||
|
mapped: MappedVps,
|
||||||
|
): MappedVps {
|
||||||
|
const dcRaw = (item.datacentername || item.datacenter || mapped.datacenter || '').trim()
|
||||||
|
const { country, location } = parseDatacenterName(dcRaw)
|
||||||
|
const paidUntil = pickPaidUntilDate(item) || mapped.paidUntil
|
||||||
|
const tariffType =
|
||||||
|
String(item.billdaily || '').toLowerCase() === 'on' ? 'daily' : mapped.tariffType
|
||||||
|
const hints = inferFirstbyteHardwareHints(item)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mapped,
|
||||||
|
country: country || mapped.country,
|
||||||
|
city: location || mapped.city,
|
||||||
|
datacenter: dcRaw || mapped.datacenter,
|
||||||
|
paidUntil,
|
||||||
|
tariffType,
|
||||||
|
diskType: mapped.diskGb ? mapped.diskType : hints.diskType || mapped.diskType,
|
||||||
|
virtualization: hints.virtualization || mapped.virtualization,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const firstbyteOverrides: BillmanagerProfileOverrides = {
|
||||||
|
id: 'firstbyte',
|
||||||
|
match: {
|
||||||
|
hostnames: ['firstbyte.ru', 'firstbyte.club', '1byte.ru'],
|
||||||
|
keywords: ['firstbyte'],
|
||||||
|
},
|
||||||
|
map: {
|
||||||
|
enrichVds: enrichFirstbyteVds,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
fetchVdsEditForSpecs: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export { DEFAULT_PROFILE } from './default.js'
|
export { DEFAULT_PROFILE } from './default.js'
|
||||||
|
export { enrichFirstbyteVds, firstbyteOverrides } from './firstbyte.js'
|
||||||
export { mergeProfile } from './merge.js'
|
export { mergeProfile } from './merge.js'
|
||||||
export { PROFILE_OVERRIDES, resolveBillmanagerProfile } from './registry.js'
|
export { PROFILE_OVERRIDES, resolveBillmanagerProfile } from './registry.js'
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -46,5 +46,9 @@ export function mergeProfile(
|
|||||||
...overrides.requestParams?.orderPricelist,
|
...overrides.requestParams?.orderPricelist,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
options: {
|
||||||
|
...defaults.options,
|
||||||
|
...overrides.options,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { DEFAULT_PROFILE } from './default.js'
|
import { DEFAULT_PROFILE } from './default.js'
|
||||||
|
import { firstbyteOverrides } from './firstbyte.js'
|
||||||
import { mergeProfile } from './merge.js'
|
import { mergeProfile } from './merge.js'
|
||||||
import type { BillmanagerProfile, BillmanagerProfileOverrides } from './types.js'
|
import type { BillmanagerProfile, BillmanagerProfileOverrides } from './types.js'
|
||||||
import { waicoreOverrides } from './waicore.js'
|
import { waicoreOverrides } from './waicore.js'
|
||||||
@@ -7,7 +8,10 @@ import { waicoreOverrides } from './waicore.js'
|
|||||||
* Hoster override list — first match wins.
|
* Hoster override list — first match wins.
|
||||||
* Add new hosters here after creating profiles/<id>.ts.
|
* Add new hosters here after creating profiles/<id>.ts.
|
||||||
*/
|
*/
|
||||||
export const PROFILE_OVERRIDES: BillmanagerProfileOverrides[] = [waicoreOverrides]
|
export const PROFILE_OVERRIDES: BillmanagerProfileOverrides[] = [
|
||||||
|
waicoreOverrides,
|
||||||
|
firstbyteOverrides,
|
||||||
|
]
|
||||||
|
|
||||||
function matchesUrl(url: string, override: BillmanagerProfileOverrides): boolean {
|
function matchesUrl(url: string, override: BillmanagerProfileOverrides): boolean {
|
||||||
const match = override.match
|
const match = override.match
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ export type BillmanagerRequestParams = {
|
|||||||
orderPricelist?: Record<string, string | number>
|
orderPricelist?: Record<string, string | number>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BillmanagerProfileOptions = {
|
||||||
|
/**
|
||||||
|
* When list `func=vds` has no CPU/RAM/disk — after tariff match,
|
||||||
|
* call `func=vds.edit&elid=` per VPS (FirstByte and similar).
|
||||||
|
*/
|
||||||
|
fetchVdsEditForSpecs?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type BillmanagerProfile = {
|
export type BillmanagerProfile = {
|
||||||
id: string
|
id: string
|
||||||
match: BillmanagerMatch
|
match: BillmanagerMatch
|
||||||
@@ -55,6 +63,7 @@ export type BillmanagerProfile = {
|
|||||||
extract: BillmanagerExtract
|
extract: BillmanagerExtract
|
||||||
map: BillmanagerMap
|
map: BillmanagerMap
|
||||||
requestParams?: BillmanagerRequestParams
|
requestParams?: BillmanagerRequestParams
|
||||||
|
options?: BillmanagerProfileOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deep-partial for hoster override files (only divergences). */
|
/** Deep-partial for hoster override files (only divergences). */
|
||||||
@@ -65,4 +74,5 @@ export type BillmanagerProfileOverrides = {
|
|||||||
extract?: Partial<BillmanagerExtract>
|
extract?: Partial<BillmanagerExtract>
|
||||||
map?: Partial<BillmanagerMap>
|
map?: Partial<BillmanagerMap>
|
||||||
requestParams?: BillmanagerRequestParams
|
requestParams?: BillmanagerRequestParams
|
||||||
|
options?: BillmanagerProfileOptions
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ import {
|
|||||||
type TariffItem,
|
type TariffItem,
|
||||||
} from './operations.js'
|
} from './operations.js'
|
||||||
import { resolveBillmanagerProfile } from './profiles/index.js'
|
import { resolveBillmanagerProfile } from './profiles/index.js'
|
||||||
|
import {
|
||||||
|
applyEditSpecs,
|
||||||
|
enrichMappedVpsFromTariffs,
|
||||||
|
fetchVdsEditSpecs,
|
||||||
|
needsHardwareSpecs,
|
||||||
|
} from './vds-specs.js'
|
||||||
|
|
||||||
export interface SyncFromBillmanagerOptions {
|
export interface SyncFromBillmanagerOptions {
|
||||||
skipTariffs?: boolean
|
skipTariffs?: boolean
|
||||||
@@ -54,12 +60,33 @@ const SYNC_UPDATE_FIELDS = [
|
|||||||
'paidUntil',
|
'paidUntil',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
const SYNC_HARDWARE_FIELDS = ['vcpu', 'ramGb', 'diskGb', 'diskType', 'virtualization'] as const
|
||||||
|
|
||||||
function normVal(v: unknown): string {
|
function normVal(v: unknown): string {
|
||||||
if (v == null || v === '') return ''
|
if (v == null || v === '') return ''
|
||||||
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
|
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
|
||||||
return String(v)
|
return String(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function mapVdsWithSpecs(
|
||||||
|
profile: ReturnType<typeof resolveBillmanagerProfile>,
|
||||||
|
item: Record<string, string>,
|
||||||
|
providerId: string,
|
||||||
|
accountId: string,
|
||||||
|
tariffItems: TariffItem[],
|
||||||
|
apiBaseUrl: string,
|
||||||
|
authinfo: string,
|
||||||
|
) {
|
||||||
|
let vps = mapVdsWithProfile(profile, item, providerId, accountId)
|
||||||
|
vps = enrichMappedVpsFromTariffs(item, vps, tariffItems)
|
||||||
|
if (needsHardwareSpecs(vps) && profile.options?.fetchVdsEditForSpecs) {
|
||||||
|
const elid = String(item.id || vps.externalId || '').trim()
|
||||||
|
const specs = await fetchVdsEditSpecs(apiBaseUrl, authinfo, elid)
|
||||||
|
if (specs) vps = applyEditSpecs(vps, specs)
|
||||||
|
}
|
||||||
|
return vps
|
||||||
|
}
|
||||||
|
|
||||||
export async function syncFromBillmanager(
|
export async function syncFromBillmanager(
|
||||||
account: BillmanagerSyncAccount,
|
account: BillmanagerSyncAccount,
|
||||||
opts: SyncFromBillmanagerOptions = {},
|
opts: SyncFromBillmanagerOptions = {},
|
||||||
@@ -111,7 +138,15 @@ export async function syncFromBillmanager(
|
|||||||
|
|
||||||
if (fetchVpsPayments) {
|
if (fetchVpsPayments) {
|
||||||
for (const item of vdsItems) {
|
for (const item of vdsItems) {
|
||||||
const vps = mapVdsWithProfile(profile, item, providerId, accountId)
|
const vps = await mapVdsWithSpecs(
|
||||||
|
profile,
|
||||||
|
item,
|
||||||
|
providerId,
|
||||||
|
accountId,
|
||||||
|
tariffItems,
|
||||||
|
apiBaseUrl,
|
||||||
|
authinfo,
|
||||||
|
)
|
||||||
const id = `vps-bm-${accountId}-${vps.externalId}`
|
const id = `vps-bm-${accountId}-${vps.externalId}`
|
||||||
const additionalIps = JSON.stringify(vps.additionalIps || [])
|
const additionalIps = JSON.stringify(vps.additionalIps || [])
|
||||||
const dailyRate = vps.dailyRate
|
const dailyRate = vps.dailyRate
|
||||||
@@ -153,13 +188,41 @@ export async function syncFromBillmanager(
|
|||||||
monthlyRate,
|
monthlyRate,
|
||||||
paidUntil,
|
paidUntil,
|
||||||
notes,
|
notes,
|
||||||
|
vcpu: vps.vcpu,
|
||||||
|
ramGb: vps.ramGb,
|
||||||
|
diskGb: vps.diskGb,
|
||||||
|
diskType: vps.diskType,
|
||||||
|
virtualization: vps.virtualization,
|
||||||
}
|
}
|
||||||
for (const f of SYNC_UPDATE_FIELDS) {
|
for (const f of SYNC_UPDATE_FIELDS) {
|
||||||
if (userOverrides.includes(f)) {
|
if (userOverrides.includes(f)) {
|
||||||
merged[f] = existing[f as keyof typeof existing] as never
|
merged[f] = existing[f as keyof typeof existing] as never
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const compareFields = ['ip', 'ipv6', 'dns', ...SYNC_UPDATE_FIELDS] as const
|
for (const f of SYNC_HARDWARE_FIELDS) {
|
||||||
|
if (userOverrides.includes(f)) {
|
||||||
|
merged[f] = existing[f as keyof typeof existing] as never
|
||||||
|
} else {
|
||||||
|
// Keep previous non-zero hardware if API still returns empty
|
||||||
|
const next = merged[f]
|
||||||
|
const prev = existing[f as keyof typeof existing]
|
||||||
|
if (
|
||||||
|
(next == null || next === 0 || next === '') &&
|
||||||
|
prev != null &&
|
||||||
|
prev !== 0 &&
|
||||||
|
prev !== ''
|
||||||
|
) {
|
||||||
|
merged[f] = prev as never
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const compareFields = [
|
||||||
|
'ip',
|
||||||
|
'ipv6',
|
||||||
|
'dns',
|
||||||
|
...SYNC_UPDATE_FIELDS,
|
||||||
|
...SYNC_HARDWARE_FIELDS,
|
||||||
|
] as const
|
||||||
const changedFields = compareFields.filter(
|
const changedFields = compareFields.filter(
|
||||||
(f) => normVal(merged[f as keyof typeof merged]) !== normVal(existing[f as keyof typeof existing]),
|
(f) => normVal(merged[f as keyof typeof merged]) !== normVal(existing[f as keyof typeof existing]),
|
||||||
)
|
)
|
||||||
@@ -184,6 +247,11 @@ export async function syncFromBillmanager(
|
|||||||
monthlyRate: merged.monthlyRate,
|
monthlyRate: merged.monthlyRate,
|
||||||
paidUntil: merged.paidUntil,
|
paidUntil: merged.paidUntil,
|
||||||
notes: merged.notes,
|
notes: merged.notes,
|
||||||
|
vcpu: merged.vcpu,
|
||||||
|
ramGb: merged.ramGb,
|
||||||
|
diskGb: merged.diskGb,
|
||||||
|
diskType: merged.diskType,
|
||||||
|
virtualization: merged.virtualization,
|
||||||
})
|
})
|
||||||
.where(eq(schema.vps.id, existing.id))
|
.where(eq(schema.vps.id, existing.id))
|
||||||
.run()
|
.run()
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* Enrich VPS hardware specs when list `func=vds` has no CPU/RAM/disk
|
||||||
|
* (typical for FirstByte: only pricelist / pricelist_id).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { billmanagerRequest } from './client.js'
|
||||||
|
import { elemToObject } from './parsers.js'
|
||||||
|
import type { MappedVps } from './mappers.js'
|
||||||
|
|
||||||
|
/** Minimal tariff shape for matching (from vds.order). */
|
||||||
|
export type TariffSpecsSource = {
|
||||||
|
externalId: string
|
||||||
|
name: string
|
||||||
|
desc: string
|
||||||
|
vcpu: number
|
||||||
|
ramGb: number
|
||||||
|
diskGb: number
|
||||||
|
diskType: string
|
||||||
|
virtualization: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function needsHardwareSpecs(mapped: MappedVps): boolean {
|
||||||
|
return !mapped.vcpu || !mapped.ramGb || !mapped.diskGb
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Match VDS list row → tariff from vds.order by pricelist_id / name. */
|
||||||
|
export function findTariffForVdsItem(
|
||||||
|
item: Record<string, string>,
|
||||||
|
tariffs: TariffSpecsSource[],
|
||||||
|
): TariffSpecsSource | undefined {
|
||||||
|
if (!tariffs.length) return undefined
|
||||||
|
|
||||||
|
const id = String(item.pricelist_id || '').trim()
|
||||||
|
if (id) {
|
||||||
|
const byId = tariffs.find((t) => String(t.externalId) === id)
|
||||||
|
if (byId && (byId.vcpu || byId.ramGb || byId.diskGb)) return byId
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = [
|
||||||
|
String(item.pricelist || '').trim(),
|
||||||
|
String(item.intname || '').trim(),
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
for (const raw of names) {
|
||||||
|
const needle = raw.toLowerCase()
|
||||||
|
const hit = tariffs.find((t) => {
|
||||||
|
if (t.vcpu || t.ramGb || t.diskGb) {
|
||||||
|
const tName = (t.name || '').toLowerCase()
|
||||||
|
const tDesc = (t.desc || '').toLowerCase()
|
||||||
|
return (
|
||||||
|
tName === needle ||
|
||||||
|
tName.includes(needle) ||
|
||||||
|
needle.includes(tName) ||
|
||||||
|
tDesc.includes(needle)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same id even if specs empty (caller may still use name hints)
|
||||||
|
if (id) return tariffs.find((t) => String(t.externalId) === id)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTariffSpecs(mapped: MappedVps, tariff: TariffSpecsSource): MappedVps {
|
||||||
|
return {
|
||||||
|
...mapped,
|
||||||
|
vcpu: mapped.vcpu || tariff.vcpu || 0,
|
||||||
|
ramGb: mapped.ramGb || tariff.ramGb || 0,
|
||||||
|
diskGb: mapped.diskGb || tariff.diskGb || 0,
|
||||||
|
diskType:
|
||||||
|
mapped.diskGb && mapped.diskType
|
||||||
|
? mapped.diskType
|
||||||
|
: tariff.diskType || mapped.diskType,
|
||||||
|
virtualization: mapped.virtualization || tariff.virtualization || 'KVM',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enrichMappedVpsFromTariffs(
|
||||||
|
item: Record<string, string>,
|
||||||
|
mapped: MappedVps,
|
||||||
|
tariffs: TariffSpecsSource[],
|
||||||
|
): MappedVps {
|
||||||
|
if (!needsHardwareSpecs(mapped)) return mapped
|
||||||
|
const tariff = findTariffForVdsItem(item, tariffs)
|
||||||
|
return tariff ? applyTariffSpecs(mapped, tariff) : mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenModel(data: Record<string, unknown>): Record<string, string> {
|
||||||
|
const raw =
|
||||||
|
(data.model as Record<string, unknown> | undefined) ??
|
||||||
|
((data.doc as Record<string, unknown> | undefined)?.model as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined) ??
|
||||||
|
data
|
||||||
|
return elemToObject(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRamToGb(raw: string): number {
|
||||||
|
const s = String(raw || '').trim()
|
||||||
|
if (!s) return 0
|
||||||
|
const n = parseFloat(s.replace(/[^\d.]/g, ''))
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return 0
|
||||||
|
// BM often stores RAM in MB for addons
|
||||||
|
if (/mb/i.test(s) || (!/gb/i.test(s) && n >= 128)) {
|
||||||
|
return Math.max(0.5, Math.round((n / 1024) * 10) / 10)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDiskGb(raw: string): number {
|
||||||
|
const n = parseFloat(String(raw || '').replace(/[^\d.]/g, ''))
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelForAddon(
|
||||||
|
key: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): string {
|
||||||
|
const messages = data.messages ?? (data.doc as Record<string, unknown> | undefined)?.messages
|
||||||
|
if (Array.isArray(messages)) {
|
||||||
|
for (const m of messages) {
|
||||||
|
if (!m || typeof m !== 'object') continue
|
||||||
|
const row = m as Record<string, unknown>
|
||||||
|
const name = String(row.name ?? row.k ?? '')
|
||||||
|
if (name === key || name.endsWith(key)) {
|
||||||
|
return String(row.msg ?? row.v ?? row.$ ?? '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// metadata on fields sometimes: metadata.addon_N
|
||||||
|
const meta = data.metadata as Record<string, unknown> | undefined
|
||||||
|
if (meta && typeof meta[key] === 'object' && meta[key]) {
|
||||||
|
const m = meta[key] as Record<string, unknown>
|
||||||
|
return String(m.label ?? m.name ?? m.msg ?? '')
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse CPU/RAM/disk from `func=vds.edit&elid=` form response. */
|
||||||
|
export function parseSpecsFromVdsEdit(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): Pick<MappedVps, 'vcpu' | 'ramGb' | 'diskGb' | 'diskType' | 'virtualization'> {
|
||||||
|
const model = flattenModel(data)
|
||||||
|
let vcpu = 0
|
||||||
|
let ramGb = 0
|
||||||
|
let diskGb = 0
|
||||||
|
let diskType = ''
|
||||||
|
let virtualization = ''
|
||||||
|
|
||||||
|
for (const key of ['ncpu', 'cpu', 'vcpu', 'cpus', 'cpu_count'] as const) {
|
||||||
|
const n = parseInt(String(model[key] || ''), 10)
|
||||||
|
if (n > 0) {
|
||||||
|
vcpu = n
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of ['ram', 'memory', 'mem', 'ram_mb', 'ram_gb'] as const) {
|
||||||
|
if (model[key]) {
|
||||||
|
ramGb = parseRamToGb(model[key]!)
|
||||||
|
if (ramGb) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of ['disc', 'disk', 'hdd', 'disksize', 'disk_gb', 'hdd_gb'] as const) {
|
||||||
|
if (model[key]) {
|
||||||
|
diskGb = parseDiskGb(model[key]!)
|
||||||
|
if (diskGb) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, val] of Object.entries(model)) {
|
||||||
|
if (!key.startsWith('addon_') || !val) continue
|
||||||
|
const label = labelForAddon(key, data).toLowerCase()
|
||||||
|
const n = parseFloat(String(val).replace(/[^\d.]/g, ''))
|
||||||
|
if (!Number.isFinite(n) || n <= 0) continue
|
||||||
|
|
||||||
|
if (/ядер|процессор|cpu|v?cpu|core/i.test(label) && !vcpu) {
|
||||||
|
vcpu = Math.round(n)
|
||||||
|
} else if (/память|memory|ram|озу/i.test(label) && !ramGb) {
|
||||||
|
ramGb = parseRamToGb(String(val))
|
||||||
|
} else if (/диск|disk|hdd|ssd|nvme|sas|хранилище/i.test(label) && !diskGb) {
|
||||||
|
diskGb = parseDiskGb(String(val))
|
||||||
|
if (/nvme/i.test(label)) diskType = 'NVMe'
|
||||||
|
else if (/sas/i.test(label)) diskType = 'SAS'
|
||||||
|
else if (/hdd/i.test(label)) diskType = 'HDD'
|
||||||
|
else if (/ssd/i.test(label)) diskType = 'SSD'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hay = `${model.pricelist || ''} ${model.intname || ''} ${model.name || ''}`
|
||||||
|
if (/\bKVM\b/i.test(hay)) virtualization = 'KVM'
|
||||||
|
else if (/\bOpenVZ\b/i.test(hay)) virtualization = 'OpenVZ'
|
||||||
|
else if (/\bLXC\b/i.test(hay)) virtualization = 'LXC'
|
||||||
|
if (!diskType) {
|
||||||
|
if (/\bNVMe\b/i.test(hay)) diskType = 'NVMe'
|
||||||
|
else if (/\bSAS\b/i.test(hay)) diskType = 'SAS'
|
||||||
|
else if (/\bHDD\b/i.test(hay)) diskType = 'HDD'
|
||||||
|
else if (/\bSSD\b/i.test(hay)) diskType = 'SSD'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
vcpu,
|
||||||
|
ramGb,
|
||||||
|
diskGb,
|
||||||
|
diskType: diskType || 'SSD',
|
||||||
|
virtualization: virtualization || 'KVM',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyEditSpecs(
|
||||||
|
mapped: MappedVps,
|
||||||
|
specs: ReturnType<typeof parseSpecsFromVdsEdit>,
|
||||||
|
): MappedVps {
|
||||||
|
return {
|
||||||
|
...mapped,
|
||||||
|
vcpu: mapped.vcpu || specs.vcpu || 0,
|
||||||
|
ramGb: mapped.ramGb || specs.ramGb || 0,
|
||||||
|
diskGb: mapped.diskGb || specs.diskGb || 0,
|
||||||
|
diskType: mapped.diskGb ? mapped.diskType : specs.diskType || mapped.diskType,
|
||||||
|
virtualization: mapped.virtualization || specs.virtualization || 'KVM',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVdsEditSpecs(
|
||||||
|
baseUrl: string,
|
||||||
|
authinfo: string,
|
||||||
|
elid: string,
|
||||||
|
): Promise<ReturnType<typeof parseSpecsFromVdsEdit> | null> {
|
||||||
|
const id = String(elid || '').trim()
|
||||||
|
if (!id) return null
|
||||||
|
try {
|
||||||
|
const data = await billmanagerRequest(baseUrl, authinfo, 'vds.edit', { elid: id })
|
||||||
|
const specs = parseSpecsFromVdsEdit(data)
|
||||||
|
if (!specs.vcpu && !specs.ramGb && !specs.diskGb) return null
|
||||||
|
return specs
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
'fetchVdsEditSpecs failed:',
|
||||||
|
id,
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user