From 1908f841087962b78d2ccc9a3b6f54be857ea0fc 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: Mon, 3 Aug 2026 23:40:12 +0300
Subject: [PATCH] =?UTF-8?q?fix(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0=D0=BC=D0=B0)?=
=?UTF-8?q?:=20=D1=81=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA=20=D0=B0=D1=83?=
=?UTF-8?q?=D0=B4=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=B8=20=D0=BF=D0=BE=D1=81?=
=?UTF-8?q?=D0=BB=D0=B5=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8?=
=?UTF-8?q?=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0=20=D0=B8=20=D1=81=D0=BA?=
=?UTF-8?q?=D0=BB=D0=B5=D0=B9=D0=BA=D0=B0=20=D0=BE=D0=B4=D0=B8=D0=BD=D0=B0?=
=?UTF-8?q?=D0=BA=D0=BE=D0=B2=D1=8B=D1=85=20=D1=81=D0=BB=D0=BE=D0=B2=20?=
=?UTF-8?q?=D0=AF=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=D0=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Две мелочи из приёмки 03.08.2026, обе со сторожами, принятыми красным.
1. Счётчик аудитории врал после загрузки списка номеров.
Загрузили 1693 номера — под кнопкой оставалось «Наберётся ~15 человек»
и красное «аудитория меньше 100». Правда появлялась, только если тронуть
любое другое поле. Клиент делает единственный разумный вывод — список
не принялся — и грузит его снова.
Починка: после удачной загрузки счётчик пересчитывается сам.
Сторож держит не «позвали ли пересчёт», а то, что видит клиент на экране.
2. Лента кампании показывала стену из одинакового текста.
Яндекс отвечает по каждому объявлению отдельно, и на общую беду
«предоставьте документы» приходит пятнадцать одинаковых слов сразу.
Замер на кампании #6: 15 сообщений, разных из них всего два — второе
терялось в стене.
Починка только на показе: одинаковый текст подряд склеивается в одну
строку с подписью «и ещё N объявлений». Запись по объявлениям не тронута —
по ней разбирают, кого именно не пустили.
Второй сторож держит границу склейки: сообщения с приложенным файлом
не склеиваются никогда, иначе ссылка на документ исчезнет с экрана.
Проверено: 2045 сторожей фронта зелёные, типы Vue чисты, стиль в изменённых
файлах чист. Бэкенд и схема не тронуты.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../advertising/CampaignMessages.vue | 71 ++++++++++++++++++-
.../components/advertising/CampaignWizard.vue | 4 ++
.../advertising-campaign-messages.spec.ts | 57 +++++++++++++++
.../advertising-campaign-wizard.spec.ts | 46 ++++++++++++
4 files changed, 176 insertions(+), 2 deletions(-)
diff --git a/app/resources/js/components/advertising/CampaignMessages.vue b/app/resources/js/components/advertising/CampaignMessages.vue
index d3f183da..9651ba3a 100644
--- a/app/resources/js/components/advertising/CampaignMessages.vue
+++ b/app/resources/js/components/advertising/CampaignMessages.vue
@@ -3,7 +3,7 @@
* CampaignMessages — окно передачи между Яндексом и клиентом по одной кампании.
* Слова Яндекса показываем как есть, без своей трактовки.
*/
-import { ref, watch } from 'vue';
+import { computed, ref, watch } from 'vue';
import { fetchCampaignMessages, sendCampaignMessage, type CampaignMessage } from '../../api/advertising';
const props = defineProps<{ campaignId: number | null }>();
@@ -55,6 +55,66 @@ async function send(): Promise {
}
}
+/**
+ * Одинаковый текст подряд — одна строка на экране.
+ *
+ * Яндекс отвечает по каждому объявлению отдельно, и на общую беду («предоставьте
+ * документы») приходит пятнадцать одинаковых слов сразу. Записаны они по объявлениям
+ * не зря — по записи разбирают, кого именно не пустили, — но клиенту показывать стену
+ * из одинакового текста нельзя: в ней теряется второе, РАЗНОЕ сообщение.
+ * Замер 03.08.2026 на кампании #6: 15 сообщений, из них разных всего два.
+ *
+ * 🪤 Склеиваем только соседей: разнести одинаковые ответы, между которыми клиент писал
+ * своё, значило бы переставить переписку местами. И никогда не склеиваем сообщения
+ * с приложенным файлом — иначе ссылка на документ исчезнет с экрана.
+ */
+type SklejennoeSoobshchenie = {
+ id: number;
+ author: CampaignMessage['author'];
+ body: string;
+ file_name: string | null;
+ povtory: number;
+};
+
+const sklejennye = computed(() => {
+ const rows: SklejennoeSoobshchenie[] = [];
+
+ for (const m of messages.value) {
+ const last = rows[rows.length - 1];
+ const povtor =
+ last !== undefined &&
+ last.file_name === null &&
+ (m.file_name ?? null) === null &&
+ last.author === m.author &&
+ last.body === m.body;
+
+ if (povtor && last !== undefined) {
+ last.povtory += 1;
+ continue;
+ }
+
+ rows.push({ id: m.id, author: m.author, body: m.body, file_name: m.file_name ?? null, povtory: 0 });
+ }
+
+ return rows;
+});
+
+/** «и ещё 2 объявления» — с правильным русским окончанием. */
+function povtoryuSlovami(n: number): string {
+ const posledn = n % 10;
+ const desyatki = n % 100;
+ const slovo =
+ desyatki >= 11 && desyatki <= 14
+ ? 'объявлений'
+ : posledn === 1
+ ? 'объявление'
+ : posledn >= 2 && posledn <= 4
+ ? 'объявления'
+ : 'объявлений';
+
+ return `и ещё ${n} ${slovo}`;
+}
+
watch(() => props.campaignId, reload, { immediate: true });
@@ -65,13 +125,20 @@ watch(() => props.campaignId, reload, { immediate: true });
-
+
{{ AUTHOR_LABEL[m.author] }}
{{ m.body }}
+
+ {{ povtoryuSlovami(m.povtory) }}
+
{
file: phonesFile.value,
text: phonesText.value,
});
+ // Список меняет размер аудитории — пересчитываем сразу, не дожидаясь, пока клиент
+ // тронет что-нибудь ещё. Приёмка 03.08.2026: загрузили 1693 номера, а под кнопкой
+ // осталось «Наберётся ~15 человек» — клиент решает, что список не принялся.
+ await refreshAudienceSize();
} catch (e) {
phonesUploadError.value = extractErrorMessage(e, 'Не удалось загрузить список номеров.');
} finally {
diff --git a/app/tests/Frontend/advertising-campaign-messages.spec.ts b/app/tests/Frontend/advertising-campaign-messages.spec.ts
index c5f0304f..290f2127 100644
--- a/app/tests/Frontend/advertising-campaign-messages.spec.ts
+++ b/app/tests/Frontend/advertising-campaign-messages.spec.ts
@@ -72,6 +72,63 @@ describe('CampaignMessages.vue — переписка по кампании', ()
expect(w.text()).toContain('Не получилось загрузить переписку');
});
+ /**
+ * Приёмка на бою 03.08.2026: у кампании #6 одно и то же слово Яндекса
+ * «Для показа в заданных регионах предоставьте документы» лежало в ленте 13 раз —
+ * по строке на каждое объявление. Клиент видит стену одинакового текста и не может
+ * найти в ней второе, РАЗНОЕ сообщение.
+ *
+ * Записывать по объявлению правильно — по этой записи разбирают, кого именно не пустили.
+ * Ошибка в показе: одинаковый текст подряд надо склеивать в одну строку и говорить,
+ * скольких объявлений он касается.
+ */
+ it('одинаковое слово Яндекса по многим объявлениям склеивается в одну строку', async () => {
+ const documents = 'Для показа в заданных регионах предоставьте документы';
+ fetchMessagesMock.mockResolvedValue([
+ { id: 1, author: 'yandex', banner_id: 101, body: documents, file_name: null, file_size: null, created_at: null },
+ { id: 2, author: 'yandex', banner_id: 102, body: documents, file_name: null, file_size: null, created_at: null },
+ { id: 3, author: 'yandex', banner_id: 103, body: documents, file_name: null, file_size: null, created_at: null },
+ { id: 4, author: 'yandex', banner_id: 104, body: 'Нет предупреждения: финансовые услуги', file_name: null, file_size: null, created_at: null },
+ ]);
+
+ const w = mount(CampaignMessages, {
+ props: { campaignId: 13 },
+ global: { plugins: [vuetify] },
+ });
+ await flush();
+ await w.vm.$nextTick();
+
+ const items = w.findAll('[data-testid="campaign-message"]');
+ expect(items).toHaveLength(2);
+ expect(items[0].text()).toContain(documents);
+ expect(items[0].text()).toContain('ещё 2 объявлени');
+ // Второе, РАЗНОЕ сообщение не должно потеряться в склейке.
+ expect(items[1].text()).toContain('Нет предупреждения');
+ expect(items[1].text()).not.toContain('ещё');
+ });
+
+ /**
+ * Склейка не должна съесть приложенный файл: у сообщения с документом всегда своя строка,
+ * иначе ссылка на него исчезнет с экрана.
+ */
+ it('сообщения с приложенным файлом не склеиваются, даже если текст совпал', async () => {
+ fetchMessagesMock.mockResolvedValue([
+ { id: 1, author: 'client', banner_id: null, body: 'Приложил документ', file_name: 'licenziya.pdf', file_size: 100, created_at: null },
+ { id: 2, author: 'client', banner_id: null, body: 'Приложил документ', file_name: 'ustav.pdf', file_size: 100, created_at: null },
+ ]);
+
+ const w = mount(CampaignMessages, {
+ props: { campaignId: 13 },
+ global: { plugins: [vuetify] },
+ });
+ await flush();
+ await w.vm.$nextTick();
+
+ expect(w.findAll('[data-testid="campaign-message"]')).toHaveLength(2);
+ const links = w.findAll('[data-testid="campaign-message-file"]');
+ expect(links.map((l) => l.text())).toEqual(['licenziya.pdf', 'ustav.pdf']);
+ });
+
it('отправляет ответ и перечитывает ленту', async () => {
fetchMessagesMock.mockResolvedValue([]);
sendMessageMock.mockResolvedValue(undefined);
diff --git a/app/tests/Frontend/advertising-campaign-wizard.spec.ts b/app/tests/Frontend/advertising-campaign-wizard.spec.ts
index 4ace4e66..939f900b 100644
--- a/app/tests/Frontend/advertising-campaign-wizard.spec.ts
+++ b/app/tests/Frontend/advertising-campaign-wizard.spec.ts
@@ -401,6 +401,52 @@ describe('CampaignWizard.vue (T18 — загрузка «моего списка
expect(error.text()).toContain('Не удалось разобрать файл.');
});
+ /**
+ * Приёмка на бою 03.08.2026: загрузили 1693 номера, а под кнопкой по-прежнему висело
+ * «Наберётся ~15 человек» и красное «аудитория меньше 100». Правда появлялась, только
+ * если тронуть любое другое поле. Клиент делает единственный разумный вывод — список
+ * не принялся — и грузит его снова.
+ *
+ * Сторож держит не «позвали ли пересчёт», а то, ЧТО клиент видит на экране: после
+ * удачной загрузки там обязано стоять новое число.
+ */
+ it('после удачной загрузки списка счётчик аудитории пересчитывается сам', async () => {
+ uploadCampaignPhonesMock.mockResolvedValue({ recognized: 1693, skipped: 0 });
+ fetchAudienceSizeMock.mockResolvedValue({ size: 15, min: 100, enough: false, hint: 'Маловато для запуска' });
+
+ const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+ const form = (w.vm as unknown as { form: { mode: string; snapshot_from: string; snapshot_to: string } }).form;
+ form.mode = 'manual';
+ // Без обеих дат счётчик в ручном режиме вообще не считается — как на бою, ставим период.
+ form.snapshot_from = '2026-01-01';
+ form.snapshot_to = '2026-08-02';
+ await w.vm.$nextTick();
+
+ const uploadSwitchInput = w.find('[data-testid="use-list-switch"] input');
+ await uploadSwitchInput.setValue(true);
+ await w.vm.$nextTick();
+
+ // Даём отработать отложенному пересчёту от смены режима (задержка ~400 мс), иначе он
+ // догонит нас уже после загрузки и сторож позеленеет по чужой причине.
+ await new Promise((r) => setTimeout(r, 500));
+ await w.vm.$nextTick();
+ expect(w.find('[data-testid="audience-size-result"]').text()).toContain('~15');
+
+ // Список ушёл на сервер — теперь тот же запрос вернёт настоящий размер аудитории.
+ fetchAudienceSizeMock.mockResolvedValue({ size: 1701, min: 100, enough: true, hint: null });
+
+ (w.vm as unknown as { phonesText: string }).phonesText = '79267654321';
+ await w.vm.$nextTick();
+
+ await w.find('[data-testid="phones-upload-btn"]').trigger('click');
+ await flush();
+ await w.vm.$nextTick();
+
+ expect(w.find('[data-testid="audience-size-result"]').text()).toContain('~1701');
+ });
+
it('кнопка «Загрузить список» выключена, пока и файл, и текст пустые', async () => {
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
await flush();