{
payload.phones = parsePhones();
}
ocenka.value = await estimateTelegram(payload);
+ // Прайс держим ОТДЕЛЬНО от охвата: охват на ошибке обнуляем (устаревшее число
+ // хуже никакого), а цены за тысячу от настроек аудитории не зависят — гасить
+ // их значило бы мигать подсказкой на каждой опечатке в поле.
+ if (ocenka.value.ceny_za_tysyachu) {
+ ceny.value = ocenka.value.ceny_za_tysyachu;
+ }
} catch {
// 🔴 Молчим, а не показываем прежнее число: устаревший охват хуже, чем никакого —
// клиент решил бы, что считает под нынешние настройки, и запустил вслепую.
@@ -263,6 +272,8 @@ function formatMoney(value: string): string {
}
const smetaRub = computed(() => ocenka.value?.estimated_cost_rub ?? null);
+/** Текст подсказки «?» у поля медиа — с ценами сервера, пока их нет — без чисел. */
+const podskazkaMedia = computed(() => podskazkaProMedia(ceny.value));
/** Смета больше баланса — клиент упрётся в отказ при запуске, скажем заранее. */
const maloDeneg = computed(() => {
@@ -437,6 +448,19 @@ defineExpose({ step, obnovitOhvat });
/>
+
+
+ Скачать образец
+
-
+
diff --git a/app/resources/js/views/advertising/obrazecBazy.ts b/app/resources/js/views/advertising/obrazecBazy.ts
new file mode 100644
index 00000000..3a64230f
--- /dev/null
+++ b/app/resources/js/views/advertising/obrazecBazy.ts
@@ -0,0 +1,50 @@
+/**
+ * Образец файла для «Моей базы номеров».
+ *
+ * 🔴 Замечание владельца 03.08.2026: «нету скачать файл с примером как надо заполнить
+ * для нас файл». Клиент видел поле «Файл с номерами» и подпись про первый столбец — и
+ * должен был догадаться сам. Догадка стоит дорого: неподходящий файл отклоняется
+ * целиком, а клиент решает, что портал сломан.
+ *
+ * 🪤 Заголовка-строки («Телефон») здесь намеренно НЕТ. Разборщик на сервере
+ * (TelegramBazaService) нормализует первый столбец КАЖДОЙ строки — слово попало бы в
+ * «не похоже на номер», и наш собственный образец показал бы клиенту ошибку.
+ *
+ * Написания нарочно разные: с плюсом, с восьмёркой, со скобками и пробелами. Так видно,
+ * что формат подгонять не нужно — приведём сами.
+ *
+ * Номера синтетические (7999…). Настоящие в репозиторий не кладём НИКОГДА — это ПДн.
+ *
+ * Датчики — tests/Frontend/telegram-obrazec-bazy.spec.ts.
+ */
+
+/** Имя скачиваемого файла: без пробелов и кириллицы — такое не ломает ни один браузер. */
+export const IMYA_OBRAZCA = 'obrazec-bazy-nomerov.csv';
+
+/** Содержимое образца: один номер в строке, разными написаниями. */
+export const OBRAZEC_BAZY = [
+ '+7 999 000-00-01',
+ '+79990000002',
+ '89990000003',
+ '8 999 000 00 04',
+ '7 (999) 000-00-05',
+].join('\r\n');
+
+/**
+ * Отдать образец браузеру как скачиваемый файл.
+ *
+ * Файл собираем прямо здесь, а не просим у сервера: содержимое неизменное, лишний поход
+ * на сервер добавил бы ещё одну точку отказа ради пяти строк.
+ *
+ * Перевод строки — CRLF: Excel открывает такой файл ровно, LF он на части не делит.
+ */
+export function skachatObrazecBazy(): void {
+ const blob = new Blob([OBRAZEC_BAZY], { type: 'text/csv;charset=utf-8' });
+ const ssylka = document.createElement('a');
+ ssylka.href = URL.createObjectURL(blob);
+ ssylka.download = IMYA_OBRAZCA;
+ document.body.appendChild(ssylka);
+ ssylka.click();
+ document.body.removeChild(ssylka);
+ URL.revokeObjectURL(ssylka.href);
+}
diff --git a/app/tests/Feature/ClientTg/EstimateApiTest.php b/app/tests/Feature/ClientTg/EstimateApiTest.php
index ec54a635..a7b56f0a 100644
--- a/app/tests/Feature/ClientTg/EstimateApiTest.php
+++ b/app/tests/Feature/ClientTg/EstimateApiTest.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\Contact;
use App\Models\ClientTg\Optout;
+use App\Models\ClientTg\Tariff;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
@@ -181,3 +182,38 @@ it('без входа в кабинет ручка недоступна', functi
'audience_kind' => Campaign::AUDIENCE_BASE,
])->assertStatus(401);
});
+
+/**
+ * Цена за тысячу показов — ОТ СЕРВЕРА, а не вбитая в текст экрана.
+ *
+ * 🔴 Откуда взялось (приёмка владельца 03.08.2026). Подсказка «?» у поля медиа обещала
+ * «600 ₽ за тысячу с картинкой, 680 ₽ с видео». Настоящие клиентские цены на тот момент —
+ * 1008 и 1142,40 ₽: числа были вбиты в текст руками и протухли в ту же минуту, когда
+ * миграция client_tg_cena_po_media поменяла тариф. Обещать клиенту цену вдвое ниже
+ * настоящей — это не опечатка, это неверная цена в кабинете.
+ *
+ * Лечение: цену называет тот же, кто её считает. Экран не имеет права знать числа.
+ */
+it('оценка отдаёт клиентскую цену за тысячу по каждому виду медиа', function () {
+ $otvet = $this->postJson('/api/telegram/campaigns/estimate', [
+ 'audience_kind' => Campaign::AUDIENCE_BASE,
+ ])->assertOk();
+
+ // Себестоимость 0.480 / 0.720 / 0.816 ₽ за показ × наценка 1.40 × 1000 показов.
+ expect($otvet->json('ceny_za_tysyachu.none'))->toBe('672.00');
+ expect($otvet->json('ceny_za_tysyachu.image'))->toBe('1008.00');
+ expect($otvet->json('ceny_za_tysyachu.video'))->toBe('1142.40');
+});
+
+it('цена за тысячу едет из таблицы тарифа — поменяли тариф, поменялся ответ', function () {
+ Tariff::where('media_kind', Campaign::MEDIA_IMAGE)->update(['price_rub' => '1.000']);
+
+ $otvet = $this->postJson('/api/telegram/campaigns/estimate', [
+ 'audience_kind' => Campaign::AUDIENCE_BASE,
+ ])->assertOk();
+
+ // 1.000 × 1.40 × 1000 = 1400. Если бы число было вбито в код, ответ не сдвинулся бы.
+ expect($otvet->json('ceny_za_tysyachu.image'))->toBe('1400.00');
+ // Соседние виды медиа при этом не поехали.
+ expect($otvet->json('ceny_za_tysyachu.none'))->toBe('672.00');
+});
diff --git a/app/tests/Frontend/podskazki-na-ekranah.spec.ts b/app/tests/Frontend/podskazki-na-ekranah.spec.ts
index 37ab902d..459f8a86 100644
--- a/app/tests/Frontend/podskazki-na-ekranah.spec.ts
+++ b/app/tests/Frontend/podskazki-na-ekranah.spec.ts
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
-import { PODSKAZKI } from '../../resources/js/composables/podskazkiReklamy';
+import { PODSKAZKI, podskazkaProMedia } from '../../resources/js/composables/podskazkiReklamy';
/**
* Подсказки «?» стоят у нужных полей на обоих рекламных экранах (пачка 3).
@@ -144,7 +144,9 @@ describe('мастер телеграм-кампании — подсказки
const w = await masterNaShage(2);
expect(podskazkaVnutri(w, '[data-testid="tg-ord"]')).toBe(PODSKAZKI.ordCategory);
- expect(podskazkaVnutri(w, '[data-testid="tg-media"]')).toBe(PODSKAZKI.media);
+ // Подсказка про медиа больше не строка словаря: цены в ней — живые, от сервера.
+ // Здесь сервер ещё не ответил, значит показывается вариант без чисел.
+ expect(podskazkaVnutri(w, '[data-testid="tg-media"]')).toBe(podskazkaProMedia(null));
});
it('шаг «Деньги»: лимит на объявление объяснён', async () => {
diff --git a/app/tests/Frontend/podskazki-reklamy.spec.ts b/app/tests/Frontend/podskazki-reklamy.spec.ts
index 8cf30531..58ece8af 100644
--- a/app/tests/Frontend/podskazki-reklamy.spec.ts
+++ b/app/tests/Frontend/podskazki-reklamy.spec.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { PODSKAZKI, type KlyuchPodskazki } from '../../resources/js/composables/podskazkiReklamy';
+import { PODSKAZKI, podskazkaProMedia, type KlyuchPodskazki } from '../../resources/js/composables/podskazkiReklamy';
/**
* Тексты подсказок «?» на рекламных экранах (пачка 3).
@@ -16,7 +16,7 @@ const VSE_KLYUCHI: KlyuchPodskazki[] = [
'audienceDays',
'budgetCap',
'ordCategory',
- 'media',
+
'autoThreshold',
'autoBudget',
'autoDailyLimit',
@@ -91,3 +91,60 @@ describe('подсказки, у которых текст задан не на
expect(PODSKAZKI.walletFrozen.toLowerCase()).toContain('нельзя');
});
});
+
+/**
+ * Подсказка про медиа собирается из ЖИВЫХ цен сервера (приёмка владельца 03.08.2026).
+ *
+ * 🔴 Откуда взялось. Текст обещал «600 ₽ за тысячу с картинкой, 680 ₽ с видео», а
+ * клиент платил 1008 и 1142,40 ₽. Числа были вбиты в строку руками и протухли в ту
+ * минуту, когда миграция поменяла тариф. Экран не имеет права знать цену — он обязан
+ * назвать ту, которую ему сказал сервер.
+ */
+describe('подсказка про медиа — цены не вбиты в текст', () => {
+ const CENY = { none: '672.00', image: '1008.00', video: '1142.40' };
+
+ it('называет ровно те цены, которые дал сервер', () => {
+ const tekst = podskazkaProMedia(CENY);
+
+ expect(tekst).toContain('1008');
+ expect(tekst).toContain('1142');
+ });
+
+ it('поменялся тариф — поменялся текст', () => {
+ const tekst = podskazkaProMedia({ none: '672.00', image: '1400.00', video: '1600.00' });
+
+ expect(tekst).toContain('1400');
+ expect(tekst).not.toContain('1008');
+ });
+
+ it('старых выдуманных чисел 600 и 680 в тексте нет', () => {
+ const tekst = podskazkaProMedia(CENY);
+
+ expect(tekst).not.toMatch(/\b600\b/);
+ expect(tekst).not.toMatch(/\b680\b/);
+ });
+
+ it('собранный текст живёт по тем же правилам языка, что и словарь', () => {
+ const tekst = podskazkaProMedia(CENY);
+
+ expect(tekst).not.toMatch(/[a-zA-Z]/);
+ expect(tekst.trim()).toBe(tekst);
+ expect(tekst.length).toBeGreaterThanOrEqual(40);
+ expect(tekst.length).toBeLessThanOrEqual(200);
+
+ const predlozheniy = tekst.split(/[.!?]+/).filter((ch) => ch.trim() !== '').length;
+ expect(predlozheniy).toBeGreaterThanOrEqual(2);
+ expect(predlozheniy).toBeLessThanOrEqual(3);
+ });
+});
+
+describe('подсказка про медиа — пока сервер не ответил', () => {
+ it('без цен говорит честно «дороже», но НИКАКИХ чисел не выдумывает', () => {
+ const tekst = podskazkaProMedia(null);
+
+ expect(tekst).not.toMatch(/\d/);
+ expect(tekst.length).toBeGreaterThanOrEqual(40);
+ expect(tekst.length).toBeLessThanOrEqual(200);
+ expect(tekst).not.toMatch(/[a-zA-Z]/);
+ });
+});
diff --git a/app/tests/Frontend/telegram-media-cena.spec.ts b/app/tests/Frontend/telegram-media-cena.spec.ts
index f0ca4f45..e548fc9a 100644
--- a/app/tests/Frontend/telegram-media-cena.spec.ts
+++ b/app/tests/Frontend/telegram-media-cena.spec.ts
@@ -119,3 +119,39 @@ describe('живой счётчик и вид медиа', () => {
expect(posledniyZapros().media_kind).toBe('video');
});
});
+
+/**
+ * Подсказка «?» у поля медиа называет цены СЕРВЕРА (приёмка владельца 03.08.2026).
+ *
+ * 🔴 Обещала «600 ₽ за тысячу с картинкой, 680 ₽ с видео», а списывалось 1008 и
+ * 1142,40 ₽: числа жили в тексте экрана и протухли при первой же правке тарифа.
+ */
+describe('подсказка про медиа берёт цены у сервера', () => {
+ beforeEach(() => {
+ estimateMock.mockReset();
+ estimateMock.mockResolvedValue({
+ ...OTVET,
+ ceny_za_tysyachu: { none: '672.00', image: '1008.00', video: '1142.40' },
+ });
+ });
+
+ it('до ответа сервера чисел не выдумывает', () => {
+ const w = smontirovat();
+ const tekst = (w.vm as unknown as { podskazkaMedia: string }).podskazkaMedia;
+
+ expect(tekst).not.toMatch(/\d/);
+ });
+
+ it('после ответа показывает цены сервера, а не 600 и 680', async () => {
+ const w = smontirovat();
+ await (w.vm as unknown as { obnovitOhvat: () => Promise }).obnovitOhvat();
+ await flushPromises();
+
+ const tekst = (w.vm as unknown as { podskazkaMedia: string }).podskazkaMedia;
+
+ expect(tekst).toContain('1008');
+ expect(tekst).toContain('1142');
+ expect(tekst).not.toMatch(/\b600\b/);
+ expect(tekst).not.toMatch(/\b680\b/);
+ });
+});
diff --git a/app/tests/Frontend/telegram-obrazec-bazy.spec.ts b/app/tests/Frontend/telegram-obrazec-bazy.spec.ts
new file mode 100644
index 00000000..a30c571c
--- /dev/null
+++ b/app/tests/Frontend/telegram-obrazec-bazy.spec.ts
@@ -0,0 +1,139 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { mount, flushPromises } from '@vue/test-utils';
+import { createVuetify } from 'vuetify';
+import { OBRAZEC_BAZY, IMYA_OBRAZCA } from '../../resources/js/views/advertising/obrazecBazy';
+
+/**
+ * Образец файла для «Моей базы номеров» (приёмка владельца 03.08.2026).
+ *
+ * 🔴 Замечание дословно: «нету скачать файл с примером как надо заполнить для нас файл».
+ * Клиент видел поле «Файл с номерами» и подпись про первый столбец — и должен был
+ * догадаться сам. Догадка стоит дорого: неверный файл отклоняется целиком, а клиент
+ * решает, что портал не работает.
+ *
+ * 🪤 Образец обязан проходить наш же разборщик БЕЗ единой отброшенной строки. Строка
+ * заголовка («Телефон») сюда не годится: TelegramBazaService нормализует первый столбец
+ * каждой строки, и слово попало бы в «не похоже на номер» — наш собственный образец
+ * показал бы клиенту ошибку.
+ *
+ * Номера синтетические (7999…) — настоящие в репозиторий не кладём НИКОГДА, это ПДн.
+ */
+
+vi.mock('vue-router', () => ({
+ useRouter: vi.fn(() => ({ push: vi.fn() })),
+}));
+
+const { estimateMock, fetchBazaMock, uploadBazaMock, clearBazaMock } = vi.hoisted(() => ({
+ estimateMock: vi.fn(),
+ fetchBazaMock: vi.fn(),
+ uploadBazaMock: vi.fn(),
+ clearBazaMock: vi.fn(),
+}));
+
+vi.mock('../../resources/js/api/telegram', () => ({
+ estimateTelegram: estimateMock,
+ fetchTgBaza: fetchBazaMock,
+ uploadTgBaza: uploadBazaMock,
+ clearTgBaza: clearBazaMock,
+ createTelegram: vi.fn(),
+ launchTelegram: vi.fn(),
+ uploadTelegramMedia: vi.fn(),
+ fetchTelegram: vi.fn(),
+ fetchAutoRule: vi.fn(),
+ resubmitTelegram: vi.fn(),
+ uploadAutoRuleMedia: vi.fn(),
+ saveAutoRule: vi.fn(),
+ MEDIA_ACCEPT: 'image/jpeg,image/png,video/mp4',
+ MEDIA_TREBOVANIYA: 'Картинка: JPEG или PNG, до 25 МБ.',
+ MEDIA_CENA: 'Картинка и видео делают показ дороже.',
+}));
+
+import TelegramCampaignWizard from '../../resources/js/views/advertising/TelegramCampaignWizard.vue';
+
+const vuetify = createVuetify();
+
+type Wrapper = ReturnType;
+
+function smontirovat(): Wrapper {
+ return mount(TelegramCampaignWizard, {
+ props: { balanceRub: '5000.00' },
+ global: { plugins: [vuetify] },
+ });
+}
+
+async function vybratBazu(w: Wrapper): Promise {
+ await w.find('[data-testid="tg-audience-base"] input').setValue(true);
+ await flushPromises();
+}
+
+/** Непустые строки образца — то, что увидит разборщик. */
+function stroki(): string[] {
+ return OBRAZEC_BAZY.split(/\r?\n/).filter((s) => s.trim() !== '');
+}
+
+describe('образец файла с номерами — содержимое', () => {
+ it('в образце только номера: ни одной строки со словами', () => {
+ for (const stroka of stroki()) {
+ expect(stroka, `строка «${stroka}» не похожа на номер`).toMatch(/^[\d+()\s-]+$/);
+ }
+ });
+
+ it('каждая строка — ровно 11 цифр, как ждёт разборщик', () => {
+ for (const stroka of stroki()) {
+ const cifr = (stroka.match(/\d/g) ?? []).length;
+ expect(cifr, `в строке «${stroka}» цифр ${cifr}`).toBe(11);
+ }
+ });
+
+ it('показывает разные написания — чтобы клиент не боялся формата', () => {
+ expect(stroki().some((s) => s.startsWith('+7'))).toBe(true);
+ expect(stroki().some((s) => s.startsWith('8'))).toBe(true);
+ expect(stroki().some((s) => /\s/.test(s))).toBe(true);
+ });
+
+ it('номера синтетические 7999… — настоящих ПДн в репозитории нет', () => {
+ for (const stroka of stroki()) {
+ const cifry = (stroka.match(/\d/g) ?? []).join('');
+ expect(cifry.slice(1, 4), `строка «${stroka}»`).toBe('999');
+ }
+ });
+
+ it('строк хватает, чтобы было понятно, но файл не простыня', () => {
+ expect(stroki().length).toBeGreaterThanOrEqual(3);
+ expect(stroki().length).toBeLessThanOrEqual(10);
+ });
+
+ it('имя файла говорит само за себя и открывается таблицей', () => {
+ expect(IMYA_OBRAZCA).toMatch(/\.csv$/);
+ // Без пробелов и кириллицы: такое имя не ломается при скачивании ни в одном браузере.
+ expect(IMYA_OBRAZCA).toMatch(/^[a-z0-9-]+\.csv$/);
+ });
+});
+
+describe('образец файла с номерами — на экране', () => {
+ beforeEach(() => {
+ estimateMock.mockReset();
+ estimateMock.mockResolvedValue({
+ planned_count: 0,
+ estimated_cost_rub: '0.00',
+ min_count: 367,
+ enough: false,
+ ceny_za_tysyachu: { none: '672.00', image: '1008.00', video: '1142.40' },
+ });
+ fetchBazaMock.mockReset();
+ fetchBazaMock.mockResolvedValue({ count: 0 });
+ });
+
+ it('в карточке «Моя база» есть чем скачать образец', async () => {
+ const w = smontirovat();
+ await vybratBazu(w);
+
+ expect(w.find('[data-testid="tg-baza-obrazec"]').exists()).toBe(true);
+ });
+
+ it('пока база не выбрана — образца на экране нет', () => {
+ const w = smontirovat();
+
+ expect(w.find('[data-testid="tg-baza-obrazec"]').exists()).toBe(false);
+ });
+});