Для daily FirstByte считать dailyRate из cost/30 и общий paidUntil по балансу аккаунта; для 4VPS парсить страну/город из dc_name+flag. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -40,7 +40,7 @@ export interface MappedVps {
|
|||||||
status: string
|
status: string
|
||||||
tariffType: string
|
tariffType: string
|
||||||
currency: string
|
currency: string
|
||||||
dailyRate: null
|
dailyRate: number | null
|
||||||
monthlyRate: number | null
|
monthlyRate: number | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
paidUntil: string
|
paidUntil: string
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import {
|
|||||||
enrichMappedVpsFromTariffs,
|
enrichMappedVpsFromTariffs,
|
||||||
parseSpecsFromVdsEdit,
|
parseSpecsFromVdsEdit,
|
||||||
} from './vds-specs.js'
|
} from './vds-specs.js'
|
||||||
|
import {
|
||||||
|
applyFirstbyteSharedDailyPaidUntil,
|
||||||
|
monthlyToDailyRate,
|
||||||
|
} from './profiles/firstbyte.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 = {
|
||||||
@@ -156,6 +160,7 @@ describe('billmanager profiles', () => {
|
|||||||
expect(monthly.diskType).toBe('SSD')
|
expect(monthly.diskType).toBe('SSD')
|
||||||
expect(monthly.virtualization).toBe('KVM')
|
expect(monthly.virtualization).toBe('KVM')
|
||||||
expect(monthly.vcpu).toBe(0)
|
expect(monthly.vcpu).toBe(0)
|
||||||
|
expect(monthly.dailyRate).toBeNull()
|
||||||
|
|
||||||
const daily = mapVdsWithProfile(
|
const daily = mapVdsWithProfile(
|
||||||
profile,
|
profile,
|
||||||
@@ -165,9 +170,61 @@ describe('billmanager profiles', () => {
|
|||||||
)
|
)
|
||||||
expect(daily.country).toBe('Россия')
|
expect(daily.country).toBe('Россия')
|
||||||
expect(daily.city).toBe('Москва')
|
expect(daily.city).toBe('Москва')
|
||||||
expect(daily.paidUntil).toBe('2026-07-20')
|
expect(daily.paidUntil).toBe('')
|
||||||
expect(daily.tariffType).toBe('daily')
|
expect(daily.tariffType).toBe('daily')
|
||||||
expect(daily.diskType).toBe('SAS')
|
expect(daily.diskType).toBe('SAS')
|
||||||
|
expect(daily.monthlyRate).toBe(129)
|
||||||
|
expect(daily.dailyRate).toBe(monthlyToDailyRate(129))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FirstByte: shared balance → same paidUntil for all daily VPS', () => {
|
||||||
|
const profile = resolveBillmanagerProfile('https://my.firstbyte.ru/')
|
||||||
|
expect(profile.map.enrichVdsBatch).toBeTypeOf('function')
|
||||||
|
|
||||||
|
const a = mapVdsWithProfile(
|
||||||
|
profile,
|
||||||
|
elemToObject({
|
||||||
|
id: '1',
|
||||||
|
billdaily: 'on',
|
||||||
|
expiredate: 'Ежедневное списание',
|
||||||
|
cost: '129.00 RUB / Месяц',
|
||||||
|
currency_str: 'RUB',
|
||||||
|
item_status_orig: '2',
|
||||||
|
ip: '1.1.1.1',
|
||||||
|
datacentername: '1 Датацентр Россия, Москва',
|
||||||
|
}),
|
||||||
|
'p',
|
||||||
|
'a',
|
||||||
|
)
|
||||||
|
const b = mapVdsWithProfile(
|
||||||
|
profile,
|
||||||
|
elemToObject({
|
||||||
|
id: '2',
|
||||||
|
billdaily: 'on',
|
||||||
|
expiredate: 'Ежедневное списание',
|
||||||
|
cost: '129.00 RUB / Месяц',
|
||||||
|
currency_str: 'RUB',
|
||||||
|
item_status_orig: '2',
|
||||||
|
ip: '2.2.2.2',
|
||||||
|
datacentername: '1 Датацентр Россия, Москва',
|
||||||
|
}),
|
||||||
|
'p',
|
||||||
|
'a',
|
||||||
|
)
|
||||||
|
const monthly = mapVdsWithProfile(
|
||||||
|
profile,
|
||||||
|
elemToObject(FIRSTBYTE_VDS_FIXTURE.elem[0]!),
|
||||||
|
'p',
|
||||||
|
'a',
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2 × (129/30) = 8.6/day; balance 86 → 10 days
|
||||||
|
const asOf = new Date(Date.UTC(2026, 6, 19))
|
||||||
|
const out = applyFirstbyteSharedDailyPaidUntil([a, b, monthly], 86, asOf)
|
||||||
|
expect(out[0]!.paidUntil).toBe('2026-07-29')
|
||||||
|
expect(out[1]!.paidUntil).toBe('2026-07-29')
|
||||||
|
expect(out[2]!.paidUntil).toBe(monthly.paidUntil)
|
||||||
|
expect(out[0]!.dailyRate).toBe(monthlyToDailyRate(129))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FirstByte: specs from vds.order by pricelist_id', () => {
|
it('FirstByte: specs from vds.order by pricelist_id', () => {
|
||||||
|
|||||||
@@ -50,4 +50,4 @@ Sync и operations **не** содержат `if (hoster)` — только пр
|
|||||||
| id | Match | Что переопределяет |
|
| id | Match | Что переопределяет |
|
||||||
|----|-------|-------------------|
|
|----|-------|-------------------|
|
||||||
| `waicore` | `waicore.com` / keyword | `funcs.listVds = vds.vps` |
|
| `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` |
|
| `firstbyte` | `firstbyte.ru`, `firstbyte.club`, `1byte.ru` | `enrichVds`: страна/город, daily из `billdaily`/`Ежедневное списание`, `dailyRate=cost/30`; `enrichVdsBatch`: общий баланс ÷ сумма daily → один `paidUntil`; specs из `vds.order` / `vds.edit` |
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { parseDatacenterName } from '../parsers.js'
|
import { parseDatacenterName } from '../parsers.js'
|
||||||
import type { MappedVps } from '../mappers.js'
|
import type { MappedVps } from '../mappers.js'
|
||||||
import type { BillmanagerProfileOverrides } from './types.js'
|
import type {
|
||||||
|
BillmanagerMapOverrides,
|
||||||
|
BillmanagerProfileOverrides,
|
||||||
|
} from './types.js'
|
||||||
|
|
||||||
|
/** BILLmanager daily billing usually divides monthly price by 30. */
|
||||||
|
export const FIRSTBYTE_DAYS_PER_MONTH = 30
|
||||||
|
|
||||||
/** YYYY-MM-DD only — skip labels like «Ежедневное списание». */
|
/** YYYY-MM-DD only — skip labels like «Ежедневное списание». */
|
||||||
function pickPaidUntilDate(item: Record<string, string>): string {
|
export function pickPaidUntilDate(item: Record<string, string>): string {
|
||||||
for (const key of ['real_expiredate', 'expiredate'] as const) {
|
for (const key of ['real_expiredate', 'expiredate'] as const) {
|
||||||
const raw = String(item[key] ?? '').trim()
|
const raw = String(item[key] ?? '').trim()
|
||||||
const m = raw.match(/^(\d{4}-\d{2}-\d{2})/)
|
const m = raw.match(/^(\d{4}-\d{2}-\d{2})/)
|
||||||
@@ -12,6 +18,66 @@ function pickPaidUntilDate(item: Record<string, string>): string {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isFirstbyteDailyBilling(item: Record<string, string>): boolean {
|
||||||
|
if (String(item.billdaily || '').toLowerCase() === 'on') return true
|
||||||
|
const expire = String(item.expiredate || '').trim()
|
||||||
|
return /ежедневн/i.test(expire)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse «129.00 RUB / Месяц» or item_cost → monthly amount. */
|
||||||
|
export function parseFirstbyteMonthlyCost(item: Record<string, string>): number {
|
||||||
|
const fromCost = parseFloat(
|
||||||
|
String(item.cost || '')
|
||||||
|
.replace(/[^\d.,-]/g, '')
|
||||||
|
.replace(',', '.'),
|
||||||
|
)
|
||||||
|
if (Number.isFinite(fromCost) && fromCost > 0) return fromCost
|
||||||
|
const fromItem = parseFloat(String(item.item_cost || '').replace(',', '.'))
|
||||||
|
return Number.isFinite(fromItem) && fromItem > 0 ? fromItem : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function monthlyToDailyRate(monthly: number): number {
|
||||||
|
if (!monthly || monthly <= 0) return 0
|
||||||
|
return Math.round((monthly / FIRSTBYTE_DAYS_PER_MONTH) * 10000) / 10000
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDaysIso(from: Date, days: number): string {
|
||||||
|
const d = new Date(Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate()))
|
||||||
|
d.setUTCDate(d.getUTCDate() + days)
|
||||||
|
return d.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared account balance ÷ sum(dailyRate of all daily VPS) → same paidUntil for each.
|
||||||
|
* Monthly VPS are left unchanged.
|
||||||
|
*/
|
||||||
|
export function applyFirstbyteSharedDailyPaidUntil(
|
||||||
|
list: MappedVps[],
|
||||||
|
balance: number | null | undefined,
|
||||||
|
asOf: Date = new Date(),
|
||||||
|
): MappedVps[] {
|
||||||
|
const dailyOnes = list.filter((v) => v.tariffType === 'daily')
|
||||||
|
if (dailyOnes.length === 0) return list
|
||||||
|
|
||||||
|
const totalDaily = dailyOnes.reduce((sum, v) => sum + (Number(v.dailyRate) || 0), 0)
|
||||||
|
const bal = typeof balance === 'number' && Number.isFinite(balance) ? balance : 0
|
||||||
|
|
||||||
|
let paidUntil = ''
|
||||||
|
if (totalDaily > 0 && bal > 0) {
|
||||||
|
const daysLeft = Math.floor(bal / totalDaily)
|
||||||
|
paidUntil = addDaysIso(asOf, daysLeft)
|
||||||
|
} else if (totalDaily > 0) {
|
||||||
|
// Нет средств — считаем оплаченным до сегодня (0 полных дней)
|
||||||
|
paidUntil = addDaysIso(asOf, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paidUntil) return list
|
||||||
|
|
||||||
|
return list.map((v) =>
|
||||||
|
v.tariffType === 'daily' ? { ...v, paidUntil } : v,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Disk / virt hints from FirstByte tariff codes (MSK-KVM-SSD-START, KVM-SAS-1). */
|
/** Disk / virt hints from FirstByte tariff codes (MSK-KVM-SSD-START, KVM-SAS-1). */
|
||||||
export function inferFirstbyteHardwareHints(item: Record<string, string>): {
|
export function inferFirstbyteHardwareHints(item: Record<string, string>): {
|
||||||
diskType?: string
|
diskType?: string
|
||||||
@@ -36,8 +102,10 @@ export function inferFirstbyteHardwareHints(item: Record<string, string>): {
|
|||||||
* Sync additionally fills specs via:
|
* Sync additionally fills specs via:
|
||||||
* 1) vds.order match by pricelist_id (`enrichMappedVpsFromTariffs`)
|
* 1) vds.order match by pricelist_id (`enrichMappedVpsFromTariffs`)
|
||||||
* 2) func=vds.edit&elid= (`options.fetchVdsEditForSpecs`)
|
* 2) func=vds.edit&elid= (`options.fetchVdsEditForSpecs`)
|
||||||
|
* 3) enrichVdsBatch: shared balance → paidUntil for daily VPS
|
||||||
*
|
*
|
||||||
* This enrich: geo, paidUntil, daily billing, diskType/KVM from tariff name.
|
* This enrich: geo, rates, daily flag, diskType/KVM from tariff name.
|
||||||
|
* For daily: paidUntil left empty until enrichVdsBatch (shared balance).
|
||||||
*/
|
*/
|
||||||
export function enrichFirstbyteVds(
|
export function enrichFirstbyteVds(
|
||||||
item: Record<string, string>,
|
item: Record<string, string>,
|
||||||
@@ -45,32 +113,61 @@ export function enrichFirstbyteVds(
|
|||||||
): MappedVps {
|
): MappedVps {
|
||||||
const dcRaw = (item.datacentername || item.datacenter || mapped.datacenter || '').trim()
|
const dcRaw = (item.datacentername || item.datacenter || mapped.datacenter || '').trim()
|
||||||
const { country, location } = parseDatacenterName(dcRaw)
|
const { country, location } = parseDatacenterName(dcRaw)
|
||||||
const paidUntil = pickPaidUntilDate(item) || mapped.paidUntil
|
const daily = isFirstbyteDailyBilling(item)
|
||||||
const tariffType =
|
const monthly = parseFirstbyteMonthlyCost(item) || mapped.monthlyRate || 0
|
||||||
String(item.billdaily || '').toLowerCase() === 'on' ? 'daily' : mapped.tariffType
|
|
||||||
const hints = inferFirstbyteHardwareHints(item)
|
const hints = inferFirstbyteHardwareHints(item)
|
||||||
|
|
||||||
|
if (daily) {
|
||||||
|
const dailyRate = monthlyToDailyRate(monthly) || mapped.dailyRate
|
||||||
|
return {
|
||||||
|
...mapped,
|
||||||
|
country: country || mapped.country,
|
||||||
|
city: location || mapped.city,
|
||||||
|
datacenter: dcRaw || mapped.datacenter,
|
||||||
|
tariffType: 'daily',
|
||||||
|
monthlyRate: monthly || mapped.monthlyRate,
|
||||||
|
dailyRate,
|
||||||
|
// Не брать real_expiredate — дата из общего баланса в enrichVdsBatch
|
||||||
|
paidUntil: '',
|
||||||
|
diskType: mapped.diskGb ? mapped.diskType : hints.diskType || mapped.diskType,
|
||||||
|
virtualization: hints.virtualization || mapped.virtualization,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const paidUntil = pickPaidUntilDate(item) || mapped.paidUntil
|
||||||
return {
|
return {
|
||||||
...mapped,
|
...mapped,
|
||||||
country: country || mapped.country,
|
country: country || mapped.country,
|
||||||
city: location || mapped.city,
|
city: location || mapped.city,
|
||||||
datacenter: dcRaw || mapped.datacenter,
|
datacenter: dcRaw || mapped.datacenter,
|
||||||
paidUntil,
|
paidUntil,
|
||||||
tariffType,
|
tariffType: mapped.tariffType || 'monthly',
|
||||||
|
monthlyRate: monthly || mapped.monthlyRate,
|
||||||
|
dailyRate: null,
|
||||||
diskType: mapped.diskGb ? mapped.diskType : hints.diskType || mapped.diskType,
|
diskType: mapped.diskGb ? mapped.diskType : hints.diskType || mapped.diskType,
|
||||||
virtualization: hints.virtualization || mapped.virtualization,
|
virtualization: hints.virtualization || mapped.virtualization,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function enrichFirstbyteVdsBatch(
|
||||||
|
list: MappedVps[],
|
||||||
|
ctx: { balance: number | null },
|
||||||
|
): MappedVps[] {
|
||||||
|
return applyFirstbyteSharedDailyPaidUntil(list, ctx.balance)
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstbyteMap: BillmanagerMapOverrides = {
|
||||||
|
enrichVds: enrichFirstbyteVds,
|
||||||
|
enrichVdsBatch: enrichFirstbyteVdsBatch,
|
||||||
|
}
|
||||||
|
|
||||||
export const firstbyteOverrides: BillmanagerProfileOverrides = {
|
export const firstbyteOverrides: BillmanagerProfileOverrides = {
|
||||||
id: 'firstbyte',
|
id: 'firstbyte',
|
||||||
match: {
|
match: {
|
||||||
hostnames: ['firstbyte.ru', 'firstbyte.club', '1byte.ru'],
|
hostnames: ['firstbyte.ru', 'firstbyte.club', '1byte.ru'],
|
||||||
keywords: ['firstbyte'],
|
keywords: ['firstbyte'],
|
||||||
},
|
},
|
||||||
map: {
|
map: firstbyteMap,
|
||||||
enrichVds: enrichFirstbyteVds,
|
|
||||||
},
|
|
||||||
options: {
|
options: {
|
||||||
fetchVdsEditForSpecs: true,
|
fetchVdsEditForSpecs: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export { DEFAULT_PROFILE } from './default.js'
|
export { DEFAULT_PROFILE } from './default.js'
|
||||||
export { enrichFirstbyteVds, firstbyteOverrides } from './firstbyte.js'
|
export { enrichFirstbyteVds, enrichFirstbyteVdsBatch, 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 {
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ export type BillmanagerExtract = {
|
|||||||
paymentsKey: string
|
paymentsKey: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EnrichVdsFn = (
|
||||||
|
item: Record<string, string>,
|
||||||
|
mapped: MappedVps,
|
||||||
|
) => MappedVps
|
||||||
|
|
||||||
|
export type EnrichVdsBatchFn = (
|
||||||
|
list: MappedVps[],
|
||||||
|
ctx: { balance: number | null },
|
||||||
|
) => MappedVps[]
|
||||||
|
|
||||||
export type BillmanagerMap = {
|
export type BillmanagerMap = {
|
||||||
vds: (
|
vds: (
|
||||||
item: Record<string, string>,
|
item: Record<string, string>,
|
||||||
@@ -35,10 +45,20 @@ export type BillmanagerMap = {
|
|||||||
accountId: string,
|
accountId: string,
|
||||||
) => MappedPayment | null
|
) => MappedPayment | null
|
||||||
/** Optional post-process after map.vds (specs / geo / etc.) */
|
/** Optional post-process after map.vds (specs / geo / etc.) */
|
||||||
enrichVds?: (
|
enrichVds?: EnrichVdsFn
|
||||||
item: Record<string, string>,
|
/**
|
||||||
mapped: MappedVps,
|
* Optional post-process after all VDS mapped (e.g. shared balance → paidUntil).
|
||||||
) => MappedVps
|
* Receives list in same order as sync will upsert.
|
||||||
|
*/
|
||||||
|
enrichVdsBatch?: EnrichVdsBatchFn
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Partial map for hoster overrides — lists every hook explicitly (IDE/Partial safe). */
|
||||||
|
export type BillmanagerMapOverrides = {
|
||||||
|
vds?: BillmanagerMap['vds']
|
||||||
|
payment?: BillmanagerMap['payment']
|
||||||
|
enrichVds?: EnrichVdsFn
|
||||||
|
enrichVdsBatch?: EnrichVdsBatchFn
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BillmanagerRequestParams = {
|
export type BillmanagerRequestParams = {
|
||||||
@@ -72,7 +92,7 @@ export type BillmanagerProfileOverrides = {
|
|||||||
match?: BillmanagerMatch
|
match?: BillmanagerMatch
|
||||||
funcs?: Partial<BillmanagerFuncs>
|
funcs?: Partial<BillmanagerFuncs>
|
||||||
extract?: Partial<BillmanagerExtract>
|
extract?: Partial<BillmanagerExtract>
|
||||||
map?: Partial<BillmanagerMap>
|
map?: BillmanagerMapOverrides
|
||||||
requestParams?: BillmanagerRequestParams
|
requestParams?: BillmanagerRequestParams
|
||||||
options?: BillmanagerProfileOptions
|
options?: BillmanagerProfileOptions
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,16 +137,27 @@ export async function syncFromBillmanager(
|
|||||||
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
|
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
|
||||||
|
|
||||||
if (fetchVpsPayments) {
|
if (fetchVpsPayments) {
|
||||||
|
const mappedRaw: Awaited<ReturnType<typeof mapVdsWithSpecs>>[] = []
|
||||||
for (const item of vdsItems) {
|
for (const item of vdsItems) {
|
||||||
const vps = await mapVdsWithSpecs(
|
mappedRaw.push(
|
||||||
profile,
|
await mapVdsWithSpecs(
|
||||||
item,
|
profile,
|
||||||
providerId,
|
item,
|
||||||
accountId,
|
providerId,
|
||||||
tariffItems,
|
accountId,
|
||||||
apiBaseUrl,
|
tariffItems,
|
||||||
authinfo,
|
apiBaseUrl,
|
||||||
|
authinfo,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
const mappedList = profile.map.enrichVdsBatch
|
||||||
|
? profile.map.enrichVdsBatch(mappedRaw, {
|
||||||
|
balance: dashboardInfo?.balance ?? null,
|
||||||
|
})
|
||||||
|
: mappedRaw
|
||||||
|
|
||||||
|
for (const vps of mappedList) {
|
||||||
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
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* 4VPS datacenter location helpers
|
||||||
|
* @see https://4vps.su/page/api — getDcList: dc_name + flag
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** ISO 3166-1 alpha-2 (flag) → русское название страны */
|
||||||
|
export const FOURVPS_FLAG_COUNTRY: Record<string, string> = {
|
||||||
|
AE: 'ОАЭ',
|
||||||
|
AT: 'Австрия',
|
||||||
|
AU: 'Австралия',
|
||||||
|
BE: 'Бельгия',
|
||||||
|
BG: 'Болгария',
|
||||||
|
CA: 'Канада',
|
||||||
|
CH: 'Швейцария',
|
||||||
|
CZ: 'Чехия',
|
||||||
|
DE: 'Германия',
|
||||||
|
DK: 'Дания',
|
||||||
|
EE: 'Эстония',
|
||||||
|
ES: 'Испания',
|
||||||
|
FI: 'Финляндия',
|
||||||
|
FR: 'Франция',
|
||||||
|
GB: 'Великобритания',
|
||||||
|
HK: 'Гонконг',
|
||||||
|
HU: 'Венгрия',
|
||||||
|
IE: 'Ирландия',
|
||||||
|
IL: 'Израиль',
|
||||||
|
IT: 'Италия',
|
||||||
|
JP: 'Япония',
|
||||||
|
KZ: 'Казахстан',
|
||||||
|
LT: 'Литва',
|
||||||
|
LV: 'Латвия',
|
||||||
|
MD: 'Молдова',
|
||||||
|
NL: 'Нидерланды',
|
||||||
|
NO: 'Норвегия',
|
||||||
|
PL: 'Польша',
|
||||||
|
PT: 'Португалия',
|
||||||
|
RO: 'Румыния',
|
||||||
|
RU: 'Россия',
|
||||||
|
SE: 'Швеция',
|
||||||
|
SG: 'Сингапур',
|
||||||
|
TR: 'Турция',
|
||||||
|
UA: 'Украина',
|
||||||
|
UK: 'Великобритания',
|
||||||
|
US: 'США',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** English / short codes often used in dc_name / tname prefixes */
|
||||||
|
const NAME_COUNTRY_ALIASES: Record<string, string> = {
|
||||||
|
UAE: 'ОАЭ',
|
||||||
|
USA: 'США',
|
||||||
|
UK: 'Великобритания',
|
||||||
|
GB: 'Великобритания',
|
||||||
|
NL: 'Нидерланды',
|
||||||
|
DE: 'Германия',
|
||||||
|
FR: 'Франция',
|
||||||
|
FI: 'Финляндия',
|
||||||
|
SE: 'Швеция',
|
||||||
|
PL: 'Польша',
|
||||||
|
CZ: 'Чехия',
|
||||||
|
RU: 'Россия',
|
||||||
|
AE: 'ОАЭ',
|
||||||
|
CA: 'Канада',
|
||||||
|
HK: 'Гонконг',
|
||||||
|
SG: 'Сингапур',
|
||||||
|
TR: 'Турция',
|
||||||
|
KZ: 'Казахстан',
|
||||||
|
}
|
||||||
|
|
||||||
|
function countryFromFlag(flag: unknown): string {
|
||||||
|
const code = String(flag || '')
|
||||||
|
.trim()
|
||||||
|
.toUpperCase()
|
||||||
|
if (!code) return ''
|
||||||
|
return FOURVPS_FLAG_COUNTRY[code] || NAME_COUNTRY_ALIASES[code] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip trailing «ДЦ1» / «DC 2» labels (not a city). */
|
||||||
|
function stripDcOrdinal(s: string): string {
|
||||||
|
return s.replace(/\s*(?:ДЦ|DC)\s*\d+\s*$/i, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse 4VPS dc_name + flag → country / city.
|
||||||
|
* Examples:
|
||||||
|
* - («ОАЭ ДЦ1», ae) → ОАЭ / ''
|
||||||
|
* - («USA DC1», us) → США / ''
|
||||||
|
* - («Нидерланды Амстердам», nl) → Нидерланды / Амстердам
|
||||||
|
* - («Германия, Франкфурт», de) → Германия / Франкфурт
|
||||||
|
*/
|
||||||
|
export function parseFourVpsDcLocation(
|
||||||
|
dcName: unknown,
|
||||||
|
flag?: unknown,
|
||||||
|
): { country: string; city: string } {
|
||||||
|
const raw = String(dcName || '').trim()
|
||||||
|
const fromFlag = countryFromFlag(flag)
|
||||||
|
|
||||||
|
if (!raw) return { country: fromFlag, city: '' }
|
||||||
|
|
||||||
|
const comma = raw.match(/^([^,]+),\s*(.+)$/)
|
||||||
|
if (comma) {
|
||||||
|
const left = comma[1]!.trim()
|
||||||
|
const right = stripDcOrdinal(comma[2]!.trim())
|
||||||
|
const leftCountry =
|
||||||
|
NAME_COUNTRY_ALIASES[left.toUpperCase()] ||
|
||||||
|
FOURVPS_FLAG_COUNTRY[left.toUpperCase()] ||
|
||||||
|
(fromFlag && left.toUpperCase() === String(flag).toUpperCase() ? fromFlag : left)
|
||||||
|
return {
|
||||||
|
country: fromFlag || leftCountry,
|
||||||
|
city: right,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const withoutOrdinal = stripDcOrdinal(raw)
|
||||||
|
if (!withoutOrdinal) {
|
||||||
|
return { country: fromFlag || raw, city: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact country / alias match for whole string (e.g. «ОАЭ», «USA»)
|
||||||
|
const upper = withoutOrdinal.toUpperCase()
|
||||||
|
if (NAME_COUNTRY_ALIASES[upper] || FOURVPS_FLAG_COUNTRY[upper]) {
|
||||||
|
return {
|
||||||
|
country: fromFlag || NAME_COUNTRY_ALIASES[upper] || FOURVPS_FLAG_COUNTRY[upper]!,
|
||||||
|
city: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Country name at start (RU list + aliases), rest = city
|
||||||
|
const countryNames = [
|
||||||
|
...Object.values(FOURVPS_FLAG_COUNTRY),
|
||||||
|
...Object.keys(NAME_COUNTRY_ALIASES),
|
||||||
|
].sort((a, b) => b.length - a.length)
|
||||||
|
|
||||||
|
for (const name of countryNames) {
|
||||||
|
const re = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\s+|$)`, 'i')
|
||||||
|
if (re.test(withoutOrdinal)) {
|
||||||
|
const rest = withoutOrdinal.replace(re, '').trim()
|
||||||
|
const country =
|
||||||
|
fromFlag ||
|
||||||
|
NAME_COUNTRY_ALIASES[name.toUpperCase()] ||
|
||||||
|
FOURVPS_FLAG_COUNTRY[name.toUpperCase()] ||
|
||||||
|
name
|
||||||
|
return { country, city: rest }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: first token country-ish, rest city
|
||||||
|
const parts = withoutOrdinal.split(/\s+/).filter(Boolean)
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const first = parts[0]!
|
||||||
|
const firstCountry =
|
||||||
|
NAME_COUNTRY_ALIASES[first.toUpperCase()] ||
|
||||||
|
FOURVPS_FLAG_COUNTRY[first.toUpperCase()] ||
|
||||||
|
''
|
||||||
|
if (firstCountry || fromFlag) {
|
||||||
|
return {
|
||||||
|
country: fromFlag || firstCountry,
|
||||||
|
city: parts.slice(1).join(' '),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { country: fromFlag || withoutOrdinal, city: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Country for tariffs when only flag is known. */
|
||||||
|
export function countryFromFourVpsFlag(flag: unknown): string {
|
||||||
|
return countryFromFlag(flag)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { parseFourVpsDcLocation } from './location.js'
|
||||||
import { mapServerToVps } from './mappers.js'
|
import { mapServerToVps } from './mappers.js'
|
||||||
import type { FourVpsServer } from './operations.js'
|
import type { FourVpsServer } from './operations.js'
|
||||||
|
|
||||||
@@ -19,8 +20,39 @@ const sampleServer: FourVpsServer = {
|
|||||||
expired: 1666781101,
|
expired: 1666781101,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('parseFourVpsDcLocation', () => {
|
||||||
|
it('parses UAE DC ordinal → country only', () => {
|
||||||
|
expect(parseFourVpsDcLocation('ОАЭ ДЦ1', 'ae')).toEqual({
|
||||||
|
country: 'ОАЭ',
|
||||||
|
city: '',
|
||||||
|
})
|
||||||
|
expect(parseFourVpsDcLocation('AE DC1', 'ae')).toEqual({
|
||||||
|
country: 'ОАЭ',
|
||||||
|
city: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses USA DC1 → США', () => {
|
||||||
|
expect(parseFourVpsDcLocation('USA DC1', 'us')).toEqual({
|
||||||
|
country: 'США',
|
||||||
|
city: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses country + city', () => {
|
||||||
|
expect(parseFourVpsDcLocation('Нидерланды Амстердам', 'nl')).toEqual({
|
||||||
|
country: 'Нидерланды',
|
||||||
|
city: 'Амстердам',
|
||||||
|
})
|
||||||
|
expect(parseFourVpsDcLocation('Германия, Франкфурт', 'de')).toEqual({
|
||||||
|
country: 'Германия',
|
||||||
|
city: 'Франкфурт',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('mapServerToVps', () => {
|
describe('mapServerToVps', () => {
|
||||||
it('maps myservers fields to VPS model', () => {
|
it('maps myservers fields to VPS model with country/city from DC', () => {
|
||||||
const dcMap = new Map([
|
const dcMap = new Map([
|
||||||
[7, { id: 7, dc_name: 'USA DC1', flag: 'us', cpu_name: 'E5' }],
|
[7, { id: 7, dc_name: 'USA DC1', flag: 'us', cpu_name: 'E5' }],
|
||||||
])
|
])
|
||||||
@@ -35,8 +67,15 @@ describe('mapServerToVps', () => {
|
|||||||
expect(vps.status).toBe('active')
|
expect(vps.status).toBe('active')
|
||||||
expect(vps.monthlyRate).toBe(420)
|
expect(vps.monthlyRate).toBe(420)
|
||||||
expect(vps.datacenter).toBe('USA DC1')
|
expect(vps.datacenter).toBe('USA DC1')
|
||||||
expect(vps.country).toBe('US')
|
expect(vps.country).toBe('США')
|
||||||
|
expect(vps.city).toBe('')
|
||||||
expect(vps.paidUntil).toBe('2022-10-26')
|
expect(vps.paidUntil).toBe('2022-10-26')
|
||||||
expect(vps.notes).toContain('4vps-4140')
|
expect(vps.notes).toContain('4vps-4140')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('falls back to tname prefix when DC map empty', () => {
|
||||||
|
const vps = mapServerToVps(sampleServer, 'prov-1', 'acc-1', new Map())
|
||||||
|
expect(vps.country).toBe('США')
|
||||||
|
expect(vps.datacenter).toBe('7')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { FourVpsDatacenter, FourVpsServer } from './operations.js'
|
import type { FourVpsDatacenter, FourVpsServer } from './operations.js'
|
||||||
|
import { parseFourVpsDcLocation } from './location.js'
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, string> = {
|
const STATUS_MAP: Record<string, string> = {
|
||||||
active: 'active',
|
active: 'active',
|
||||||
@@ -16,6 +17,14 @@ function unixToIso(ts: number | null | undefined): string {
|
|||||||
return new Date(ts * 1000).toISOString().slice(0, 10)
|
return new Date(ts * 1000).toISOString().slice(0, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fallback when getDcList miss: «USA-cx01» / «AE-cx01» → flag-like prefix. */
|
||||||
|
function locationFromTariffName(tname: unknown): { country: string; city: string } {
|
||||||
|
const raw = String(tname || '').trim()
|
||||||
|
const m = raw.match(/^([A-Za-z]{2,3})-/)
|
||||||
|
if (!m) return { country: '', city: '' }
|
||||||
|
return parseFourVpsDcLocation(m[1], m[1])
|
||||||
|
}
|
||||||
|
|
||||||
export interface MappedVps {
|
export interface MappedVps {
|
||||||
externalId: string
|
externalId: string
|
||||||
ip: string
|
ip: string
|
||||||
@@ -59,7 +68,12 @@ export function mapServerToVps(
|
|||||||
): MappedVps {
|
): MappedVps {
|
||||||
const dc = dcMap.get(server.dc)
|
const dc = dcMap.get(server.dc)
|
||||||
const datacenter = dc?.dc_name ?? String(server.dc)
|
const datacenter = dc?.dc_name ?? String(server.dc)
|
||||||
const country = (dc?.flag ?? '').toUpperCase()
|
let { country, city } = parseFourVpsDcLocation(dc?.dc_name, dc?.flag)
|
||||||
|
if (!country) {
|
||||||
|
const fromTariff = locationFromTariffName(server.tname)
|
||||||
|
country = fromTariff.country
|
||||||
|
city = city || fromTariff.city
|
||||||
|
}
|
||||||
const status = STATUS_MAP[String(server.status).toLowerCase()] ?? 'active'
|
const status = STATUS_MAP[String(server.status).toLowerCase()] ?? 'active'
|
||||||
const monthlyRate = Number.isFinite(server.price) ? server.price : null
|
const monthlyRate = Number.isFinite(server.price) ? server.price : null
|
||||||
const name = String(server.name || '').trim()
|
const name = String(server.name || '').trim()
|
||||||
@@ -73,7 +87,7 @@ export function mapServerToVps(
|
|||||||
providerId,
|
providerId,
|
||||||
providerAccountId,
|
providerAccountId,
|
||||||
country,
|
country,
|
||||||
city: '',
|
city,
|
||||||
datacenter,
|
datacenter,
|
||||||
os: String(server.image || '').trim(),
|
os: String(server.image || '').trim(),
|
||||||
vcpu: server.cpu ?? 0,
|
vcpu: server.cpu ?? 0,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { parseFourVpsCredentials } from '@cfdm/shared/utils/api-credentials'
|
import { parseFourVpsCredentials } from '@cfdm/shared/utils/api-credentials'
|
||||||
|
|
||||||
import { fourvpsRequest } from './client.js'
|
import { fourvpsRequest } from './client.js'
|
||||||
|
import { countryFromFourVpsFlag, parseFourVpsDcLocation } from './location.js'
|
||||||
|
|
||||||
export interface FourVpsServer {
|
export interface FourVpsServer {
|
||||||
id: number
|
id: number
|
||||||
@@ -126,6 +127,7 @@ function mapPresetToTariffItem(
|
|||||||
dcName: string,
|
dcName: string,
|
||||||
country: string,
|
country: string,
|
||||||
cpuModel: string,
|
cpuModel: string,
|
||||||
|
location = dcName,
|
||||||
): FourVpsTariffItem {
|
): FourVpsTariffItem {
|
||||||
const diskMib = preset.disks?.[0]?.size_mib ?? (preset.rom ?? 0)
|
const diskMib = preset.disks?.[0]?.size_mib ?? (preset.rom ?? 0)
|
||||||
const diskGb = diskMib > 0 ? Math.round(diskMib / 1024) : preset.rom ?? 0
|
const diskGb = diskMib > 0 ? Math.round(diskMib / 1024) : preset.rom ?? 0
|
||||||
@@ -145,7 +147,7 @@ function mapPresetToTariffItem(
|
|||||||
diskType: diskTag.toUpperCase(),
|
diskType: diskTag.toUpperCase(),
|
||||||
virtualization: 'KVM',
|
virtualization: 'KVM',
|
||||||
channel: preset.commentParsed?.eth ?? '',
|
channel: preset.commentParsed?.eth ?? '',
|
||||||
location: dcName,
|
location,
|
||||||
country,
|
country,
|
||||||
cpuModel,
|
cpuModel,
|
||||||
orderAvailable: true,
|
orderAvailable: true,
|
||||||
@@ -187,13 +189,16 @@ export async function fetchTarifList(
|
|||||||
const dcId = String(clusterInfo.id ?? dcKey)
|
const dcId = String(clusterInfo.id ?? dcKey)
|
||||||
const dcFromList = dcMap.get(Number(clusterInfo.id ?? dcKey))
|
const dcFromList = dcMap.get(Number(clusterInfo.id ?? dcKey))
|
||||||
const dcName = clusterInfo.dc_name ?? dcFromList?.dc_name ?? ''
|
const dcName = clusterInfo.dc_name ?? dcFromList?.dc_name ?? ''
|
||||||
const country = (clusterInfo.flag ?? dcFromList?.flag ?? '').toUpperCase()
|
const flag = clusterInfo.flag ?? dcFromList?.flag ?? ''
|
||||||
|
const { country: parsedCountry, city } = parseFourVpsDcLocation(dcName, flag)
|
||||||
|
const country = parsedCountry || countryFromFourVpsFlag(flag)
|
||||||
|
const location = city || dcName
|
||||||
const cpuModel = clusterInfo.cpu_name ?? dcFromList?.cpu_name ?? ''
|
const cpuModel = clusterInfo.cpu_name ?? dcFromList?.cpu_name ?? ''
|
||||||
const presets = cluster.presets ?? {}
|
const presets = cluster.presets ?? {}
|
||||||
|
|
||||||
for (const preset of Object.values(presets)) {
|
for (const preset of Object.values(presets)) {
|
||||||
if (!preset?.id) continue
|
if (!preset?.id) continue
|
||||||
items.push(mapPresetToTariffItem(preset, dcId, dcName, country, cpuModel))
|
items.push(mapPresetToTariffItem(preset, dcId, dcName, country, cpuModel, location))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,10 +113,19 @@ describe('syncFromFourvps', () => {
|
|||||||
expect(result.balance?.balance).toBe(77825)
|
expect(result.balance?.balance).toBe(77825)
|
||||||
|
|
||||||
const vps = getSqlite()
|
const vps = getSqlite()
|
||||||
.prepare(`SELECT id, ip FROM vps WHERE providerAccountId = ?`)
|
.prepare(`SELECT id, ip, country, city, datacenter FROM vps WHERE providerAccountId = ?`)
|
||||||
.get('acc-4vps') as { id: string; ip: string }
|
.get('acc-4vps') as {
|
||||||
|
id: string
|
||||||
|
ip: string
|
||||||
|
country: string
|
||||||
|
city: string
|
||||||
|
datacenter: string
|
||||||
|
}
|
||||||
expect(vps.id).toBe('vps-4vps-acc-4vps-100')
|
expect(vps.id).toBe('vps-4vps-acc-4vps-100')
|
||||||
expect(vps.ip).toBe('1.2.3.4')
|
expect(vps.ip).toBe('1.2.3.4')
|
||||||
|
expect(vps.country).toBe('ОАЭ')
|
||||||
|
expect(vps.city).toBe('')
|
||||||
|
expect(vps.datacenter).toBe('AE DC1')
|
||||||
|
|
||||||
const tariffs = getSqlite()
|
const tariffs = getSqlite()
|
||||||
.prepare(`SELECT COUNT(*) as c FROM active_tariffs WHERE providerAccountId = ?`)
|
.prepare(`SELECT COUNT(*) as c FROM active_tariffs WHERE providerAccountId = ?`)
|
||||||
|
|||||||
Reference in New Issue
Block a user