fix(admin): клиентский диапазон ступеней в тарифной сетке + живой предпросмотр в редакторе
Админка показывала сырую ширину ступени (leads_in_tier: 250/500/1000...), а кабинет клиента — накопительный диапазон (1-250, 251-750...). Одни и те же данные, но выглядят как разные тарифы; в форме редактирования подпись 'Лидов в ступени' читается как потолок, а система понимает её как ширину - ловушка при смене цен. Логика денег не менялась. Изменён только показ в админке: - колонка 'Диапазон у клиента' в активной сетке; - живой предпросмотр диапазона в форме редактирования; - расчёт диапазона вынесен в общий utils/pricingTiers.ts, чтобы админка и кабинет больше не расходились. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@
|
||||
*
|
||||
* Billing v2 Spec A §3.4.8.
|
||||
*/
|
||||
import { tierRangeText } from '../../utils/pricingTiers';
|
||||
|
||||
interface TierPreview {
|
||||
tier_no: number;
|
||||
leads_in_tier: number | null;
|
||||
@@ -18,14 +20,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
function rangeText(idx: number): string {
|
||||
let start = 1;
|
||||
for (let i = 0; i < idx; i++) {
|
||||
const cap = props.tiers[i].leads_in_tier;
|
||||
if (cap !== null) start += cap;
|
||||
}
|
||||
const cap = props.tiers[idx].leads_in_tier;
|
||||
if (cap === null) return `${start}+`;
|
||||
return `${start}–${start + cap - 1}`;
|
||||
return tierRangeText(props.tiers, idx);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Единый источник истины для показа объёмных ступеней тарифа.
|
||||
*
|
||||
* В БД у каждой ступени хранится ШИРИНА (`leads_in_tier`) — сколько лидов
|
||||
* помещается именно в эту ступень, а НЕ «до какого лида она действует».
|
||||
* Живой расчёт списаний (PricingTierResolver / BalanceToLeadsConverter)
|
||||
* накапливает эти ширины: tier 1 = 1..w1, tier 2 = w1+1..w1+w2, и т.д.
|
||||
*
|
||||
* Эта функция переводит ширину в тот накопительный диапазон, который видит
|
||||
* клиент в кабинете. Держим её в ОДНОМ месте, чтобы админка и кабинет никогда
|
||||
* не разошлись в цифрах.
|
||||
*/
|
||||
export interface TierWidth {
|
||||
leads_in_tier: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Диапазон номеров лидов для ступени с индексом `idx` (0-based) в массиве
|
||||
* ступеней, отсортированном по возрастанию tier_no.
|
||||
*
|
||||
* Пример (ширины 250/500/…): idx=0 → «1–250», idx=1 → «251–750».
|
||||
* Последняя ступень (`leads_in_tier === null`) → «19251+».
|
||||
*/
|
||||
export function tierRangeText(tiers: ReadonlyArray<TierWidth>, idx: number): string {
|
||||
let start = 1;
|
||||
for (let i = 0; i < idx; i++) {
|
||||
const cap = tiers[i].leads_in_tier;
|
||||
if (cap !== null) start += cap;
|
||||
}
|
||||
const cap = tiers[idx].leads_in_tier;
|
||||
if (cap === null) return `${start}+`;
|
||||
return `${start}–${start + cap - 1}`;
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
</span>
|
||||
</v-card-title>
|
||||
<v-data-table
|
||||
:headers="tierHeaders"
|
||||
:headers="activeHeaders"
|
||||
:items="active"
|
||||
:items-per-page="7"
|
||||
density="comfortable"
|
||||
@@ -33,10 +33,17 @@
|
||||
<span v-if="item.leads_in_tier !== null">{{ item.leads_in_tier }}</span>
|
||||
<span v-else class="text-medium-emphasis">все свыше</span>
|
||||
</template>
|
||||
<template #[`item.client_range`]="{ item }">
|
||||
<span :data-testid="`active-range-${item.tier_no}`">{{ activeRangeText(item) }} лидов</span>
|
||||
</template>
|
||||
<template #[`item.price_rub`]="{ item }">
|
||||
{{ fmtRub(item.price_per_lead_kopecks) }}
|
||||
</template>
|
||||
</v-data-table>
|
||||
<div class="text-caption text-medium-emphasis px-4 pb-3">
|
||||
«Лидов в ступени» — сколько заявок помещается именно в эту ступень (ширина). «Диапазон» —
|
||||
как эти ступени видит клиент в кабинете: с какого по какой лид действует цена.
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<v-card v-if="hasScheduled" class="mb-6" elevation="1">
|
||||
@@ -68,11 +75,16 @@
|
||||
style="max-width: 240px"
|
||||
data-testid="effective-from-input"
|
||||
/>
|
||||
<p class="text-caption text-medium-emphasis mb-2">
|
||||
В поле «Лидов в ступени» вводится <strong>ширина</strong> ступени (сколько заявок в неё
|
||||
помещается). Справа сразу видно, <strong>что увидит клиент</strong> — накопительный диапазон.
|
||||
</p>
|
||||
<table class="editor-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ступень</th>
|
||||
<th>Лидов в ступени</th>
|
||||
<th>Клиент увидит</th>
|
||||
<th>Цена за лид (₽)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -90,6 +102,9 @@
|
||||
/>
|
||||
<span v-else class="text-medium-emphasis">все свыше</span>
|
||||
</td>
|
||||
<td :data-testid="`editor-range-${t.tier_no}`" class="editor-range num">
|
||||
{{ editorRangeText(t) }} лидов
|
||||
</td>
|
||||
<td>
|
||||
<v-text-field
|
||||
v-model="t.price_rub"
|
||||
@@ -148,6 +163,7 @@ import {
|
||||
type PricingTierEditorRow,
|
||||
} from '../../api/admin';
|
||||
import { extractErrorMessage } from '../../api/client';
|
||||
import { tierRangeText } from '../../utils/pricingTiers';
|
||||
|
||||
/**
|
||||
* SaaS-admin → Тарифная сетка (Plan 4 Task 9, Sprint 1 G1 error handling).
|
||||
@@ -198,6 +214,28 @@ const tierHeaders = [
|
||||
{ title: 'Цена за лид', key: 'price_rub', sortable: false },
|
||||
];
|
||||
|
||||
// Активная сетка показывает ещё и накопительный диапазон — ровно то, что видит
|
||||
// клиент в кабинете (устраняет путаницу «в админке одни числа, у клиента другие»).
|
||||
const activeHeaders = [
|
||||
{ title: '№', key: 'tier_no', sortable: false, width: 80 },
|
||||
{ title: 'Лидов в ступени', key: 'leads_in_tier', sortable: false },
|
||||
{ title: 'Диапазон (у клиента)', key: 'client_range', sortable: false },
|
||||
{ title: 'Цена за лид', key: 'price_rub', sortable: false },
|
||||
];
|
||||
|
||||
// Диапазон для строки активной сетки: находим позицию ступени по tier_no
|
||||
// и переводим ширины в накопительный диапазон (общий помощник — как в кабинете).
|
||||
function activeRangeText(item: AdminPricingTier): string {
|
||||
const idx = active.value.findIndex((t) => t.tier_no === item.tier_no);
|
||||
return tierRangeText(active.value, idx);
|
||||
}
|
||||
|
||||
// Живой предпросмотр диапазона в редакторе — пересчитывается при правке ширины.
|
||||
function editorRangeText(row: PricingTierEditorRow): string {
|
||||
const idx = editor.value.findIndex((r) => r.tier_no === row.tier_no);
|
||||
return tierRangeText(editor.value, idx);
|
||||
}
|
||||
|
||||
const nextMonthStart = computed(() => {
|
||||
const d = new Date();
|
||||
d.setDate(1);
|
||||
@@ -307,4 +345,12 @@ defineExpose({
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
.editor-range {
|
||||
color: #6b6456;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -143,6 +143,62 @@ describe('AdminPricingTiersView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminPricingTiersView — клиентский диапазон (устранение путаницы admin↔кабинет)', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} });
|
||||
vi.mocked(adminApi.createPricingTiers).mockResolvedValue({ effective_from: '2026-06-01' });
|
||||
});
|
||||
|
||||
it('в активной сетке рядом с шириной показывает накопительный диапазон, как у клиента', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// ширины 100,200 → ступень 2 у клиента = 101–300 (а не «200»)
|
||||
const cell = wrapper.find('[data-testid="active-range-2"]');
|
||||
expect(cell.exists()).toBe(true);
|
||||
expect(cell.text()).toContain('101–300');
|
||||
});
|
||||
|
||||
it('ступень 7 в активной сетке — открытый диапазон со знаком +', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// 100+200+400+800+1500+3000 = 6000 → ступень 7 = 6001+
|
||||
expect(wrapper.find('[data-testid="active-range-7"]').text()).toContain('6001+');
|
||||
});
|
||||
|
||||
it('редактор показывает живой клиентский диапазон рядом с полем ширины', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, {
|
||||
global: {
|
||||
plugins: [vuetify],
|
||||
stubs: { VDialog: { template: '<div><slot /></div>' } },
|
||||
},
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(wrapper.vm as any).editorOpen = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
// defaultEditor: ширины 100,200 → ступень 2 = 101–300
|
||||
const cell = wrapper.find('[data-testid="editor-range-2"]');
|
||||
expect(cell.exists()).toBe(true);
|
||||
expect(cell.text()).toContain('101–300');
|
||||
});
|
||||
|
||||
it('предпросмотр в редакторе пересчитывается при изменении ширины', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, {
|
||||
global: {
|
||||
plugins: [vuetify],
|
||||
stubs: { VDialog: { template: '<div><slot /></div>' } },
|
||||
},
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
vm.editorOpen = true;
|
||||
vm.editor[0].leads_in_tier = 250; // была 100 → ступень 2 сдвигается на 251–450
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('[data-testid="editor-range-2"]').text()).toContain('251–450');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminPricingTiersView error handling (Sprint 1 G1)', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { tierRangeText } from '../../resources/js/utils/pricingTiers';
|
||||
|
||||
/**
|
||||
* Единый источник истины для «ширина ступени → накопительный диапазон».
|
||||
* Числа — ровно те, что клиент видит в кабинете (боевая сетка 08.07.2026):
|
||||
* ширины 250/500/1000/2500/5000/10000/∞ → 1–250, 251–750, …, 19251+.
|
||||
*/
|
||||
const tiers = [
|
||||
{ leads_in_tier: 250 },
|
||||
{ leads_in_tier: 500 },
|
||||
{ leads_in_tier: 1000 },
|
||||
{ leads_in_tier: 2500 },
|
||||
{ leads_in_tier: 5000 },
|
||||
{ leads_in_tier: 10000 },
|
||||
{ leads_in_tier: null },
|
||||
];
|
||||
|
||||
describe('tierRangeText', () => {
|
||||
it('первая ступень начинается с 1', () => {
|
||||
expect(tierRangeText(tiers, 0)).toBe('1–250');
|
||||
});
|
||||
|
||||
it('вторая ступень — накопительный диапазон, а не «сама ширина»', () => {
|
||||
expect(tierRangeText(tiers, 1)).toBe('251–750');
|
||||
});
|
||||
|
||||
it('третья ступень продолжает накопление', () => {
|
||||
expect(tierRangeText(tiers, 2)).toBe('751–1750');
|
||||
});
|
||||
|
||||
it('шестая ступень', () => {
|
||||
// 250+500+1000+2500 = 4250 → пятая 4251–9250 → шестая 9251–19250
|
||||
expect(tierRangeText(tiers, 5)).toBe('9251–19250');
|
||||
});
|
||||
|
||||
it('последняя ступень (leads_in_tier=null) — открытый диапазон со знаком +', () => {
|
||||
// 250+500+1000+2500+5000+10000 = 19250 → tier 7 стартует с 19251
|
||||
expect(tierRangeText(tiers, 6)).toBe('19251+');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user