fix(userapi): период тарифа Macloud и отображение ставки на списке VPS
Docker / build (push) Failing after 1m45s
Docker / build (push) Failing after 1m45s
Месячные планы Macloud определяются через inferPlanPeriod; в UI показывается суточная или месячная ставка по tariffType, а не месячный эквивалент. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -106,6 +106,37 @@ describe('mapServerToVps', () => {
|
||||
expect(vps.monthlyRate).toBe(46.5)
|
||||
})
|
||||
|
||||
it('maps macloud monthly plan when period fields are missing', () => {
|
||||
const planIndex = new Map([
|
||||
[
|
||||
'3:7',
|
||||
{
|
||||
id: 7,
|
||||
name: '1 RAM / 1 CPU / 10 NVMe',
|
||||
cost: 150,
|
||||
period: '',
|
||||
'server-group': 3,
|
||||
},
|
||||
],
|
||||
])
|
||||
const vps = mapServerToVps(
|
||||
{
|
||||
...server,
|
||||
'server-plan': { id: 7, name: '1 RAM / 1 CPU / 10 NVMe' },
|
||||
'server-group': { id: 3, name: 'VDS' },
|
||||
data: { cpu: { value: 1 }, ram: { value: 1 }, disk: { value: 10 } },
|
||||
},
|
||||
'macloud',
|
||||
'prov-1',
|
||||
'acc-1',
|
||||
planIndex,
|
||||
'RUB',
|
||||
)
|
||||
expect(vps.tariffType).toBe('monthly')
|
||||
expect(vps.monthlyRate).toBe(150)
|
||||
expect(vps.dailyRate).toBeNull()
|
||||
})
|
||||
|
||||
it('parses string plan cost from index', () => {
|
||||
const planIndex = new Map([
|
||||
[
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
UserApiServerPlan,
|
||||
UserApiTariffItem,
|
||||
} from './operations.js'
|
||||
import { normalizePlanPeriod, effectivePlanCost, findPlanInIndex, parsePlanCost } from './operations.js'
|
||||
import { inferPlanPeriod, effectivePlanCost, findPlanInIndex, parsePlanCost } from './operations.js'
|
||||
|
||||
const STATUS_MAP: Record<string, string> = {
|
||||
active: 'active',
|
||||
@@ -101,12 +101,15 @@ function resolveRatesFromTariffItem(
|
||||
|
||||
function resolvePlanRates(
|
||||
server: UserApiServerDetail,
|
||||
apiType: UserApiType,
|
||||
billingMode: string | null | undefined,
|
||||
planIndex?: UserApiPlanCostIndex,
|
||||
tariffByPlanId?: Map<string, UserApiTariffItem>,
|
||||
): { tariffType: string; dailyRate: number | null; monthlyRate: number | null } {
|
||||
const planId = server['server-plan']?.id
|
||||
const defaultType = inferPlanPeriod(undefined, apiType, billingMode) === 'month' ? 'monthly' : 'daily'
|
||||
if (planId == null) {
|
||||
return { tariffType: 'daily', dailyRate: null, monthlyRate: null }
|
||||
return { tariffType: defaultType, dailyRate: null, monthlyRate: null }
|
||||
}
|
||||
|
||||
const planKey = String(planId)
|
||||
@@ -116,7 +119,7 @@ function resolvePlanRates(
|
||||
const baseCost = plan ? effectivePlanCost(plan) : null
|
||||
if (plan && baseCost != null) {
|
||||
const cost = baseCost + calculateConstructorExtraCost(server, plan)
|
||||
return ratesFromCost(cost, normalizePlanPeriod(plan.period))
|
||||
return ratesFromCost(cost, inferPlanPeriod(plan, apiType, billingMode))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +129,7 @@ function resolvePlanRates(
|
||||
if (fromTariff) return fromTariff
|
||||
}
|
||||
|
||||
return { tariffType: 'daily', dailyRate: null, monthlyRate: null }
|
||||
return { tariffType: defaultType, dailyRate: null, monthlyRate: null }
|
||||
}
|
||||
|
||||
export interface MappedVps {
|
||||
@@ -183,6 +186,7 @@ export function mapServerToVps(
|
||||
planIndex?: UserApiPlanCostIndex,
|
||||
currency = 'RUB',
|
||||
tariffByPlanId?: Map<string, UserApiTariffItem>,
|
||||
billingMode?: string | null,
|
||||
): MappedVps {
|
||||
const { ip, ipv6 } = extractIp(server)
|
||||
const data = server.data ?? {}
|
||||
@@ -199,7 +203,13 @@ export function mapServerToVps(
|
||||
: traffGb > 0
|
||||
? Math.round((traffGb / 1024) * 100) / 100
|
||||
: 0
|
||||
const { tariffType, dailyRate, monthlyRate } = resolvePlanRates(server, planIndex, tariffByPlanId)
|
||||
const { tariffType, dailyRate, monthlyRate } = resolvePlanRates(
|
||||
server,
|
||||
apiType,
|
||||
billingMode,
|
||||
planIndex,
|
||||
tariffByPlanId,
|
||||
)
|
||||
const resolvedCurrency = (currency || 'RUB').trim().toUpperCase() || 'RUB'
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { inferPlanPeriod, normalizePlanPeriod } from './operations.js'
|
||||
|
||||
describe('normalizePlanPeriod', () => {
|
||||
it('detects day and month from period and period_name', () => {
|
||||
expect(normalizePlanPeriod('day')).toBe('day')
|
||||
expect(normalizePlanPeriod('month')).toBe('month')
|
||||
expect(normalizePlanPeriod(undefined, 'день')).toBe('day')
|
||||
expect(normalizePlanPeriod(undefined, 'месяц')).toBe('month')
|
||||
})
|
||||
|
||||
it('returns null when period is unknown', () => {
|
||||
expect(normalizePlanPeriod()).toBeNull()
|
||||
expect(normalizePlanPeriod('', '')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('inferPlanPeriod', () => {
|
||||
it('defaults macloud to month when plan has no period', () => {
|
||||
expect(inferPlanPeriod({ id: 1, name: 'x', cost: 150 }, 'macloud')).toBe('month')
|
||||
})
|
||||
|
||||
it('defaults vdsina to day when plan has no period', () => {
|
||||
expect(inferPlanPeriod({ id: 1, name: 'x', cost: 2.1 }, 'vdsina')).toBe('day')
|
||||
})
|
||||
|
||||
it('respects explicit plan period over apiType default', () => {
|
||||
expect(
|
||||
inferPlanPeriod({ id: 1, name: 'x', cost: 2.1, period: 'day' }, 'macloud'),
|
||||
).toBe('day')
|
||||
})
|
||||
|
||||
it('uses account billingMode when plan period is missing', () => {
|
||||
expect(inferPlanPeriod({ id: 1, name: 'x', cost: 90 }, 'vdsina', 'monthly')).toBe('month')
|
||||
})
|
||||
})
|
||||
@@ -72,6 +72,7 @@ export interface UserApiServerPlan {
|
||||
cost: number | string
|
||||
full_cost?: number | string
|
||||
period?: string
|
||||
period_name?: string
|
||||
description?: string
|
||||
active?: boolean
|
||||
enable?: boolean
|
||||
@@ -199,20 +200,39 @@ export function findPlanInIndex(
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function normalizePlanPeriod(period?: string): 'day' | 'month' {
|
||||
const p = (period || 'day').toLowerCase()
|
||||
if (p === 'month' || p === 'monthly') return 'month'
|
||||
/** Распознаёт период из полей UserAPI (period, period_name). */
|
||||
export function normalizePlanPeriod(period?: string, periodName?: string): 'day' | 'month' | null {
|
||||
const sources = [period, periodName].filter((s) => Boolean(String(s ?? '').trim()))
|
||||
for (const raw of sources) {
|
||||
const p = String(raw).toLowerCase().trim()
|
||||
if (['month', 'monthly', 'mo', 'месяц', 'мес'].some((k) => p === k || p.includes(k))) return 'month'
|
||||
if (['day', 'daily', 'день', 'дн', 'сут', 'сутки'].some((k) => p === k || p.includes(k))) return 'day'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Период тарифа: поля плана → billingMode аккаунта → дефолт по apiType (macloud — месяц). */
|
||||
export function inferPlanPeriod(
|
||||
plan: UserApiServerPlan | undefined,
|
||||
apiType?: string,
|
||||
billingMode?: string | null,
|
||||
): 'day' | 'month' {
|
||||
const fromPlan = plan ? normalizePlanPeriod(plan.period, plan.period_name) : null
|
||||
if (fromPlan) return fromPlan
|
||||
if (billingMode === 'monthly') return 'month'
|
||||
if (billingMode === 'daily') return 'day'
|
||||
if (apiType === 'macloud') return 'month'
|
||||
return 'day'
|
||||
}
|
||||
|
||||
/** Формат цены для active_tariffs.price — понятен parseTariffPrice. */
|
||||
export function formatUserApiTariffPrice(
|
||||
cost: number,
|
||||
period: string | undefined,
|
||||
period: 'day' | 'month',
|
||||
currency: string,
|
||||
): string {
|
||||
const cur = (currency || 'RUB').trim().toUpperCase()
|
||||
if (normalizePlanPeriod(period) === 'day') {
|
||||
if (period === 'day') {
|
||||
return `${cost} ${cur}/day`
|
||||
}
|
||||
return `${cost} ${cur}`
|
||||
@@ -253,17 +273,18 @@ function mapPlanToTariffItem(
|
||||
country: '',
|
||||
cpuModel: '',
|
||||
orderAvailable: Boolean(plan.active && plan.enable),
|
||||
price: cost != null ? formatUserApiTariffPrice(cost, plan.period, currency) : '',
|
||||
price: cost != null ? formatUserApiTariffPrice(cost, inferPlanPeriod(plan), currency) : '',
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePlanInIndex(plan: UserApiServerPlan, groupId: number): UserApiServerPlan {
|
||||
const cost = effectivePlanCost(plan)
|
||||
const period = inferPlanPeriod(plan)
|
||||
return {
|
||||
...plan,
|
||||
'server-group': plan['server-group'] ?? groupId,
|
||||
cost: cost ?? plan.cost,
|
||||
period: normalizePlanPeriod(plan.period) === 'month' ? 'month' : 'day',
|
||||
period,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ export async function syncFromUserApi(
|
||||
planIndex,
|
||||
fallbackCurrency,
|
||||
tariffByPlanId,
|
||||
account.billingMode,
|
||||
)
|
||||
const id = `vps-${idPrefix}-${accountId}-${vps.externalId}`
|
||||
const additionalIps = JSON.stringify(vps.additionalIps || [])
|
||||
|
||||
@@ -120,6 +120,38 @@ export function tariffTypeLabel(type: string): string {
|
||||
return TARIFF_TYPE_LABELS[type] ?? type
|
||||
}
|
||||
|
||||
function tariffRateNumber(value: number | string | null | undefined): number | null {
|
||||
if (value === '' || value == null) return null
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
/** Сумма тарифа в валюте провайдера: суточная или месячная — по tariffType. */
|
||||
export function vpsTariffRateAmount(vps: {
|
||||
tariffType?: string | null
|
||||
dailyRate?: number | string | null
|
||||
monthlyRate?: number | string | null
|
||||
}): number {
|
||||
const daily = tariffRateNumber(vps.dailyRate)
|
||||
const monthly = tariffRateNumber(vps.monthlyRate)
|
||||
if (vps.tariffType === 'daily') {
|
||||
return daily ?? 0
|
||||
}
|
||||
return monthly ?? 0
|
||||
}
|
||||
|
||||
/** Месячный burn-rate для сортировки и отчётов. */
|
||||
export function vpsTariffMonthlyBurn(vps: {
|
||||
tariffType?: string | null
|
||||
dailyRate?: number | string | null
|
||||
monthlyRate?: number | string | null
|
||||
}): number {
|
||||
const daily = tariffRateNumber(vps.dailyRate) ?? 0
|
||||
const monthly = tariffRateNumber(vps.monthlyRate) ?? 0
|
||||
if (vps.tariffType === 'daily') return daily * 30
|
||||
return monthly
|
||||
}
|
||||
|
||||
const ENVIRONMENT_LABELS: Record<string, string> = {
|
||||
prod: 'Production', dev: 'Development', staging: 'Staging',
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
tariffTypeLabel,
|
||||
vpsStatusLabel,
|
||||
paymentTypeLabel,
|
||||
vpsTariffRateAmount,
|
||||
} from '@/lib/format'
|
||||
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
||||
import { VPS_SYNC_OVERRIDE_FIELDS, parseUserOverrides } from '@/lib/vps-sync-fields'
|
||||
@@ -215,11 +216,7 @@ function VpsDetailPage() {
|
||||
<InfoRow
|
||||
label="Ставка"
|
||||
value={formatCurrency(
|
||||
row.monthlyRate != null
|
||||
? Number(row.monthlyRate)
|
||||
: row.tariffType === 'daily'
|
||||
? Number(row.dailyRate || 0) * 30
|
||||
: Number(row.monthlyRate || 0),
|
||||
vpsTariffRateAmount(row),
|
||||
effectiveVpsTariffCurrency(row, provider),
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { toast } from 'sonner'
|
||||
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
import type { VpsFormValues } from '@/lib/schemas'
|
||||
import { normalizeRatesPayload, effectiveVpsTariffCurrency, formatCurrency, vpsStatusLabel, tariffTypeLabel } from '@/lib/format'
|
||||
import { normalizeRatesPayload, effectiveVpsTariffCurrency, formatCurrency, vpsStatusLabel, tariffTypeLabel, vpsTariffRateAmount, vpsTariffMonthlyBurn } from '@/lib/format'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
@@ -408,21 +408,11 @@ function VpsPage() {
|
||||
key: 'tariff',
|
||||
header: 'Тариф',
|
||||
icon: CreditCardIcon,
|
||||
sortValue: (v) =>
|
||||
v.monthlyRate != null
|
||||
? Number(v.monthlyRate)
|
||||
: v.tariffType === 'daily'
|
||||
? Number(v.dailyRate || 0) * 30
|
||||
: 0,
|
||||
sortValue: (v) => vpsTariffMonthlyBurn(v),
|
||||
cell: (v) => {
|
||||
const provider = providerById.get(v.providerId)
|
||||
const currency = effectiveVpsTariffCurrency(v, provider)
|
||||
const amount =
|
||||
v.monthlyRate != null
|
||||
? Number(v.monthlyRate)
|
||||
: v.tariffType === 'daily'
|
||||
? Number(v.dailyRate || 0) * 30
|
||||
: Number(v.monthlyRate || 0)
|
||||
const amount = vpsTariffRateAmount(v)
|
||||
return dataGridCellStack(formatCurrency(amount, currency), tariffTypeLabel(v.tariffType))
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user