From 656cf80f31efdaa9da2b69f20abbf6131054cbeb 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: Tue, 4 Aug 2026 09:04:27 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0=D0=BC=D0=B0?= =?UTF-8?q?):=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20=C2=AB=D0=97=D0=B0?= =?UTF-8?q?=D0=BF=D1=83=D1=81=D1=82=D0=B8=D1=82=D1=8C=C2=BB=20=D1=83=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B0=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B5=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Кампания в состоянии «Готова к запуску» не двигалась ничем — ни человеком, ни расписанием. Клиент собирал рекламу, заливал пятнадцать картинок, отправлял заявку и упирался в тупик: на карточке только «Изменить» и «Отчёт». Запуск был возможен лишь обращением, к которому в кабинете нет кнопки. Поймано приёмкой 03.08.2026 на кампании #11. Новых дыр кнопка не открывает: путь запуска и так лежал в клиентской группе, все проверки — деньги, минимум площадки, годность аудитории — внутри самого запуска. Три ответа портала показываются по-разному: «готовим картинки» и «аудитория ещё готовится» — спокойным синим сообщением, это нормальный ход, деньги не тронуты и в кабинете ничего не создано; смета мала, не хватает денег, Яндекс молчит — красным. Денежная плашка перечитывается только после удачного запуска. Отдельного «вы уверены?» намеренно нет: кнопка подписана, смета показов стоит тут же на карточке, пауза возвращает заморозку целиком — проверено живьём 03.08 на кампании #6. Вместо окна под кнопкой строчка «При запуске 1 020,60 руб. будут зарезервированы на кошельке»: клиент знает про деньги до нажатия, а не узнаёт потом из кошелька. Сторожа: кнопка есть только у «Готовы к запуску» и нет у работающей и у черновика; удачный запуск перечитывает список и денежную плашку; «аудитория ещё готовится» даёт спокойное сообщение и не тревожит плашку; отказ даёт красное; под кнопкой сказано про резерв сметы. Прогон: 2049 сторожей фронта зелёные, типы Vue чисты, стиль чист. Co-Authored-By: Claude Opus 5 (1M context) --- app/resources/js/api/advertising.ts | 17 ++- .../components/advertising/CampaignList.vue | 100 +++++++++++++++ .../advertising-campaign-list.spec.ts | 118 ++++++++++++++++++ .../2026-08-03-rezultaty-priyomki-yandex.md | 46 ++++++- 4 files changed, 275 insertions(+), 6 deletions(-) diff --git a/app/resources/js/api/advertising.ts b/app/resources/js/api/advertising.ts index db47288a..b9dab01e 100644 --- a/app/resources/js/api/advertising.ts +++ b/app/resources/js/api/advertising.ts @@ -295,10 +295,23 @@ export async function approveBanners(id: number): Promise<{ approved_at: string return data; } +/** + * Ответы «зайдите позже»: портал отвечает ими, когда всё в порядке — картинки ещё грузятся + * в рекламный кабинет либо рекламная система ещё сводит список номеров с людьми. Это НЕ + * отказы: кампания остаётся как была, деньги не тронуты, в кабинете ничего не создано. + */ +export type LaunchPendingStatus = 'creatives_pending' | 'audience_pending'; + +export type LaunchResult = { + status: CampaignStatus | LaunchPendingStatus; + /** Человеческое пояснение — есть только у ответов «зайдите позже». */ + message?: string; +}; + /** POST /api/advertising/campaigns/{id}/launch — запустить кампанию (модерация Яндекса). */ -export async function launchCampaign(id: number): Promise<{ status: CampaignStatus }> { +export async function launchCampaign(id: number): Promise { await ensureCsrfCookie(); - const { data } = await apiClient.post<{ status: CampaignStatus }>(`/api/advertising/campaigns/${id}/launch`); + const { data } = await apiClient.post(`/api/advertising/campaigns/${id}/launch`); return data; } diff --git a/app/resources/js/components/advertising/CampaignList.vue b/app/resources/js/components/advertising/CampaignList.vue index 0e5e03e4..00ef5d10 100644 --- a/app/resources/js/components/advertising/CampaignList.vue +++ b/app/resources/js/components/advertising/CampaignList.vue @@ -24,6 +24,7 @@ import { resumeCampaign, deleteCampaign, reviveCampaign, + launchCampaign, type Campaign, type CampaignStatus, } from '../../api/advertising'; @@ -50,6 +51,14 @@ const loading = ref(true); const loadError = ref(null); const actionError = ref(null); +/** + * Спокойное сообщение «зайдите позже» — отдельно от ошибок. Портал отвечает им на запуск, + * когда всё в порядке: картинки ещё грузятся в рекламный кабинет либо рекламная система + * ещё сводит список номеров с людьми (на бою 30.07.2026 — около 15 часов). Красным такое + * показывать нельзя: клиент решит, что сделал что-то не так, и начнёт переделывать готовое. + */ +const actionNote = ref(null); + // id кампании, для которой сейчас идёт запрос паузы/возобновления — управляет :loading кнопки. const busyId = ref(null); @@ -137,6 +146,52 @@ async function onEdit(campaign: Campaign): Promise { } } +/** + * «Запустить» — у кампании, готовой к запуску. + * + * Приёмка 03.08.2026: клиент собирал рекламу, заливал пятнадцать картинок, отправлял + * заявку — и упирался в тупик. Кампания вставала «Готова к запуску», а на карточке были + * только «Изменить» и «Отчёт»: запустить её можно было лишь обращением, к которому в + * кабинете нет кнопки. Модуль клиенту не работал вообще. + * + * Новых дыр кнопка не открывает: путь запуска и так лежит в клиентской группе, а все + * проверки — деньги, минимум площадки, готовность аудитории — живут внутри самого запуска. + */ +function canLaunch(status: CampaignStatus): boolean { + return status === 'queued'; +} + +/** + * Три ответа портала на запуск клиент должен видеть по-разному: + * — «готовим картинки» и «аудитория ещё готовится» — спокойным сообщением: это не отказ, + * деньги не тронуты, надо просто зайти позже; + * — всё остальное (смета мала, не хватает денег, Яндекс не отвечает) — красным; + * — удачный запуск запирает смету, поэтому денежная плашка обязана перечитаться. + */ +async function onLaunch(campaign: Campaign): Promise { + busyId.value = campaign.id; + actionError.value = null; + actionNote.value = null; + try { + const res = await launchCampaign(campaign.id); + const podozhdite = res.status === 'creatives_pending' || res.status === 'audience_pending'; + + if (podozhdite) { + actionNote.value = res.message ?? 'Пока рано — зайдите позже и нажмите «Запустить» ещё раз.'; + } + + await loadCampaigns(); + + if (!podozhdite) { + emit('money-moved'); // запуск запер смету + } + } catch (e) { + actionError.value = extractErrorMessage(e, 'Не удалось запустить кампанию.'); + } finally { + busyId.value = null; + } +} + /** Статусы, из которых можно поставить на паузу. */ function canPause(status: CampaignStatus): boolean { return status === 'running' || status === 'pending_moderation'; @@ -259,6 +314,20 @@ defineExpose({ loadCampaigns }); {{ actionError }} + + + {{ actionNote }} + + @@ -344,6 +413,19 @@ defineExpose({ loadCampaigns }); {{ campaign.status === 'rejected' ? 'Исправить' : 'Изменить' }} + + Запустить + + + + +
+ {{ + campaign.smeta_rub + ? `При запуске ${formatMoney(campaign.smeta_rub)} будут зарезервированы на кошельке.` + : 'При запуске смета будет зарезервирована на кошельке.' + }} +
diff --git a/app/tests/Frontend/advertising-campaign-list.spec.ts b/app/tests/Frontend/advertising-campaign-list.spec.ts index 15bfcf19..8eb3efa4 100644 --- a/app/tests/Frontend/advertising-campaign-list.spec.ts +++ b/app/tests/Frontend/advertising-campaign-list.spec.ts @@ -9,6 +9,7 @@ const { fetchCampaignMock, deleteCampaignMock, reviveCampaignMock, + launchCampaignMock, } = vi.hoisted(() => ({ fetchCampaignsMock: vi.fn(), pauseCampaignMock: vi.fn(), @@ -16,6 +17,7 @@ const { fetchCampaignMock: vi.fn(), deleteCampaignMock: vi.fn(), reviveCampaignMock: vi.fn(), + launchCampaignMock: vi.fn(), })); vi.mock('../../resources/js/api/advertising', () => ({ @@ -25,6 +27,7 @@ vi.mock('../../resources/js/api/advertising', () => ({ fetchCampaign: fetchCampaignMock, deleteCampaign: deleteCampaignMock, reviveCampaign: reviveCampaignMock, + launchCampaign: launchCampaignMock, })); import CampaignList from '../../resources/js/components/advertising/CampaignList.vue'; @@ -54,6 +57,7 @@ describe('CampaignList.vue (B2-4 — вкладка «Мои кампании») resumeCampaignMock.mockReset(); fetchCampaignMock.mockReset(); deleteCampaignMock.mockReset(); + launchCampaignMock.mockReset(); fetchCampaignMock.mockResolvedValue({ campaign: { id: 1, @@ -463,4 +467,118 @@ describe('CampaignList.vue — кнопка «Исправить» оживля expect(w.emitted('edit')).toBeUndefined(); expect(w.find('[data-testid="campaign-action-error"]').exists()).toBe(true); }); + + /** + * Приёмка 03.08.2026: клиент собрал рекламу, залил пятнадцать картинок, отправил заявку — + * и упёрся в тупик. Кампания встала «Готова к запуску», а на карточке только «Изменить» + * и «Отчёт». Запустить её можно было лишь обращением, к которому в кабинете нет кнопки: + * модуль клиенту не работал вообще. + */ + const gotovaKZapusku = [ + { + id: 11, + name: 'Реклама от 03.08.2026', + status: 'queued', + audience_days: 30, + launched_at: null, + }, + ]; + + it('у кампании «Готова к запуску» есть кнопка «Запустить», и она запускает', async () => { + fetchCampaignsMock.mockResolvedValue(gotovaKZapusku); + launchCampaignMock.mockResolvedValue({ status: 'pending_moderation' }); + + const w = mount(CampaignList, { global: { plugins: [vuetify], stubs: DIALOG_STUBS } }); + await flush(); + await w.vm.$nextTick(); + + const btn = w.find('[data-testid="campaign-launch-btn"]'); + expect(btn.exists()).toBe(true); + + await btn.trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(launchCampaignMock).toHaveBeenCalledWith(11); + expect(fetchCampaignsMock).toHaveBeenCalledTimes(2); // список перечитан + // Удачный запуск запирает смету — денежная плашка обязана перечитаться. + expect(w.emitted('money-moved')).toBeTruthy(); + }); + + /** + * «Аудитория ещё готовится» и «готовим картинки» — НЕ отказы: портал отвечает ими, + * когда всё в порядке и надо просто зайти позже. Деньги при этом не тронуты и + * в рекламном кабинете ничего не создано. Показать их красным — напугать клиента + * и заставить переделывать то, что уже сделано верно. + */ + it('«аудитория ещё готовится» показывается спокойным сообщением, а не ошибкой', async () => { + fetchCampaignsMock.mockResolvedValue(gotovaKZapusku); + launchCampaignMock.mockResolvedValue({ + status: 'audience_pending', + message: 'Аудитория ещё готовится — зайдите позже и нажмите «Запустить» ещё раз.', + }); + + const w = mount(CampaignList, { global: { plugins: [vuetify], stubs: DIALOG_STUBS } }); + await flush(); + await w.vm.$nextTick(); + + await w.find('[data-testid="campaign-launch-btn"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + const note = w.find('[data-testid="campaign-action-note"]'); + expect(note.exists()).toBe(true); + expect(note.text()).toContain('Аудитория ещё готовится'); + expect(w.find('[data-testid="campaign-action-error"]').exists()).toBe(false); + // Ничего не запиралось — тревожить денежную плашку незачем. + expect(w.emitted('money-moved')).toBeFalsy(); + }); + + /** Настоящий отказ — красным и словами сервера: «смета мала», «не хватает денег». */ + it('отказ запуска показывается ошибкой', async () => { + fetchCampaignsMock.mockResolvedValue(gotovaKZapusku); + launchCampaignMock.mockRejectedValue(new Error('смета мала')); + + const w = mount(CampaignList, { global: { plugins: [vuetify], stubs: DIALOG_STUBS } }); + await flush(); + await w.vm.$nextTick(); + + await w.find('[data-testid="campaign-launch-btn"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(w.find('[data-testid="campaign-action-error"]').exists()).toBe(true); + expect(w.find('[data-testid="campaign-action-note"]').exists()).toBe(false); + }); + + /** + * Запуск запирает деньги — клиент должен знать это ДО нажатия, а не узнать из кошелька. + * Отдельного «вы уверены?» не делаем: кнопка подписана, смета показов стоит тут же на + * карточке, а пауза возвращает заморозку целиком (проверено живьём 03.08.2026). + */ + it('под кнопкой «Запустить» сказано, что смета будет зарезервирована', async () => { + fetchCampaignsMock.mockResolvedValue([{ ...gotovaKZapusku[0], smeta_rub: '1020.60' }]); + + const w = mount(CampaignList, { global: { plugins: [vuetify], stubs: DIALOG_STUBS } }); + await flush(); + await w.vm.$nextTick(); + + const hint = w.find('[data-testid="campaign-launch-hint"]'); + expect(hint.exists()).toBe(true); + expect(hint.text()).toContain('зарезерв'); + }); + + /** Кнопка ровно там, где ей место: уже работающую кампанию запускать нечего. */ + it('у работающей кампании и у черновика кнопки «Запустить» нет', async () => { + fetchCampaignsMock.mockResolvedValue([ + { id: 1, name: 'Крутится', status: 'running', audience_days: 30, launched_at: null }, + { id: 2, name: 'Черновик', status: 'draft', audience_days: 30, launched_at: null }, + ]); + + const w = mount(CampaignList, { global: { plugins: [vuetify], stubs: DIALOG_STUBS } }); + await flush(); + await w.vm.$nextTick(); + + expect(w.findAll('[data-testid="campaign-launch-btn"]')).toHaveLength(0); + }); }); diff --git a/docs/superpowers/runbooks/2026-08-03-rezultaty-priyomki-yandex.md b/docs/superpowers/runbooks/2026-08-03-rezultaty-priyomki-yandex.md index 7e5fba4c..9d47ab98 100644 --- a/docs/superpowers/runbooks/2026-08-03-rezultaty-priyomki-yandex.md +++ b/docs/superpowers/runbooks/2026-08-03-rezultaty-priyomki-yandex.md @@ -331,7 +331,7 @@ Windows показывает верное настенное время (18:26 | # | Что | Замер | |---|---|---| | 1 | ✅ **ПОЧИНЕНО 04.08.** «Показывать 1 день» превращалось в период ДВУХ дней: `resolvePeriod` ставил конец «сегодня + `run_days`», а Яндекс считает оба края. Минимум площадки удваивался: вместо 300 ₽ требовал 600 ₽. Разбор — §6г. | смета 612,36 ₽ отклонена, потребовано 833,40 ₽ | -| 2 | 🔴 **У клиента НЕТ кнопки «Запустить».** После отправки заявки кампания висит «Готова к запуску» и ждёт оператора; на карточке только «Изменить» и «Отчёт». Запуск возможен лишь обращением, к которому в кабинете нет кнопки. | карточка #11: кнопки «ИЗМЕНИТЬ, ОТЧЁТ» | +| 2 | ✅ **ПОЧИНЕНО 04.08.** У клиента НЕ было кнопки «Запустить»: после отправки заявки кампания висела «Готова к запуску», а на карточке только «Изменить» и «Отчёт». Разбор — §6д. | карточка #11: кнопки «ИЗМЕНИТЬ, ОТЧЁТ» | | 3 | **Про минимум узнаёшь только на запуске** — мастер даёт спокойно собрать кампанию за 612 ₽, залить 15 картинок и лишь потом отказывает. Предупреждения на шаге сметы нет. | шаг 2 показывает «Итого 612,36 ₽» без единого слова | | 4 | **Счётчик аудитории не пересчитывается после загрузки списка.** Загрузили 1693 номера — под кнопкой по-прежнему «Наберётся ~15 человек» и красное «аудитория меньше 100». Правда появляется, только если тронуть любое другое поле. Клиент решит, что список не принялся. | было ~15, стало ~1701 после правки даты | | 5 | **Красная подпись про неверный размер остаётся** на карточке баннера и после того, как в неё загружен верный файл. Уходит только перезаливкой. | «Нужен ровно 160×600» висела при живой картинке 160×600 | @@ -460,6 +460,42 @@ Laravel («ожидаемого запроса не было») не говор --- +## 6д. ✅ Кнопка «Запустить» у клиента — сделано 04.08 + +**Было:** кампания в состоянии «Готова к запуску» не двигалась ничем — ни человеком, ни +расписанием. Клиент собирал рекламу, заливал пятнадцать картинок, отправлял заявку и упирался +в тупик: на карточке только «Изменить» и «Отчёт». Модуль ему не работал вообще. + +**Стало:** на карточке «Готова к запуску» появилась кнопка **«Запустить»**. + +🪤 **Новых дыр она не открывает:** путь запуска и так лежал в клиентской группе, а все +проверки — деньги, минимум площадки, годность аудитории — живут внутри самого запуска. +Кнопка лишь даёт клиенту нажать то, что до сих пор жали из консоли браузера. + +**Три ответа портала показываются по-разному** — иначе кнопка пугала бы: + +| Ответ портала | Что видит клиент | +|---|---| +| «готовим картинки в кабинете» | спокойное синее сообщение: зайдите позже | +| «аудитория ещё готовится» | то же — это **нормальный ход**, а не отказ | +| смета мала / не хватает денег / Яндекс молчит | красным, словами сервера | + +Разница не косметическая: на «зайдите позже» деньги **не тронуты** и в рекламном кабинете +ничего не создано. Покажи это красным — клиент решит, что ошибся, и начнёт переделывать +готовое. Поэтому же денежная плашка перечитывается только после **удачного** запуска. + +**Отдельного «вы уверены?» намеренно нет.** Кнопка подписана, смета показов стоит тут же на +карточке, а пауза возвращает заморозку целиком — это проверено живьём 03.08 на кампании #6. +Вместо окна под кнопкой стоит строчка: «При запуске 1 020,60 ₽ будут зарезервированы на +кошельке» — клиент знает про деньги **до** нажатия, а не узнаёт потом из кошелька. + +Сторожа: кнопка есть только у «Готовы к запуску» и нет у работающей и у черновика; удачный +запуск перечитывает список и денежную плашку; «аудитория ещё готовится» даёт спокойное +сообщение и **не** тревожит плашку; отказ даёт красное. Прогон: **2049 сторожей фронта +зелёные** (плюс 3 отложенных), типы Vue чисты, стиль чист. + +--- + ## 7. Что делать дальше 1. ✅ **Сделано 03.08 вечером** — денежная плашка, летопись, история на экране кошелька и @@ -472,12 +508,14 @@ Laravel («ожидаемого запроса не было») не говор 🪤 Считать заранее: Яндекс не берёт кампанию дешевле **300 ₽ за каждый день** периода. На один день это примерно **417 ₽** клиентских — в потолок 1000 ₽ помещается, на неделю уже нет. -4. **Выкатить на боевой** починку порядка денег и вчерашнюю починку кошелька — обе только - в ветке. +4. **Выкатить на боевой** починку порядка денег, вчерашнюю починку кошелька и правки 04.08 + (лишний день, кнопка «Запустить», счётчик аудитории, склейка ленты) — всё это только + в ветке. 🪤 Кнопка «Запустить» появится у клиента **только после выката**. 5. Кампанию #6 на 06.08 должно закрыть по сроку с возвратом заморозки — **проверить 07.08**, это бесплатный замер второй половины Я-Д5 (выход «по сроку», а не «по паузе»). 6. **Завтра, 04.08: третий заход «Запустить» у кампании #11.** Сведение сегмента 58389879 началось 03.08 около 18:44 МСК, Яндексу нужно ~15 часов — значит после ~10:00 МСК. Этот заход заморозит 1 020,60 ₽ и заведёт кампанию в Директе. Дальше по ранбуку: Я-Д1 живьём → Я-М2 (одобрение) → Я-Д3/Д4/Д6 (списание по факту показов). - 🪤 Кнопки «Запустить» у клиента нет — жать придётся тем же путём, что и вечером 03.08. + 🪤 Кнопка «Запустить» починена 04.08, но **на боевой не выкачена** — жать придётся тем же + путём, что и вечером 03.08, обращением из консоли браузера.