Files
portal/app/resources/js/views/admin/AdminAdvertisingView.vue
T
Дмитрий 3fdcd88ad3 feat реклама за показы: правда про документ, экран «ждёт разбора» и разбор своих ошибок
Задача 16 закрыта решением владельца. Дороги «робот везёт документ в кабинет Яндекса»
не существует — доказано двумя нарочными отказами, обычной тематикой и лицензируемой:
в окне отказа ноль полей для файла, документы Яндекс принимает только снаружи кабинета.

Приём документа оставлен, но портал больше не молчит: клиент приложил файл — в ленту сразу
ложится отметка «документ у нас, передать его Яндексу автоматически нельзя, при
необходимости отнесём сами и напишем здесь», а владельцу уходит письмо на адрес алертов.
Без письма обещание было бы пустым: файл просто лёг бы на диск. Сам файл письмом
не отправляем — это чужие бумаги. Обычный ответ без файла ни отметки, ни письма не даёт.

Экран «ждёт разбора» в админке: ручка была, экрана не было. Третья карточка на странице
«Реклама» — клиент, кампания, что робот делал человеческими словами, номер объявления
и на чём споткнулся. В подписи прямо сказано, чего там НЕ будет: обычных отказов,
их клиент разбирает сам.

Дальше — разбор собственной работы этого дня. Найдено четыре ошибки, все исправлены.

1. ТЯЖЁЛАЯ. Доклад разведки на бою уронил бы очередь заданий целиком. Робот пишет в ленту
   под служебной ролью, а у неё на этой таблице было только чтение. Отказ по правам,
   500 роботу, три повтора — и задание навсегда «в работе». Пока хоть одно задание
   в работе, выдача отвечает «работы нет» ВСЕМ клиентам. Лечение — запись схемы v9.17:
   право на запись плюс нумератор. В плане про это было написано прямым текстом,
   я прошёл мимо. Тесты поймать не могли: ходят суперпользователем.
2. Признак «набор создан» я выдумал: взял метку, которая в нашей же разметке описана
   как СКРЫТАЯ галочка. Проверка «видно ли её» не сработала бы никогда. Признак с экрана
   убран совсем: успех определяет портал слепком креативов, а «окно не закрылось» —
   это норма, так и есть живьём.
3. Сломал ленту для повторного отказа. Поменял защиту от дублей на «такой текст уже
   когда-либо был» — и клиент, починивший рекламу и получивший тот же отказ, не увидел бы
   ничего. Вернул сравнение с последним сообщением, а заглушку «причину выясняем» держит
   теперь сам джоб: показываем один раз, пока сказать нечего.
4. Мой собственный тест оказался пустышкой: оставался зелёным при вырезанной защите.
   В нём отклонялись ВСЕ объявления, а тогда кампания уходит в «отклонена» и обход её
   больше не берёт. Сценарий существует только при частичном отказе — тест переписан
   на два объявления и теперь вырезание защиты его роняет.

Портал 391/391, админские экраны 9/9, фронт на затронутых наборах 52/52, робот 74/74,
мест снятия заморозки денег по-прежнему четыре.
На боевой не выкатывалось, рубильник Директа выключен, живьём разведка не гонялась.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 20:10:18 +03:00

551 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* Админка SaaS → Реклама: расход и маржа по тенантам.
*
* Сколько тенанты тратят на рекламу (client_spend), сколько из этого мы
* платим Яндексу за закупку (yandex_cost) и что остаётся нам маржой
* (our_margin). Источник данных: GET /api/admin/advertising/spend.
*/
import { computed, onMounted, reactive, ref, watch } from 'vue';
import * as adminApi from '../../api/admin';
import type { AdminAwaitingCampaign, AdminStuckRobotJob, AdvertisingSpendPeriod } from '../../api/admin';
import { extractErrorMessage } from '../../api/client';
import { formatPlain } from '../../composables/billingFormatters';
type SpendRow = {
tenant_id: number;
tenant_name: string;
client_spend_rub: number;
yandex_cost_rub: number;
our_margin_rub: number;
};
const periodOptions: { value: AdvertisingSpendPeriod; label: string }[] = [
{ value: 'current_month', label: 'Текущий месяц' },
{ value: 'last_month', label: 'Прошлый месяц' },
{ value: '90d', label: '90 дней' },
{ value: 'all', label: 'Всё время' },
];
const period = ref<AdvertisingSpendPeriod>('current_month');
const rows = reactive<SpendRow[]>([]);
const totals = reactive({
client_spend_rub: 0,
yandex_cost_rub: 0,
our_margin_rub: 0,
});
const adMarginPercent = ref('');
const loading = ref(false);
const fetchError = ref(false);
const errorMessage = ref('');
const cpm = ref('');
const margin = ref('');
const cpmSaving = ref(false);
const cpmSaved = ref(false);
const cpmError = ref('');
async function loadCpm() {
try {
const res = await adminApi.fetchAdSettings();
cpm.value = res.client_cpm_rub;
margin.value = res.ad_margin_percent;
} catch (e) {
cpmError.value = extractErrorMessage(e);
}
}
async function saveCpm() {
cpmSaving.value = true;
cpmError.value = '';
cpmSaved.value = false;
try {
await adminApi.updateAdSettings({ client_cpm_rub: cpm.value, ad_margin_percent: margin.value });
cpmSaved.value = true;
} catch (e) {
cpmError.value = extractErrorMessage(e);
} finally {
cpmSaving.value = false;
}
}
async function loadSpend() {
loading.value = true;
fetchError.value = false;
errorMessage.value = '';
try {
const res = await adminApi.fetchAdvertisingSpend(period.value);
const mapped: SpendRow[] = res.data.map((r) => ({
tenant_id: r.tenant_id,
tenant_name: r.tenant_name,
client_spend_rub: parseFloat(r.client_spend_rub),
yandex_cost_rub: parseFloat(r.yandex_cost_rub),
our_margin_rub: parseFloat(r.our_margin_rub),
}));
rows.splice(0, rows.length, ...mapped);
totals.client_spend_rub = parseFloat(res.totals.client_spend_rub);
totals.yandex_cost_rub = parseFloat(res.totals.yandex_cost_rub);
totals.our_margin_rub = parseFloat(res.totals.our_margin_rub);
adMarginPercent.value = res.ad_margin_percent;
} catch (e) {
fetchError.value = true;
errorMessage.value = extractErrorMessage(e);
} finally {
loading.value = false;
}
}
onMounted(loadSpend);
onMounted(loadCpm);
watch(period, loadSpend);
const isEmpty = computed(() => !loading.value && !fetchError.value && rows.length === 0);
// === Кампании, ждущие номер креатива Яндекса (8e) ===
const awaitingCampaigns = reactive<AdminAwaitingCampaign[]>([]);
const awaitingLoading = ref(false);
const awaitingError = ref('');
/** Ввод «номер креатива» построчно, id кампании → строка из поля. */
const creativeInputs = reactive<Record<number, string>>({});
const creativeSaving = reactive<Record<number, boolean>>({});
const creativeSaved = reactive<Record<number, boolean>>({});
const creativeErrors = reactive<Record<number, string>>({});
const awaitingIsEmpty = computed(() => !awaitingLoading.value && awaitingCampaigns.length === 0);
async function loadAwaiting() {
awaitingLoading.value = true;
awaitingError.value = '';
try {
const list = await adminApi.fetchAdAwaitingCampaigns();
awaitingCampaigns.splice(0, awaitingCampaigns.length, ...list);
for (const c of list) {
creativeInputs[c.id] = c.yandex_creative_id !== null ? String(c.yandex_creative_id) : '';
}
} catch (e) {
awaitingError.value = extractErrorMessage(e);
} finally {
awaitingLoading.value = false;
}
}
async function saveCreative(campaignId: number) {
creativeSaving[campaignId] = true;
creativeSaved[campaignId] = false;
creativeErrors[campaignId] = '';
try {
const value = Number(creativeInputs[campaignId]);
const res = await adminApi.setAdCampaignCreative(campaignId, value);
const row = awaitingCampaigns.find((c) => c.id === campaignId);
if (row) row.yandex_creative_id = res.yandex_creative_id;
creativeSaved[campaignId] = true;
} catch (e) {
creativeErrors[campaignId] = extractErrorMessage(e);
} finally {
creativeSaving[campaignId] = false;
}
}
onMounted(loadAwaiting);
// === «Ждёт разбора»: сбойные задания робота (задача 15) ===
//
// 🔴 Это НЕ список обычных отказов модерации — их клиент разбирает сам, по причине
// в переписке. Это места, где ЦЕПОЧКА ВСТАЛА: робот сходил в кабинет и не понял, что
// видит, или не смог отвезти картинки. Без этого экрана обрыв тихий: клиент ждёт ответа,
// которого не будет, и человек об этом узнаёт только из письма — а письмо теряется.
const stuckJobs = reactive<AdminStuckRobotJob[]>([]);
const stuckLoading = ref(false);
const stuckError = ref('');
const stuckIsEmpty = computed(() => !stuckLoading.value && !stuckError.value && stuckJobs.length === 0);
const KIND_LABEL: Record<string, string> = {
upload: 'Отвезти картинки',
inspect: 'Узнать причину отказа',
deliver: 'Отвезти документ',
};
function kindLabel(kind: string): string {
return KIND_LABEL[kind] ?? kind;
}
async function loadStuck() {
stuckLoading.value = true;
stuckError.value = '';
try {
const list = await adminApi.fetchAdRobotStuck();
stuckJobs.splice(0, stuckJobs.length, ...list);
} catch (e) {
stuckError.value = extractErrorMessage(e);
} finally {
stuckLoading.value = false;
}
}
onMounted(loadStuck);
defineExpose({
stuckJobs,
stuckLoading,
stuckError,
loadStuck,
rows,
totals,
adMarginPercent,
loading,
fetchError,
errorMessage,
period,
loadSpend,
cpm,
margin,
cpmSaving,
cpmSaved,
cpmError,
saveCpm,
loadCpm,
awaitingCampaigns,
awaitingLoading,
awaitingError,
creativeInputs,
creativeSaving,
creativeSaved,
creativeErrors,
loadAwaiting,
saveCreative,
});
</script>
<template>
<v-container fluid class="admin-advertising pa-6" data-testid="admin-advertising-view">
<header class="page-head mb-4 d-flex justify-space-between align-start flex-wrap ga-4">
<div>
<h1 class="text-h4 page-title">Реклама расход и маржа</h1>
<p class="text-body-2 text-medium-emphasis ma-0">
Сколько тенанты тратят на рекламу и сколько из этого остаётся нам маржой.
</p>
</div>
<v-btn
variant="outlined"
prepend-icon="mdi-refresh"
:loading="loading"
data-testid="reload-btn"
@click="loadSpend"
>
Обновить
</v-btn>
</header>
<v-card variant="outlined" class="pa-4 mb-4" data-testid="ad-cpm-card">
<div class="text-h6 mb-1">Цена клиенту за показы</div>
<p class="text-caption text-medium-emphasis mb-3">Сколько клиент платит за 1000 показов, </p>
<div class="d-flex align-center flex-wrap ga-3">
<v-text-field
v-model="cpm"
type="number"
min="0"
density="comfortable"
variant="outlined"
hide-details
style="max-width: 200px"
class="font-mono tabular"
data-testid="ad-cpm-input"
/>
<v-text-field
v-model="margin"
type="number"
min="0"
max="90"
density="comfortable"
variant="outlined"
label="Наша наценка, %"
hint="В Директ уходит на столько меньше. Клиент не видит."
persistent-hint
style="max-width: 240px"
class="font-mono tabular"
data-testid="ad-margin-input"
/>
<v-btn
color="primary"
:loading="cpmSaving"
data-testid="ad-cpm-save"
@click="saveCpm"
>
Сохранить
</v-btn>
<span v-if="cpmSaved" class="text-success-strong text-body-2" data-testid="ad-cpm-saved">
Цена сохранена
</span>
<span v-if="cpmError" class="text-error text-body-2" data-testid="ad-cpm-error">
{{ cpmError }}
</span>
</div>
</v-card>
<v-btn-toggle
v-model="period"
mandatory
density="comfortable"
color="primary"
variant="outlined"
class="mb-4"
data-testid="ad-spend-period"
>
<v-btn
v-for="opt in periodOptions"
:key="opt.value"
:value="opt.value"
:data-testid="`period-${opt.value}`"
>
{{ opt.label }}
</v-btn>
</v-btn-toggle>
<v-alert
v-if="fetchError"
type="warning"
variant="tonal"
density="compact"
closable
class="mb-4"
data-testid="fetch-error-alert"
>
{{ errorMessage || 'Не удалось загрузить расход на рекламу. Попробуйте обновить.' }}
</v-alert>
<!-- ИТОГО-строка -->
<v-row class="mb-4" data-testid="ad-spend-totals">
<v-col cols="12" sm="4">
<v-card variant="outlined" class="pa-3">
<div class="text-caption text-medium-emphasis">Расход клиентов</div>
<div class="text-h6 font-mono tabular" data-testid="ad-spend-total-client">
{{ formatPlain(totals.client_spend_rub) }}
</div>
</v-card>
</v-col>
<v-col cols="12" sm="4">
<v-card variant="outlined" class="pa-3">
<div class="text-caption text-medium-emphasis">Расход у Яндекса</div>
<div class="text-h6 font-mono tabular" data-testid="ad-spend-total-yandex">
{{ formatPlain(totals.yandex_cost_rub) }}
</div>
</v-card>
</v-col>
<v-col cols="12" sm="4">
<v-card variant="outlined" class="pa-3">
<div class="text-caption text-medium-emphasis">Наша маржа</div>
<div class="text-h6 font-mono tabular text-success-strong" data-testid="ad-spend-total-margin">
{{ formatPlain(totals.our_margin_rub) }}
</div>
</v-card>
</v-col>
</v-row>
<v-card variant="outlined" class="pa-4">
<div class="d-flex justify-space-between align-center mb-3">
<h2 class="text-h6 ma-0">По тенантам</h2>
<span class="text-caption text-medium-emphasis" data-testid="ad-spend-markup">
Наценка: {{ adMarginPercent }}%
</span>
</div>
<div v-if="loading" class="d-flex justify-center pa-8">
<v-progress-circular indeterminate color="primary" data-testid="ad-spend-loading" />
</div>
<p v-else-if="isEmpty" class="text-body-2 text-medium-emphasis ma-0 pa-4" data-testid="ad-spend-empty">
За период рекламных расходов нет.
</p>
<v-table v-else density="comfortable" class="font-mono-nums">
<thead>
<tr>
<th>Тенант</th>
<th class="text-right">Расход клиента</th>
<th class="text-right">Расход Яндекса</th>
<th class="text-right">Наша маржа</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.tenant_id" data-testid="ad-spend-row">
<td class="font-weight-medium">{{ row.tenant_name }}</td>
<td class="text-right font-mono tabular">{{ formatPlain(row.client_spend_rub) }}</td>
<td class="text-right font-mono tabular">{{ formatPlain(row.yandex_cost_rub) }}</td>
<td class="text-right font-mono tabular text-success-strong">
{{ formatPlain(row.our_margin_rub) }}
</td>
</tr>
</tbody>
</v-table>
</v-card>
<v-card variant="outlined" class="pa-4 mt-4" data-testid="ad-awaiting-card">
<div class="text-h6 mb-1">Кампании на запуск номер креатива</div>
<p class="text-caption text-medium-emphasis mb-3">
Оформите креатив в конструкторе Яндекса и впишите его номер, чтобы кампанию можно было запустить.
</p>
<v-alert
v-if="awaitingError"
type="warning"
variant="tonal"
density="compact"
closable
class="mb-4"
data-testid="ad-awaiting-fetch-error"
>
{{ awaitingError }}
</v-alert>
<div v-if="awaitingLoading" class="d-flex justify-center pa-8">
<v-progress-circular indeterminate color="primary" data-testid="ad-awaiting-loading" />
</div>
<p
v-else-if="awaitingIsEmpty"
class="text-body-2 text-medium-emphasis ma-0 pa-4"
data-testid="ad-awaiting-empty"
>
Кампаний, ждущих номер креатива, нет.
</p>
<v-table v-else density="comfortable">
<thead>
<tr>
<th>Тенант</th>
<th>Кампания</th>
<th class="text-right">Смета показов</th>
<th>Ссылка</th>
<th>Номер креатива</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="c in awaitingCampaigns" :key="c.id" data-testid="ad-awaiting-row">
<td class="font-weight-medium">{{ c.tenant_name }}</td>
<td>{{ c.name }}</td>
<td class="text-right font-mono tabular">
{{ c.estimated_impressions !== null ? c.estimated_impressions : '—' }}
</td>
<td>
<a v-if="c.landing_url" :href="c.landing_url" target="_blank" rel="noopener">{{
c.landing_url
}}</a>
<span v-else></span>
</td>
<td>
<v-text-field
v-model="creativeInputs[c.id]"
type="number"
min="1"
density="comfortable"
variant="outlined"
hide-details
style="max-width: 200px"
class="font-mono tabular"
data-testid="ad-creative-input"
/>
</td>
<td>
<v-btn
color="primary"
size="small"
:loading="creativeSaving[c.id]"
data-testid="ad-creative-save"
@click="saveCreative(c.id)"
>
Сохранить
</v-btn>
<div
v-if="creativeSaved[c.id]"
class="text-success-strong text-body-2"
data-testid="ad-creative-saved"
>
Сохранено
</div>
<div
v-if="creativeErrors[c.id]"
class="text-error text-body-2"
data-testid="ad-creative-error"
>
{{ creativeErrors[c.id] }}
</div>
</td>
</tr>
</tbody>
</v-table>
</v-card>
<!--
«Ждёт разбора» робот сходил в кабинет Яндекса и не понял, что видит.
Не обычные отказы: их клиент разбирает сам по причине в переписке.
-->
<v-card class="mt-6 pa-4" data-testid="ad-stuck-card">
<div class="d-flex align-center justify-space-between mb-2">
<h2 class="text-h6 ma-0">Ждёт разбора</h2>
<v-btn variant="text" size="small" :loading="stuckLoading" data-testid="ad-stuck-refresh" @click="loadStuck">
Обновить
</v-btn>
</div>
<p class="text-body-2 text-medium-emphasis mb-4">
Здесь только сбои робота: он сходил в кабинет и не разобрался, что там написано,
либо не смог отвезти картинки. Обычные отказы модерации сюда не попадают
их клиент разбирает сам, причина лежит у него в переписке по кампании.
</p>
<v-alert v-if="stuckError" type="error" variant="tonal" class="mb-4" data-testid="ad-stuck-error">
{{ stuckError }}
</v-alert>
<div v-if="stuckLoading" class="d-flex justify-center pa-8">
<v-progress-circular indeterminate color="primary" data-testid="ad-stuck-loading" />
</div>
<p v-else-if="stuckIsEmpty" class="text-body-2 text-medium-emphasis ma-0 pa-4" data-testid="ad-stuck-empty">
Разбирать нечего робот со всеми заданиями справился.
</p>
<v-table v-else density="comfortable">
<thead>
<tr>
<th>Клиент</th>
<th>Кампания</th>
<th>Что делал робот</th>
<th>Объявление в Яндексе</th>
<th>На чём споткнулся</th>
</tr>
</thead>
<tbody>
<tr v-for="j in stuckJobs" :key="j.id" data-testid="ad-stuck-row">
<td class="font-weight-medium">{{ j.tenant_name }}</td>
<td>{{ j.campaign_name }}</td>
<td>{{ kindLabel(j.kind) }}</td>
<td class="font-mono tabular">{{ j.yandex_ad_id !== null ? j.yandex_ad_id : '—' }}</td>
<td>{{ j.failure_reason || 'Причина не записана' }}</td>
</tr>
</tbody>
</v-table>
</v-card>
</v-container>
</template>
<style scoped>
.admin-advertising {
max-width: 1400px;
}
.page-title {
font-variation-settings: 'opsz' 28;
letter-spacing: -0.018em;
}
.font-mono {
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
.tabular {
font-feature-settings: 'tnum';
}
</style>