From 83ed3b8a4237409bd1662bc16327e6a73cc7d593 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 19 Jul 2026 22:38:33 +0700 Subject: [PATCH] =?UTF-8?q?feat(billmanager):=20=D0=BF=D1=80=D0=BE=D1=84?= =?UTF-8?q?=D0=B8=D0=BB=D1=8C=20FirstByte=20=E2=80=94=20=D0=94=D0=A6,=20?= =?UTF-8?q?=D0=BE=D0=BF=D0=BB=D0=B0=D1=82=D0=B0=20=D0=B8=20specs=20VPS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Парсинг страны/города и paidUntil из list vds; CPU/RAM/диск из vds.order по pricelist_id, иначе vds.edit. Co-authored-by: Cursor --- .../src/services/billmanager/profiles.test.ts | 134 +++++++++- .../services/billmanager/profiles/README.md | 7 + .../billmanager/profiles/firstbyte.ts | 77 ++++++ .../services/billmanager/profiles/index.ts | 1 + .../services/billmanager/profiles/merge.ts | 4 + .../services/billmanager/profiles/registry.ts | 6 +- .../services/billmanager/profiles/types.ts | 10 + apps/api/src/services/billmanager/sync.ts | 72 ++++- .../api/src/services/billmanager/vds-specs.ts | 246 ++++++++++++++++++ 9 files changed, 553 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/services/billmanager/profiles/firstbyte.ts create mode 100644 apps/api/src/services/billmanager/vds-specs.ts diff --git a/apps/api/src/services/billmanager/profiles.test.ts b/apps/api/src/services/billmanager/profiles.test.ts index 88b9247..d6b2d9a 100644 --- a/apps/api/src/services/billmanager/profiles.test.ts +++ b/apps/api/src/services/billmanager/profiles.test.ts @@ -1,6 +1,6 @@ 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 { DEFAULT_PROFILE, @@ -9,6 +9,10 @@ import { waicoreOverrides, } from './profiles/index.js' import { fetchVds, mapVdsWithProfile } from './operations.js' +import { + enrichMappedVpsFromTariffs, + parseSpecsFromVdsEdit, +} from './vds-specs.js' /** Minimal Waicore-style bjson (no credentials / addon noise). */ 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', () => { afterEach(() => { 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', () => { const p = resolveBillmanagerProfile('https://bill.hoster.ru/') expect(p.id).toBe('default') @@ -67,6 +116,13 @@ describe('billmanager profiles', () => { 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', () => { const profile = resolveBillmanagerProfile('https://my.waicore.com/') const elems = extractList(WAICORE_VDS_FIXTURE, profile.extract.listVdsKey) @@ -81,6 +137,82 @@ describe('billmanager profiles', () => { 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
Процессор: 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 () => { const calls: string[] = [] vi.stubGlobal( diff --git a/apps/api/src/services/billmanager/profiles/README.md b/apps/api/src/services/billmanager/profiles/README.md index 2d574ff..4f95917 100644 --- a/apps/api/src/services/billmanager/profiles/README.md +++ b/apps/api/src/services/billmanager/profiles/README.md @@ -44,3 +44,10 @@ export const myHosterOverrides: BillmanagerProfileOverrides = { `resolveBillmanagerProfile(apiBaseUrl)` → `merge(DEFAULT, override)` или `DEFAULT`. 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` | diff --git a/apps/api/src/services/billmanager/profiles/firstbyte.ts b/apps/api/src/services/billmanager/profiles/firstbyte.ts new file mode 100644 index 0000000..57d0a5f --- /dev/null +++ b/apps/api/src/services/billmanager/profiles/firstbyte.ts @@ -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 { + 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): { + 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, + 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, + }, +} diff --git a/apps/api/src/services/billmanager/profiles/index.ts b/apps/api/src/services/billmanager/profiles/index.ts index 18f25b0..6653fd0 100644 --- a/apps/api/src/services/billmanager/profiles/index.ts +++ b/apps/api/src/services/billmanager/profiles/index.ts @@ -1,4 +1,5 @@ export { DEFAULT_PROFILE } from './default.js' +export { enrichFirstbyteVds, firstbyteOverrides } from './firstbyte.js' export { mergeProfile } from './merge.js' export { PROFILE_OVERRIDES, resolveBillmanagerProfile } from './registry.js' export type { diff --git a/apps/api/src/services/billmanager/profiles/merge.ts b/apps/api/src/services/billmanager/profiles/merge.ts index bca37b0..10afb43 100644 --- a/apps/api/src/services/billmanager/profiles/merge.ts +++ b/apps/api/src/services/billmanager/profiles/merge.ts @@ -46,5 +46,9 @@ export function mergeProfile( ...overrides.requestParams?.orderPricelist, }, }, + options: { + ...defaults.options, + ...overrides.options, + }, } } diff --git a/apps/api/src/services/billmanager/profiles/registry.ts b/apps/api/src/services/billmanager/profiles/registry.ts index d68dd43..72ce240 100644 --- a/apps/api/src/services/billmanager/profiles/registry.ts +++ b/apps/api/src/services/billmanager/profiles/registry.ts @@ -1,4 +1,5 @@ import { DEFAULT_PROFILE } from './default.js' +import { firstbyteOverrides } from './firstbyte.js' import { mergeProfile } from './merge.js' import type { BillmanagerProfile, BillmanagerProfileOverrides } from './types.js' import { waicoreOverrides } from './waicore.js' @@ -7,7 +8,10 @@ import { waicoreOverrides } from './waicore.js' * Hoster override list — first match wins. * Add new hosters here after creating profiles/.ts. */ -export const PROFILE_OVERRIDES: BillmanagerProfileOverrides[] = [waicoreOverrides] +export const PROFILE_OVERRIDES: BillmanagerProfileOverrides[] = [ + waicoreOverrides, + firstbyteOverrides, +] function matchesUrl(url: string, override: BillmanagerProfileOverrides): boolean { const match = override.match diff --git a/apps/api/src/services/billmanager/profiles/types.ts b/apps/api/src/services/billmanager/profiles/types.ts index 2419ec7..befb64d 100644 --- a/apps/api/src/services/billmanager/profiles/types.ts +++ b/apps/api/src/services/billmanager/profiles/types.ts @@ -48,6 +48,14 @@ export type BillmanagerRequestParams = { orderPricelist?: Record } +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 = { id: string match: BillmanagerMatch @@ -55,6 +63,7 @@ export type BillmanagerProfile = { extract: BillmanagerExtract map: BillmanagerMap requestParams?: BillmanagerRequestParams + options?: BillmanagerProfileOptions } /** Deep-partial for hoster override files (only divergences). */ @@ -65,4 +74,5 @@ export type BillmanagerProfileOverrides = { extract?: Partial map?: Partial requestParams?: BillmanagerRequestParams + options?: BillmanagerProfileOptions } diff --git a/apps/api/src/services/billmanager/sync.ts b/apps/api/src/services/billmanager/sync.ts index c6bff74..6c0d7f4 100644 --- a/apps/api/src/services/billmanager/sync.ts +++ b/apps/api/src/services/billmanager/sync.ts @@ -18,6 +18,12 @@ import { type TariffItem, } from './operations.js' import { resolveBillmanagerProfile } from './profiles/index.js' +import { + applyEditSpecs, + enrichMappedVpsFromTariffs, + fetchVdsEditSpecs, + needsHardwareSpecs, +} from './vds-specs.js' export interface SyncFromBillmanagerOptions { skipTariffs?: boolean @@ -54,12 +60,33 @@ const SYNC_UPDATE_FIELDS = [ 'paidUntil', ] as const +const SYNC_HARDWARE_FIELDS = ['vcpu', 'ramGb', 'diskGb', 'diskType', 'virtualization'] as const + function normVal(v: unknown): string { if (v == null || v === '') return '' if (typeof v === 'number') return Number.isFinite(v) ? String(v) : '' return String(v) } +async function mapVdsWithSpecs( + profile: ReturnType, + item: Record, + 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( account: BillmanagerSyncAccount, opts: SyncFromBillmanagerOptions = {}, @@ -111,7 +138,15 @@ export async function syncFromBillmanager( if (fetchVpsPayments) { 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 additionalIps = JSON.stringify(vps.additionalIps || []) const dailyRate = vps.dailyRate @@ -153,13 +188,41 @@ export async function syncFromBillmanager( monthlyRate, paidUntil, notes, + vcpu: vps.vcpu, + ramGb: vps.ramGb, + diskGb: vps.diskGb, + diskType: vps.diskType, + virtualization: vps.virtualization, } for (const f of SYNC_UPDATE_FIELDS) { if (userOverrides.includes(f)) { 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( (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, paidUntil: merged.paidUntil, 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)) .run() diff --git a/apps/api/src/services/billmanager/vds-specs.ts b/apps/api/src/services/billmanager/vds-specs.ts new file mode 100644 index 0000000..5415564 --- /dev/null +++ b/apps/api/src/services/billmanager/vds-specs.ts @@ -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, + 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, + 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): Record { + const raw = + (data.model as Record | undefined) ?? + ((data.doc as Record | undefined)?.model as + | Record + | 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 { + const messages = data.messages ?? (data.doc as Record | undefined)?.messages + if (Array.isArray(messages)) { + for (const m of messages) { + if (!m || typeof m !== 'object') continue + const row = m as Record + 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 | undefined + if (meta && typeof meta[key] === 'object' && meta[key]) { + const m = meta[key] as Record + 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, +): Pick { + 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, +): 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 | 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 + } +}