From b35d3888bb3ec722e89724e2fe29895265558278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 8 Jul 2026 15:10:08 +0300 Subject: [PATCH] =?UTF-8?q?fix(admin):=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D1=81=D0=BA=D0=B8=D0=B9=20=D0=B4=D0=B8=D0=B0=D0=BF=D0=B0?= =?UTF-8?q?=D0=B7=D0=BE=D0=BD=20=D1=81=D1=82=D1=83=D0=BF=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=B9=20=D0=B2=20=D1=82=D0=B0=D1=80=D0=B8=D1=84=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D1=81=D0=B5=D1=82=D0=BA=D0=B5=20+=20=D0=B6=D0=B8?= =?UTF-8?q?=D0=B2=D0=BE=D0=B9=20=D0=BF=D1=80=D0=B5=D0=B4=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=D0=BC=D0=BE=D1=82=D1=80=20=D0=B2=20=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Админка показывала сырую ширину ступени (leads_in_tier: 250/500/1000...), а кабинет клиента — накопительный диапазон (1-250, 251-750...). Одни и те же данные, но выглядят как разные тарифы; в форме редактирования подпись 'Лидов в ступени' читается как потолок, а система понимает её как ширину - ловушка при смене цен. Логика денег не менялась. Изменён только показ в админке: - колонка 'Диапазон у клиента' в активной сетке; - живой предпросмотр диапазона в форме редактирования; - расчёт диапазона вынесен в общий utils/pricingTiers.ts, чтобы админка и кабинет больше не расходились. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../js/components/billing/TierPricesPanel.vue | 11 +--- app/resources/js/utils/pricingTiers.ts | 33 +++++++++++ .../js/views/admin/AdminPricingTiersView.vue | 48 +++++++++++++++- .../Frontend/AdminPricingTiersView.spec.ts | 56 +++++++++++++++++++ app/tests/Frontend/pricingTiers.spec.ts | 42 ++++++++++++++ 5 files changed, 181 insertions(+), 9 deletions(-) create mode 100644 app/resources/js/utils/pricingTiers.ts create mode 100644 app/tests/Frontend/pricingTiers.spec.ts diff --git a/app/resources/js/components/billing/TierPricesPanel.vue b/app/resources/js/components/billing/TierPricesPanel.vue index 773ac9f7..69b84e05 100644 --- a/app/resources/js/components/billing/TierPricesPanel.vue +++ b/app/resources/js/components/billing/TierPricesPanel.vue @@ -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); } diff --git a/app/resources/js/utils/pricingTiers.ts b/app/resources/js/utils/pricingTiers.ts new file mode 100644 index 00000000..27056e9d --- /dev/null +++ b/app/resources/js/utils/pricingTiers.ts @@ -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, 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}`; +} diff --git a/app/resources/js/views/admin/AdminPricingTiersView.vue b/app/resources/js/views/admin/AdminPricingTiersView.vue index 2e326115..7b6c1429 100644 --- a/app/resources/js/views/admin/AdminPricingTiersView.vue +++ b/app/resources/js/views/admin/AdminPricingTiersView.vue @@ -23,7 +23,7 @@ {{ item.leads_in_tier }} все свыше + +
+ «Лидов в ступени» — сколько заявок помещается именно в эту ступень (ширина). «Диапазон» — + как эти ступени видит клиент в кабинете: с какого по какой лид действует цена. +
@@ -68,11 +75,16 @@ style="max-width: 240px" data-testid="effective-from-input" /> +

+ В поле «Лидов в ступени» вводится ширина ступени (сколько заявок в неё + помещается). Справа сразу видно, что увидит клиент — накопительный диапазон. +

+ @@ -90,6 +102,9 @@ /> все свыше +
Ступень Лидов в ступениКлиент увидит Цена за лид (₽)
+ {{ editorRangeText(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; +} diff --git a/app/tests/Frontend/AdminPricingTiersView.spec.ts b/app/tests/Frontend/AdminPricingTiersView.spec.ts index 183ba5ff..cc6c3680 100644 --- a/app/tests/Frontend/AdminPricingTiersView.spec.ts +++ b/app/tests/Frontend/AdminPricingTiersView.spec.ts @@ -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: '
' } }, + }, + }); + 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: '
' } }, + }, + }); + 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(); diff --git a/app/tests/Frontend/pricingTiers.spec.ts b/app/tests/Frontend/pricingTiers.spec.ts new file mode 100644 index 00000000..d935fe0d --- /dev/null +++ b/app/tests/Frontend/pricingTiers.spec.ts @@ -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+'); + }); +});