feat(реклама): кнопка «Запустить» у клиента на карточке кампании

Кампания в состоянии «Готова к запуску» не двигалась ничем — ни человеком,
ни расписанием. Клиент собирал рекламу, заливал пятнадцать картинок,
отправлял заявку и упирался в тупик: на карточке только «Изменить» и
«Отчёт». Запуск был возможен лишь обращением, к которому в кабинете нет
кнопки. Поймано приёмкой 03.08.2026 на кампании #11.

Новых дыр кнопка не открывает: путь запуска и так лежал в клиентской группе,
все проверки — деньги, минимум площадки, годность аудитории — внутри самого
запуска.

Три ответа портала показываются по-разному: «готовим картинки» и «аудитория
ещё готовится» — спокойным синим сообщением, это нормальный ход, деньги не
тронуты и в кабинете ничего не создано; смета мала, не хватает денег, Яндекс
молчит — красным. Денежная плашка перечитывается только после удачного
запуска.

Отдельного «вы уверены?» намеренно нет: кнопка подписана, смета показов
стоит тут же на карточке, пауза возвращает заморозку целиком — проверено
живьём 03.08 на кампании #6. Вместо окна под кнопкой строчка «При запуске
1 020,60 руб. будут зарезервированы на кошельке»: клиент знает про деньги
до нажатия, а не узнаёт потом из кошелька.

Сторожа: кнопка есть только у «Готовы к запуску» и нет у работающей и у
черновика; удачный запуск перечитывает список и денежную плашку; «аудитория
ещё готовится» даёт спокойное сообщение и не тревожит плашку; отказ даёт
красное; под кнопкой сказано про резерв сметы.

Прогон: 2049 сторожей фронта зелёные, типы Vue чисты, стиль чист.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-08-04 09:04:27 +03:00
parent f85452a805
commit 656cf80f31
4 changed files with 275 additions and 6 deletions
+15 -2
View File
@@ -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<LaunchResult> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ status: CampaignStatus }>(`/api/advertising/campaigns/${id}/launch`);
const { data } = await apiClient.post<LaunchResult>(`/api/advertising/campaigns/${id}/launch`);
return data;
}
@@ -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<string | null>(null);
const actionError = ref<string | null>(null);
/**
* Спокойное сообщение «зайдите позже» — отдельно от ошибок. Портал отвечает им на запуск,
* когда всё в порядке: картинки ещё грузятся в рекламный кабинет либо рекламная система
* ещё сводит список номеров с людьми (на бою 30.07.2026 — около 15 часов). Красным такое
* показывать нельзя: клиент решит, что сделал что-то не так, и начнёт переделывать готовое.
*/
const actionNote = ref<string | null>(null);
// id кампании, для которой сейчас идёт запрос паузы/возобновления — управляет :loading кнопки.
const busyId = ref<number | null>(null);
@@ -137,6 +146,52 @@ async function onEdit(campaign: Campaign): Promise<void> {
}
}
/**
* «Запустить» — у кампании, готовой к запуску.
*
* Приёмка 03.08.2026: клиент собирал рекламу, заливал пятнадцать картинок, отправлял
* заявку — и упирался в тупик. Кампания вставала «Готова к запуску», а на карточке были
* только «Изменить» и «Отчёт»: запустить её можно было лишь обращением, к которому в
* кабинете нет кнопки. Модуль клиенту не работал вообще.
*
* Новых дыр кнопка не открывает: путь запуска и так лежит в клиентской группе, а все
* проверки — деньги, минимум площадки, готовность аудитории — живут внутри самого запуска.
*/
function canLaunch(status: CampaignStatus): boolean {
return status === 'queued';
}
/**
* Три ответа портала на запуск клиент должен видеть по-разному:
* — «готовим картинки» и «аудитория ещё готовится» — спокойным сообщением: это не отказ,
* деньги не тронуты, надо просто зайти позже;
* — всё остальное (смета мала, не хватает денег, Яндекс не отвечает) — красным;
* — удачный запуск запирает смету, поэтому денежная плашка обязана перечитаться.
*/
async function onLaunch(campaign: Campaign): Promise<void> {
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 }}
</v-alert>
<!-- «Зайдите позже» не отказ: синим, спокойно, без слова «ошибка». -->
<v-alert
v-if="actionNote"
type="info"
variant="tonal"
density="compact"
class="mb-4"
closable
data-testid="campaign-action-note"
@click:close="actionNote = null"
>
{{ actionNote }}
</v-alert>
<v-row dense>
<v-col v-for="campaign in campaigns" :key="campaign.id" cols="12" sm="6" lg="4">
<v-card variant="outlined" class="pa-4 h-100" data-testid="campaign-card">
@@ -344,6 +413,19 @@ defineExpose({ loadCampaigns });
{{ campaign.status === 'rejected' ? 'Исправить' : 'Изменить' }}
</v-btn>
<v-btn
v-if="canLaunch(campaign.status)"
size="small"
variant="flat"
color="primary"
prepend-icon="mdi-rocket-launch-outline"
:loading="busyId === campaign.id"
data-testid="campaign-launch-btn"
@click="onLaunch(campaign)"
>
Запустить
</v-btn>
<v-btn
v-if="canPause(campaign.status)"
size="small"
@@ -393,6 +475,24 @@ defineExpose({ loadCampaigns });
Удалить
</v-btn>
</div>
<!--
Запуск запирает смету на кошельке. Клиент должен знать это ДО
нажатия, а не узнать потом из кошелька. Отдельного «вы уверены?»
не делаем: кнопка подписана, смета стоит тут же на карточке,
а пауза возвращает заморозку целиком (проверено 03.08.2026).
-->
<div
v-if="canLaunch(campaign.status)"
class="text-caption text-medium-emphasis mt-2"
data-testid="campaign-launch-hint"
>
{{
campaign.smeta_rub
? `При запуске ${formatMoney(campaign.smeta_rub)} будут зарезервированы на кошельке.`
: 'При запуске смета будет зарезервирована на кошельке.'
}}
</div>
</v-card>
</v-col>
</v-row>
@@ -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);
});
});
@@ -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, обращением из консоли браузера.