merge: сведение ветки «Реклама Телеграм» с боевым main
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled

Слияние feat/client-telegram-ads с main 8bdd58e8. Десять швов разобраны вручную:
денежный файл AdWalletService взят из main целиком — проверено поимённо, что все три
починки на месте: свой контекст клиента, оживление брони, таяние заморозки. В расписании
объединены оба набора заданий: телеграмные два и рекламные четыре. В боковом меню и в
мобильном «Ещё» сохранён пункт «Рекламный кошелёк», подписи поправлены — на реальные
экраны ведут ОБА канала. Словарь, пример настроек и журнал схемы объединены.

Сверх самого слияния:

- Журнал схемы: телеграмные записи v8.86-v8.95 перенумерованы в v9.18-v9.27, блок
  переставлен наверх, пометки «номер предварительный» сняты и заменены одной врезкой
  о перенумерации. Задвоенных номеров не осталось. Врезка шапки теперь называет и
  телеграмные таблицы: их DDL, как и рекламный, живёт только в дельта-миграциях.
- Новый сторож денег tests/Feature/ClientTg/TgMoneyUnderRealRoleTest.php: списание и
  возврат под боевой ролью crm_app_user. С контекстом клиента деньги двигаются, без
  контекста возврат падает громко. Обычные тесты ходят суперюзером и этот класс дыр
  увидеть не могут.
- Помощник rejectedCampaign переименован в tgRejectedCampaign: одноимённый помощник
  есть у рекламного модуля, помощники Pest глобальные, полный прогон падал фаталом.
  Каждая ветка по отдельности этого увидеть не могла.
- Два теста уведомлений считали ВСЮ таблицу целиком вместо строк своего пользователя:
  в одиночку зелёные, в полном прогоне красные. Счёт сужен до конкретного пользователя.
- Убраны две проверки отменённой сущности «своё имя отправителя» — сама сущность
  дропнута в v9.27 как СМС-фантазия, её адрес отдаёт 405.

Прогоны: телеграм 193/193, реклама 336/336 при 1029 проверках, вместе 532/532,
экраны 1704/1708, сборка фронта чисто, полный Unit+Feature 3923/3960. Шестнадцать
падений полного прогона совпадают построчно с прогоном ветки без телеграма — слияние
не добавило ни одного. Статанализ в свежем каталоге запустить не удалось: он требует
сгенерированного файла-подсказки, которого нет в репозитории, и без него молча падает
на обеих ветках.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-29 16:41:38 +03:00
314 changed files with 42708 additions and 148 deletions
+5
View File
@@ -153,6 +153,11 @@ paths = [
# Internal design specs — внутренние проектные доки с демо-данными (демо-телефоны
# в примерах, напр. spec про log-PII-scrubbing), не реальные ПДн. Как plans/audits.
'''docs/superpowers/specs/.*\.md''',
# Файл-образец, который клиент скачивает перед загрузкой своего списка номеров.
# Номера синтетические — 79001234567, 79161112233, 79995551234 и т.п. На main он
# уже разрешён по коммиту 5a4c0e02, но при сведении ветки тот же текст приходит
# НОВЫМ коммитом, и разрешение по хешу его не покрывает — нужен путь.
'''app/public/examples/.*\.csv''',
# Mock-данные для UI-разводки фронтенда (фиктивные имена/телефоны)
'''app/resources/js/composables/mockDeals\.ts''',
# Vitest-тесты с assertion на mock-данные (mock-телефоны из mockDeals)
+2
View File
@@ -131,3 +131,5 @@ TG_ROBOT_NODE=node
TG_ROBOT_SCRIPT=
TG_ROBOT_CWD=
TG_ROBOT_TIMEOUT=300
# Канал робота-грузчика креативов в веб-кабинет Яндекса. Пусто → канал закрыт.
CREATIVE_ROBOT_TOKEN=
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AdCreativeJob;
use Illuminate\Console\Command;
use Illuminate\Contracts\Database\Query\Builder;
use Illuminate\Support\Facades\Log;
/**
* Сторож зависших заданий робота-грузчика креативов.
*
* Задание «в работе» пробка на всю очередь: пока хоть одно из них живо, выдача заданий
* отвечает «работы нет» ВСЕМ, и ни одна кампания больше не стартует. Робот умирает молча
* (упал процесс, перезагрузили сервер, портал не принял отчёт) и без сторожа пробка
* вечная: `taken_at` до сих пор никто не читал, а число попыток ничем не ограничивалось.
*
* 🔴 Ходим через `pgsql_admin` (роль crm_admin_user, у неё разрешающая политика srv_bypass).
* На дефолтной роли `crm_app_user` расписание работает БЕЗ tenant-контекста, и RLS отдал бы
* ноль строк сторож бодро рапортовал бы об успехе, ничего не разбирая. Ровно тот самый
* «тихий ноль», от которого этот сторож и защищает.
*/
class ReapStuckCreativeJobs extends Command
{
protected $signature = 'creative-jobs:reap';
protected $description = 'Разобрать пробку: вернуть в очередь задания робота, зависшие «в работе»';
/** Столько минут молчания — и считаем, что робот задание бросил. */
private const STUCK_MINUTES = 30;
/** Столько всего попыток даём одному заданию, дальше закрываем сбоем. */
private const MAX_ATTEMPTS = 3;
public function handle(): int
{
$deadline = now()->subMinutes(self::STUCK_MINUTES);
// taken_at без значения тоже считаем зависшим: такое задание иначе не разобрать
// ничем, а в работе оно висит и очередь держит.
$stuck = AdCreativeJob::on('pgsql_admin')
->where('status', AdCreativeJob::STATUS_TAKEN)
->where(fn (Builder $q) => $q->whereNull('taken_at')->orWhere('taken_at', '<', $deadline))
->get();
foreach ($stuck as $job) {
if ($job->attempts >= self::MAX_ATTEMPTS) {
$job->update([
'status' => AdCreativeJob::STATUS_FAILED,
'failure_reason' => sprintf(
'Робот не отчитался за %d минут, и попытки исчерпаны (%d). Задание закрыто сторожем очереди.',
self::STUCK_MINUTES,
$job->attempts,
),
'finished_at' => now(),
]);
Log::warning('Задание робота закрыто сторожем: попытки исчерпаны', [
'job_id' => $job->id, 'campaign_id' => $job->campaign_id, 'attempts' => $job->attempts,
]);
$this->warn("Задание #{$job->id}: попытки исчерпаны, закрыто сбоем.");
continue;
}
$job->update(['status' => AdCreativeJob::STATUS_QUEUED, 'taken_at' => null]);
Log::warning('Задание робота возвращено в очередь: робот не отчитался', [
'job_id' => $job->id, 'campaign_id' => $job->campaign_id, 'attempts' => $job->attempts,
]);
$this->info("Задание #{$job->id}: возвращено в очередь.");
}
if ($stuck->isEmpty()) {
$this->info('Зависших заданий нет.');
}
return self::SUCCESS;
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Реклама тенанта остановлена целиком (например, из-за нехватки денег
* на рекламном кошельке). Каналы (Часть B) слушают событие и ставят
* свои кампании на паузу.
*/
final class AdvertisingStopped
{
use Dispatchable, SerializesModels;
public function __construct(
public readonly int $tenantId,
) {}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Advertising;
use RuntimeException;
final class AudienceTooSmallException extends RuntimeException
{
public function __construct(public readonly int $size)
{
parent::__construct("Аудитория слишком мала для запуска: {$size} (нужно минимум 100).");
}
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Advertising;
use RuntimeException;
/**
* Слепок креативов «до/после» не сошёлся с ожидаемым набором размеров: загрузилось не то
* количество, не те размеры или два креатива одного размера. Привязку не делаем наугад
* задание уходит в «требует внимания», кампания остаётся черновиком.
*/
final class CreativeMatchFailedException extends RuntimeException {}
@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\AdCampaign;
use App\Models\AdCreativeJob;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* SaaS-admin: «Реклама: расход и наша маржа» расход тенантов в Яндексе
* (ad_wallet_transactions, type=charge, channel=yandex) и наша наценка сверху
* (ad_settings.ad_margin_percent глобальная настройка, не tenant-scoped).
*
* Зона saas-admin/admin-db (crm_admin_user); GRANT миграция
* 2026_07_25_100300_grant_admin_read_advertising.
*
* client_spend = сколько списали с тенанта (наценка внутри);
* yandex_cost = client_spend × (1 ad_margin_percent/100) сколько отдали Яндексу;
* our_margin = client_spend yandex_cost (= client_spend × ad_margin_percent/100).
*/
class AdminAdvertisingController extends Controller
{
private const PERIODS = ['current_month', 'last_month', '90d', 'all'];
public function spend(Request $request): JsonResponse
{
$period = (string) $request->query('period', 'current_month');
if (! in_array($period, self::PERIODS, true)) {
$period = 'current_month';
}
$marginPercent = (string) (DB::table('ad_settings')->value('ad_margin_percent') ?? '40.00');
// share = (100 ad_margin_percent) / 100 — доля client_spend, уходящая в Директ.
$share = bcdiv(bcsub('100', $marginPercent, 4), '100', 6);
$query = DB::table('ad_wallet_transactions as w')
->join('tenants as t', 't.id', '=', 'w.tenant_id')
->where('w.type', 'charge')
->where('w.channel', 'yandex');
$this->applyPeriod($query, 'w.created_at', $period);
$rows = $query
->selectRaw('w.tenant_id as tenant_id, t.organization_name as tenant_name, SUM(ABS(w.amount_rub)) as client_spend_rub')
->groupBy('w.tenant_id', 't.organization_name')
->havingRaw('SUM(ABS(w.amount_rub)) > 0')
->orderByRaw('SUM(ABS(w.amount_rub)) DESC')
->get();
$data = [];
$totalClientSpend = '0.00';
$totalYandexCost = '0.00';
$totalMargin = '0.00';
foreach ($rows as $r) {
// bcadd(..., '0', 2) — нормализация к money-строке scale=2, без float.
$clientSpend = bcadd((string) $r->client_spend_rub, '0', 2);
// Усечение bcmul (без округления) — безопасная сторона, см. ТЗ T4a.
$yandexCost = bcmul($clientSpend, $share, 2);
$margin = bcsub($clientSpend, $yandexCost, 2);
$data[] = [
'tenant_id' => (int) $r->tenant_id,
'tenant_name' => $r->tenant_name,
'client_spend_rub' => $clientSpend,
'yandex_cost_rub' => $yandexCost,
'our_margin_rub' => $margin,
];
$totalClientSpend = bcadd($totalClientSpend, $clientSpend, 2);
$totalYandexCost = bcadd($totalYandexCost, $yandexCost, 2);
$totalMargin = bcadd($totalMargin, $margin, 2);
}
return response()->json([
'data' => $data,
'totals' => [
'client_spend_rub' => $totalClientSpend,
'yandex_cost_rub' => $totalYandexCost,
'our_margin_rub' => $totalMargin,
],
'ad_margin_percent' => $marginPercent,
]);
}
/**
* Клиентская цена за 1000 показов (ad_settings.client_cpm_rub, single-row, дефолт 120.00)
* и наша наценка сверху (ad_settings.ad_margin_percent, дефолт 40.00 клиенту НЕ видна).
*/
public function settings(): JsonResponse
{
return response()->json([
'client_cpm_rub' => (string) (DB::table('ad_settings')->value('client_cpm_rub') ?? '120.00'),
'ad_margin_percent' => (string) (DB::table('ad_settings')->value('ad_margin_percent') ?? '40.00'),
]);
}
public function updateSettings(Request $request): JsonResponse
{
// required_without (не «sometimes|required» на обоих) — нужно допустить обновление
// ОДНОГО из двух полей, но отклонить полностью пустой запрос (см. тест «без поля → 422»).
$v = $request->validate([
'client_cpm_rub' => ['required_without:ad_margin_percent', 'numeric', 'gt:0'],
'ad_margin_percent' => ['required_without:client_cpm_rub', 'numeric', 'min:0', 'max:90'],
]);
$upd = [];
if (array_key_exists('client_cpm_rub', $v)) {
$upd['client_cpm_rub'] = $v['client_cpm_rub'];
}
if (array_key_exists('ad_margin_percent', $v)) {
$upd['ad_margin_percent'] = $v['ad_margin_percent'];
}
if ($upd !== []) {
DB::table('ad_settings')->update($upd);
}
return response()->json([
'client_cpm_rub' => (string) (DB::table('ad_settings')->value('client_cpm_rub') ?? '120.00'),
'ad_margin_percent' => (string) (DB::table('ad_settings')->value('ad_margin_percent') ?? '40.00'),
]);
}
/**
* 8e: кампании в статусе queued (ждут номер креатива Яндекса) по всем тенантам
* оператор оформляет адаптивный креатив в конструкторе Яндекса и вписывает номер
* (см. setCampaignCreative), после чего кампанию можно запускать (CampaignLauncher).
*/
public function campaignsAwaiting(): JsonResponse
{
$rows = DB::table('ad_campaigns as c')
->join('tenants as t', 't.id', '=', 'c.tenant_id')
->where('c.status', AdCampaign::STATUS_QUEUED)
->orderByDesc('c.id')
->select([
'c.id',
'c.tenant_id',
't.organization_name as tenant_name',
'c.name',
'c.status',
'c.yandex_creative_id',
'c.landing_url',
'c.estimated_impressions',
])
->get();
$data = $rows->map(fn ($r) => [
'id' => (int) $r->id,
'tenant_id' => (int) $r->tenant_id,
'tenant_name' => $r->tenant_name,
'name' => $r->name,
'status' => $r->status,
'yandex_creative_id' => $r->yandex_creative_id !== null ? (int) $r->yandex_creative_id : null,
'landing_url' => $r->landing_url,
'estimated_impressions' => $r->estimated_impressions !== null ? (int) $r->estimated_impressions : null,
])->values();
return response()->json(['data' => $data]);
}
/**
* Кампании, где робот сходил в кабинет и НЕ ПОНЯЛ, что видит: сбойные задания.
*
* 🔴 Это не список обычных отказов их клиент разбирает сам, по причине в переписке.
* Это список мест, где ЦЕПОЧКА ВСТАЛА: разметка кабинета поменялась, страница не
* открылась, объявления в списке не нашлось. Без такого экрана обрыв тихий: клиент
* ждёт ответа, которого не будет, и никто об этом не узнает.
*
* Показываем все виды заданий, а не только разведку: сорванная заливка картинок тоже
* вставшая кампания, и владельцу её видеть надо.
*/
public function robotStuck(): JsonResponse
{
$rows = DB::table('ad_creative_jobs as j')
->join('ad_campaigns as c', 'c.id', '=', 'j.campaign_id')
->join('tenants as t', 't.id', '=', 'j.tenant_id')
->where('j.status', AdCreativeJob::STATUS_FAILED)
->orderByDesc('j.id')
->limit(200)
->select([
'j.id',
'j.campaign_id',
'j.tenant_id',
'j.kind',
'j.yandex_ad_id',
'j.failure_reason',
'j.attempts',
'j.finished_at',
'c.name as campaign_name',
'c.status as campaign_status',
't.organization_name as tenant_name',
])
->get();
$data = $rows->map(fn ($r) => [
'id' => (int) $r->id,
'campaign_id' => (int) $r->campaign_id,
'tenant_id' => (int) $r->tenant_id,
'tenant_name' => $r->tenant_name,
'campaign_name' => $r->campaign_name,
'campaign_status' => $r->campaign_status,
'kind' => $r->kind,
'yandex_ad_id' => $r->yandex_ad_id !== null ? (int) $r->yandex_ad_id : null,
'failure_reason' => $r->failure_reason,
'attempts' => (int) $r->attempts,
'finished_at' => $r->finished_at,
])->values();
return response()->json(['data' => $data]);
}
/**
* Вписать номер оформленного в Яндекс.Директе адаптивного креатива кампании.
*
* ⚠️ Колоночный GRANT на ad_campaigns у crm_admin_user разрешает UPDATE ТОЛЬКО
* yandex_creative_id обновляем строго через query-builder (не Eloquent save()),
* иначе Eloquent попытается тронуть updated_at/другие колонки и упадёт по гранту.
*/
public function setCampaignCreative(Request $request, int $id): JsonResponse
{
$data = $request->validate([
'yandex_creative_id' => ['required', 'integer', 'min:1'],
]);
// DB::table (сырой query-builder), НЕ Eloquent: Eloquent-билдер добавил бы
// updated_at, а колоночный GRANT разрешает crm_admin_user писать ТОЛЬКО
// yandex_creative_id → на проде UPDATE с updated_at упал бы «permission denied».
$updated = DB::table('ad_campaigns')->where('id', $id)->update([
'yandex_creative_id' => $data['yandex_creative_id'],
]);
if ($updated === 0) {
return response()->json(['message' => 'Кампания не найдена.'], 404);
}
return response()->json([
'id' => $id,
'yandex_creative_id' => $data['yandex_creative_id'],
]);
}
/** Зеркало TenantChargesController::applyPeriodTo — Europe/Moscow, тот же набор периодов. */
private function applyPeriod($query, string $column, string $period): void
{
$now = Carbon::now('Europe/Moscow');
if ($period === 'current_month') {
$query->where($column, '>=', $now->copy()->startOfMonth());
} elseif ($period === 'last_month') {
$query->whereBetween($column, [
$now->copy()->subMonth()->startOfMonth(),
$now->copy()->subMonth()->endOfMonth(),
]);
} elseif ($period === '90d') {
$query->where($column, '>=', $now->copy()->subDays(90));
}
// 'all' — без фильтра по дате.
}
}
@@ -0,0 +1,947 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Exceptions\Advertising\AudienceTooSmallException;
use App\Exceptions\Billing\InsufficientBalanceException;
use App\Http\Controllers\Controller;
use App\Models\AdCampaign;
use App\Models\AdCampaignAd;
use App\Models\AdCampaignBanner;
use App\Models\AdWalletTransaction;
use App\Services\Advertising\AdImpressionPricing;
use App\Services\Advertising\AdWalletService;
use App\Services\Advertising\BannerSizes;
use App\Services\Advertising\BannerUploadPolicy;
use App\Services\Advertising\CampaignAudienceBuilder;
use App\Services\Advertising\CampaignEstimateService;
use App\Services\Advertising\CampaignLauncher;
use App\Services\Advertising\CampaignReviveService;
use App\Services\Advertising\CreativeJobService;
use App\Services\Advertising\CreativeValidator;
use App\Services\Advertising\YandexDirectClient;
use App\Support\PhoneNormalizer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use Throwable;
/**
* HTTP API кампаний Директа для клиентского портала (Часть A, Task 12) тонкий
* слой над готовыми сервисами CampaignAudienceBuilder / CampaignLauncher /
* CreativeValidator / YandexDirectClient. Фронт (Часть B2) отдельная задача.
*
* tenant_id всегда из $request->user()->tenant_id; все запросы к ad_* с явным
* ->where('tenant_id', ...) поверх RLS (defense-in-depth, в тестах PG superuser
* BYPASSRLS) паттерн из AdvertisingWalletController/TenantChargesController.
*/
class AdvertisingCampaignController extends Controller
{
public function index(Request $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaigns = AdCampaign::where('tenant_id', $tenantId)
->orderByDesc('id')
// moderation_reason — подпись под красным ярлыком «Отклонено»: без неё
// клиент видел отказ и ни слова о том, что переделывать. Наценку
// (yandex_cost_rub, ad_margin_percent) сюда не добавлять никогда.
->get(['id', 'name', 'status', 'audience_days', 'frequency', 'estimated_impressions', 'budget_rub', 'launched_at', 'moderation_reason']);
return response()->json(['data' => $campaigns]);
}
public function store(Request $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'audience_days' => ['required', 'integer', 'min:1', 'max:90'],
'use_uploaded_list' => ['boolean'],
'frequency' => ['nullable', 'integer', 'min:1', 'max:1000'],
'frequency_period_days' => ['nullable', 'integer', 'min:1', 'max:90'],
'estimated_impressions' => ['nullable', 'integer', 'min:0'],
'budget_rub' => ['nullable', 'numeric', 'min:0'],
'mode' => ['sometimes', 'in:auto,manual'],
'snapshot_from' => ['nullable', 'date'],
'snapshot_to' => ['nullable', 'date', 'after_or_equal:snapshot_from'],
'run_days' => ['nullable', 'integer', 'min:1', 'max:365'],
'client_cpm_rub' => ['nullable', 'numeric', 'gt:0'],
'landing_url' => ['nullable', 'url', 'max:1024'],
]);
$mode = $data['mode'] ?? AdCampaign::MODE_AUTO;
$campaign = AdCampaign::create([
'tenant_id' => $tenantId,
'status' => AdCampaign::STATUS_DRAFT,
'name' => $data['name'],
'mode' => $mode,
'audience_days' => $data['audience_days'],
'snapshot_from' => $data['snapshot_from'] ?? null,
'snapshot_to' => $data['snapshot_to'] ?? null,
'run_days' => $data['run_days'] ?? null,
// Свой список номеров — только в ручном режиме (Ф-правило); в авто форсим false
// независимо от того, что пришло в запросе.
'use_uploaded_list' => $mode === AdCampaign::MODE_MANUAL ? ($data['use_uploaded_list'] ?? false) : false,
'frequency' => $data['frequency'] ?? null,
'frequency_period_days' => $data['frequency_period_days'] ?? null,
'estimated_impressions' => $data['estimated_impressions'] ?? null,
'budget_rub' => $data['budget_rub'] ?? null,
'client_cpm_rub' => $data['client_cpm_rub'] ?? null,
'landing_url' => $data['landing_url'] ?? null,
]);
return response()->json($campaign, 201);
}
public function show(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
$ads = AdCampaignAd::where('tenant_id', $tenantId)->where('campaign_id', $campaign->id)->get();
$chargeAmounts = AdWalletTransaction::where('tenant_id', $tenantId)
->where('type', AdWalletTransaction::TYPE_CHARGE)
->where('channel', 'yandex')
->where('related_type', 'campaign')
->where('related_id', $campaign->id)
->pluck('amount_rub');
$spentRub = '0.00';
foreach ($chargeAmounts as $amount) {
$amount = (string) $amount;
$abs = bccomp($amount, '0', 2) < 0 ? bcmul($amount, '-1', 2) : $amount;
$spentRub = bcadd($spentRub, $abs, 2);
}
return response()->json([
'campaign' => $campaign,
'ads' => $ads,
'spent_rub' => $spentRub,
]);
}
public function update(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
$data = $request->validate([
'name' => ['sometimes', 'required', 'string', 'max:255'],
'audience_days' => ['sometimes', 'required', 'integer', 'min:1', 'max:90'],
'use_uploaded_list' => ['sometimes', 'boolean'],
'frequency' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:1000'],
'frequency_period_days' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:90'],
'estimated_impressions' => ['sometimes', 'nullable', 'integer', 'min:0'],
'budget_rub' => ['sometimes', 'nullable', 'numeric', 'min:0'],
'mode' => ['sometimes', 'in:auto,manual'],
'snapshot_from' => ['sometimes', 'nullable', 'date'],
'snapshot_to' => ['sometimes', 'nullable', 'date', 'after_or_equal:snapshot_from'],
'run_days' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:365'],
'client_cpm_rub' => ['sometimes', 'nullable', 'numeric', 'gt:0'],
'landing_url' => ['sometimes', 'nullable', 'url', 'max:1024'],
]);
// ЗАМОК: как только в Яндексе что-то заведено, параметры показа править нельзя —
// менять можно только название (на Яндекс и на деньги оно не влияет).
//
// Замок по номерам созданных в Яндексе сущностей, а НЕ по статусу: если запуск
// оборвался на середине, статус так и остался draft, но кампания, группа и часть
// объявлений в кабинете Яндекса уже созданы — по статусу такую кампанию не отличить
// от нетронутого черновика. Номера сущностей — единственный честный признак «там уже есть».
//
// Сегмент Яндекс.Аудиторий создаётся РАНЬШЕ кампании Директа, поэтому одного
// yandex_campaign_id мало: обрыв ровно в этом окне оставлял настройки аудитории
// открытыми. Клиент менял срок сбора или список номеров, портал показывал новое —
// а возобновлённый запуск переиспользовал СТАРЫЙ сегмент, и реклама шла по прежним
// телефонам. Молча.
//
// Что ломала правка без замка: в Яндексе остаются старые AverageCpm / SpendLimit /
// даты, а портал показывает новые; paid_impressions (потолок биллинга, пишется при
// запуске) расходится с тем, что видит клиент. Отдельно landing_url — он уходит в
// Яндекс адресом перехода по клику у КАЖДОГО объявления: смена посреди возобновляемого
// запуска развела бы объявления по разным адресам (часть со старым, часть с новым).
//
// ОДНО узкое исключение: кампанию, которую Яндекс отклонил, клиент обязан иметь
// возможность починить. Показов у неё нет и денег на ней нет — расходиться
// с Яндексом нечему.
//
// 🪤 Исключение написано по отказу и отметке «отдана на починку», а НЕ по «есть ли
// номер кампании»: иначе оно открыло бы правку работающей рекламе, которая крутится
// за деньги клиента.
// 🪤 Одного статуса `rejected` мало: «Исправить» тут же переводит кампанию
// в черновик, и исключение по статусу гасло в ту же секунду — мастер открывался,
// а сервер правку не пускал. Поймано живой проверкой в браузере 28.07.2026.
// Черновик после ОБОРВАВШЕГОСЯ запуска отметки не имеет и остаётся запертым.
$editable = array_diff(array_keys($data), ['name']);
$inYandex = ($campaign->yandex_campaign_id !== null || $campaign->yandex_segment_id !== null)
&& ! $this->underRepair($campaign);
if ($inYandex && $editable !== []) {
return response()->json([
'message' => 'Кампания уже заведена в Яндексе — менять параметры показа нельзя. Название поменять можно.',
], 409);
}
// Свой список номеров — только в ручном режиме. Итоговый режим = то, что пришло в
// запросе, иначе — текущий режим кампании. Если итог auto — форсим use_uploaded_list=false,
// даже если это поле в запросе не пришло (не затираем остальные несогласованные поля).
$effectiveMode = $data['mode'] ?? $campaign->mode;
if ($effectiveMode === AdCampaign::MODE_AUTO) {
$data['use_uploaded_list'] = false;
}
// Правка аудитории/списка (audience_days, use_uploaded_list) применяется на
// следующий день ночным replace-джобом (Р30) — здесь только сохраняем поле,
// текущий прогон кампании её не подхватывает. Бюджет — применяется сразу.
$campaign->update($data);
return response()->json($campaign->fresh());
}
/**
* T15 удаление черновика. Только status=draft своего тенанта; running/paused/
* отказ 409 (менеджер не должен молча терять запущенную кампанию). Дочерние
* ad_campaign_ads/ad_campaign_phones cascadeOnDelete на уровне схемы.
*/
public function destroy(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
if ($campaign->status !== AdCampaign::STATUS_DRAFT) {
return response()->json([
'message' => 'Удалить можно только черновик кампании.',
], 409);
}
$campaign->delete();
return response()->json(null, 204);
}
public function audienceSize(Request $request, int $id, CampaignAudienceBuilder $builder, CampaignEstimateService $estimator): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
$data = $request->validate([
'days' => ['nullable', 'integer', 'min:1', 'max:90'],
'frequency' => ['nullable', 'integer', 'min:1', 'max:1000'],
'mode' => ['nullable', 'in:auto,manual'],
'from' => ['nullable', 'date'],
'to' => ['nullable', 'date'],
'cpm' => ['nullable', 'numeric', 'gt:0'],
]);
// Живой счётчик (Р22-Р23): временно проставляем режим/даты/дни на инстансе,
// НЕ сохраняя в БД (нет вызова ->save()) — мастер ещё правит черновик.
if (isset($data['mode'])) {
$campaign->mode = $data['mode'];
}
$campaign->audience_days = $data['days'] ?? $campaign->audience_days;
if (isset($data['from'])) {
$campaign->snapshot_from = $data['from'];
}
if (isset($data['to'])) {
$campaign->snapshot_to = $data['to'];
}
$size = $builder->size($campaign);
$enough = $size >= 100;
$payload = [
'size' => $size,
'min' => 100,
'enough' => $enough,
'hint' => $enough ? null : 'Аудитория меньше 100 — увеличьте число дней или добавьте свой список',
];
if (isset($data['frequency'])) {
$cpm = isset($data['cpm']) ? number_format((float) $data['cpm'], 2, '.', '') : $campaign->effectiveCpm();
$est = $estimator->estimate($size, (int) $data['frequency'], $cpm);
$payload['frequency'] = $est['frequency'];
$payload['impressions'] = $est['impressions'];
$payload['cpm_rub'] = $est['cpm_rub'];
$payload['cost_rub'] = $est['cost_rub'];
}
return response()->json($payload);
}
public function launch(Request $request, int $id, CampaignLauncher $launcher): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
// Креативы в кабинет Яндекса заливает робот-грузчик: картиночный креатив через API
// не создать (findings 2026-07-27). Если номеров ещё нет — это не ошибка, а «подождите»:
// ставим задание роботу и отвечаем 202. Кампания остаётся черновиком, деньги не морозятся,
// в Яндексе ничего не создаётся.
//
// Рубильник Директа проверяем ЗДЕСЬ, до постановки задания: enqueue() ходит в API Яндекса
// за слепком креативов. Раньше рубильник проверял только CampaignLauncher, и эта ветка
// проскочила бы мимо него — при выключенном рубильнике портал полез бы в живой Яндекс.
// Если рубильник выключен — просто идём дальше, launcher отдаст прежний отказ 409.
if (config('services.yandex_direct.enabled')) {
$needCreatives = AdCampaignBanner::where('tenant_id', $tenantId)
->where('campaign_id', $campaign->id)
->where('included', true)
->whereNull('yandex_creative_id')
->exists();
if ($needCreatives) {
try {
app(CreativeJobService::class)->enqueue($campaign);
} catch (Throwable $e) {
// Постановка задания ходит в живой Яндекс за слепком креативов, и слепок
// обязателен: без него потом не опознать, какие креативы залил робот.
// Яндекс лёг — раньше клиент получал голый 500 «что-то пошло не так»
// и не понимал, виноват ли он и надо ли заливать картинки заново.
// Отвечаем честно: это не вы, попробуйте позже. Кампания остаётся
// черновиком, задание не создаётся, деньги не трогаются.
Log::error('Не смогли поставить задание роботу на креативы', [
'campaign_id' => $campaign->id,
'tenant_id' => $tenantId,
'error' => $e->getMessage(),
]);
return response()->json([
'status' => 'yandex_unavailable',
'message' => 'Рекламный кабинет Яндекса сейчас не отвечает — запустить не получилось. Ваши картинки и настройки сохранены, попробуйте ещё раз через несколько минут.',
], 503);
}
return response()->json([
'status' => 'creatives_pending',
'message' => 'Готовим картинки в рекламном кабинете. Обычно занимает несколько минут — попробуйте запустить чуть позже.',
], 202);
}
}
try {
$launcher->launch($campaign);
} catch (AudienceTooSmallException $e) {
return response()->json([
'message' => "Аудитория слишком мала: {$e->size}. Нужно минимум 100 — увеличьте дни или добавьте свой список.",
], 422);
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 409);
}
return response()->json(['status' => $campaign->fresh()->status]);
}
/**
* «Исправить» у отклонённой кампании вернуть её в черновик, чтобы клиент переделал
* картинки привычными экранами и нажал обычную кнопку «Запустить». Второго пути
* запуска не появляется: дальше работает существующий CampaignLauncher.
*
* Второе нажатие видит кампанию уже черновиком и получает отказ 409 оно стёрло бы
* номера объявлений, которые к тому моменту мог создать новый запуск.
*/
public function revive(Request $request, int $id, CampaignReviveService $service): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
try {
$service->revive($campaign);
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 409);
}
return response()->json(['status' => $campaign->refresh()->status]);
}
/**
* Ч.5b «отправить заявку на запуск». Пока Директ закрыт (заявка на доступ на рассмотрении),
* мастер не запускает кампанию в Директе (это Часть 4), а переводит готовую кампанию в статус
* `queued` «готова к запуску, ждёт оператора». Требует: утверждённые баннеры + заданную частоту.
* Реальный запуск/заморозку денег доделает Часть 4.
*/
public function submit(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
if ($campaign->banners_approved_at === null) {
return response()->json(['message' => 'Сначала утвердите баннеры.'], 422);
}
// Защита от «пустого показа»: галочку «в показ» могли снять уже ПОСЛЕ утверждения
// (utverждение не сбрасывается при переключении) — на момент отправки нужен хотя бы один включённый.
$hasIncluded = AdCampaignBanner::where('tenant_id', $tenantId)
->where('campaign_id', $campaign->id)->where('included', true)->exists();
if (! $hasIncluded) {
return response()->json(['message' => 'Ни один баннер не отмечен «в показ» — отметьте хотя бы один и утвердите заново.'], 422);
}
if ($campaign->frequency === null || $campaign->estimated_impressions === null) {
return response()->json(['message' => 'Не заданы частота и смета показов.'], 422);
}
if ($campaign->mode === AdCampaign::MODE_MANUAL
&& ($campaign->snapshot_from === null || $campaign->snapshot_to === null || $campaign->run_days === null)) {
return response()->json(['message' => 'Для ручного режима задайте период дат и срок показа.'], 422);
}
$campaign->update(['status' => AdCampaign::STATUS_QUEUED]);
return response()->json(['status' => $campaign->fresh()->status]);
}
public function pause(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
if (! in_array($campaign->status, [AdCampaign::STATUS_RUNNING, AdCampaign::STATUS_PENDING_MODERATION], true)) {
return response()->json([
'message' => 'Кампанию нельзя поставить на паузу из текущего состояния.',
], 409);
}
// Пауза, которая не дошла до Директа, — не пауза. Раньше ошибку глотали в журнал,
// ставили статус «на паузе» и БЕЗУСЛОВНО размораживали деньги: реклама в Яндексе
// продолжала крутиться и тратить, портал показывал «на паузе», а деньги за неё уже
// были свободны и могли уйти на другую кампанию. Клиент уходил в минус молча.
$error = $this->callDirect($campaign, fn (YandexDirectClient $direct, int $yandexCampaignId) => $direct->suspendCampaign($yandexCampaignId));
if ($error !== null) {
return response()->json([
'message' => 'Не удалось остановить рекламу в Яндексе — попробуйте ещё раз через минуту. Кампания продолжает работать, деньги под неё зарезервированы.',
], 409);
}
$campaign->update(['status' => AdCampaign::STATUS_PAUSED]);
// ВЫХОД 4 — на паузе кампания не крутится и не тратит деньги, поэтому держать
// их зарезервированными незачем: возвращаем в свободные, клиент волен пустить
// их на другую рекламу. При возобновлении остаток сметы морозится заново.
app(AdWalletService::class)->release($tenantId, 'yandex', 'campaign', (int) $campaign->id);
return response()->json(['status' => $campaign->fresh()->status]);
}
public function resume(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
if ($campaign->status !== AdCampaign::STATUS_PAUSED) {
return response()->json([
'message' => 'Возобновить можно только кампанию на паузе.',
], 409);
}
// ВЫХОД 4 (обратно) — резервируем неоткрученный остаток сметы ДО обращения к
// Директу: если денег не хватает, кампания не должна ожить в Яндексе.
$remaining = $this->remainingBudgetRub($campaign);
if (bccomp($remaining, '0.00', 2) > 0) {
try {
app(AdWalletService::class)->freeze($tenantId, 'yandex', 'campaign', (int) $campaign->id, $remaining);
} catch (InsufficientBalanceException) {
return response()->json([
'message' => 'Не хватает денег на рекламном кошельке, чтобы возобновить кампанию. Пополните кошелёк.',
], 409);
}
}
$this->callDirect($campaign, fn (YandexDirectClient $direct, int $yandexCampaignId) => $direct->resumeCampaign($yandexCampaignId));
$campaign->update(['status' => AdCampaign::STATUS_RUNNING]);
return response()->json(['status' => $campaign->fresh()->status]);
}
/**
* Неоткрученный остаток сметы в клиентских рублях: полная стоимость оплаченных
* показов минус уже списанное. Для кампаний без сметы (старые, «за клики») 0.
*/
private function remainingBudgetRub(AdCampaign $campaign): string
{
$paid = (int) ($campaign->paid_impressions ?? 0);
if ($paid <= 0) {
return '0.00';
}
$total = app(AdImpressionPricing::class)->clientCostRub($paid, $campaign->effectiveCpm());
$rest = bcsub($total, (string) ($campaign->charged_client_rub ?? '0.00'), 2);
return bccomp($rest, '0.00', 2) > 0 ? $rest : '0.00';
}
/**
* Вызывает Директ (suspend/resume) под рубильником, если у кампании уже есть
* yandex_campaign_id. Деньги не трогает.
*
* Возвращает null при успехе (в том числе когда идти в Директ не нужно вовсе) либо
* текст ошибки. Решать, что делать с неудачей, задача вызывающего: для паузы она
* критична (иначе реклама крутится, а деньги уже разморожены), для возобновления
* нет: там деньги заморожены заранее, и клиент увидит кампанию работающей, даже если
* до Яндекса мы не достучались. Отказывать на возобновлении опаснее: заморозка уже
* стоит, а снять её обратно можно было бы только новым местом разморозки а их
* в системе ровно четыре и пятое заводить нельзя.
*/
private function callDirect(AdCampaign $campaign, callable $action): ?string
{
// 🪤 Была проверка `!== true`. Рубильник приходит из env строкой, и «1» в .env
// читалась бы здесь как «выключено»: в Директ мы бы не пошли, а пауза приняла бы
// это за успех и разморозила деньги — при работающей в Яндексе рекламе.
if (! config('services.yandex_direct.enabled') || $campaign->yandex_campaign_id === null) {
return null;
}
$direct = new YandexDirectClient(
(string) config('services.yandex_direct.base_url'),
(string) config('services.yandex_direct.token'),
);
try {
$action($direct, (int) $campaign->yandex_campaign_id);
} catch (RuntimeException $e) {
Log::warning('advertising.campaign_direct_call_failed', [
'campaign_id' => $campaign->id,
'error' => $e->getMessage(),
]);
return $e->getMessage();
}
return null;
}
public function storeAd(Request $request, int $id, CreativeValidator $validator): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
$data = $request->validate([
'title' => ['required', 'string', 'max:56'],
'text' => ['required', 'string', 'max:120'],
'href' => ['required', 'url', 'max:1024'],
'title2' => ['nullable', 'string', 'max:60'],
]);
$errors = $validator->validateText($data['title'], $data['text'], $data['title2'] ?? null);
if ($errors !== []) {
return response()->json(['errors' => $errors], 422);
}
$ad = AdCampaignAd::create([
'tenant_id' => $tenantId,
'campaign_id' => $campaign->id,
'title' => $data['title'],
'text' => $data['text'],
'href' => $data['href'],
'title2' => $data['title2'] ?? null,
'moderation_status' => 'draft',
]);
return response()->json($ad, 201);
}
public function uploadAdImage(Request $request, int $id, int $adId, CreativeValidator $validator): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
$ad = AdCampaignAd::where('tenant_id', $tenantId)
->where('campaign_id', $campaign->id)
->where('id', $adId)
->firstOrFail();
$request->validate([
'file' => ['required', 'image', 'max:10240'],
]);
/** @var UploadedFile $file */
$file = $request->file('file');
$path = $file->getRealPath() ?: $file->getPathname();
$size = @getimagesize($path);
$width = (int) ($size[0] ?? 0);
$height = (int) ($size[1] ?? 0);
$errors = $validator->validateImage($width, $height, (string) $file->getMimeType(), (int) $file->getSize());
if ($errors !== []) {
return response()->json(['errors' => $errors], 422);
}
// 🪤 Была проверка `=== false`. Значение приходит из env, и `YANDEX_DIRECT_ENABLED=0`
// в .env даёт СТРОКУ «0»: рубильник считает её выключенным, а строгое сравнение —
// включённым, и запрос уходил бы в живой Яндекс при выключенном рубильнике.
// Остальные места проверяют именно так — приводим к общему виду.
if (! config('services.yandex_direct.enabled')) {
return response()->json(['message' => 'Яндекс.Директ выключен — картинку пока не загрузить.'], 409);
}
$direct = new YandexDirectClient(
(string) config('services.yandex_direct.base_url'),
(string) config('services.yandex_direct.token'),
);
$base64 = base64_encode((string) file_get_contents($path));
$hash = $direct->uploadAdImage($file->getClientOriginalName(), $base64);
$ad->update(['image_normal_hash' => $hash]);
return response()->json(['hash' => $hash]);
}
/**
* T17 загрузка «моего списка» номеров. Тело: либо `file` (csv/txt обычный
* текст, номера построчно), либо `text` (номера через перевод строки/запятую).
* xlsx НЕ поддерживаем новую зависимость не тянем сверх уже установленного
* phpspreadsheet, а его подключение под xlsx отдельная задача.
*
* Нормализация PhoneNormalizer::normalize() (формат «+7XXXXXXXXXX»), храним
* без «+» («79XXXXXXXXX» как в остальных телефонах/Яндекс.Аудиториях).
* Невалидные строки отбрасываем, дубли схлопываем перед вставкой; unique
* (tenant_id, campaign_id, phone) на таблице закрывает повторные заливки
* insertOrIgnore, чтобы повтор не падал по ключу.
*/
public function storePhones(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
$data = $request->validate([
'file' => ['nullable', 'file', 'mimes:csv,txt', 'max:5120'],
'text' => ['nullable', 'string'],
]);
// «Тихий ноль» (Ф3): без этой проверки пустая отправка (ни файла, ни текста)
// тихо считается «успехом» с recognized=0 — клиент не понимает, что ничего
// не загрузилось. Отдаём явный 422 с понятным сообщением.
$hasFile = $request->hasFile('file');
$hasText = trim((string) ($data['text'] ?? '')) !== '';
if (! $hasFile && ! $hasText) {
return response()->json([
'message' => 'Загрузите файл или вставьте номера',
], 422);
}
$raw = '';
if ($request->hasFile('file')) {
/** @var UploadedFile $file */
$file = $request->file('file');
$raw .= (string) file_get_contents($file->getRealPath() ?: $file->getPathname());
}
if (($data['text'] ?? '') !== '') {
$raw .= "\n".$data['text'];
}
$lines = preg_split('/[\r\n,;]+/', $raw) ?: [];
$recognized = 0;
$skipped = 0;
$unique = [];
foreach ($lines as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
$normalized = PhoneNormalizer::normalize($line);
if ($normalized === null) {
$skipped++;
continue;
}
$phone = substr($normalized, 1); // "+7XXXXXXXXXX" → "7XXXXXXXXXX"
if (isset($unique[$phone])) {
$skipped++;
continue;
}
$unique[$phone] = true;
$recognized++;
}
if ($unique !== []) {
$now = now();
$rows = array_map(fn (string $phone) => [
'tenant_id' => $tenantId,
'campaign_id' => $campaign->id,
'phone' => $phone,
'created_at' => $now,
'updated_at' => $now,
], array_keys($unique));
DB::table('ad_campaign_phones')->insertOrIgnore($rows);
}
return response()->json([
'recognized' => $recognized,
'skipped' => $skipped,
]);
}
/**
* Новая модель: клиент грузит СВОЙ готовый баннер на КАЖДЫЙ размер (не
* автогенерация из одной картинки) HANDOFF reklama-yandex-ux-audit.
* `slots` все 15 размеров BannerSizes::all() по порядку; для загруженных
* заполнены uploaded/banner_id/bytes/included/preview_url.
*/
public function listBanners(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
return response()->json([
'approved_at' => $campaign->banners_approved_at,
'max_bytes' => BannerUploadPolicy::MAX_BYTES,
'formats' => BannerUploadPolicy::FORMATS,
'slots' => $this->bannerSlots($campaign),
]);
}
public function uploadBanner(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
if (($locked = $this->bannersLocked($campaign)) !== null) {
return $locked;
}
$validated = $request->validate([
'width' => ['required', 'integer'],
'height' => ['required', 'integer'],
'file' => ['required', 'file', 'mimes:'.implode(',', BannerUploadPolicy::FORMATS), 'max:'.(int) (BannerUploadPolicy::MAX_BYTES / 1024)],
]);
$w = (int) $validated['width'];
$h = (int) $validated['height'];
if (! in_array([$w, $h], BannerSizes::all(), true)) {
return response()->json(['message' => 'Неизвестный размер баннера.'], 422);
}
/** @var UploadedFile $file */
$file = $request->file('file');
$realPath = $file->getRealPath() ?: $file->getPathname();
[$imgW, $imgH] = @getimagesize($realPath) ?: [0, 0];
if ($imgW !== $w || $imgH !== $h) {
return response()->json(['message' => "Нужен ровно {$w}×{$h}. Вы загрузили {$imgW}×{$imgH}."], 422);
}
$binary = (string) file_get_contents($realPath);
// Расширение — из ПРОВАЛИДИРОВАННОГО содержимого (guessed по MIME), НЕ из имени файла клиента
// (иначе клиент задаёт произвольное расширение на диске — риск полиглот-файла).
$guessed = $file->extension();
$ext = in_array($guessed, BannerUploadPolicy::FORMATS, true) ? $guessed : 'jpg';
$path = "ad-banners/{$tenantId}/{$campaign->id}/{$w}x{$h}.{$ext}";
$disk = Storage::disk('local');
$existing = AdCampaignBanner::where('tenant_id', $tenantId)
->where('campaign_id', $campaign->id)
->where('width', $w)->where('height', $h)
->first();
if ($existing) {
$disk->delete($existing->path);
}
$disk->put($path, $binary);
// Номер креатива обнуляем вместе с картинкой. Робот мог уже отвезти прежнюю картинку
// в кабинет Яндекса и проставить номер; сюда мы попадаем только пока кампании в Директе
// нет (иначе набор заперт), объявлений тоже нет — но запуск взял бы СТАРЫЙ номер, и
// клиент заплатил бы за показы картинки, которую сам же и заменил. Обнулённый номер
// означает «нужен новый заход робота» — ровно то, что и требуется.
$banner = AdCampaignBanner::updateOrCreate(
['tenant_id' => $tenantId, 'campaign_id' => $campaign->id, 'width' => $w, 'height' => $h],
['path' => $path, 'bytes' => strlen($binary), 'included' => $existing->included ?? true, 'yandex_creative_id' => null],
);
$campaign->update(['banners_approved_at' => null]);
return response()->json(['slot' => $this->bannerSlot($campaign, $w, $h, $banner)], 201);
}
public function toggleBannerIncluded(Request $request, int $id, int $bannerId): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
$banner = AdCampaignBanner::where('tenant_id', $tenantId)
->where('campaign_id', $campaign->id)->where('id', $bannerId)->firstOrFail();
if (($locked = $this->bannersLocked($campaign)) !== null) {
return $locked;
}
$data = $request->validate(['included' => ['required', 'boolean']]);
$banner->update(['included' => $data['included']]);
return response()->json(['slot' => $this->bannerSlot($campaign, (int) $banner->width, (int) $banner->height, $banner->fresh())]);
}
public function deleteBanner(Request $request, int $id, int $bannerId): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
$banner = AdCampaignBanner::where('tenant_id', $tenantId)
->where('campaign_id', $campaign->id)->where('id', $bannerId)->firstOrFail();
// У баннера есть номер объявления в Яндексе — значит объявление создано и крутится.
// Удалить строку = забыть про живое объявление: портал перестанет его видеть, а
// возобновляемый запуск заведёт ВТОРОЕ того же размера. Запрещаем всегда, даже если
// номер кампании почему-то пуст.
if ($banner->yandex_ad_id !== null) {
return response()->json([
'message' => 'По этому размеру в Яндексе уже создано объявление — удалить баннер нельзя.',
], 409);
}
if (($locked = $this->bannersLocked($campaign)) !== null) {
return $locked;
}
Storage::disk('local')->delete($banner->path);
$banner->delete();
$campaign->update(['banners_approved_at' => null]);
return response()->json(null, 204);
}
/**
* Замок на баннерах: кампания заведена в Яндексе набор картинок трогать нельзя.
* Возвращает готовый ответ 409, либо null, если правка разрешена.
*
* Признак тот же, что и у замка на параметрах кампании (см. update()) `yandex_campaign_id`,
* а НЕ статус: оборвавшийся запуск оставляет статус `draft`, хотя кампания, группа и часть
* объявлений в кабинете уже созданы, и по статусу такую кампанию от нетронутого черновика
* не отличить.
*
* Что ломалось без замка:
* - перезаливка картинки: строка баннера обновляется, а `yandex_creative_id`/`yandex_ad_id`
* остаются от старого креатива в портале новая картинка, в Яндексе крутится старая, молча;
* - удаление и повторная заливка: строка с `yandex_ad_id` уничтожалась, возобновление видело
* «номера объявления нет» и создавало второе объявление того же размера, а старое
* продолжало крутиться за деньги клиента.
*/
/**
* Кампанию сейчас чинят после отказа Яндекса правка ей разрешена.
*
* Два состояния одного и того же: Яндекс только что отклонил (`rejected`) либо клиент
* уже нажал «Исправить» и кампания вернулась в черновик с отметкой `revived_at`.
* Отметка гаснет при следующем запуске замок закрывается сам.
*/
private function underRepair(AdCampaign $campaign): bool
{
return $campaign->status === AdCampaign::STATUS_REJECTED
|| ($campaign->status === AdCampaign::STATUS_DRAFT && $campaign->revived_at !== null);
}
private function bannersLocked(AdCampaign $campaign): ?JsonResponse
{
if ($campaign->yandex_campaign_id === null) {
return null;
}
// То же узкое исключение, что и у замка на параметрах (см. update()): кампании,
// которую чинят после отказа, нужна новая картинка — иначе кнопка «Исправить»
// ведёт в тупик. Показов у неё нет, деньги вернулись клиенту при отказе.
//
// Отдельная защита баннера с собственным `yandex_ad_id` (см. deleteBanner)
// остаётся на месте и после оживления снимается сама: сервис оживления обнуляет
// номера только у отклонённых баннеров, а принятые не трогает.
if ($this->underRepair($campaign)) {
return null;
}
return response()->json([
'message' => 'Кампания уже заведена в Яндексе — менять набор баннеров нельзя.',
], 409);
}
public function previewBanner(Request $request, int $id, int $bannerId): mixed
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
$banner = AdCampaignBanner::where('tenant_id', $tenantId)
->where('campaign_id', $campaign->id)->where('id', $bannerId)->firstOrFail();
$ext = strtolower(pathinfo($banner->path, PATHINFO_EXTENSION));
$contentType = match ($ext) {
'png' => 'image/png',
'gif' => 'image/gif',
default => 'image/jpeg',
};
return Storage::disk('local')->response($banner->path, null, ['Content-Type' => $contentType]);
}
public function approveBanners(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
$has = AdCampaignBanner::where('tenant_id', $tenantId)->where('campaign_id', $campaign->id)
->where('included', true)->exists();
if (! $has) {
return response()->json(['message' => 'Отметьте хотя бы один баннер для показа.'], 422);
}
$campaign->update(['banners_approved_at' => now()]);
return response()->json(['approved_at' => $campaign->fresh()->banners_approved_at]);
}
/** @return list<array{width:int,height:int,uploaded:bool,banner_id:?int,bytes:?int,included:bool,preview_url:?string}> */
private function bannerSlots(AdCampaign $campaign): array
{
$existing = AdCampaignBanner::where('tenant_id', $campaign->tenant_id)
->where('campaign_id', $campaign->id)
->get()
->keyBy(fn ($b) => $b->width.'x'.$b->height);
$slots = [];
foreach (BannerSizes::all() as [$w, $h]) {
$banner = $existing->get($w.'x'.$h);
$slots[] = $this->bannerSlot($campaign, $w, $h, $banner);
}
return $slots;
}
/** @return array{width:int,height:int,uploaded:bool,banner_id:?int,bytes:?int,included:bool,preview_url:?string} */
private function bannerSlot(AdCampaign $campaign, int $w, int $h, ?AdCampaignBanner $banner): array
{
if ($banner === null) {
return [
'width' => $w,
'height' => $h,
'uploaded' => false,
'banner_id' => null,
'bytes' => null,
'included' => true,
'preview_url' => null,
];
}
return [
'width' => $w,
'height' => $h,
'uploaded' => true,
'banner_id' => (int) $banner->id,
'bytes' => (int) $banner->bytes,
'included' => (bool) $banner->included,
'preview_url' => "/api/advertising/campaigns/{$campaign->id}/banners/{$banner->id}/preview",
];
}
}
@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Mail\AdDocumentAttachedMail;
use App\Models\AdCampaign;
use App\Models\AdCampaignMessage;
use App\Services\Advertising\CampaignMessageService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
/**
* Лента сообщений по рекламной кампании окно передачи между Яндексом и клиентом.
* Всё строго в пределах своего тенанта: чужая переписка это чужие бумаги.
*
* Наценки в ленте быть не может: наружу отдаются только поля сообщения, ни
* `yandex_cost_rub`, ни `ad_margin_percent` тут не появляются никогда.
*/
class AdvertisingCampaignMessageController extends Controller
{
public function index(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
$messages = AdCampaignMessage::where('tenant_id', $tenantId)
->where('campaign_id', $campaign->id)
->orderBy('id')
->get()
->map(fn (AdCampaignMessage $m) => [
'id' => (int) $m->id,
'author' => $m->author,
'banner_id' => $m->banner_id,
'body' => $m->body,
'file_name' => $m->file_name,
'file_size' => $m->file_size,
'created_at' => $m->created_at?->toIso8601String(),
]);
return response()->json(['messages' => $messages]);
}
/**
* Ответ клиента. Пустое сообщение без файла принимать бессмысленно окно передачи
* должно что-то передавать.
*
* Что принимаем: pdf, jpg, png до 10 МБ. Проверяем и расширение, и настоящий тип
* файла переименованный exe правилом `mimes` не пройдёт. Проверка идёт ДО записи
* на диск: чужой исполняемый файл на боевом сервере это не «неудобство», а дыра.
*/
public function store(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
$data = $request->validate([
'body' => ['nullable', 'string', 'max:4000'],
'file' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:10240'],
]);
$body = trim((string) ($data['body'] ?? ''));
$file = $request->file('file');
if ($body === '' && ! $file instanceof UploadedFile) {
return response()->json([
'message' => 'Напишите сообщение или приложите документ.',
'errors' => ['body' => ['Напишите сообщение или приложите документ.']],
], 422);
}
$attributes = [
'tenant_id' => $tenantId,
'campaign_id' => (int) $campaign->id,
'author' => AdCampaignMessage::AUTHOR_CLIENT,
'body' => $body === '' ? 'Приложен документ' : $body,
];
if ($file instanceof UploadedFile) {
// Приватный диск: наружу файл уходит только через ручку ниже, с проверкой тенанта.
$path = $file->store("ad-messages/{$tenantId}/{$campaign->id}", 'local');
$attributes += [
'file_path' => $path,
'file_name' => mb_substr($file->getClientOriginalName(), 0, 255),
'file_size' => $file->getSize(),
'file_mime' => $file->getMimeType(),
];
}
$message = AdCampaignMessage::create($attributes);
if ($file instanceof UploadedFile) {
$this->tellTruthAboutDocument($campaign, $message, $body);
}
return response()->json(['id' => (int) $message->id], 201);
}
/**
* Правда про приложенный документ клиенту в ленту, владельцу письмом.
*
* 🔴 Замысел предполагал, что документ отвезёт робот прямо в кабинет Яндекса. **Такой
* дороги нет** проверено двумя нарочными отказами 28.07.2026, обычной тематикой
* и лицензируемой: в окне отказа ноль полей для файла, документы Яндекс принимает
* только снаружи кабинета (чат поддержки, форма обратной связи).
*
* Молчать про это нельзя. Клиент, приложивший лицензию, будет ждать ответа Яндекса,
* которого не будет: файл просто ляжет на диск. Поэтому говорим прямо и зовём живого
* человека иначе «разберёмся вручную» было бы пустым обещанием.
*
* Всё внутри под Throwable: отметка и письмо дело второстепенное, а принятый документ
* клиента нет. Беда с почтой не должна возвращать клиенту отказ на успешно принятый файл.
*/
private function tellTruthAboutDocument(AdCampaign $campaign, AdCampaignMessage $message, string $comment): void
{
try {
app(CampaignMessageService::class)->postSystem(
$campaign,
'Документ получен и сохранён у нас. Передать его Яндексу автоматически нельзя — '
.'он принимает документы только от человека. Если по вашему отказу документ нужен, '
.'мы отнесём его сами и напишем здесь.',
);
$to = (string) config('services.monitoring.alert_email');
if ($to !== '') {
Mail::to($to)->queue(new AdDocumentAttachedMail(
(string) $campaign->name,
(int) $campaign->id,
(int) $campaign->tenant_id,
(string) $message->file_name,
$comment,
));
}
} catch (Throwable $e) {
Log::warning('Не смогли отметить приложенный документ: '.$e->getMessage(), [
'campaign' => $campaign->id, 'message' => $message->id,
]);
}
}
/** Файл отдаём только своему тенанту и только через портал — диск закрытый. */
public function file(Request $request, int $id, int $messageId): StreamedResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
$message = AdCampaignMessage::where('tenant_id', $tenantId)
->where('campaign_id', $campaign->id)
->where('id', $messageId)
->whereNotNull('file_path')
->firstOrFail();
return Storage::disk('local')->download((string) $message->file_path, (string) $message->file_name);
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\AdWallet;
use App\Services\Advertising\AdWalletGate;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Tenant-scoped статус рекламного кошелька (Task 9, часть B) питает
* баннер «пополните рекламный кошелёк» на фронте.
*
* RLS изоляция через SetTenantContext middleware (auth:sanctum + tenant),
* как в TenantChargesController / BillingController.
*/
class AdvertisingWalletController extends Controller
{
public function show(Request $request, AdWalletGate $gate): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$wallet = AdWallet::where('tenant_id', $tenantId)->first();
$balanceRub = (string) ($wallet->balance_rub ?? '0.00');
$frozenRub = (string) ($wallet->frozen_rub ?? '0.00');
return response()->json([
'solvent' => $gate->isSolvent($tenantId),
'balance_rub' => $balanceRub,
'frozen_rub' => $frozenRub,
'free_rub' => bcsub($balanceRub, $frozenRub, 2),
]);
}
}
@@ -12,6 +12,7 @@ use App\Models\SaasTransaction;
use App\Models\Tenant;
use App\Models\User;
use App\Repositories\PricingTierRepository;
use App\Services\Advertising\AdWalletService;
use App\Services\Billing\BalanceToLeadsConverter;
use App\Services\Billing\BillingTopupService;
use App\Services\Billing\Gateway\PaymentGatewayManager;
@@ -46,16 +47,22 @@ class BillingController extends Controller
* Развилка: если флаг billing_yookassa_enabled ВКЛ создаём платёж через
* шлюз и возвращаем confirmation_url (баланс не меняется до webhook).
* Если ВЫКЛ MVP-stub мгновенного зачисления (текущее прод-поведение до Б-1).
*
* credit_target (рекламный кошелёк, Часть A): 'leads' (умолчание, старое
* поведение байт-в-байт) | 'advertising' (зачисление в ad_wallets вместо
* tenants.balance_rub). Зеркалит InvoicePaymentService.
*/
public function topup(Request $request): JsonResponse
{
$validated = $request->validate([
'amount_rub' => ['required', 'numeric', 'min:100', 'max:1000000', 'decimal:0,2'],
'credit_target' => ['sometimes', 'in:leads,advertising'],
]);
/** @var User $user */
$user = $request->user();
$amountRub = bcadd((string) $validated['amount_rub'], '0', 2);
$creditTarget = $validated['credit_target'] ?? SaasTransaction::CREDIT_TARGET_LEADS;
// Развилка: реальный шлюз (флаг ВКЛ) ИЛИ мгновенная заглушка (флаг ВЫКЛ).
if (SystemSettings::bool('billing_yookassa_enabled')) {
@@ -67,12 +74,22 @@ class BillingController extends Controller
$returnUrl = rtrim((string) config('app.url'), '/').'/billing?topup=return';
$result = app(OnlineTopupService::class)->start(
(int) $user->tenant_id, $amountRub, $gateway, $returnUrl, (int) $user->id
(int) $user->tenant_id, $amountRub, $gateway, $returnUrl, (int) $user->id, $creditTarget
);
return response()->json(['confirmation_url' => $result->confirmationUrl], 201);
}
// Заглушка (флаг ВЫКЛ): advertising — прямое пополнение рекламного кошелька
// (свой ledger, не balance_transactions); иначе прежнее поведение байт-в-байт.
if ($creditTarget === SaasTransaction::CREDIT_TARGET_ADVERTISING) {
app(AdWalletService::class)->topup(
(int) $user->tenant_id, $amountRub, 'yookassa', 'Пополнение рекламного кошелька'
);
return response()->json(['ok' => true], 201);
}
// Заглушка (текущее прод-поведение до Б-1): мгновенное зачисление.
$tx = $this->topupService->topup((int) $user->tenant_id, $amountRub, (int) $user->id);
@@ -0,0 +1,267 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Exceptions\Advertising\CreativeMatchFailedException;
use App\Http\Controllers\Controller;
use App\Models\AdCampaign;
use App\Models\AdCampaignBanner;
use App\Models\AdCreativeJob;
use App\Services\Advertising\CampaignMessageService;
use App\Services\Advertising\CreativeJobService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
/**
* Служебный канал робота-грузчика креативов. Три действия: взять задание, скачать файл
* баннера, отчитаться о результате.
*
* Робот ходит по сервис-токену, без пользователя и без tenant-контекста. Кросс-тенантный
* доступ даёт посредник `admin-db`: он подменяет активное подключение на pgsql_admin
* (роль crm_admin_user, у неё разрешающая политика srv_bypass). Поэтому внутри работаем
* обычным подключением по умолчанию прибивать модели к конкретному соединению НЕ надо.
*
* Запустить кампанию, потратить деньги или изменить смету через этот канал нельзя
* таких действий в нём просто нет.
*/
class CreativeRobotController extends Controller
{
public function __construct(private readonly CreativeJobService $jobs) {}
/** Выдать роботу одно задание с перечнем файлов. */
public function next(): JsonResponse
{
$job = $this->jobs->takeNext();
if ($job === null) {
return response()->json(['job' => null]);
}
// Разведка — это «сходить посмотреть», а не «отвезти картинки»: роботу нужен только
// номер объявления в кабинете. Списка файлов у неё нет и быть не может, а вызывать
// ради неё подбор баннеров — значит выдать роботу работу, которой ему не поручали.
if ($job->kind !== AdCreativeJob::KIND_UPLOAD) {
// Номер кампании в Яндексе роботу нужен: список объявлений открывается только
// по нему. Наш внутренний номер кабинету ничего не говорит.
$campaign = AdCampaign::find($job->campaign_id);
return response()->json(['job' => [
'id' => $job->id,
'campaign_id' => $job->campaign_id,
'kind' => $job->kind,
'yandex_ad_id' => $job->yandex_ad_id,
'yandex_campaign_id' => $campaign?->yandex_campaign_id,
'banners' => [],
]]);
}
// Ровно тот же набор, который потом сопоставляется при отчёте: включённые баннеры
// без номера креатива. Возить в кабинет то, что там уже лежит, — значит плодить
// дубли, которые вычищаются только руками.
$banners = $this->jobs->bannersToUpload($job);
return response()->json(['job' => [
'id' => $job->id,
'campaign_id' => $job->campaign_id,
'kind' => $job->kind,
'banners' => $banners->map(fn ($b) => [
'banner_id' => $b->id,
'width' => (int) $b->width,
'height' => (int) $b->height,
'file_url' => url("/api/creative-robot/jobs/{$job->id}/banners/{$b->id}/file"),
])->values()->all(),
]]);
}
/**
* Отдать роботу файл баннера.
*
* Отдаём ТОЛЬКО баннеры задания, номер которого робот прислал в адресе, и только пока
* это задание в работе. Иначе утёкший токен позволил бы перебором номеров вычерпать
* картинки всех клиентов.
*
* Раньше задание искалось как «какое-нибудь в работе». Пока в работе строго одно
* задание, результат совпадал, но защита держалась на внешнем условии, а не на самом
* запросе. Теперь «в работе не больше одного» обеспечивает частичный уникальный индекс
* `uq_creative_job_single_taken` в базе, а выдача файла ни на что постороннее не
* опирается она проверяет ровно то задание, о котором спросили.
*/
public function file(int $jobId, int $bannerId): StreamedResponse
{
$job = AdCreativeJob::where('id', $jobId)
->where('status', AdCreativeJob::STATUS_TAKEN)
->first();
abort_if($job === null, 404, 'Задание не в работе — файлы по нему не выдаются.');
$banner = AdCampaignBanner::where('id', $bannerId)
->where('campaign_id', $job->campaign_id)
->where('included', true)
->firstOrFail();
abort_unless(Storage::disk('local')->exists($banner->path), 404, 'Файл баннера не найден.');
// Расширение и тип — НАСТОЯЩИЕ, из самого файла. Клиенту разрешены jpg, png и gif,
// а отдавали мы всё под именем «.jpg»: робот сохранял PNG как «картинка.jpg» и таким
// же скармливал кабинету Яндекса. Кабинет либо отвергнет файл, либо примет с
// искажением — и разбираться придётся человеку по письму «не смог загрузить».
// Имя начинается с номера баннера: он уникален, размер — нет.
$ext = strtolower(pathinfo($banner->path, PATHINFO_EXTENSION)) ?: 'jpg';
$contentType = match ($ext) {
'png' => 'image/png',
'gif' => 'image/gif',
default => 'image/jpeg',
};
return Storage::disk('local')->download(
$banner->path,
"{$banner->id}-{$banner->width}x{$banner->height}.{$ext}",
['Content-Type' => $contentType],
);
}
/**
* Принять доклад разведки: что робот прочитал на экране кабинета про отклонённое
* объявление, и снимок этого экрана.
*
* 🔑 Отдельная ручка, а не `done`, потому что у разведки другой смысл слова «готово».
* У заливки готово = креативы в кабинете, портал идёт за слепком. У разведки готово =
* робот принёс ТЕКСТ, и без текста доклада не бывает. Причина отказа единственное,
* ради чего разведка затевалась: программный интерфейс Яндекса её не отдаёт вовсе.
*
* Текст ложится в ленту от имени `yandex` и слово в слово: портал ничего не толкует
* и не сокращает. Клиенту уходит письмо и колокольчик этим занимается сервис ленты.
*/
public function inspection(Request $request, int $jobId): JsonResponse
{
$data = $request->validate([
// Пустой доклад — это «ничего не выяснил», а не причина отказа. Такой доклад
// клиенту в ленту класть нельзя: он выглядит как ответ Яндекса, а им не является.
'report' => ['required', 'string', 'min:1', 'max:20000'],
'screenshot' => ['nullable', 'file', 'mimes:png,jpg,jpeg', 'max:5120'],
]);
if (trim($data['report']) === '') {
return response()->json([
'message' => 'Пустой доклад разведки не принимается.',
'errors' => ['report' => ['Пустой доклад разведки не принимается.']],
], 422);
}
$job = AdCreativeJob::findOrFail($jobId);
abort_if(
$job->status !== AdCreativeJob::STATUS_TAKEN,
409,
"Задание #{$job->id} не в работе (статус «{$job->status}») — доклад по нему не принимается."
);
// Разошлись в том, какую работу робот делал. Принять такой доклад — значит положить
// клиенту в ленту неизвестно что от имени Яндекса.
abort_if(
$job->kind !== AdCreativeJob::KIND_INSPECT,
409,
"Задание #{$job->id} — не разведка (вид «{$job->kind}»), доклад разведки по нему не принимается."
);
$campaign = AdCampaign::findOrFail($job->campaign_id);
// Баннер ищем связью ОТ КАМПАНИИ: номер объявления — чужой, из системы Яндекса,
// и брать по нему что-либо в обход кампании нельзя. Не нашёлся — доклад всё равно
// кладём, просто без привязки к размеру блока: причина важнее привязки.
$banner = $campaign->banners()
->where('yandex_ad_id', $job->yandex_ad_id)
->first();
$file = null;
$screenshot = $request->file('screenshot');
if ($screenshot instanceof UploadedFile) {
// Приватный диск: снимок экрана кабинета — чужая внутренняя кухня, наружу он
// уходит только через ручку портала с проверкой тенанта.
$file = [
'path' => $screenshot->store("ad-messages/{$campaign->tenant_id}/{$campaign->id}", 'local'),
'name' => mb_substr($screenshot->getClientOriginalName(), 0, 255),
'size' => (int) $screenshot->getSize(),
'mime' => (string) $screenshot->getMimeType(),
];
}
// 🔴 Именно postFromRobot, а НЕ postFromYandex: у второго стоит защита от дублей
// по последнему сообщению, и повторный отказ с той же формулировкой после починки
// она бы съела — клиент не узнал бы, что его опять не пустили. Уникальность
// разведки обеспечена на входе: одно задание на номер объявления.
app(CampaignMessageService::class)->postFromRobot(
$campaign,
$banner === null ? null : (int) $banner->id,
$data['report'],
$file,
);
$job->update(['status' => AdCreativeJob::STATUS_DONE, 'finished_at' => now()]);
return response()->json(['status' => AdCreativeJob::STATUS_DONE]);
}
/** Принять отчёт робота: готово или сбой. */
public function done(Request $request, int $jobId): JsonResponse
{
$data = $request->validate([
'ok' => ['required', 'boolean'],
'reason' => ['nullable', 'string', 'max:1024'],
]);
// Отчёт принимаем ТОЛЬКО по заданию, которое сейчас в работе. Номер задания робот
// присылает в адресе, и без этой проверки он брался как есть: «готово» по чужому
// ЕЩЁ НЕ выданному заданию разложило бы номера креативов чужой кампании по её
// баннерам (картинка одного клиента уехала бы в объявление другого), а «сбой» по
// уже закрытому заданию переписал бы правильный результат на failed.
$job = AdCreativeJob::findOrFail($jobId);
abort_if(
$job->status !== AdCreativeJob::STATUS_TAKEN,
409,
"Задание #{$job->id} не в работе (статус «{$job->status}») — отчёт по нему не принимается."
);
if ($data['ok'] === false) {
$this->jobs->fail($job, (string) ($data['reason'] ?? 'Робот не сообщил причину.'));
return response()->json(['status' => AdCreativeJob::STATUS_FAILED]);
}
try {
$this->jobs->complete($job);
} catch (CreativeMatchFailedException $e) {
// Задание уже помечено сбойным внутри complete(). Роботу отвечаем 200: свою
// работу он сделал, разошёлся слепок креативов — это наша сторона, не его.
return response()->json(['status' => AdCreativeJob::STATUS_FAILED, 'message' => $e->getMessage()]);
} catch (Throwable $e) {
// Приём отчёта ходит в живой Яндекс за слепком креативов. Любая другая беда
// (API недоступен, лимит, оборвалась сеть) раньше улетала наружу: робот получал
// 500, а задание НАВСЕГДА оставалось «в работе». А пока хоть одно задание в
// работе, выдача отвечает «работы нет» ВСЕМ — очередь встаёт колом для всех
// клиентов сразу. Поэтому закрываем задание сбойным: возобновляемый запуск
// поставит новое, и работа продолжится.
Log::error('Не смогли принять отчёт робота о креативах', [
'job_id' => $job->id,
'campaign_id' => $job->campaign_id,
'error' => $e->getMessage(),
]);
$fresh = $job->fresh();
if ($fresh !== null && $fresh->status === AdCreativeJob::STATUS_TAKEN) {
$this->jobs->fail($fresh, 'Портал не смог принять отчёт: '.$e->getMessage());
}
return response()->json(['status' => AdCreativeJob::STATUS_FAILED, 'message' => $e->getMessage()]);
}
return response()->json(['status' => AdCreativeJob::STATUS_DONE]);
}
}
@@ -28,13 +28,15 @@ class InvoiceController extends Controller
{
$validated = $request->validate([
'amount_rub' => ['required', 'numeric', 'min:100', 'max:1000000', 'decimal:0,2'],
'credit_target' => ['sometimes', 'in:leads,advertising'],
]);
/** @var User $user */
$user = $request->user();
$amountRub = bcadd((string) $validated['amount_rub'], '0', 2);
$creditTarget = $validated['credit_target'] ?? 'leads';
try {
$invoice = $this->invoices->create((int) $user->tenant_id, $amountRub, (int) $user->id);
$invoice = $this->invoices->create((int) $user->tenant_id, $amountRub, (int) $user->id, $creditTarget);
} catch (RequisitesIncompleteException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
@@ -63,6 +63,10 @@ class PaymentWebhookController extends Controller
// тот же путь, что и у команды billing:reconcile-payments.
$verify = $this->driver->verifyPayment($this->settlement->gatewayFor($tx), $paymentId);
// Рекламный кошелёк, Часть A: settle() маршрутизирует зачисление по
// saas_transactions.credit_target — 'leads' (умолчание) идёт в баланс за лиды
// (BillingTopupService) как раньше, 'advertising' — в ad_wallets (AdWalletService).
// Карта ЮKassa теперь зачисляет рекламный кошелёк наравне со счёт-фактурой (Б-1 go-live).
return response()->json(['status' => $this->settlement->settle($tx, $verify, $paymentId)], 200);
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Сервис-токен канала «Робот-грузчик креативов Портал» (/api/creative-robot/*).
*
* Проверяет заголовок X-Creative-Robot-Token против config('services.creative_robot.token').
* Это НЕ пользовательская аутентификация: робот ходит от своего имени, без tenant-контекста.
* Пустой токен в настройках канал закрыт (401), чтобы его нельзя было случайно открыть
* без секрета тот же порядок, что у SalesIntegrationToken.
*/
class CreativeRobotToken
{
public function handle(Request $request, Closure $next): Response
{
$expected = (string) config('services.creative_robot.token', '');
$given = (string) $request->header('X-Creative-Robot-Token', '');
if ($expected === '' || ! hash_equals($expected, $given)) {
abort(401, 'Неверный сервис-токен робота.');
}
return $next($request);
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\AdCampaign;
use App\Services\Advertising\AdStopAllService;
use App\Services\Advertising\AdWalletGate;
use App\Services\Advertising\CampaignImpressionCharger;
use App\Services\Advertising\YandexDirectClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Суточное списание расхода Директа по ПОКАЗАМ (плоская цена /1000, наценка
* вычитанием) со всех тенантов сразу.
*
* 🔴 На проде очередь бежит под ролью `crm_app_user` (НЕ BYPASSRLS) на дефолтном
* соединении, БЕЗ tenant-контекста. Перечисление кампаний через дефолтное
* соединение без контекста дало бы 0 строк по RLS (молчаливый сбой), а чтение
* кошелька внутри CampaignImpressionCharger/AdWalletGate/AdStopAllService вообще
* упало бы ModelNotFound. Поэтому:
* кампании перечисляем плоскими строками через `pgsql_supplier` (BYPASSRLS,
* аналог SyncSupplierProjectsJob/SendNewLeadsDigestJob), БЕЗ открытой
* транзакции не держим транзакцию во время сетевого запроса к Директу;
* денежная операция (charge через CampaignImpressionCharger/isSolvent/stopAll)
* под tenant-контекстом (`SET LOCAL app.current_tenant_id`) на ДЕФОЛТНОМ
* соединении, где живут AdCampaign/AdWallet/AdWalletTransaction; тогда
* crm_app_user с контекстом проходит RLS корректно. Денежные операции
* всегда принимают tenant_id явным аргументом никакого смешения
* кошельков между тенантами.
*
* Рубильник: пока services.yandex_direct.enabled=false джоб не делает ни
* одного обращения к Яндексу и ничего не списывает.
*
* Идемпотентность через CampaignImpressionCharger AdWalletService::charge
* по external_key "yandex-imp:{campaign->id}:{billable}" (billable число
* оплачиваемых показов на момент списания, счётчик сам считает дельту от
* уже списанного charged_client_rub).
*/
class ChargeCampaignSpendJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(): void
{
if (! config('services.yandex_direct.enabled')) {
return;
}
$charger = app(CampaignImpressionCharger::class);
$gate = app(AdWalletGate::class);
$stopAll = app(AdStopAllService::class);
$direct = new YandexDirectClient(
$this->configString('services.yandex_direct.base_url'),
$this->configString('services.yandex_direct.token'),
);
$rows = DB::connection('pgsql_supplier')->table('ad_campaigns')
->where('status', AdCampaign::STATUS_RUNNING)
->whereNotNull('yandex_campaign_id')
->get(['id', 'tenant_id', 'yandex_campaign_id']);
foreach ($rows as $row) {
try {
$delivered = $direct->getCampaignImpressions((int) $row->yandex_campaign_id);
$tenantId = (int) $row->tenant_id;
DB::transaction(function () use ($row, $delivered, $tenantId, $charger, $gate, $stopAll): void {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// Замок строки обязателен. Идемпотентность списания держится на ключе
// «yandex-imp:{кампания}:{показы}», а число показов приходит из отчёта
// Директа: два прогона, начавшихся одновременно (ручной запуск поверх
// расписания, повтор упавшей задачи), получат чуть разные числа — значит
// разные ключи, и уникальный индекс по ключу дубль уже не остановит.
// Клиента списали бы дважды. Под замком прогоны выстраиваются в очередь:
// второй увидит уже обновлённый charged_client_rub и спишет только дельту.
$campaign = AdCampaign::where('id', $row->id)->lockForUpdate()->firstOrFail();
$charger->charge($campaign, $delivered);
if (! $gate->isSolvent($tenantId)) {
$stopAll->stopAll($tenantId);
}
});
} catch (Throwable $e) {
Log::warning('ChargeCampaignSpendJob: сбой списания по кампании', [
'campaign_id' => $row->id,
'tenant_id' => $row->tenant_id,
'error' => $e->getMessage(),
]);
}
}
}
/** Достаёт строковое значение из config() без cast'а mixed (phpstan level=max). */
private function configString(string $key, string $default = ''): string
{
$value = config($key, $default);
return is_string($value) ? $value : $default;
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\AdCampaign;
use App\Services\Advertising\CampaignAudienceBuilder;
use App\Services\Sales\YandexAudienceClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Ночная replace-заливка аудитории КАЖДОЙ активной рекламной кампании клиента
* (не «фирм на прогреве» тот поток закрывает SyncAdAudienceJob) в её сегмент
* Яндекс.Аудиторий.
*
* Режим replace: отдаём полный текущий состав, повторный запуск безопасен.
*
* 🔴 На проде очередь бежит под ролью `crm_app_user` (НЕ BYPASSRLS) на дефолтном
* соединении, БЕЗ tenant-контекста без явного перечисления через BYPASSRLS-
* соединение RLS-политика (`tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint`)
* при пустом контексте даёт 0 строк, и джоб молча ничего не делает. Поэтому
* перечисляем кампании через `pgsql_supplier` (BYPASSRLS, аналог
* SyncSupplierProjectsJob/SendNewLeadsDigestJob); аудитория каждой кампании
* строится билдером в его СОБСТВЕННОМ tenant-контексте CampaignAudienceBuilder::build()
* сам ставит SET LOCAL на дефолтном соединении, этому джобу дополнительно
* фильтровать тенантов не нужно.
*
* Рубильник: services.yandex_direct.enabled пока выключен, джоб не делает
* ни одного обращения к Яндексу.
*
* Билдер резолвится ВНУТРИ handle() через app(), а не method-injection
* так handle() одинаково работает и при обычном диспатче через очередь,
* и при прямом вызове app(SyncCampaignAudienceJob::class)->handle() в тестах
* (см. разбор в задаче 9: параметры handle() не резолвятся при прямом вызове).
*/
class SyncCampaignAudienceJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(): void
{
if (! config('services.yandex_direct.enabled')) {
return;
}
$token = $this->configString('services.yandex_audience.token');
if ($token === '') {
return;
}
$builder = app(CampaignAudienceBuilder::class);
$client = new YandexAudienceClient($token);
$campaigns = AdCampaign::on('pgsql_supplier')->whereIn('status', [
AdCampaign::STATUS_RUNNING,
AdCampaign::STATUS_PENDING_MODERATION,
])->whereNotNull('yandex_segment_id')->get();
foreach ($campaigns as $campaign) {
$phones = $builder->build($campaign);
if ($phones === []) {
// Пустая замена — Яндекс её отвергает; тихо пропускаем эту кампанию.
continue;
}
try {
$client->modifyData((int) $campaign->yandex_segment_id, $phones, 'replace');
} catch (Throwable $e) {
// Сбой одной кампании не должен валить всю ночную заливку остальных.
// Телефоны (ПДн) в текст лога не попадают — только id кампании.
Log::warning('SyncCampaignAudienceJob: '.$e->getMessage(), ['campaign' => $campaign->id]);
}
}
}
/** Достаёт строковое значение из config() без cast'а mixed (phpstan level=max). */
private function configString(string $key, string $default = ''): string
{
$value = config($key, $default);
return is_string($value) ? $value : $default;
}
}
+218
View File
@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\AdCampaign;
use App\Models\AdCampaignBanner;
use App\Services\Advertising\AdWalletService;
use App\Services\Advertising\CampaignMessageService;
use App\Services\Advertising\CreativeJobService;
use App\Services\Advertising\ModerationReason;
use App\Services\Advertising\YandexDirectClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Опрос модерации объявлений Яндекс.Директа passthrough статусов (Р27):
* что сказал Яндекс про объявление, то и записываем себе, без своей трактовки.
*
* Единица модерации БАННЕР набора (ad_campaign_banners): у медийной кампании
* своё объявление на каждый размер блока, и статус модерации живёт на баннере.
*
* Агрегация на уровне кампании (спека §6): кампания РАБОТАЕТ, если принято хотя бы
* одно объявление отклонённые у Яндекса просто не показываются, остальные крутятся.
* В rejected (со снятием заморозки) уходим, только когда отклонены ВСЕ. Пока хоть одно
* объявление не получило финального вердикта ждём, статус кампании не трогаем.
*
* 🔴 На проде очередь бежит под ролью `crm_app_user` (НЕ BYPASSRLS) на дефолтном
* соединении, БЕЗ tenant-контекста без явного перечисления через BYPASSRLS-
* соединение RLS-политика при пустом контексте даёт 0 строк, и джоб молча ничего
* не делает. Перечисляем кампании через `pgsql_supplier` (BYPASSRLS, аналог
* SyncCampaignAudienceJob/ChargeCampaignSpendJob); модели загружены через это
* соединение их ->update() (баннер и кампания) идут по BYPASSRLS, пишем строго
* по загруженным строкам (без доп. tenant-фильтра).
*
* Рубильник: services.yandex_direct.enabled пока выключен, джоб не делает
* ни одного обращения к Яндексу.
*/
class SyncCampaignModerationJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(): void
{
if (! config('services.yandex_direct.enabled')) {
return;
}
$direct = new YandexDirectClient(
$this->configString('services.yandex_direct.base_url'),
$this->configString('services.yandex_direct.token'),
);
$campaigns = AdCampaign::on('pgsql_supplier')->whereIn('status', [
AdCampaign::STATUS_RUNNING,
AdCampaign::STATUS_PENDING_MODERATION,
])->whereNotNull('yandex_campaign_id')->with('banners')->get();
foreach ($campaigns as $campaign) {
$banners = $campaign->banners->filter(fn ($b) => $b->yandex_ad_id !== null);
if ($banners->isEmpty()) {
continue;
}
// Под защитой не только поход в сеть, но и запись ответа: беда на одной кампании
// не должна срывать обход остальных клиентов. Сорванный обход — это чужая реклама,
// про которую никто не узнал, что её приняли или отклонили, и не вернувшиеся деньги.
try {
$moderation = $direct->getAdsModeration(
$banners->pluck('yandex_ad_id')->map(fn ($v) => (int) $v)->all()
);
foreach ($banners as $banner) {
$info = $moderation[(int) $banner->yandex_ad_id] ?? null;
if ($info === null) {
continue;
}
// Яндекс прислал объявление, но без статуса. Пустой статус класть нельзя —
// колонка его не принимает; да и «неизвестно» это не вердикт. Оставляем
// прежний статус: баннер считается ещё не решённым и держит кампанию в ожидании.
$status = $info['status'] ?? null;
if (! is_string($status) || $status === '') {
continue;
}
// Причина отказа у Яндекса бывает длиннее нашей колонки (модератор перечисляет
// все претензии списком). Храним сколько влезает — клиенту важно начало,
// а потеря причины целиком хуже обрезанной.
//
// 🔴 А бывает и наоборот: на отказ Яндекс отдаёт машине только
// «\nОтклонено на модерации.» — ни слова о том, что не так (проверено
// живьём 28.07.2026, см. ModerationReason). Тогда вместо отписки клиент
// получает честное «причину выясняем», а настоящую принесёт разведка.
$raw = is_string($info['reason'] ?? null) ? $info['reason'] : null;
$reason = $status === AdCampaignBanner::MOD_REJECTED
? ModerationReason::forRejected($raw)
: ModerationReason::normalize($raw);
$banner->update([
'moderation_status' => $status,
'moderation_reason' => $reason === null ? null : mb_substr($reason, 0, 255),
]);
// Пояснение модератора кладём в ленту кампании ЦЕЛИКОМ: в колонке
// баннера оно обрезано до 255 знаков ради ярлыка, а клиенту нужен
// весь текст — именно по нему он поймёт, что переделывать.
//
// Отдельный try: лента — вещь второстепенная, а статус модерации нет.
// Беда с лентой не должна стоить клиенту незаписанного вердикта и
// невозвращённых денег.
if ($reason !== null) {
try {
$messages = app(CampaignMessageService::class);
// 🪤 Заглушку «Яндекс причину не назвал» показываем ОДИН раз —
// пока настоящей причины нет. После доклада разведки она уже
// не новость, а шаг назад: клиент, прочитавший настоящую
// причину, получил бы поверх неё «причину не назвали» и ещё
// одно письмо. Обычная защита от дублей тут не спасает — она
// смотрит на ПОСЛЕДНЕЕ сообщение, а последним лежит доклад робота.
$zaglushka = $reason === ModerationReason::PRICHINA_NEIZVESTNA;
if (! $zaglushka || ! $messages->hasFromYandex($campaign, (int) $banner->id)) {
$messages->postFromYandex($campaign, (int) $banner->id, $reason);
}
} catch (Throwable $e) {
Log::warning('Не смогли положить пояснение Яндекса в ленту: '.$e->getMessage(), [
'campaign' => $campaign->id, 'banner' => $banner->id,
]);
}
}
// Отклонили — посылаем робота посмотреть, ЗА ЧТО. Своими словами Яндекс
// машине этого не говорит: на отказ приходит «Отклонено на модерации.»
// и всё (проверено живьём 28.07.2026). Причина висит только на экране
// кабинета, и добыть её может лишь тот, у кого есть глаза.
//
// Отдельный try по той же причине, что и у ленты: очередь заданий — вещь
// второстепенная, а вердикт модерации и возврат денег нет. Беда с очередью
// не должна стоить клиенту незаписанного отказа и невернувшихся денег.
if ($status === AdCampaignBanner::MOD_REJECTED) {
try {
app(CreativeJobService::class)->enqueueInspection($campaign, $banner);
} catch (Throwable $e) {
Log::warning('Не смогли поставить роботу разведку по отказу: '.$e->getMessage(), [
'campaign' => $campaign->id, 'banner' => $banner->id,
]);
}
}
}
} catch (Throwable $e) {
// ПДн в лог не попадают — только id кампании.
Log::warning('SyncCampaignModerationJob: '.$e->getMessage(), ['campaign' => $campaign->id]);
continue;
}
// Перечитывать баннеры не нужно: ->update() уже положил новые значения
// в ту же модель в памяти. Баннер, про который Яндекс промолчал, остаётся
// со своим прежним статусом — то есть считается ещё не решённым и держит
// кампанию в ожидании (как и раньше при пропаже объявления в ответе).
$anyAccepted = $banners->contains(
fn ($b) => $b->moderation_status === AdCampaignBanner::MOD_ACCEPTED
);
$anyPending = $banners->contains(fn ($b) => ! in_array(
$b->moderation_status, [AdCampaignBanner::MOD_ACCEPTED, AdCampaignBanner::MOD_REJECTED], true
));
// Правило спеки §6: кампания работает, если принято ХОТЯ БЫ ОДНО объявление —
// отклонённые просто не показываются. В «отклонено» с разморозкой денег уходим,
// только когда отклонены ВСЕ. Так не появляется пятый выход кампании: существующие
// четыре выхода снятия заморозки остаются как есть.
if ($anyPending) {
continue; // ждём вердикта по остальным, статус кампании не трогаем
}
$newStatus = $anyAccepted ? AdCampaign::STATUS_RUNNING : AdCampaign::STATUS_REJECTED;
if ($newStatus === $campaign->status) {
continue;
}
$rejectedReason = $anyAccepted
? $campaign->moderation_reason
: $banners->pluck('moderation_reason')->first(fn ($r) => $r !== null && $r !== '');
$campaign->update([
'status' => $newStatus,
'moderation_reason' => $rejectedReason,
]);
// ВЫХОД 2 — Яндекс отклонил ВЕСЬ набор объявлений. Показов не было, деньги
// не тратились: заморозку возвращаем клиенту целиком, кампания мертва.
if ($newStatus === AdCampaign::STATUS_REJECTED) {
app(AdWalletService::class)->release(
(int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id,
);
}
}
}
/** Достаёт строковое значение из config() без cast'а mixed (phpstan level=max). */
private function configString(string $key, string $default = ''): string
{
$value = config($key, $default);
return is_string($value) ? $value : $default;
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Events\AdvertisingStopped;
use App\Models\AdCampaign;
use App\Services\Advertising\AdWalletService;
use App\Services\Advertising\YandexDirectClient;
use Illuminate\Support\Facades\Log;
use Throwable;
final class PauseCampaignsOnAdStop
{
public function __construct(private readonly AdWalletService $wallet) {}
public function handle(AdvertisingStopped $event): void
{
// ЯВНЫЙ tenant-фильтр: слушатель бежит вне веб-запроса (очередь, BYPASSRLS).
$campaigns = AdCampaign::query()
->where('tenant_id', $event->tenantId)
->whereIn('status', [AdCampaign::STATUS_RUNNING, AdCampaign::STATUS_PENDING_MODERATION])
->get();
if ($campaigns->isEmpty()) {
return;
}
$enabled = (bool) config('services.yandex_direct.enabled');
$direct = $enabled
? new YandexDirectClient((string) config('services.yandex_direct.base_url'), (string) config('services.yandex_direct.token'))
: null;
foreach ($campaigns as $campaign) {
if ($direct !== null && $campaign->yandex_campaign_id !== null) {
try {
$direct->suspendCampaign((int) $campaign->yandex_campaign_id);
} catch (Throwable $e) {
Log::warning('PauseCampaignsOnAdStop suspend failed: '.$e->getMessage(), ['campaign' => $campaign->id]);
}
}
$campaign->update(['status' => AdCampaign::STATUS_STOPPED_NO_FUNDS]);
// ВЫХОД 3 — реклама заглушена из-за нехватки денег. Держать заморозку на
// мёртвой кампании нельзя: она занижает свободный остаток и не даёт клиенту
// распорядиться пополнением (в т.ч. перезапустить рекламу).
$this->wallet->release((int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id);
}
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Письмо ВЛАДЕЛЬЦУ: клиент приложил документ к рекламной кампании.
*
* 🔴 Зачем оно вообще существует. Замысел предполагал, что документ отвезёт робот прямо
* в кабинет Яндекса. Такой дороги нет проверено двумя нарочными отказами 28.07.2026:
* в окне отказа ноль полей для файла, документы Яндекс принимает только снаружи кабинета
* (чат поддержки, форма обратной связи). Значит документ обязан попасть к живому человеку,
* иначе он просто ляжет на диск и о нём никто не узнает а клиенту мы в этот момент
* пишем «разберёмся вручную».
*
* Сам файл письмом НЕ отправляем: это чужие бумаги (лицензии, свидетельства), и рассылать
* их почтой незачем в письме только адрес кампании в портале.
*/
final class AdDocumentAttachedMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly string $campaignName,
public readonly int $campaignId,
public readonly int $tenantId,
public readonly string $fileName,
public readonly string $comment,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Клиент приложил документ по рекламной кампании «'.$this->campaignName.'»',
);
}
public function content(): Content
{
return new Content(
view: 'mail.ad-document-attached',
with: [
'campaignName' => $this->campaignName,
'campaignId' => $this->campaignId,
'tenantId' => $this->tenantId,
'fileName' => $this->fileName,
'comment' => $this->comment,
],
);
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Письмо клиенту: по его рекламной кампании пришёл ответ Яндекса.
* Получателей ставит вызывающий код через Mail::to($email)->queue(new ...).
*/
final class AdModerationMessageMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly string $campaignName,
public readonly int $campaignId,
public readonly string $body,
) {}
public function envelope(): Envelope
{
return new Envelope(subject: 'Ответ Яндекса по рекламной кампании «'.$this->campaignName.'»');
}
public function content(): Content
{
return new Content(
view: 'mail.ad-moderation-message',
with: [
'campaignName' => $this->campaignName,
'campaignId' => $this->campaignId,
'body' => $this->body,
],
);
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Tenant;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* Email владельцу тенанта: реклама остановлена целиком на рекламном
* кошельке не хватает денег покрыть заморозки (Task 9, часть A).
*
* Триггер: AdStopAllService::stopAll() после события AdvertisingStopped.
*
* 🔴 БЕЗ SerializesModels НЕ возвращать: см. предупреждение в BalanceFrozenMail /
* ZeroBalancePausedMail (RLS lazy-reload воркером роняет письмо целиком).
*/
final class AdWalletLowBalanceNotification extends Mailable
{
use Queueable;
public function __construct(
public readonly Tenant $tenant,
public readonly string $balanceRub,
public readonly string $frozenRub,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Реклама остановлена — пополните рекламный кошелёк',
to: [$this->tenant->contact_email],
);
}
public function content(): Content
{
return new Content(view: 'emails.ad_wallet_low_balance');
}
}
+192
View File
@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB;
/**
* Рекламная кампания клиента в Яндекс.Директе (по своей аудитории телефонов).
*
* Tenant-aware с RLS.
*
* Источник: db/schema.sql, table `ad_campaigns`.
*/
class AdCampaign extends Model
{
use HasFactory;
public const STATUS_DRAFT = 'draft';
public const STATUS_PENDING_MODERATION = 'pending_moderation';
public const STATUS_RUNNING = 'running';
public const STATUS_PAUSED = 'paused';
public const STATUS_REJECTED = 'rejected';
public const STATUS_STOPPED_NO_FUNDS = 'stopped_no_funds';
public const STATUS_QUEUED = 'queued';
public const STATUS_COMPLETED = 'completed';
/**
* Промежуточный статус «запуск идёт прямо сейчас»: кампания захвачена лаунчером.
*
* Держится считанные секунды и нужен ровно для одного не дать двум одновременным
* нажатиям «запустить» завести в кабинете Яндекса две одинаковые кампании (заморозка
* денег при этом была бы одна, а крутились бы обе). Любой исход запуска статус снимает:
* успех переводит в `pending_moderation`, ошибка возвращает прежний.
*/
public const STATUS_LAUNCHING = 'launching';
public const MODE_AUTO = 'auto';
public const MODE_MANUAL = 'manual';
/** yandex_cost_rub наш расход у Яндекса (основа маржи), клиенту НИКОГДА не показываем.
* Только атрибут-доступ (админка строит явные массивы), сериализация скрыта (Ч.5b, спека §3). */
protected $hidden = ['yandex_cost_rub'];
protected $attributes = [
'status' => self::STATUS_DRAFT,
'mode' => self::MODE_AUTO,
'delivered_impressions' => 0,
];
protected $fillable = [
'tenant_id',
'channel',
'name',
'mode',
'status',
'audience_days',
'snapshot_from',
'snapshot_to',
'run_days',
'shows_until',
'use_uploaded_list',
'frequency',
'frequency_period_days',
'estimated_impressions',
'paid_impressions',
'delivered_impressions',
'budget_rub',
'client_cpm_rub',
'landing_url',
'yandex_cost_rub',
'charged_client_rub',
'banners_approved_at',
'daily_budget_rub',
'yandex_segment_id',
'yandex_retargeting_list_id',
'yandex_campaign_id',
'yandex_ad_group_id',
'yandex_creative_id',
'yandex_ad_id',
'moderation_reason',
'launched_at',
'revived_at',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
'mode' => 'string',
'audience_days' => 'integer',
'snapshot_from' => 'date:Y-m-d',
'snapshot_to' => 'date:Y-m-d',
'run_days' => 'integer',
'shows_until' => 'date:Y-m-d',
'use_uploaded_list' => 'boolean',
'frequency' => 'integer',
'frequency_period_days' => 'integer',
'estimated_impressions' => 'integer',
'paid_impressions' => 'integer',
'delivered_impressions' => 'integer',
'budget_rub' => 'decimal:2',
'client_cpm_rub' => 'decimal:2',
'landing_url' => 'string',
'yandex_cost_rub' => 'decimal:2',
'charged_client_rub' => 'decimal:2',
'banners_approved_at' => 'datetime',
'daily_budget_rub' => 'decimal:2',
'yandex_segment_id' => 'integer',
'yandex_retargeting_list_id' => 'integer',
'yandex_campaign_id' => 'integer',
'yandex_ad_group_id' => 'integer',
'yandex_creative_id' => 'integer',
'yandex_ad_id' => 'integer',
'launched_at' => 'datetime',
// Когда отклонённую кампанию вернули клиенту на починку. Держит открытым
// исключение из замка правки до следующего запуска.
'revived_at' => 'datetime',
];
}
/**
* Клиентская цена за 1000 показов (CPM) для этой кампании: своя, если задана,
* иначе глобальный дефолт из ad_settings.client_cpm_rub (сам дефолт наценки клиенту не виден).
*/
public function effectiveCpm(): string
{
// Деньги — bcmath, без float: приводим к строке scale=2 (client_cpm_rub уже decimal:2).
if ($this->client_cpm_rub !== null) {
return bcadd((string) $this->client_cpm_rub, '0', 2);
}
$default = DB::table('ad_settings')->value('client_cpm_rub');
return $default !== null ? bcadd((string) $default, '0', 2) : '120.00';
}
/** @return HasMany<AdCampaignAd, $this> */
public function ads(): HasMany
{
return $this->hasMany(AdCampaignAd::class, 'campaign_id');
}
/** Набор баннеров кампании: строка = «размер + файл + креатив + объявление».
*
* @return HasMany<AdCampaignBanner, $this>
*/
public function banners(): HasMany
{
return $this->hasMany(AdCampaignBanner::class, 'campaign_id');
}
/** @return HasMany<AdCampaignPhone, $this> */
public function phones(): HasMany
{
return $this->hasMany(AdCampaignPhone::class, 'campaign_id');
}
/**
* Лента переписки по кампании слова Яндекса, ответы клиента, служебные отметки.
*
* 🔴 Это **единственный допустимый путь** к сообщению, когда по нему принимается
* решение: искать сообщение сырым номером нельзя. Служебный канал робота ходит под
* ролью с кросс-тенантным доступом, где RLS не отфильтрует, и поиск по номеру отдал бы
* чужой документ. Связь привязывает выборку к кампании, а кампания к её владельцу.
*
* @return HasMany<AdCampaignMessage, $this>
*/
public function messages(): HasMany
{
return $this->hasMany(AdCampaignMessage::class, 'campaign_id');
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Объявление рекламной кампании (Директ TextAd) заголовок/текст/ссылка/картинка + статус модерации.
*
* Tenant-aware с RLS.
*
* Источник: db/schema.sql, table `ad_campaign_ads`.
*/
class AdCampaignAd extends Model
{
use HasFactory;
public const MOD_DRAFT = 'draft';
public const MOD_MODERATION = 'MODERATION';
public const MOD_ACCEPTED = 'ACCEPTED';
public const MOD_REJECTED = 'REJECTED';
protected $fillable = [
'tenant_id',
'campaign_id',
'title',
'title2',
'text',
'href',
'image_normal_hash',
'image_wide_hash',
'yandex_ad_id',
'moderation_status',
'moderation_reason',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
'campaign_id' => 'integer',
'yandex_ad_id' => 'integer',
];
}
/** @return BelongsTo<AdCampaign, $this> */
public function campaign(): BelongsTo
{
return $this->belongsTo(AdCampaign::class, 'campaign_id');
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Один сгенерированный баннер набора кампании (точный размер блока Яндекса), файл на
* приватном диске 'local'. Tenant-aware с RLS. Источник: db/schema.sql, ad_campaign_banners.
*/
class AdCampaignBanner extends Model
{
public const MOD_DRAFT = 'draft';
public const MOD_MODERATION = 'MODERATION';
public const MOD_ACCEPTED = 'ACCEPTED';
public const MOD_REJECTED = 'REJECTED';
protected $fillable = [
'tenant_id', 'campaign_id', 'width', 'height', 'path', 'bytes', 'included',
'yandex_creative_id', 'yandex_ad_id', 'moderation_status', 'moderation_reason',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
'campaign_id' => 'integer',
'width' => 'integer',
'height' => 'integer',
'bytes' => 'integer',
'included' => 'boolean',
'yandex_creative_id' => 'integer',
'yandex_ad_id' => 'integer',
];
}
/** @return BelongsTo<AdCampaign, $this> */
public function campaign(): BelongsTo
{
return $this->belongsTo(AdCampaign::class, 'campaign_id');
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Одно сообщение ленты по рекламной кампании. Tenant-aware с RLS.
*
* Автор `yandex` слова Яндекса, откуда бы мы их ни взяли: из ответа `ads.get` или
* увиденные роботом в кабинете. Мы их не трактуем и не сокращаем.
*/
class AdCampaignMessage extends Model
{
public const AUTHOR_YANDEX = 'yandex';
public const AUTHOR_CLIENT = 'client';
public const AUTHOR_SYSTEM = 'system';
protected $fillable = [
'tenant_id', 'campaign_id', 'banner_id', 'author', 'body',
'file_path', 'file_name', 'file_size', 'file_mime',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
'campaign_id' => 'integer',
'banner_id' => 'integer',
'file_size' => 'integer',
];
}
/** @return BelongsTo<AdCampaign, $this> */
public function campaign(): BelongsTo
{
return $this->belongsTo(AdCampaign::class, 'campaign_id');
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Свой загруженный список телефонов клиента для кампании (со сроком действия).
*
* Tenant-aware с RLS.
*
* Источник: db/schema.sql, table `ad_campaign_phones`.
*/
class AdCampaignPhone extends Model
{
use HasFactory;
protected $fillable = [
'tenant_id',
'campaign_id',
'phone',
'expires_at',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
'campaign_id' => 'integer',
'expires_at' => 'datetime',
];
}
/** @return BelongsTo<AdCampaign, $this> */
public function campaign(): BelongsTo
{
return $this->belongsTo(AdCampaign::class, 'campaign_id');
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Задание роботу-грузчику: отнести набор баннеров кампании в веб-кабинет Яндекса.
*
* Tenant-aware с RLS. Источник: db/schema.sql, table `ad_creative_jobs`.
*/
class AdCreativeJob extends Model
{
public const STATUS_QUEUED = 'queued';
public const STATUS_TAKEN = 'taken';
public const STATUS_DONE = 'done';
public const STATUS_FAILED = 'failed';
/** Отвезти картинки в кабинет — то, что робот умел с самого начала. */
public const KIND_UPLOAD = 'upload';
/** Сходить посмотреть, что кабинет говорит про объявление. Ничего не меняет. */
public const KIND_INSPECT = 'inspect';
/** Отвезти документ клиента и отправить объявление на повторную модерацию. */
public const KIND_DELIVER = 'deliver';
protected $fillable = [
'tenant_id', 'campaign_id', 'status', 'attempts',
'snapshot_before', 'failure_reason', 'taken_at', 'finished_at',
'kind', 'message_id', 'yandex_ad_id',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
'campaign_id' => 'integer',
'message_id' => 'integer',
'yandex_ad_id' => 'integer',
'attempts' => 'integer',
'snapshot_before' => 'array',
'taken_at' => 'datetime',
'finished_at' => 'datetime',
];
}
/** @return BelongsTo<AdCampaign, $this> */
public function campaign(): BelongsTo
{
return $this->belongsTo(AdCampaign::class, 'campaign_id');
}
/**
* Сообщение ленты, документ из которого везёт задание вида `deliver`.
*
* @return BelongsTo<AdCampaignMessage, $this>
*/
public function message(): BelongsTo
{
return $this->belongsTo(AdCampaignMessage::class, 'message_id');
}
}
+2 -1
View File
@@ -29,6 +29,7 @@ use Illuminate\Support\Carbon;
* @property string|null $vat_amount
* @property string $amount_total
* @property string|null $payment_purpose
* @property string $credit_target
* @property int|null $transaction_id
* @property string|null $pdf_path
* @property string $status
@@ -56,7 +57,7 @@ class SaasInvoice extends Model
protected $fillable = [
'tenant_id', 'legal_entity_id', 'invoice_number',
'payer_type', 'payer_name', 'payer_inn', 'payer_kpp', 'payer_address', 'payer_email',
'amount_net', 'vat_rate', 'vat_amount', 'amount_total', 'payment_purpose',
'amount_net', 'vat_rate', 'vat_amount', 'amount_total', 'payment_purpose', 'credit_target',
'transaction_id', 'pdf_path', 'status',
'issued_at', 'expires_at', 'paid_at', 'cancelled_at',
];
+7 -1
View File
@@ -27,11 +27,17 @@ class SaasTransaction extends Model
public const TYPE_TOPUP = 'topup';
/** Куда зачисляется онлайн-оплата: 'leads' (баланс за лиды, умолчание) | 'advertising' (рекламный кошелёк). */
public const CREDIT_TARGET_LEADS = 'leads';
public const CREDIT_TARGET_ADVERTISING = 'advertising';
protected $fillable = [
'tenant_id', 'type', 'amount_rub', 'balance_rub_after', 'leads_credited',
'gateway_id', 'gateway_code', 'gateway_payment_id', 'gateway_idempotence_key',
'payment_method', 'legal_entity_id', 'invoice_id', 'upd_id', 'status',
'description', 'failure_reason', 'balance_transaction_id', 'created_at', 'completed_at',
'description', 'failure_reason', 'balance_transaction_id', 'credit_target',
'created_at', 'completed_at',
];
protected function casts(): array
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Events\AdvertisingStopped;
use App\Listeners\PauseCampaignsOnAdStop;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
final class AdvertisingEventServiceProvider extends ServiceProvider
{
public function boot(): void
{
Event::listen(AdvertisingStopped::class, PauseCampaignsOnAdStop::class);
}
}
+41
View File
@@ -5,6 +5,7 @@ namespace App\Providers;
use App\Models\Deal;
use App\Models\ImpersonationToken;
use App\Models\PersonalAccessToken;
use App\Models\SalesUser;
use App\Models\User;
use App\Observers\DealTelegramObserver;
use App\Services\Billing\Gateway\PaymentGatewayDriver;
@@ -257,5 +258,45 @@ class AppServiceProvider extends ServiceProvider
->orderBy('id')
->first();
});
// Инцидент 24.07.2026: гвард портала отдела продаж — ТОЛЬКО по Bearer-токену.
// Штатный Sanctum-гвард перед проверкой токена смотрит config('sanctum.guard')
// = ['web'] и, если на stateful-домене lk.liderra.ru в том же браузере открыт
// обычный кабинет (активная web-сессия), возвращает App\Models\User вместо
// SalesUser — даже не заглянув в токен. Это ломало ВЕСЬ портал (500 на всех
// маршрутах, роль слетала в «менеджера»). Свой резолвер в сессию не смотрит:
// сразу проверяет токен и пускает только SalesUser. Проверки повторяют
// Laravel\Sanctum\Guard (срок жизни, expires_at, отметка last_used_at).
// Чтение personal_access_tokens — на дефолтном соединении, которое middleware
// 'admin-db' (crm_admin_user) уже переключил ДО auth:sales — как и раньше.
Auth::viaRequest('sales-token', function (Request $request) {
$bearer = $request->bearerToken();
if ($bearer === null || $bearer === '') {
return null;
}
$accessToken = PersonalAccessToken::findToken($bearer);
if ($accessToken === null) {
return null;
}
$tokenable = $accessToken->tokenable;
if (! $tokenable instanceof SalesUser) {
return null; // токен не портала продаж — этому гварду не подходит
}
$expiration = config('sanctum.expiration');
if (is_int($expiration) && $accessToken->created_at !== null
&& $accessToken->created_at->lte(now()->subMinutes($expiration))) {
return null;
}
if ($accessToken->expires_at !== null && $accessToken->expires_at->isPast()) {
return null;
}
$accessToken->forceFill(['last_used_at' => now()])->save();
return $tokenable->withAccessToken($accessToken);
});
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
/**
* Расчёт денег для медийной рекламы «за показы». Чистый, без БД и без Яндекса.
*
* MONEY: только bcmath (scale 2). Клиентская цена ПЛОСКАЯ ( за 1000 показов),
* наценка клиенту НЕ показывается. Округление клиентской суммы ВВЕРХ до копейки.
*/
final class AdImpressionPricing
{
/** Оценка показов: размер аудитории × частота на человека. */
public function impressionsForAudience(int $audienceCount, int $frequency): int
{
return max(0, $audienceCount) * max(0, $frequency);
}
/** Клиентская сумма (₽) за показы по плоской цене cpmRub за 1000, округление ВВЕРХ до копейки. */
public function clientCostRub(int $impressions, string $cpmRub): string
{
if ($impressions <= 0) {
return '0.00';
}
$raw = bcdiv(bcmul((string) $impressions, $cpmRub, 6), '1000', 6);
return $this->roundUpToKopeck($raw);
}
/** Маржа (₽) = списано клиенту − расход Яндекса. */
public function marginRub(string $chargedClientRub, string $yandexCostRub): string
{
return bcsub($chargedClientRub, $yandexCostRub, 2);
}
/** Округление денежной строки ВВЕРХ до 2 знаков (копейки). */
private function roundUpToKopeck(string $value): string
{
$kopecks = bcmul($value, '100', 6);
$floor = bcdiv($kopecks, '1', 0);
if (bccomp($kopecks, $floor, 6) > 0) {
$floor = bcadd($floor, '1', 0);
}
return bcdiv($floor, '100', 2);
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
use App\Events\AdvertisingStopped;
use App\Mail\AdWalletLowBalanceNotification;
use App\Models\AdWallet;
use App\Models\Tenant;
use Illuminate\Support\Facades\Mail;
/**
* Стоп всей рекламы тенанта при нехватке денег на рекламном кошельке.
*
* Каналы (Часть B) слушают AdvertisingStopped и ставят свои кампании
* на паузу. Оповещение владельца письмом Task 9, часть A.
*/
final class AdStopAllService
{
public function stopAll(int $tenantId): void
{
event(new AdvertisingStopped($tenantId));
$tenant = Tenant::find($tenantId);
if ($tenant === null || $tenant->contact_email === '') {
return;
}
$wallet = AdWallet::where('tenant_id', $tenantId)->first();
Mail::queue(new AdWalletLowBalanceNotification(
$tenant,
(string) ($wallet->balance_rub ?? '0.00'),
(string) ($wallet->frozen_rub ?? '0.00'),
));
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
use App\Models\AdWallet;
/**
* Проверка платёжеспособности тенанта по рекламному кошельку.
*
* Платёжеспособен, если баланс покрывает все активные заморозки
* (balance_rub frozen_rub). Отсутствие кошелька считается
* платёжеспособным (реклама ещё не запускалась).
*/
final class AdWalletGate
{
public function isSolvent(int $tenantId): bool
{
$wallet = AdWallet::where('tenant_id', $tenantId)->first();
if ($wallet === null) {
return true;
}
return bccomp((string) $wallet->balance_rub, (string) $wallet->frozen_rub, 2) >= 0;
}
}
@@ -16,13 +16,36 @@ use Illuminate\Support\Facades\DB;
* MONEY-код: только bcmath (scale 2), атомарно в DB::transaction, замок по
* строке кошелька БЕРЁТСЯ ДО чтения баланса порядок зеркалит
* App\Services\Sms\SmsChargeService.
*
* 🔴 Каждый метод ставит СВОЙ tenant-контекст первым делом в транзакции.
* Защита по клиентам на `ad_wallet*` устроена СТРОГО:
* `USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)`
* без контекста сравнение с NULL, и база молча отдаёт ноль строк. В вебе контекст
* ставит middleware, но из очереди (SyncCampaignModerationJob) его нет, и `release()`
* тихо не возвращал клиенту заморозку: ни ошибки, ни строки в журнале. Тесты ходят
* суперюзером и увидеть это не могут сторож `AdWalletUnderRealRoleTest` гоняет
* деньги под боевой ролью.
*
* Полагаться на контекст вызывающего тут нельзя: `$tenantId` приходит явным доводом,
* значит и контекст забота этого сервиса, а не каждого, кто его позовёт.
*/
final class AdWalletService
{
/**
* Контекст клиента для защиты по строкам. SET LOCAL живёт до конца транзакции
* и PgBouncer-safe. Значение int из сигнатуры метода, подстановка безопасна.
*/
private function tenantContext(int $tenantId): void
{
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
}
/** Пополнение рекламного кошелька. Атомарно: замок по кошельку → баланс → транзакция. */
public function topup(int $tenantId, string $amountRub, ?string $channel, string $description): void
{
DB::transaction(function () use ($tenantId, $amountRub, $channel, $description): void {
$this->tenantContext($tenantId);
$wallet = AdWallet::where('tenant_id', $tenantId)->lockForUpdate()->firstOrCreate(
['tenant_id' => $tenantId],
['balance_rub' => '0.00', 'frozen_rub' => '0.00'],
@@ -51,6 +74,8 @@ final class AdWalletService
public function freeze(int $tenantId, string $channel, string $sourceType, int $sourceId, string $amountRub): void
{
DB::transaction(function () use ($tenantId, $channel, $sourceType, $sourceId, $amountRub): void {
$this->tenantContext($tenantId);
$wallet = AdWallet::where('tenant_id', $tenantId)->lockForUpdate()->firstOrFail();
$already = AdWalletHold::where('tenant_id', $tenantId)
@@ -71,11 +96,11 @@ final class AdWalletService
$newFrozen = bcadd((string) $wallet->frozen_rub, $amountRub, 2);
DB::table('ad_wallets')->where('id', $wallet->id)->update(['frozen_rub' => $newFrozen, 'updated_at' => now()]);
// Уникальный ключ hold — (tenant, channel, source_type, source_id) БЕЗ статуса,
// а release() лишь метит строку 'released'. Активной брони тут уже нет (выше
// ранний return), значит существующая строка — released: реактивируем её
// (updateOrCreate по 4-ключу), иначе create() упал бы на дубле ключа (23505)
// при пересдаче кампании / повторной заявке на имя.
// Уникальный ключ брони — (tenant, channel, source_type, source_id) БЕЗ статуса,
// а release() строку не удаляет, а метит 'released'. Активной брони тут уже нет
// (выше ранний return), значит существующая строка — released: оживляем её.
// Через create() второй заход упал бы на дубле ключа (23505) при паузе→возобновлении
// и при пересдаче отклонённой кампании.
AdWalletHold::updateOrCreate(
[
'tenant_id' => $tenantId, 'channel' => $channel,
@@ -97,7 +122,15 @@ final class AdWalletService
public function release(int $tenantId, string $channel, string $sourceType, int $sourceId): void
{
DB::transaction(function () use ($tenantId, $channel, $sourceType, $sourceId): void {
$wallet = AdWallet::where('tenant_id', $tenantId)->lockForUpdate()->firstOrFail();
$this->tenantContext($tenantId);
// Снятие заморозки — идемпотентная уборка на выходах кампании (завершена /
// отклонена / остановлена без средств / поставлена на паузу). Кошелька или
// активного холда может не быть вовсе (заморозки не было) — это не ошибка.
$wallet = AdWallet::where('tenant_id', $tenantId)->lockForUpdate()->first();
if ($wallet === null) {
return;
}
$hold = AdWalletHold::where('tenant_id', $tenantId)
->where('channel', $channel)->where('source_type', $sourceType)
->where('source_id', $sourceId)->where('status', AdWalletHold::STATUS_ACTIVE)->lockForUpdate()->first();
@@ -121,6 +154,8 @@ final class AdWalletService
public function charge(int $tenantId, string $channel, string $relatedType, int $relatedId, string $amountRub, string $externalKey): void
{
DB::transaction(function () use ($tenantId, $channel, $relatedType, $relatedId, $amountRub, $externalKey): void {
$this->tenantContext($tenantId);
$wallet = AdWallet::where('tenant_id', $tenantId)->lockForUpdate()->firstOrFail();
$already = AdWalletTransaction::where('tenant_id', $tenantId)
@@ -133,7 +168,42 @@ final class AdWalletService
if (bccomp($newBalance, '0', 2) < 0) {
$newBalance = '0.00'; // не уходим в минус; недобор ловит AdStopAll (Task 8)
}
DB::table('ad_wallets')->where('id', $wallet->id)->update(['balance_rub' => $newBalance, 'updated_at' => now()]);
$walletUpdate = ['balance_rub' => $newBalance, 'updated_at' => now()];
// Заморозка ТАЕТ вместе со списанием: списанные деньги ушли с баланса и
// больше не зарезервированы. Без этого одни и те же рубли считались бы
// дважды — свободный остаток (balance − frozen) уходил бы в минус, и
// AdWalletGate::isSolvent() объявил бы клиента неплатёжеспособным сразу
// после первого суточного списания (→ AdStopAll глушит все кампании).
$hold = AdWalletHold::where('tenant_id', $tenantId)
->where('channel', $channel)->where('source_type', $relatedType)
->where('source_id', $relatedId)->where('status', AdWalletHold::STATUS_ACTIVE)
->lockForUpdate()->first();
if ($hold !== null) {
// Больше, чем зарезервировано, не размораживаем (потолок сметы уже
// держит CampaignImpressionCharger, но арифметику страхуем здесь).
$melt = bccomp($amountRub, (string) $hold->amount_rub, 2) > 0
? (string) $hold->amount_rub
: $amountRub;
$newFrozen = bcsub((string) $wallet->frozen_rub, $melt, 2);
if (bccomp($newFrozen, '0', 2) < 0) {
$newFrozen = '0.00';
}
$walletUpdate['frozen_rub'] = $newFrozen;
$holdRest = bcsub((string) $hold->amount_rub, $melt, 2);
AdWalletHold::where('id', $hold->id)->update([
'amount_rub' => $holdRest,
'status' => bccomp($holdRest, '0', 2) === 0
? AdWalletHold::STATUS_RELEASED
: AdWalletHold::STATUS_ACTIVE,
'updated_at' => now(),
]);
}
DB::table('ad_wallets')->where('id', $wallet->id)->update($walletUpdate);
AdWalletTransaction::create([
'tenant_id' => $tenantId, 'type' => AdWalletTransaction::TYPE_CHARGE,
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
use RuntimeException;
/**
* Генерация баннера точного размера блока Яндекса из одной картинки клиента.
* «Cover»: масштаб по большей стороне + центр-обрезка (как ручной gen_banners.py на Pillow),
* затем JPEG с подбором качества под вес maxBytes. Только GD (Intervention/Imagick нет).
*/
final class BannerGenerator
{
/** @return string бинарь JPEG точного размера $width×$height, вес ≤ $maxBytes. */
public function coverJpeg(string $sourceBinary, int $width, int $height, ?int $maxBytes = null): string
{
// Предел один на весь модуль — потолок Яндекса. Своё число тут уже расходилось бы
// с тем, по которому портал проверяет загрузку клиента.
$maxBytes ??= BannerUploadPolicy::MAX_BYTES;
$src = @imagecreatefromstring($sourceBinary);
if ($src === false) {
throw new RuntimeException('Не удалось прочитать изображение (ожидались JPG/PNG/GIF).');
}
$srcW = imagesx($src);
$srcH = imagesy($src);
// «Cover»: область исходника той же пропорции, что цель, максимально возможная, по центру.
$scale = max($width / $srcW, $height / $srcH);
$cropW = (int) round($width / $scale);
$cropH = (int) round($height / $scale);
$cropW = min($cropW, $srcW);
$cropH = min($cropH, $srcH);
$srcX = (int) max(0, intdiv($srcW - $cropW, 2));
$srcY = (int) max(0, intdiv($srcH - $cropH, 2));
$dst = imagecreatetruecolor($width, $height);
imagecopyresampled($dst, $src, 0, 0, $srcX, $srcY, $width, $height, $cropW, $cropH);
$quality = 88;
do {
ob_start();
imagejpeg($dst, null, $quality);
$bytes = (string) ob_get_clean();
$quality -= 6;
} while (strlen($bytes) > $maxBytes && $quality >= 40);
imagedestroy($src);
imagedestroy($dst);
// Качество упёрлось в пол, а вес всё равно больше предела. Раньше такой файл уходил
// наружу молча — кабинет Яндекса его не примет, и узнали бы мы об этом уже роботом,
// стоящим перед окном загрузки. Отказ вслух лучше заведомо негодного файла.
if (strlen($bytes) > $maxBytes) {
throw new RuntimeException(
"Картинка не ужимается до {$maxBytes} байт для размера {$width}×{$height} — нужна другая."
);
}
return $bytes;
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
/**
* Канонические базовые размеры блоков медийной кампании Яндекса («Баннеры»), 1×.
* Источник: HANDOFF reklama-progrev-mediynaya §5.2. Яндекс принимает базовый размер
* либо его 2×/3× здесь базовый (1×), для авто-подгонки из одной картинки клиента.
*
* @see BannerGenerator генерирует JPEG точного размера из одной исходной картинки.
*/
final class BannerSizes
{
/** @var list<array{0:int,1:int}> */
public const SIZES = [
[160, 600], [240, 400], [240, 600], [300, 250], [300, 300],
[300, 500], [300, 600], [320, 480], [336, 280], [480, 320],
[728, 90], [970, 250], [320, 50], [320, 100], [1000, 120],
];
/** @return list<array{0:int,1:int}> */
public static function all(): array
{
return self::SIZES;
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
final class BannerUploadPolicy
{
// 512 000 байт — предел Яндекса для графического креатива медийной кампании. Сверено
// с живой кампанией прогрева: 15 баннеров ≤ 512 КБ приняты кабинетом (HANDOFF 25.07 §5.1).
// Прежнее значение 150 КБ было нашим временным капом до сверки.
public const MAX_BYTES = 512000;
/** @var list<string> */
public const FORMATS = ['jpg', 'jpeg', 'png', 'gif'];
public static function humanMaxSize(): string
{
return (int) round(self::MAX_BYTES / 1024).' КБ';
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
use App\Models\AdCampaign;
use App\Support\PhoneNormalizer;
use Illuminate\Support\Facades\DB;
final class CampaignAudienceBuilder
{
/**
* @return list<string> уникальные телефоны 79XXXXXXXXX для кампании.
*
* Режим auto скользящее окно `audience_days` от сегодня, БЕЗ своего списка
* (даже если в ad_campaign_phones есть строки use_uploaded_list в этом режиме
* лишь индикатор для UI, на выборку не влияет).
* Режим manual фиксированный снимок `[snapshot_from 00:00 … snapshot_to 23:59:59]`
* ПЛЮС свой список ВСЕГДА (не по флагу). Без заданных дат снимка сделки пустые
* (не падаем), список подмешивается как обычно.
*/
public function build(AdCampaign $campaign): array
{
return DB::transaction(function () use ($campaign): array {
// RLS-контекст (сделки tenant-scoped; сервис-контекст, не middleware).
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $campaign->tenant_id);
// Явный where(tenant_id) — defense-in-depth: queue worker на prod
// (crm_supplier_worker) — BYPASSRLS, SET LOCAL не фильтрует
// (00_create_roles.sql §5; тот же паттерн — HistoricalImportService::loadStatusOverrides).
// Без фильтра — cross-tenant утечка телефонов.
$isManual = $campaign->mode === AdCampaign::MODE_MANUAL;
if ($isManual) {
$dealPhones = [];
if ($campaign->snapshot_from !== null && $campaign->snapshot_to !== null) {
$from = $campaign->snapshot_from->copy()->startOfDay();
$to = $campaign->snapshot_to->copy()->endOfDay();
$dealPhones = DB::table('deals')
->where('tenant_id', $campaign->tenant_id)
->whereBetween('received_at', [$from, $to])
->whereNull('deleted_at')
->pluck('phone')
->all();
}
} else {
$dealPhones = DB::table('deals')
->where('tenant_id', $campaign->tenant_id)
->where('received_at', '>=', now()->subDays((int) $campaign->audience_days))
->whereNull('deleted_at')
->pluck('phone')
->all();
}
$listPhones = [];
if ($isManual) {
$listPhones = DB::table('ad_campaign_phones')
->where('tenant_id', $campaign->tenant_id)
->where('campaign_id', $campaign->id)
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
->pluck('phone')
->all();
}
// deals.phone уже 79…; свой список приводим к формату Яндекса на всякий.
$normalized = PhoneNormalizer::cleanContactList([...$dealPhones, ...$listPhones]);
return array_values(array_unique($normalized));
});
}
public function size(AdCampaign $campaign): int
{
return count($this->build($campaign));
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
use App\Models\AdCampaign;
use App\Models\AdCampaignBanner;
use Illuminate\Support\Facades\Storage;
/**
* Генерация полного набора баннеров кампании из ОДНОЙ картинки клиента: прогон по всем
* размерам BannerSizes через BannerGenerator, файлы на приватном диске 'local', строки в БД.
* Повторная генерация заменяет прежний набор (файлы + строки).
*/
final class CampaignBannerService
{
private const DISK = 'local';
public function __construct(private readonly BannerGenerator $generator) {}
/** @return int число сгенерированных баннеров (= число размеров). */
public function generate(AdCampaign $campaign, string $sourceBinary): int
{
$disk = Storage::disk(self::DISK);
$old = AdCampaignBanner::where('tenant_id', $campaign->tenant_id)
->where('campaign_id', $campaign->id)->get();
foreach ($old as $banner) {
$disk->delete($banner->path);
$banner->delete();
}
$count = 0;
foreach (BannerSizes::all() as [$w, $h]) {
$jpeg = $this->generator->coverJpeg($sourceBinary, $w, $h);
$path = "ad-banners/{$campaign->tenant_id}/{$campaign->id}/{$w}x{$h}.jpg";
$disk->put($path, $jpeg);
AdCampaignBanner::create([
'tenant_id' => $campaign->tenant_id,
'campaign_id' => $campaign->id,
'width' => $w,
'height' => $h,
'path' => $path,
'bytes' => strlen($jpeg),
]);
$count++;
}
return $count;
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
use Illuminate\Support\Facades\DB;
/**
* Смета медийной кампании «за показы»: из размера аудитории и частоты показы и
* клиентская сумма по плоской цене ad_settings.client_cpm_rub (наценка не видна клиенту).
*/
final class CampaignEstimateService
{
public const MIN_AUDIENCE = 100;
public function __construct(private readonly AdImpressionPricing $pricing) {}
/**
* @return array{size:int, frequency:int, impressions:int, cpm_rub:string, cost_rub:string, min:int, enough:bool}
*/
public function estimate(int $size, int $frequency, ?string $cpm = null): array
{
$cpm = $cpm ?? (string) (DB::table('ad_settings')->value('client_cpm_rub') ?? '120.00');
$impressions = $this->pricing->impressionsForAudience($size, $frequency);
return [
'size' => max(0, $size),
'frequency' => max(0, $frequency),
'impressions' => $impressions,
'cpm_rub' => $cpm,
'cost_rub' => $this->pricing->clientCostRub($impressions, $cpm),
'min' => self::MIN_AUDIENCE,
'enough' => $size >= self::MIN_AUDIENCE,
];
}
}
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
use App\Models\AdCampaign;
use DateTimeInterface;
use Illuminate\Support\Facades\DB;
/**
* Списание с рекламного кошелька клиента ЗА ФАКТИЧЕСКИ показанные показы.
*
* Direct-НЕзависим: принимает накопительное число показов числом (Часть 4
* подаёт его из отчёта Директа). Списывает ДЕЛЬТУ между суммарной клиентской
* стоимостью (по плоской цене `ad_settings.client_cpm_rub`) и уже списанным
* (`charged_client_rub`) идемпотентно за счёт external_key в AdWalletService.
*
* MONEY: только bcmath (scale 2), без float.
*/
final class CampaignImpressionCharger
{
public function __construct(
private readonly AdImpressionPricing $pricing,
private readonly AdWalletService $wallet,
) {}
public function charge(AdCampaign $campaign, int $deliveredImpressions): void
{
$cpm = $campaign->effectiveCpm();
$paid = (int) ($campaign->paid_impressions ?? 0);
$billable = $paid > 0
? min($deliveredImpressions, $paid)
: max(0, $deliveredImpressions);
$totalCost = $this->pricing->clientCostRub($billable, $cpm);
$already = (string) ($campaign->charged_client_rub ?? '0.00');
$delta = bcsub($totalCost, $already, 2);
// Списываем ТОЛЬКО при росте накопленной стоимости и держим charged_client_rub
// монотонным (high-water mark). Если отчёт Директа вернул МЕНЬШЕ показов
// (коррекция/eventual-consistency ALL_TIME-отчёта), баланс не трогаем и базу не
// опускаем — иначе последующий рост списал бы дельту от заниженной базы поверх
// уже начисленного (двойное списание).
if (bccomp($delta, '0.00', 2) === 1) {
$this->wallet->charge(
(int) $campaign->tenant_id,
'yandex',
'campaign',
(int) $campaign->id,
$delta,
"yandex-imp:{$campaign->id}:{$billable}",
);
$charged = $totalCost;
} else {
$charged = $already;
}
// Наш расход у Яндекса (для админ-маржи, Часть 4) — считаем от той же суммы, что
// фактически начислена клиенту, наценка сверху НЕ видна клиенту нигде (модель hidden).
$margin = (string) (DB::table('ad_settings')->value('ad_margin_percent') ?? '40.00');
$share = bcdiv(bcsub('100', $margin, 4), '100', 6); // доля, уходящая в Директ
$yandexCost = bcmul($charged, $share, 2);
$update = [
'delivered_impressions' => $deliveredImpressions,
'charged_client_rub' => $charged,
'yandex_cost_rub' => $yandexCost,
];
// У ВЫХОДА 1 два повода закрыть кампанию, и оба ведут в один и тот же переход в
// `completed` с одним вызовом release() ниже — новых мест снятия заморозки не
// появляется:
// а) смета откручена — delivered добрался до оплаченного числа показов;
// б) срок показа истёк — EndDate в Директе прошёл, показов больше не будет.
//
// Без (б) недокрученная кампания висела бы `running` вечно, а деньги клиента
// оставались бы замороженными навсегда. Для медийки по списку телефонов недокрут —
// типовой исход: аудитория ограничена размером списка, частота показов — настройкой.
$showsUntil = $campaign->shows_until;
$periodOver = $showsUntil instanceof DateTimeInterface
&& now()->toDateString() > $showsUntil->format('Y-m-d');
if (($paid > 0 && $deliveredImpressions >= $paid) || $periodOver) {
$update['status'] = AdCampaign::STATUS_COMPLETED;
}
$campaign->update($update);
// ВЫХОД 1 — смета откручена либо срок показа истёк. Остаток заморозки (смета
// стоила меньше, чем заморозили при запуске, либо не докрутилась вовсе)
// возвращаем в свободные деньги: кампания больше не претендует на эти рубли.
if (($update['status'] ?? null) === AdCampaign::STATUS_COMPLETED) {
$this->wallet->release((int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id);
}
}
}
@@ -0,0 +1,371 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
use App\Exceptions\Advertising\AudienceTooSmallException;
use App\Models\AdCampaign;
use App\Models\AdCampaignBanner;
use App\Services\Sales\YandexAudienceClient;
use DateTimeInterface;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Throwable;
/**
* Оркестратор запуска МЕДИЙНОЙ рекламной кампании «за показы»: аудитория сегмент
* Аудиторий Директ (retargeting CPM-кампания CPM-группа медиа-таргет баннер
* по готовому креативу) заморозка клиентской суммы за смету показов в кошельке
* статус «на модерации».
*
* MONEY: суммы для Директа считаются только через bcmath (микросы = целые); клиентская
* сумма через AdImpressionPricing::clientCostRub (плоская цена /1000, округление вверх).
* Наценка (ad_settings.ad_margin_percent) НЕ показывается клиенту нигде (модель hidden).
*/
final class CampaignLauncher
{
/** Через сколько минут захват кампании под запуск считается брошенным. */
private const STUCK_LAUNCH_MINUTES = 15;
public function __construct(
private readonly CampaignAudienceBuilder $audience,
private readonly AdWalletService $wallet,
private readonly AdImpressionPricing $pricing,
) {}
public function launch(AdCampaign $campaign): void
{
if (! config('services.yandex_direct.enabled')) {
throw new RuntimeException('Яндекс.Директ выключен (рубильник yandex_direct.enabled).');
}
// Запускать можно ТОЛЬКО черновик (draft) и «готова к запуску, ждёт оператора»
// (queued, ставит submit()). Из running/pending_moderation/paused/… — отказ: сущности
// в Яндексе теперь переиспользуются, второй кампании не появится, но статус откатился
// бы на «на модерации», а launched_at перебился бы на «сейчас» — портал начал бы врать
// о состоянии кампании.
//
// Проверка стоит здесь, а не в контроллере: лаунчер — граница, за которой начинаются
// деньги (заморозка в кошельке) и Яндекс. Мимо контроллера её так не обойти (джоб,
// консоль, будущая ручка). Контроллер ловит RuntimeException и отдаёт 409 с этим же
// текстом — как у pause()/resume().
//
// Оборвавшийся запуск проверку ПРОХОДИТ: финальный update() со статусом до обрыва не
// доходит, кампания остаётся в draft — возобновление работает как прежде.
$previousStatus = $this->claim($campaign);
try {
$this->run($campaign);
} catch (Throwable $e) {
// Захват не должен превращаться в ловушку: на любой ошибке возвращаем прежний
// статус, иначе клиент, у которого не хватило аудитории или лёг Яндекс, больше
// никогда не смог бы нажать «запустить».
//
// Возвращаем именно СТАТУС и ничего больше: номера уже созданных в Яндексе
// сущностей записаны по ходу дела и должны уцелеть — на них держится
// возобновляемый запуск.
AdCampaign::where('id', $campaign->id)->update(['status' => $previousStatus]);
$campaign->setAttribute('status', $previousStatus);
throw $e;
}
}
/**
* Захватить кампанию под запуск: под замком строки перевести её в промежуточный статус
* `launching`. Возвращает статус, который был до захвата (его вернём при неудаче).
*
* Зачем замок. Прежняя проверка «статус draft или queued» была обычным чтением. Два
* одновременных нажатия «запустить» проходили её оба, каждый со своего экземпляра модели
* видел `yandex_campaign_id = 0` и оба шли по всей цепочке: два сегмента Аудиторий, два
* условия ретаргетинга, ДВЕ медийные кампании, две группы, два комплекта объявлений.
* Заморозка денег при этом одна (она идемпотентна), то есть портал списал бы за одну
* кампанию, а крутились бы две на деньги клиента. Ответ 202 «попробуйте чуть позже»
* (запуск без готовых креативов) прямо приглашает нажать ещё раз, так что повтор
* штатный сценарий, а не экзотика.
*
* Почему не одна большая транзакция на весь запуск: по ходу дела мы записываем номера
* созданных в Яндексе сущностей, и эти записи обязаны уцелеть при обрыве на них держится
* возобновляемый запуск. Откат транзакции стёр бы их, и повторный заход завёл бы вторую
* кампанию-сироту. Плюс запуск это цепочка сетевых обращений, держать всё это время
* открытую транзакцию нельзя.
*
* Брошенный захват. Если процесс убили насмерть (перезагрузка сервера, убитый воркер),
* вернуть статус некому. Поэтому захват старше пятнадцати минут считается брошенным и
* перехватывается иначе кампания застряла бы в `launching` навсегда. Признак свежести
* `updated_at`: живой запуск обновляет строку кампании по ходу дела, записывая номера
* созданных сущностей, поэтому он остаётся свежим сам собой.
*/
private function claim(AdCampaign $campaign): string
{
$previous = DB::transaction(function () use ($campaign): ?string {
$fresh = AdCampaign::where('id', $campaign->id)->lockForUpdate()->first();
if ($fresh === null) {
return null;
}
if (in_array($fresh->status, [AdCampaign::STATUS_DRAFT, AdCampaign::STATUS_QUEUED], true)) {
$previous = (string) $fresh->status;
} elseif ($this->claimAbandoned($fresh)) {
$previous = AdCampaign::STATUS_DRAFT;
} else {
return null;
}
$fresh->update(['status' => AdCampaign::STATUS_LAUNCHING]);
return $previous;
});
if ($previous === null) {
throw new RuntimeException(
'Кампания уже запущена — повторный запуск невозможен. Запустить можно только черновик или кампанию, готовую к запуску.'
);
}
$campaign->setAttribute('status', AdCampaign::STATUS_LAUNCHING);
return $previous;
}
/** Захват брошен: помечена «запускается», но строку не трогали дольше STUCK_LAUNCH_MINUTES. */
private function claimAbandoned(AdCampaign $campaign): bool
{
return $campaign->status === AdCampaign::STATUS_LAUNCHING
&& $campaign->updated_at !== null
&& $campaign->updated_at->lt(now()->subMinutes(self::STUCK_LAUNCH_MINUTES));
}
/** Сам запуск. Вызывается только из launch() — после успешного захвата кампании. */
private function run(AdCampaign $campaign): void
{
// Конструктор креативов Яндекса закрыт — креатив у каждого размера свой, номера
// проставляет робот-грузчик либо оператор руками. Запускаться без номеров нельзя:
// объявление уедет пустым. Основание — findings 2026-07-27.
$banners = AdCampaignBanner::where('tenant_id', $campaign->tenant_id)
->where('campaign_id', $campaign->id)
->where('included', true)
->orderBy('width')->orderBy('height')
->get();
if ($banners->isEmpty()) {
throw new RuntimeException('У кампании нет ни одного включённого баннера — клиенту загрузить картинки в мастере.');
}
// Ноль ловим наравне с пустотой НАМЕРЕННО: это мусор, а не номер. Раньше он попадал
// сюда случайно, нестрогим сравнением; строгое сравнение «по уму» выпустило бы ноль
// дальше — в запрос к Яндексу.
$withoutCreative = $banners->first(
fn ($b) => $b->yandex_creative_id === null || (int) $b->yandex_creative_id === 0
);
if ($withoutCreative !== null) {
throw new RuntimeException(
"У баннера {$withoutCreative->width}×{$withoutCreative->height} нет номера креатива Яндекса — креативы ещё не загружены в кабинет."
);
}
$href = trim((string) ($campaign->landing_url ?? ''));
if ($href === '') {
throw new RuntimeException('У кампании не указан адрес сайта — клиенту вписать адрес в мастере кампании.');
}
$estImpr = (int) ($campaign->estimated_impressions ?? 0);
if ($estImpr <= 0) {
throw new RuntimeException('Не задана смета показов.');
}
$phones = $this->audience->build($campaign);
if (count($phones) < 100) {
throw new AudienceTooSmallException(count($phones)); // Яндекс не запустит сегмент <100
}
// Деньги: только bcmath, микросы — целые.
$margin = (string) (DB::table('ad_settings')->value('ad_margin_percent') ?? '40.00');
$share = bcdiv(bcsub('100', $margin, 4), '100', 6); // доля, уходящая в Директ
$clientCpm = $campaign->effectiveCpm(); // ₽/1000, 2 знака
$yandexCpm = bcmul($clientCpm, $share, 2); // ₽/1000 для Яндекса
$averageCpmMicros = (int) bcmul($yandexCpm, '1000000', 0);
$yandexBudgetRub = bcdiv(bcmul((string) $estImpr, $yandexCpm, 6), '1000', 2); // ₽ за период
$guard = (string) config('services.yandex_direct.spend_limit_guard_multiplier');
$spendLimitMicros = (int) bcmul(bcmul($yandexBudgetRub, $guard, 2), '1000000', 0);
$freezeClientRub = $this->pricing->clientCostRub($estImpr, $clientCpm); // клиентские ₽
[$startDate, $endDate] = $this->resolvePeriod($campaign);
$freqImpr = (int) ($campaign->frequency ?? 1);
$freqPeriod = (int) ($campaign->frequency_period_days ?? 1);
// Запуск ВОЗОБНОВЛЯЕМЫЙ: номер каждой созданной в Яндексе сущности пишется на
// кампанию СРАЗУ. Если связь оборвётся на середине (например, на третьем объявлении
// из пятнадцати), портал будет знать, что уже создано, и повторный запуск дочертит
// недостающее, а не заведёт вторую кампанию-сироту, которая начнёт крутить деньги.
// 1) Сегмент Аудиторий.
$segmentId = (int) $campaign->yandex_segment_id;
if ($segmentId === 0) {
$segmentId = (new YandexAudienceClient((string) config('services.yandex_audience.token')))
->createSegment('Лидерра кампания #'.$campaign->id, $phones);
$campaign->update(['yandex_segment_id' => $segmentId]);
}
// 2) Директ: retargeting → CPM-кампания → CPM-группа → медиа-таргет → баннер.
$direct = new YandexDirectClient(
(string) config('services.yandex_direct.base_url'),
(string) config('services.yandex_direct.token'),
);
$retId = (int) $campaign->yandex_retargeting_list_id;
if ($retId === 0) {
$retId = $direct->addRetargetingList('Лидерра #'.$campaign->id, $segmentId);
$campaign->update(['yandex_retargeting_list_id' => $retId]);
}
$campaignId = (int) $campaign->yandex_campaign_id;
if ($campaignId === 0) {
$campaignId = $direct->addCpmBannerCampaign(
'Лидерра #'.$campaign->id,
$startDate,
$endDate,
$averageCpmMicros,
$spendLimitMicros,
$freqImpr,
$freqPeriod,
);
// Дату последнего дня показа записываем ВМЕСТЕ с номером кампании: она значима
// именно потому, что её теперь держит Яндекс (EndDate). По ней суточное списание
// закрывает кампанию, не добравшую смету, и возвращает клиенту замороженный
// остаток — иначе деньги остались бы заперты навсегда.
$campaign->update(['yandex_campaign_id' => $campaignId, 'shows_until' => $endDate]);
}
// Группа + аудитория к ней — ОДИН неделимый шаг. Номер группы пишется на кампанию
// ПОСЛЕДНИМ, только после успешного addMediaAudienceTarget: он здесь не «группа
// создана», а отметка «группа готова ЦЕЛИКОМ». Инвариант железный: есть
// yandex_ad_group_id ⇒ аудитория на этой группе точно висит.
//
// Если оборвётся между созданием группы и таргетом — номер не запишется, повторный
// запуск заведёт группу заново и повесит аудиторию на неё. В кабинете останется
// пустая группа-сирота: без аудитории и без объявлений она ничего не показывает и
// денег не тратит. Это несравнимо дешевле обратного варианта — переиспользовать
// группу без аудитории и получить кампанию, которая молча никогда не заработает.
$adGroupId = (int) $campaign->yandex_ad_group_id;
if ($adGroupId === 0) {
$adGroupId = $direct->addCpmBannerAdGroup($campaignId, 'Группа #'.$campaign->id, config('services.yandex_direct.region_ids'));
$direct->addMediaAudienceTarget($adGroupId, $retId);
$campaign->update(['yandex_ad_group_id' => $adGroupId]);
}
// Одно объявление на каждый включённый баннер: у медийной кампании нет адаптивного
// креатива, каждый размер блока — своё объявление со своим креативом. Баннеры, у
// которых номер объявления уже есть, пропускаем — их создал прошлый заход.
$pending = $banners->filter(fn ($banner) => (int) $banner->yandex_ad_id === 0);
$this->assertCreativeSizesMatch($direct, $pending);
foreach ($banners as $banner) {
if ((int) $banner->yandex_ad_id > 0) {
continue;
}
$adId = $direct->addCpmBannerAd($adGroupId, (int) $banner->yandex_creative_id, $href);
$banner->update([
'yandex_ad_id' => $adId,
'moderation_status' => AdCampaignBanner::MOD_MODERATION,
]);
}
// 3) Заморозить клиентскую сумму за смету показов в кошельке (Часть A).
$this->wallet->freeze((int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id, $freezeClientRub);
// 4) Записать статус и ОПЛАЧЕННУЮ смету показов. Номера сущностей Яндекса уже
// записаны выше по ходу создания; здесь — только то, что означает «запуск
// доведён до конца». paid_impressions = смета, за которую заморозили деньги —
// это потолок биллинга: CampaignImpressionCharger ограничивает списание этим
// числом и по достижении помечает кампанию COMPLETED. Без него списание шло бы
// без потолка (до SpendLimit Яндекса ×guard) и кампания не завершалась бы никогда.
//
// yandex_ad_id на КАМПАНИИ не пишем: объявлений теперь набор, их номера живут на
// ad_campaign_banners.yandex_ad_id. Колонка кампании остаётся в базе как
// аварийный ручной путь; продуктового кода, который её читает, нет.
//
// client_cpm_rub пишем ОБЯЗАТЕЛЬНО, даже если кампания завелась без своей цены.
// Пока поле пустое, effectiveCpm() читает глобальную цену из настроек рекламы —
// и стоило админу её поменять, как суточное списание шло уже по НОВОЙ, выше
// замороженной. Клиент платил бы больше, чем ему обещали при запуске.
// revived_at гасим здесь же: отметка «отдана клиенту на починку» держит открытым
// исключение из замка правки, и жить ей ровно до нового запуска. Объявления
// созданы — дальше правка снова расходилась бы с кабинетом Яндекса.
$campaign->update([
'paid_impressions' => $estImpr,
'client_cpm_rub' => $clientCpm,
'status' => AdCampaign::STATUS_PENDING_MODERATION,
'launched_at' => now(),
'revived_at' => null,
]);
}
/**
* Сверяет настоящий размер каждого креатива с размером баннера ПЕРЕД созданием объявлений.
*
* Номер креатива попадает на баннер двумя путями: от робота-грузчика (опознание по разнице
* слепков) и руками оператора. Ошибка на любом из них объявление 300×250, в котором
* крутится картинка 728×90: клиент платит за показы битого или вовсе чужого баннера,
* а увидеть это можно только глазами в кабинете. Одна проверка закрывает весь класс.
*
* Один запрос на весь набор, только по баннерам, которым объявление ещё предстоит создать:
* у остальных объявление уже живёт в кабинете, и сверять там нечего.
*
* @param Collection<int, AdCampaignBanner> $banners
*/
private function assertCreativeSizesMatch(YandexDirectClient $direct, $banners): void
{
if ($banners->isEmpty()) {
return;
}
$sizes = $direct->getCreativeSizes($banners->map(fn ($b) => (int) $b->yandex_creative_id)->all());
foreach ($banners as $banner) {
$creativeId = (int) $banner->yandex_creative_id;
$actual = $sizes[$creativeId] ?? null;
if ($actual === null) {
throw new RuntimeException(
"Креатива №{$creativeId} нет в рекламном кабинете — объявление {$banner->width}×{$banner->height} "
.'по нему не создать. Загрузите картинку заново.'
);
}
if ($actual[0] !== (int) $banner->width || $actual[1] !== (int) $banner->height) {
throw new RuntimeException(
"У баннера {$banner->width}×{$banner->height} стоит креатив №{$creativeId}, "
."а его настоящий размер — {$actual[0]}×{$actual[1]}. Размер не совпадает, запуск остановлен."
);
}
}
}
/**
* Период показа: manual + заданный run_days от сегодня на run_days дней;
* иначе (auto или без run_days) от сегодня на run_days (если задан) или 30 дней.
*
* @return array{0: string, 1: string} [startDate, endDate] в формате YYYY-MM-DD.
*/
private function resolvePeriod(AdCampaign $campaign): array
{
$startDate = now()->toDateString();
$runDays = (int) ($campaign->run_days ?: 30);
// Возобновляемый запуск: если дата конца уже записана, кампания в Директе заведена
// прошлым заходом ровно с ней — переиспользуем, иначе портал считал бы срок показа
// по одной дате, а Яндекс прекращал показы по другой.
$stored = $campaign->shows_until;
$endDate = $stored instanceof DateTimeInterface
? $stored->format('Y-m-d')
: now()->addDays($runDays)->toDateString();
return [$startDate, $endDate];
}
}
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
use App\Mail\AdModerationMessageMail;
use App\Models\AdCampaign;
use App\Models\AdCampaignMessage;
use App\Models\User;
use App\Services\NotificationService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* Единственное место, где создаются сообщения ленты кампании. Здесь же защита от дублей
* и отправка уведомлений чтобы «положить в ленту, но забыть сказать клиенту» стало
* невозможно по построению.
*/
class CampaignMessageService
{
public function __construct(private readonly NotificationService $notifications) {}
/**
* Слова Яндекса. Дубль по паре «кампания + баннер» не пишем: опрос модерации бежит
* по расписанию и до перемены решения присылает одну и ту же причину при каждом
* обходе иначе за сутки лента превратится в сотню одинаковых строк, а клиент
* получит сотню писем.
*
* Сравниваем с ПОСЛЕДНИМ сообщением Яндекса по этому баннеру, а не со всей перепиской.
* 🪤 Сравнение «со всей историей» кажется надёжнее, но ломает главный случай: клиент
* починил рекламу, отправил заново и получил ТОТ ЖЕ отказ и такое сообщение
* не показалось бы вовсе. Клиент остался бы без объяснения, почему опять не пустили.
*
* Работаем на том же соединении, что и переданная кампания: джоб модерации ходит
* через `pgsql_supplier`, и сообщение обязано лечь тем же каналом, иначе на бою
* RLS без tenant-контекста молча не даст ни прочитать, ни записать.
*/
public function postFromYandex(AdCampaign $campaign, ?int $bannerId, string $body): ?AdCampaignMessage
{
$body = trim($body);
if ($body === '') {
return null;
}
$last = $this->fromYandexQuery($campaign, $bannerId)
->orderByDesc('id')
->first();
if ($last !== null && $last->body === $body) {
return null;
}
return $this->write($campaign, $bannerId, $body, null);
}
/**
* Доклад робота-разведчика: что он прочитал на экране кабинета, плюс снимок этого окна.
* Автор тот же `yandex`: это слова Яндекса, откуда бы мы их ни взяли.
*
* 🔴 Защиты от дублей здесь НЕТ намеренно. Разведка ставится ровно один раз на номер
* объявления (`CreativeJobService::enqueueInspection`) уникальность уже обеспечена
* там, на входе. Повесь мы дедуп ещё и здесь, повторный отказ с той же формулировкой
* после починки был бы съеден, и клиент не узнал бы, что его опять не пустили.
*
* @param array{path: string, name: string, size: int, mime: string}|null $file снимок экрана
*/
public function postFromRobot(
AdCampaign $campaign,
?int $bannerId,
string $body,
?array $file = null,
): ?AdCampaignMessage {
$body = trim($body);
return $body === '' ? null : $this->write($campaign, $bannerId, $body, $file);
}
/**
* Есть ли по этому баннеру хоть одно слово от Яндекса.
*
* Нужно джобу модерации: заглушку «причину выясняем» имеет смысл показать ОДИН раз,
* пока настоящей причины нет. После доклада разведки она уже не новость, а шаг назад
* клиент, прочитавший настоящую причину, получил бы поверх неё «причину не назвали».
*/
public function hasFromYandex(AdCampaign $campaign, ?int $bannerId): bool
{
return $this->fromYandexQuery($campaign, $bannerId)->exists();
}
/** @return Builder<AdCampaignMessage> */
private function fromYandexQuery(AdCampaign $campaign, ?int $bannerId)
{
return AdCampaignMessage::on($campaign->getConnectionName())
->where('tenant_id', $campaign->tenant_id)
->where('campaign_id', $campaign->id)
->where('author', AdCampaignMessage::AUTHOR_YANDEX)
->where(fn ($q) => $bannerId === null ? $q->whereNull('banner_id') : $q->where('banner_id', $bannerId));
}
/** Запись сообщения от имени Яндекса + уведомление клиента. Общее для обоих входов. */
private function write(AdCampaign $campaign, ?int $bannerId, string $body, ?array $file): AdCampaignMessage
{
$message = new AdCampaignMessage([
'tenant_id' => (int) $campaign->tenant_id,
'campaign_id' => (int) $campaign->id,
'banner_id' => $bannerId,
'author' => AdCampaignMessage::AUTHOR_YANDEX,
'body' => $body,
'file_path' => $file['path'] ?? null,
'file_name' => $file['name'] ?? null,
'file_size' => $file['size'] ?? null,
'file_mime' => $file['mime'] ?? null,
]);
$message->setConnection($campaign->getConnectionName());
$message->save();
$this->notify($campaign, $body);
return $message;
}
/** Служебная отметка портала: расписка, а не новость — клиента ею не дёргаем. */
public function postSystem(AdCampaign $campaign, string $body): ?AdCampaignMessage
{
$body = trim($body);
if ($body === '') {
return null;
}
$message = new AdCampaignMessage([
'tenant_id' => (int) $campaign->tenant_id,
'campaign_id' => (int) $campaign->id,
'author' => AdCampaignMessage::AUTHOR_SYSTEM,
'body' => $body,
]);
$message->setConnection($campaign->getConnectionName());
$message->save();
return $message;
}
/**
* Письмо и колокольчик всем живым пользователям тенанта. Внутри всё под Throwable:
* упавшая почта не должна стирать саму новость сообщение уже в ленте, клиент
* увидит его, когда зайдёт.
*/
private function notify(AdCampaign $campaign, string $body): void
{
try {
$users = User::query()
->where('tenant_id', $campaign->tenant_id)
->where('is_active', true)
->whereNull('deleted_at')
->get();
foreach ($users as $user) {
$this->notifications->notifyInApp(
$user,
'ad_moderation',
'Ответ Яндекса по рекламе',
mb_substr($body, 0, 500),
['campaign_id' => (int) $campaign->id],
);
if (is_string($user->email) && $user->email !== '') {
Mail::to($user->email)->queue(new AdModerationMessageMail(
(string) $campaign->name, (int) $campaign->id, $body,
));
}
}
} catch (Throwable $e) {
Log::warning('Не смогли уведомить клиента об ответе Яндекса: '.$e->getMessage(), [
'campaign' => $campaign->id,
]);
}
}
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
use App\Models\AdCampaign;
use App\Models\AdCampaignBanner;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
/**
* Оживление отклонённой кампании: вернуть в черновик, чтобы клиент починил картинки
* привычными экранами и нажал обычную кнопку «Запустить».
*
* 🔑 Второго пути запуска не создаём намеренно. Дальше работает существующий
* CampaignLauncher, который по построению доигрывает начатое: пропускает баннеры
* с уже проставленными номерами и переиспользует созданные сущности Яндекса.
*
* 🔴 Денег этот сервис не касается вообще. Заморозка ставится там же, где и всегда
* внутри лаунчера. Пятого места разморозки здесь появиться не должно.
*/
class CampaignReviveService
{
public function revive(AdCampaign $campaign): void
{
if ($campaign->status !== AdCampaign::STATUS_REJECTED) {
throw new RuntimeException('Оживить можно только отклонённую кампанию.');
}
$rejected = AdCampaignBanner::where('tenant_id', $campaign->tenant_id)
->where('campaign_id', $campaign->id)
->where('moderation_status', AdCampaignBanner::MOD_REJECTED)
->get();
// Убрать мёртвые объявления из кабинета. Не вышло — не беда: отклонённое
// объявление не показывается и денег не стоит. Клиента это блокировать не должно.
if (config('services.yandex_direct.enabled')) {
try {
$client = new YandexDirectClient(
(string) config('services.yandex_direct.base_url'),
(string) config('services.yandex_direct.token'),
);
$client->deleteAds($rejected->pluck('yandex_ad_id')->all());
} catch (Throwable $e) {
Log::warning('Не смогли убрать отклонённые объявления из кабинета: '.$e->getMessage(), [
'campaign' => $campaign->id,
]);
}
}
DB::transaction(function () use ($campaign, $rejected): void {
foreach ($rejected as $banner) {
// Картинке нужен новый креатив и новое объявление: заменить креатив
// у существующего объявления Яндекс не даёт.
$banner->update([
'yandex_creative_id' => null,
'yandex_ad_id' => null,
'moderation_status' => AdCampaignBanner::MOD_DRAFT,
'moderation_reason' => null,
]);
}
// Номера кампании, группы и сегмента НЕ трогаем — на них держится
// возобновляемый запуск. Стереть их значит завести в Яндексе второй
// комплект всего и заплатить дважды.
// revived_at — отметка «отдана клиенту на починку». Она, а не статус, держит
// открытым исключение из замка правки: статус тут же становится черновиком,
// и исключение по статусу погасло бы в эту же секунду.
$campaign->update([
'status' => AdCampaign::STATUS_DRAFT,
'moderation_reason' => null,
'revived_at' => now(),
]);
});
}
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
use App\Exceptions\Advertising\CreativeMatchFailedException;
/**
* Опознание креативов, загруженных роботом в веб-кабинет Яндекса.
*
* Имя опознать не помогает: всем загруженным креативам Яндекс сам ставит имя
* «Новый креатив» (проверено живым запросом 27.07.2026). Поэтому берём разницу
* слепков `creatives.get` «до» и «после» загрузки и раскладываем новые креативы
* по размеру. Чистая функция без БД и без сети.
*/
final class CreativeIdMatcher
{
/**
* @param array<int, array{0: int, 1: int}> $before слепок до загрузки: номер [ш, в]
* @param array<int, array{0: int, 1: int}> $after слепок после загрузки
* @param list<array{0: int, 1: int}> $expectedSizes размеры, которые робот должен был залить
* @return array<string, int> «300x250» номер креатива
*
* @throws CreativeMatchFailedException
*/
public function match(array $before, array $after, array $expectedSizes): array
{
// Пустой список ожидаемых размеров — не «всё сошлось», а тихий ноль: цикл ниже
// просто не выполнится и вернёт пустоту как успех. Задание пометится «готово»
// с нулём проставленных номеров, запуск снова увидит баннеры без креативов и
// поставит новое задание — робот будет заливать те же файлы по кругу, оставляя
// каждый раз пачку мусорных креативов в живом кабинете. Ловим это громко.
if ($expectedSizes === []) {
throw new CreativeMatchFailedException(
'Опознавать нечего: список ожидаемых размеров пуст. Так бывает, если баннеры кампании не видны '
.'служебной роли — проверьте, что после выката перезапущен db/03_service_bypass_policies.sql.'
);
}
$newIds = array_diff_key($after, $before);
$bySize = [];
foreach ($newIds as $id => [$w, $h]) {
$bySize[$w.'x'.$h][] = (int) $id;
}
$out = [];
foreach ($expectedSizes as [$w, $h]) {
$key = $w.'x'.$h;
$found = $bySize[$key] ?? [];
if (count($found) === 0) {
throw new CreativeMatchFailedException(
"После загрузки не появился креатив размера {$key}."
);
}
if (count($found) > 1) {
throw new CreativeMatchFailedException(
"После загрузки появилось несколько креативов размера {$key} — какой чей, не определить."
);
}
$out[$key] = $found[0];
}
return $out;
}
}
@@ -0,0 +1,384 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
use App\Exceptions\Advertising\CreativeMatchFailedException;
use App\Models\AdCampaign;
use App\Models\AdCampaignBanner;
use App\Models\AdCreativeJob;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Очередь заданий роботу-грузчику креативов.
*
* Работает через слепки `creatives.get`: перед выдачей задания запоминаем номера
* картиночных креативов аккаунта, после отчёта робота берём слепок снова и
* раскладываем разницу по баннерам (CreativeIdMatcher). Робот номера не читает.
*
* Задания выдаются СТРОГО по одному: два робота или два параллельных задания
* перемешают слепки, и опознать креативы станет нельзя.
*/
final class CreativeJobService
{
public function __construct(private readonly CreativeIdMatcher $matcher) {}
/**
* Ставит задание. Если для кампании уже есть незавершённое возвращает его.
*
* В Яндекс отсюда НЕ ходим: слепок «до» снимается при выдаче задания роботу (см.
* takeNext). Иначе два задания в очереди получили бы одинаковый слепок первый робот
* залил бы креативы, а у второго задания «до» остался бы прежним, и разница слепков
* показала бы чужие креативы вдобавок к своим: второе задание падало бы всегда.
* Заодно постановка перестала зависеть от живости Яндекса: клиент ставит кампанию
* в очередь, даже когда кабинет не отвечает.
*/
public function enqueue(AdCampaign $campaign): AdCreativeJob
{
// 🪤 Отбор по виду обязателен. Пока вид был один, «незавершённое задание кампании»
// однозначно означало заливку. С появлением доставки этот же запрос вернул бы
// задание с документом — запуск решил бы, что креативы уже в очереди, и робот
// не повёз бы картинки вовсе. Молча, при зелёном журнале.
$pending = AdCreativeJob::where('campaign_id', $campaign->id)
->where('kind', AdCreativeJob::KIND_UPLOAD)
->whereIn('status', [AdCreativeJob::STATUS_QUEUED, AdCreativeJob::STATUS_TAKEN])
->first();
if ($pending !== null) {
return $pending;
}
return AdCreativeJob::create([
'tenant_id' => $campaign->tenant_id,
'campaign_id' => $campaign->id,
'kind' => AdCreativeJob::KIND_UPLOAD,
'status' => AdCreativeJob::STATUS_QUEUED,
]);
}
/**
* Ставит задание «сходить посмотреть, за что Яндекс отклонил объявление».
*
* 🔑 Почему без разведки никак: программный интерфейс Яндекса причину отказа НЕ отдаёт.
* На отклонённое объявление `ads.get` возвращает «Отклонено на модерации.» и всё
* проверено живьём боевым ключом 28.07.2026. Настоящая причина висит только на экране
* кабинета, и увидеть её может лишь тот, у кого есть глаза.
*
* 🔴 Ключ дедупа номер объявления, а не кампания. Опрос модерации бежит по расписанию,
* а отказ никуда не девается: объявление остаётся отклонённым, пока клиент его не починит.
* Дедуп «по кампании среди незавершённых» не спас бы первая разведка закрывается за
* минуту, следующий обход поставил бы вторую, и робот ходил бы в кабинет по кругу, а
* клиент получал бы один и тот же доклад снова и снова. Поэтому смотрим ВСЕ задания
* по этому номеру, включая закрытые.
*
* После кнопки «Исправить» объявление заводится в кабинете заново и получает новый
* номер новый отказ честно получит свою разведку.
*
* @return AdCreativeJob|null null, если у баннера нет номера объявления смотреть не на что
*/
public function enqueueInspection(AdCampaign $campaign, AdCampaignBanner $banner): ?AdCreativeJob
{
$adId = $banner->yandex_ad_id;
// Объявления в кабинете нет — робота гонять незачем. Такое бывает у баннера, который
// до кабинета не доехал: заливка сорвалась, номер не проставился.
if (blank($adId)) {
return null;
}
$tenantId = (int) $campaign->tenant_id;
// 🔴 Свой tenant-контекст обязателен. Ставит разведку джоб опроса модерации: он бежит
// в очереди под ролью `crm_app_user` (НЕ BYPASSRLS) на дефолтном соединении и БЕЗ
// tenant-контекста. Без этой строки на бою поиск дубля вернул бы ноль ВСЕГДА
// (политика при пустом контексте даёт tenant_id = NULL), а запись упала бы
// «new row violates row-level security policy» — молча, в Log::warning, при зелёных
// тестах: тесты ходят суперпользователем, которому права не проверяются.
//
// Соседний путь того же обхода — CampaignMessageService — обходит грабку иначе,
// соединением кампании (`pgsql_supplier`). Здесь так нельзя: у `crm_supplier_worker`
// прав на `ad_creative_jobs` нет вовсе, и это осознанно (журнал схемы v9.06).
// Берём рецепт ChargeCampaignSpendJob: своя транзакция + SET LOCAL.
return DB::transaction(function () use ($campaign, $tenantId, $adId) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$existing = AdCreativeJob::where('campaign_id', $campaign->id)
->where('kind', AdCreativeJob::KIND_INSPECT)
->where('yandex_ad_id', (int) $adId)
->first();
if ($existing !== null) {
return $existing;
}
return AdCreativeJob::create([
'tenant_id' => $tenantId,
'campaign_id' => $campaign->id,
'kind' => AdCreativeJob::KIND_INSPECT,
'yandex_ad_id' => (int) $adId,
'status' => AdCreativeJob::STATUS_QUEUED,
]);
});
}
/**
* Ставит задание «отвезти документ клиента в кабинет».
*
* 🔴 **Второй рубеж защиты документа, и главный из двух.** Сообщение берётся ТОЛЬКО
* связью от кампании сырой номер в выборку не попадает никогда. Чужой документ здесь
* просто не находится: связь привязана к кампании, кампания к её владельцу.
*
* Первый рубеж составные ключи базы (журнал схемы v9.13 и v9.15): они отказывают уже
* на записи. Но полагаться на них как на рабочий путь нельзя исключение приходит
* поздно, текстом PostgreSQL, посреди чужой транзакции. Ключи стоят на случай, если
* кто-то однажды напишет запись мимо этого метода.
*
* Зачем вообще два рубежа: служебный канал робота ходит под ролью с кросс-тенантным
* доступом, где RLS не отфильтрует. Ошибка здесь не сбой, а увезённый в чужую
* модерацию документ клиента, то есть утечка персональных данных без единой ошибки
* в журнале.
*
* @throws ModelNotFoundException документа нет у этой кампании
* @throws RuntimeException в сообщении нет вложения везти нечего
*/
public function enqueueDelivery(AdCampaign $campaign, int $messageId): AdCreativeJob
{
$message = $campaign->messages()->findOrFail($messageId);
// Везти нечего — задание не ставим. Иначе робот доедет до кабинета, не найдёт файла
// и закроет задание сбоем, а клиент увидит в ленте «не смогли отправить» на ровном месте.
if (blank($message->file_path)) {
throw new RuntimeException(
"В сообщении #{$message->id} нет вложения — отвозить в Яндекс нечего."
);
}
// Клиент нажал дважды — второго задания на тот же документ не заводим.
$pending = AdCreativeJob::where('campaign_id', $campaign->id)
->where('kind', AdCreativeJob::KIND_DELIVER)
->where('message_id', $message->id)
->whereIn('status', [AdCreativeJob::STATUS_QUEUED, AdCreativeJob::STATUS_TAKEN])
->first();
if ($pending !== null) {
return $pending;
}
return AdCreativeJob::create([
'tenant_id' => $campaign->tenant_id,
'campaign_id' => $campaign->id,
'kind' => AdCreativeJob::KIND_DELIVER,
'message_id' => $message->id,
'status' => AdCreativeJob::STATUS_QUEUED,
]);
}
/** Выдаёт роботу одно задание. Null, если очередь пуста или одно уже в работе. */
public function takeNext(): ?AdCreativeJob
{
return DB::transaction(function () {
// Замок на всю выдачу — ПЕРВОЙ строкой транзакции. Без него проверка «в работе
// никого» ничего не гарантирует: она не блокирует строку, и две одновременные
// выдачи обе её проходят. Первая берёт задание #1, вторая упирается в замок
// строки #1, после коммита перепроверяет условие, строка #1 уже не подходит —
// и вторая уносит задание #2. В работе оказываются два, слепки креативов
// перемешиваются, номер уезжает к чужому баннеру молча.
//
// Замок именно advisory (а не по строке): защищать надо саму операцию выдачи,
// строк с нужным статусом в этот момент может не быть вовсе. Снимается при
// коммите/откате транзакции автоматически.
DB::select("SELECT pg_advisory_xact_lock(hashtext('ad_creative_jobs_take'))");
$inFlight = AdCreativeJob::where('status', AdCreativeJob::STATUS_TAKEN)->exists();
if ($inFlight) {
return null;
}
$job = AdCreativeJob::where('status', AdCreativeJob::STATUS_QUEUED)
->orderBy('id')->lockForUpdate()->first();
if ($job === null) {
return null;
}
// Рубильник держит и саму выдачу, а не только слепок. Раньше проверка сидела
// внутри построения клиента Директа — то есть срабатывала только там, где мы
// ходим за слепком. Разведке слепок не нужен, и без этой строки при выключенном
// рубильнике робот получил бы задание и пошёл бы в живой кабинет Яндекса.
if (! config('services.yandex_direct.enabled')) {
throw new RuntimeException('Яндекс.Директ выключен (рубильник yandex_direct.enabled).');
}
// Слепок нужен ТОЛЬКО заливке: по разнице «до/после» опознаются номера
// созданных креативов. Разведка ничего не создаёт, опознавать нечего — лишний
// поход в Яндекс внутри открытой транзакции ей только во вред.
if ($job->kind !== AdCreativeJob::KIND_UPLOAD) {
$job->update([
'status' => AdCreativeJob::STATUS_TAKEN,
'attempts' => $job->attempts + 1,
'taken_at' => now(),
]);
return $job->fresh();
}
// Слепок «до» снимаем ЗДЕСЬ и в этой же транзакции. Здесь — потому что он должен
// отражать кабинет непосредственно перед заливкой: всё, что появится после этой
// секунды, и есть работа робота. В этой же транзакции — чтобы неудача снятия
// откатила и захват: Яндекс лёг на минуту, задание осталось в очереди, робот
// придёт снова. Иначе задание ушло бы «в работу» с пустым слепком, а опознавать
// потом было бы не от чего.
//
// Да, это сетевой вызов внутри открытой транзакции — обычно так не делают. Здесь
// осознанно: робот один, за заданием он приходит раз в минуту, а цена ошибки
// (перепутанные слепки, чужой креатив в объявлении клиента) несоизмеримо выше
// короткой занятой транзакции.
$job->update([
'status' => AdCreativeJob::STATUS_TAKEN,
'attempts' => $job->attempts + 1,
'taken_at' => now(),
'snapshot_before' => $this->client()->listImageCreativeIds(),
]);
return $job->fresh();
});
}
/**
* Принимает отчёт робота «готово»: берёт слепок «после», сопоставляет и проставляет
* номера креативов баннерам. При несовпадении не трогает ни один баннер.
*
* @throws CreativeMatchFailedException
*/
public function complete(AdCreativeJob $job): void
{
$this->assertTaken($job);
$banners = $this->bannersToUpload($job);
// Проверяем ДО похода в Яндекс. Ноль баннеров — это не «нечего делать, всё хорошо»,
// а признак, что мы их не видим: например, служебной роли не выдан кросс-тенантный
// доступ (`db/03_service_bypass_policies.sql` не перезапущен после выката). Пойди мы
// дальше — задание закрылось бы «готово» с нулём проставленных номеров, а запуск
// поставил бы новое задание, и робот заливал бы те же файлы по кругу.
if ($banners->isEmpty()) {
$this->markFailed($job, $e = new CreativeMatchFailedException(
"У кампании #{$job->campaign_id} не видно ни одного включённого баннера без креатива — опознавать нечего. "
.'Проверьте, что после выката перезапущен db/03_service_bypass_policies.sql.'
));
throw $e;
}
$expected = $banners->map(fn ($b) => [(int) $b->width, (int) $b->height])->all();
$before = $this->normalizeSnapshot($job->snapshot_before ?? []);
$after = $this->client()->listImageCreativeIds();
try {
$matched = $this->matcher->match($before, $after, $expected);
} catch (CreativeMatchFailedException $e) {
$this->markFailed($job, $e);
throw $e;
}
DB::transaction(function () use ($banners, $matched, $job) {
foreach ($banners as $banner) {
$banner->update(['yandex_creative_id' => $matched[$banner->width.'x'.$banner->height]]);
}
$job->update(['status' => AdCreativeJob::STATUS_DONE, 'finished_at' => now()]);
});
}
/**
* Набор баннеров задания: включённые в показ и БЕЗ номера креатива. Один и тот же список
* и выдаётся роботу, и сопоставляется при отчёте иначе стороны разойдутся.
*
* Почему только без номера. Баннер, чей креатив уже лежит в кабинете, возить незачем:
* повторная заливка оставляет там второй такой же креатив навсегда, вычистить его можно
* только руками. Хуже другое при отчёте такой баннер попал бы в список ожидаемых
* размеров, а нового креатива его размера в разнице слепков нет; опознание падало бы
* «не появился креатив размера на безупречной работе робота. Дальше задание ставится
* заново, слепок снимается свежий и так по кругу, каждый круг плодит дубли в кабинете.
*
* @return Collection<int, AdCampaignBanner>
*/
public function bannersToUpload(AdCreativeJob $job)
{
return AdCampaignBanner::where('campaign_id', $job->campaign_id)
->where('included', true)
->whereNull('yandex_creative_id')
->orderBy('width')->orderBy('height')
->get();
}
/** Помечает задание сбойным по причине от робота. */
public function fail(AdCreativeJob $job, string $reason): void
{
$this->assertTaken($job);
$job->update([
'status' => AdCreativeJob::STATUS_FAILED,
'failure_reason' => mb_substr($reason, 0, 1024),
'finished_at' => now(),
]);
}
/** Закрыть задание сбойным с причиной несостоявшегося опознания. */
private function markFailed(AdCreativeJob $job, CreativeMatchFailedException $e): void
{
$job->update([
'status' => AdCreativeJob::STATUS_FAILED,
'failure_reason' => mb_substr($e->getMessage(), 0, 1024),
'finished_at' => now(),
]);
}
/**
* Отчитаться можно только по заданию в работе. Проверка продублирована здесь, а не
* оставлена одному контроллеру: сервис зовут не только из него, а закрытое задание,
* переписанное вторым отчётом, ломает уже проставленные номера креативов.
*/
private function assertTaken(AdCreativeJob $job): void
{
if ($job->status !== AdCreativeJob::STATUS_TAKEN) {
throw new RuntimeException(
"Задание #{$job->id} не в работе (статус «{$job->status}») — отчёт по нему не принимается."
);
}
}
/**
* JSON возвращает ключи строками приводим обратно к int, чтобы array_diff_key
* в CreativeIdMatcher сравнивал одинаковые типы ключей.
*
* @param array<array-key, mixed> $snapshot
* @return array<int, array{0: int, 1: int}>
*/
private function normalizeSnapshot(array $snapshot): array
{
$out = [];
foreach ($snapshot as $id => $size) {
$out[(int) $id] = [(int) $size[0], (int) $size[1]];
}
return $out;
}
private function client(): YandexDirectClient
{
// Рубильник Директа держит и служебный канал робота. Раньше клиент строился
// безусловно: выдача задания и приём отчёта ходили в живой кабинет мимо рубильника —
// ровно то, от чего он и защищает. Проверка та же, что в CampaignLauncher.
if (! config('services.yandex_direct.enabled')) {
throw new RuntimeException('Яндекс.Директ выключен (рубильник yandex_direct.enabled).');
}
return new YandexDirectClient(
(string) config('services.yandex_direct.base_url'),
(string) config('services.yandex_direct.token'),
);
}
}
@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
/**
* Чистый валидатор креатива (заголовки/текст/картинка) по нормативам Яндекс.Директа.
* Без БД и без внешних вызовов только проверка строк/чисел.
*/
final class CreativeValidator
{
/** «Узкие» символы — не считаются в общий лимит символов (до 15 штук сверху). */
private const NARROW_CHARS = ['!', ',', '.', ';', ':', '"'];
private const TITLE_MAX_LENGTH = 56;
private const TITLE_MAX_WORD_LENGTH = 22;
private const TITLE2_MAX_NORMAL = 30;
private const TITLE2_MAX_NARROW = 15;
private const TEXT_MAX_NORMAL = 81;
private const TEXT_MAX_NARROW = 15;
private const TEXT_MAX_WORD_LENGTH = 23;
private const IMAGE_MIN_SIDE = 450;
private const IMAGE_MAX_BYTES = 10 * 1024 * 1024; // 10 МБ
private const IMAGE_ALLOWED_MIME = ['image/jpeg', 'image/png', 'image/gif'];
/**
* Проверка заголовка/текста/второго заголовка.
*
* @return array<string, string> ключ поля ('title'|'title2'|'text') ошибка по-русски; пусто = ок
*/
public function validateText(string $title, string $text, ?string $title2 = null): array
{
$errors = [];
$titleError = $this->validateTitle($title);
if ($titleError !== null) {
$errors['title'] = $titleError;
}
if ($title2 !== null) {
$title2Error = $this->validateTitle2($title2);
if ($title2Error !== null) {
$errors['title2'] = $title2Error;
}
}
$textError = $this->validateBodyText($text);
if ($textError !== null) {
$errors['text'] = $textError;
}
return $errors;
}
private function validateTitle(string $title): ?string
{
if (mb_strlen($title) > self::TITLE_MAX_LENGTH) {
return sprintf('Заголовок длиннее %d символов', self::TITLE_MAX_LENGTH);
}
foreach ($this->words($title) as $word) {
if (mb_strlen($word) > self::TITLE_MAX_WORD_LENGTH) {
return sprintf('В заголовке есть слово длиннее %d символов', self::TITLE_MAX_WORD_LENGTH);
}
}
return null;
}
/**
* Лимит title2: 30 обычных символов + до 15 «узких» сверху (узкие не входят в счёт обычных).
* Считаем отдельно узкие и не-узкие символы, а не общий счётчик так граничные
* случаи (много узких, мало обычных) считаются правильно по духу норматива Яндекса.
*/
private function validateTitle2(string $title2): ?string
{
[$normalCount, $narrowCount] = $this->countNormalAndNarrow($title2);
if ($normalCount > self::TITLE2_MAX_NORMAL || $narrowCount > self::TITLE2_MAX_NARROW) {
return sprintf(
'Второй заголовок длиннее нормы (%d обычных символов + до %d узких)',
self::TITLE2_MAX_NORMAL,
self::TITLE2_MAX_NARROW
);
}
return null;
}
private function validateBodyText(string $text): ?string
{
[$normalCount, $narrowCount] = $this->countNormalAndNarrow($text);
if ($normalCount > self::TEXT_MAX_NORMAL || $narrowCount > self::TEXT_MAX_NARROW) {
return sprintf(
'Текст длиннее нормы (%d обычных символов + до %d узких)',
self::TEXT_MAX_NORMAL,
self::TEXT_MAX_NARROW
);
}
foreach ($this->words($text) as $word) {
if (mb_strlen($word) > self::TEXT_MAX_WORD_LENGTH) {
return sprintf('В тексте есть слово длиннее %d символов', self::TEXT_MAX_WORD_LENGTH);
}
}
return null;
}
/**
* @return array{0: int, 1: int} [количество обычных символов, количество узких символов]
*/
private function countNormalAndNarrow(string $value): array
{
$normalCount = 0;
$narrowCount = 0;
foreach (mb_str_split($value) as $char) {
if (in_array($char, self::NARROW_CHARS, true)) {
$narrowCount++;
} else {
$normalCount++;
}
}
return [$normalCount, $narrowCount];
}
/** @return list<string> */
private function words(string $value): array
{
$parts = preg_split('/\s+/u', trim($value)) ?: [];
return array_values(array_filter($parts, static fn (string $w): bool => $w !== ''));
}
/**
* Проверка изображения-креатива (обычная картинка, не «широкая»).
*
* TODO(verify): точные пределы размера/пропорций/веса сверить с AdImage
* object page Яндекса перед боем. Здесь минимальный барьер 450×450,
* допустимые mime и вес ≤10 МБ; строгая пропорция сторон не проверяется.
*
* @return array<string, string> ключ 'image' ошибка по-русски; пусто = ок
*/
public function validateImage(int $width, int $height, string $mime, int $bytes): array
{
if (! in_array($mime, self::IMAGE_ALLOWED_MIME, true)) {
return ['image' => sprintf('Недопустимый формат картинки: %s', $mime)];
}
if ($bytes > self::IMAGE_MAX_BYTES) {
return ['image' => sprintf('Картинка тяжелее %d МБ', (int) (self::IMAGE_MAX_BYTES / 1024 / 1024))];
}
if ($width < self::IMAGE_MIN_SIDE || $height < self::IMAGE_MIN_SIDE) {
return ['image' => sprintf('Картинка меньше минимума %d×%d px', self::IMAGE_MIN_SIDE, self::IMAGE_MIN_SIDE)];
}
return [];
}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
/**
* Пояснение модерации Яндекса в вид, пригодный для показа клиенту.
*
* 🔴 Зачем это вообще нужно. 28.07.2026 проверили живьём боевым ключом, что отдаёт
* `ads.get` по отклонённому медийному объявлению:
*
* { "Status": "REJECTED", "StatusClarification": "\nОтклонено на модерации." }
*
* Это ВЕСЬ ответ. На экране кабинета в ту же секунду висит «Нет предупреждения:
* финансовые услуги» и абзац с указанием, что дописать в баннер, но машине Яндекс
* причину не сообщает. До этой правки клиент видел ярлык «Отклонено» и пустоту:
* подпись в списке кампаний берётся как первая строка причины, а первым знаком
* настоящего ответа идёт перенос строки.
*
* Настоящую причину приносит робот-разведчик (задача 15) он читает её с экрана.
* До его доклада клиенту говорим честно: отклонили, причину выясняем.
*
* @see bots/yandex-creatives/docs/cabinet-flow.md §7.7
*/
final class ModerationReason
{
/**
* Что Яндекс отвечает, когда причину называть не хочет. Список снят с живых ответов;
* 🪤 он заведомо неполон Яндекс может поменять формулировку в любой день, и тогда
* отписка снова поедет клиенту как «пояснение модератора». Поэтому робот-разведчик
* нужен независимо от этого списка, а сам список подпорка, не защита.
*/
private const OTPISKI = [
'Отклонено на модерации.',
'Отклонено на модерации',
'Ожидает модерации.',
'Ожидает модерации',
];
/** Что показываем клиенту, пока настоящей причины нет. Первая строка короткая:
* именно она идёт подписью под ярлыком «Отклонено» в списке кампаний. */
public const PRICHINA_NEIZVESTNA = "Яндекс отклонил рекламу, но причину не назвал.\n"
.'Выясняем — как только узнаем, напишем здесь.';
/**
* Причина отказа для клиента: настоящая, если Яндекс её назвал, иначе честное
* «выясняем». Вызывать ТОЛЬКО для отклонённых объявлений: у принятого пустое
* пояснение это норма, и придумывать там нечего.
*/
public static function forRejected(?string $raw): string
{
$text = self::normalize($raw);
return $text === null || in_array($text, self::OTPISKI, true)
? self::PRICHINA_NEIZVESTNA
: $text;
}
/**
* Обрезка краёв: у настоящих ответов Яндекса встречаются ведущие переносы
* и хвостовые пробелы. Внутренние переносы не трогаем модератор перечисляет
* претензии абзацами, и клиенту нужен весь список.
*/
public static function normalize(?string $raw): ?string
{
$text = trim((string) $raw);
return $text === '' ? null : $text;
}
}
@@ -0,0 +1,367 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Тонкий клиент Яндекс.Директ API v5 (JSON). Хост инжектится (песочница/бой В8).
* Суммы в API целые МИКРОсы (1 = 1 000 000). Все методы бросают RuntimeException на ошибку API.
*/
final class YandexDirectClient
{
/** Потолок Яндекса на число объектов в одном ответе `get`. */
private const PAGE_LIMIT = 10000;
public function __construct(
private readonly string $baseUrl,
private readonly string $token,
) {}
/** RetargetingLists.add: обернуть сегмент Аудиторий (ExternalId) в условие ретаргетинга. */
public function addRetargetingList(string $name, int $segmentExternalId): int
{
$res = $this->call('v5/retargetinglists', 'add', [
'RetargetingLists' => [[
'Name' => mb_substr($name, 0, 250),
'Type' => 'AUDIENCE',
'Rules' => [[
'Operator' => 'ALL',
'Arguments' => [['ExternalId' => $segmentExternalId]],
]],
]],
]);
return (int) $res['result']['AddResults'][0]['Id'];
}
/** AdImages.upload (json/v501): base64 → AdImageHash. */
public function uploadAdImage(string $name, string $base64Data): string
{
$res = $this->call('v501/adimages', 'add', [
'AdImages' => [['Name' => mb_substr($name, 0, 255), 'ImageData' => $base64Data]],
]);
return (string) $res['result']['AddResults'][0]['AdImageHash'];
}
/** Ads.get: статусы модерации по id. Возвращает [adId => ['status'=>..,'state'=>..,'reason'=>..]].
*
* @param array<int, int> $adIds
* @return array<int, array<string, mixed>>
*/
public function getAdsModeration(array $adIds): array
{
$res = $this->call('v5/ads', 'get', [
'SelectionCriteria' => ['Ids' => array_values($adIds)],
'FieldNames' => ['Id', 'Status', 'State', 'StatusClarification'],
]);
$out = [];
foreach ($res['result']['Ads'] ?? [] as $ad) {
$out[(int) $ad['Id']] = [
'status' => $ad['Status'] ?? null,
'state' => $ad['State'] ?? null,
'reason' => $ad['StatusClarification'] ?? null,
];
}
return $out;
}
/**
* Campaigns.add: CPM_BANNER_CAMPAIGN (медийная «за показы»), стратегия CP_MAXIMUM_IMPRESSIONS
* (макс. показов в рамках бюджета), Search=SERVING_OFF. Суммы микросы ( × 1e6).
*/
public function addCpmBannerCampaign(
string $name,
string $startDate,
string $endDate,
int $averageCpmMicros,
int $spendLimitMicros,
int $frequencyImpressions,
int $frequencyPeriodDays,
string $autoContinue = 'NO',
): int {
$res = $this->call('v5/campaigns', 'add', [
'Campaigns' => [[
'Name' => mb_substr($name, 0, 255),
'StartDate' => $startDate, // YYYY-MM-DD
'EndDate' => $endDate,
'CpmBannerCampaign' => [
'BiddingStrategy' => [
'Search' => ['BiddingStrategyType' => 'SERVING_OFF'],
'Network' => [
'BiddingStrategyType' => 'CP_MAXIMUM_IMPRESSIONS',
'CpMaximumImpressions' => [
'AverageCpm' => $averageCpmMicros,
'SpendLimit' => $spendLimitMicros,
'StartDate' => $startDate,
'EndDate' => $endDate,
'AutoContinue' => $autoContinue, // 'YES'|'NO'
],
],
],
'FrequencyCap' => [
'Impressions' => $frequencyImpressions,
'PeriodDays' => $frequencyPeriodDays,
],
],
]],
]);
return (int) $res['result']['AddResults'][0]['Id'];
}
/** AdGroups.add: пустая медийная (CpmBanner) группа аудитория цепляется отдельно (addMediaAudienceTarget).
*
* @param array<int, int> $regionIds
*/
public function addCpmBannerAdGroup(int $campaignId, string $name, array $regionIds): int
{
$res = $this->call('v5/adgroups', 'add', [
'AdGroups' => [[
'Name' => mb_substr($name, 0, 255),
'CampaignId' => $campaignId,
'RegionIds' => array_values($regionIds),
// Пустой JSON-объект {}, НЕ массив [] — иначе API Директа отвергает адгруппу.
'CpmBannerKeywordsAdGroup' => (object) [],
]],
]);
return (int) $res['result']['AddResults'][0]['Id'];
}
/** AudienceTargets.add для медийной группы: без ContextBid (ставка — на уровне кампании CPM-стратегии). */
public function addMediaAudienceTarget(int $adGroupId, int $retargetingListId): int
{
$res = $this->call('v5/audiencetargets', 'add', [
'AudienceTargets' => [[
'AdGroupId' => $adGroupId,
'RetargetingListId' => $retargetingListId,
]],
]);
return (int) $res['result']['AddResults'][0]['Id'];
}
/** Ads.add: CpmBannerAdBuilderAd по готовому креативу (Id из Яндекс.Аудиторий/Директа). */
public function addCpmBannerAd(int $adGroupId, int $creativeId, string $href): int
{
$res = $this->call('v5/ads', 'add', [
'Ads' => [[
'AdGroupId' => $adGroupId,
'CpmBannerAdBuilderAd' => [
'Creative' => ['CreativeId' => $creativeId],
'Href' => mb_substr($href, 0, 1024),
],
]],
]);
return (int) $res['result']['AddResults'][0]['Id'];
}
/**
* Creatives.get: превью готового креатива (для показа клиенту). Null, если креатив не найден.
*
* @return array{id: int, type: ?string, preview_url: ?string, thumbnail_url: ?string, is_adaptive: bool, width: ?int, height: ?int}|null
*/
public function getCreativePreview(int $creativeId): ?array
{
$res = $this->call('v5/creatives', 'get', [
'SelectionCriteria' => ['Ids' => [$creativeId]],
'FieldNames' => ['Id', 'Type', 'PreviewUrl', 'ThumbnailUrl', 'IsAdaptive', 'Width', 'Height'],
]);
$creative = $res['result']['Creatives'][0] ?? null;
if ($creative === null) {
return null;
}
return [
'id' => (int) $creative['Id'],
'type' => $creative['Type'] ?? null,
'preview_url' => $creative['PreviewUrl'] ?? null,
'thumbnail_url' => $creative['ThumbnailUrl'] ?? null,
'is_adaptive' => ($creative['IsAdaptive'] ?? null) === 'YES',
'width' => isset($creative['Width']) ? (int) $creative['Width'] : null,
'height' => isset($creative['Height']) ? (int) $creative['Height'] : null,
];
}
/**
* Creatives.get: слепок картиночных креативов аккаунта номер [ширина, высота].
*
* Нужен для опознания креативов, загруженных роботом в веб-кабинет: имя у всех
* загруженных одинаковое («Новый креатив» его ставит Яндекс), поэтому опознаём
* разницей слепков «до/после» и сопоставлением по размеру. Загруженные JPG/PNG/GIF
* Яндекс хранит типом HTML5_CREATIVE проверено живым запросом 27.07.2026.
*
* Опись идёт СТРАНИЦАМИ: больше PAGE_LIMIT объектов Яндекс за раз не отдаёт, а признак
* «отдал не всё» поле `LimitedBy` в ответе (номер последнего отданного объекта).
* Оборванный на первой странице слепок тихо ломает опознание: креатив, лежащий за
* границей страницы, отсутствует и в «до», и в «после» либо выглядит новым на каждом
* заходе. Поэтому листаем до конца.
*
* @return array<int, array{0: int, 1: int}>
*/
public function listImageCreativeIds(): array
{
$out = [];
$offset = 0;
do {
$res = $this->call('v5/creatives', 'get', [
'SelectionCriteria' => [],
'FieldNames' => ['Id', 'Type', 'Width', 'Height'],
'Page' => ['Limit' => self::PAGE_LIMIT, 'Offset' => $offset],
]);
foreach ($res['result']['Creatives'] ?? [] as $creative) {
if (($creative['Type'] ?? null) !== 'HTML5_CREATIVE') {
continue;
}
$out[(int) $creative['Id']] = [(int) ($creative['Width'] ?? 0), (int) ($creative['Height'] ?? 0)];
}
$limitedBy = $res['result']['LimitedBy'] ?? null;
$offset = $limitedBy === null ? null : (int) $limitedBy;
} while ($offset !== null);
return $out;
}
/**
* Creatives.get по списку номеров: настоящие размеры креативов номер [ширина, высота].
* Одним запросом на весь набор, чтобы сверка перед созданием объявлений не превращалась
* в пятнадцать обращений подряд. Номера, которых в кабинете нет, в ответе просто
* отсутствуют вызывающий сам решает, что делать с недостачей.
*
* @param array<int, int> $ids
* @return array<int, array{0: int, 1: int}>
*/
public function getCreativeSizes(array $ids): array
{
if ($ids === []) {
return [];
}
$res = $this->call('v5/creatives', 'get', [
'SelectionCriteria' => ['Ids' => array_values(array_unique(array_map('intval', $ids)))],
'FieldNames' => ['Id', 'Width', 'Height'],
]);
$out = [];
foreach ($res['result']['Creatives'] ?? [] as $creative) {
$out[(int) $creative['Id']] = [(int) ($creative['Width'] ?? 0), (int) ($creative['Height'] ?? 0)];
}
return $out;
}
/**
* ads.delete убрать объявления из кабинета. Зовём только для отклонённых:
* они всё равно не показываются и денег не стоят, но копить их в кабинете незачем.
* Пустой список наружу не отправляем: Яндекс ответил бы ошибкой на пустое дело.
*
* @param array<int, int|string|null> $adIds
*/
public function deleteAds(array $adIds): void
{
$adIds = array_values(array_map(
fn ($id) => (int) $id,
array_filter($adIds, fn ($id) => (int) $id > 0),
));
if ($adIds === []) {
return;
}
$this->call('v5/ads', 'delete', ['SelectionCriteria' => ['Ids' => $adIds]]);
}
/** Campaigns.suspend — пауза показа. */
public function suspendCampaign(int $campaignId): void
{
$this->call('v5/campaigns', 'suspend', ['SelectionCriteria' => ['Ids' => [$campaignId]]]);
}
/** Campaigns.resume — возобновление показа. */
public function resumeCampaign(int $campaignId): void
{
$this->call('v5/campaigns', 'resume', ['SelectionCriteria' => ['Ids' => [$campaignId]]]);
}
/**
* Reports CAMPAIGN_PERFORMANCE_REPORT за ВСЁ ВРЕМЯ кампании (накопительно). Возвращает
* суммарное число показов источник для CampaignImpressionCharger, который сам считает
* дельту от уже списанного и идемпотентен по external_key.
*
* ⚠️ TODO(verify): Reports часто отдаёт TSV (не JSON) и может строиться асинхронно
* (HTTP 201/202 + заголовок retryIn) перед боем сверить формат/async по
* yandex.ru/dev/direct/doc/ru/reports/* и при необходимости добавить ретрай-поллинг.
* Здесь минимальный синхронный путь (для песочницы/тестов).
*/
public function getCampaignImpressions(int $campaignId): int
{
$resp = Http::withToken($this->token)
->withHeaders([
'skipReportHeader' => 'true',
'skipReportSummaryRow' => 'true',
'skipColumnHeader' => 'true',
])
->acceptJson()
->post(rtrim($this->baseUrl, '/').'/json/v5/reports', ['params' => [
'SelectionCriteria' => [
'Filter' => [[
'Field' => 'CampaignId',
'Operator' => 'EQUALS',
'Values' => [(string) $campaignId],
]],
],
'FieldNames' => ['Impressions'],
// ReportName УНИКАЛЕН на каждый вызов: Яндекс кэширует подготовленный отчёт
// по имени, а нам при ALL_TIME нужны свежие накопительные показы каждый прогон —
// иначе повторный запрос вернёт устаревший кэш и списание замрёт (недобор).
'ReportName' => 'impressions-'.$campaignId.'-'.now()->format('YmdHis'),
'ReportType' => 'CAMPAIGN_PERFORMANCE_REPORT',
'DateRangeType' => 'ALL_TIME',
'Format' => 'TSV',
]]);
$body = trim((string) $resp->body());
if ($body === '') {
return 0;
}
return (int) $body;
}
/**
* Общий вызов JSON API. Бросает RuntimeException при error или не-2xx.
*
* @param array<string, mixed> $params
* @return array<string, mixed>
*/
private function call(string $servicePath, string $method, array $params): array
{
$resp = Http::withToken($this->token)
->acceptJson()
->asJson()
->post(rtrim($this->baseUrl, '/').'/json/'.$servicePath, [
'method' => $method,
'params' => $params,
]);
$json = $resp->json();
if (! $resp->successful() || isset($json['error'])) {
$err = $json['error'] ?? ['error_string' => $resp->body()];
throw new RuntimeException('Yandex Direct '.$servicePath.'/'.$method.' error: '.json_encode($err, JSON_UNESCAPED_UNICODE));
}
return $json;
}
}
@@ -10,19 +10,23 @@ use App\Models\SaasTransaction;
use App\Models\SaasUpdDocument;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use App\Services\Billing\BillingTopupService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
* Отметка счёта оплаченным: атомарный claim issued→paid (идемпотентно),
* зачисление баланса (BillingTopupService), создание акта, письмо клиенту.
* зачисление баланса (BillingTopupService баланс за лиды, либо
* AdWalletService рекламный кошелёк, по SaasInvoice::credit_target),
* создание акта, письмо клиенту.
* Зеркалит идемпотентность и RLS-контекст PaymentWebhookController.
*/
final class InvoicePaymentService
{
public function __construct(
private readonly BillingTopupService $topup,
private readonly AdWalletService $adWallet,
private readonly ActService $acts,
) {}
@@ -57,14 +61,35 @@ final class InvoicePaymentService
'completed_at' => now(),
]);
$balanceTx = $this->topup->topup((int) $invoice->tenant_id, (string) $invoice->amount_total, null);
$act = $this->acts->createForInvoice($invoice->fresh(), (int) $tx->id);
// Зачисление денег: маршрут по credit_target. 'leads' (умолчание) — старый путь
// БЕЗ ИЗМЕНЕНИЙ (баланс за лиды tenants.balance_rub через BillingTopupService,
// ledger balance_transactions). 'advertising' — отдельный кошелёк ad_wallets
// (AdWalletService), свой ledger (ad_wallet_transactions); ссылки
// balance_rub_after/balance_transaction_id на SaasTransaction относятся к
// ledger'у ЛИДОВ, поэтому для рекламной ветки не проставляются.
// TODO(В7): формулировка акта для рекламных услуг — по решению бухгалтера.
if ($invoice->credit_target === 'advertising') {
$this->adWallet->topup(
(int) $invoice->tenant_id,
(string) $invoice->amount_total,
null,
'Оплата по счёту '.$invoice->invoice_number,
);
$act = $this->acts->createForInvoice($invoice->fresh(), (int) $tx->id);
SaasTransaction::where('id', $tx->id)->update([
'balance_rub_after' => $balanceTx->balance_rub_after,
'balance_transaction_id' => $balanceTx->id,
'upd_id' => $act->id,
]);
SaasTransaction::where('id', $tx->id)->update([
'upd_id' => $act->id,
]);
} else {
$balanceTx = $this->topup->topup((int) $invoice->tenant_id, (string) $invoice->amount_total, null);
$act = $this->acts->createForInvoice($invoice->fresh(), (int) $tx->id);
SaasTransaction::where('id', $tx->id)->update([
'balance_rub_after' => $balanceTx->balance_rub_after,
'balance_transaction_id' => $balanceTx->id,
'upd_id' => $act->id,
]);
}
SaasInvoice::where('id', $invoice->id)->update(['transaction_id' => $tx->id]);
return true;
@@ -26,7 +26,7 @@ final class InvoiceService
private readonly PdfRenderer $pdf,
) {}
public function create(int $tenantId, string $amountRub, ?int $userId): SaasInvoice
public function create(int $tenantId, string $amountRub, ?int $userId, string $creditTarget = 'leads'): SaasInvoice
{
$req = TenantRequisites::where('tenant_id', $tenantId)->first();
if ($req === null || blank($req->inn)) {
@@ -43,7 +43,7 @@ final class InvoiceService
$payerEmail = is_string($email) && $email !== '' ? $email : null;
}
return DB::transaction(function () use ($tenantId, $amountRub, $req, $seller, $payerEmail) {
return DB::transaction(function () use ($tenantId, $amountRub, $req, $seller, $payerEmail, $creditTarget) {
$now = Carbon::now('Europe/Moscow');
$number = $this->numbers->next((int) $seller->id, $now);
@@ -62,6 +62,7 @@ final class InvoiceService
'vat_amount' => 0,
'amount_total' => $amountRub,
'payment_purpose' => 'Оплата по счёту '.$number.'. '.self::SERVICE_NAME.'. Без НДС.',
'credit_target' => $creditTarget,
'status' => SaasInvoice::STATUS_ISSUED,
'issued_at' => $now,
'expires_at' => $now->copy()->addWeekdays(5),
@@ -26,9 +26,14 @@ final class OnlineTopupService
PaymentGateway $gateway,
string $returnUrl,
?int $userId,
string $creditTarget = SaasTransaction::CREDIT_TARGET_LEADS,
): CreatePaymentResult {
$idempotenceKey = (string) Str::uuid();
$description = $creditTarget === SaasTransaction::CREDIT_TARGET_ADVERTISING
? 'Онлайн-пополнение рекламного кошелька'
: 'Онлайн-пополнение баланса';
$tx = SaasTransaction::create([
'tenant_id' => $tenantId,
'type' => SaasTransaction::TYPE_TOPUP,
@@ -37,8 +42,9 @@ final class OnlineTopupService
'gateway_code' => $gateway->code,
'gateway_idempotence_key' => $idempotenceKey,
'legal_entity_id' => $gateway->legal_entity_id,
'credit_target' => $creditTarget,
'status' => SaasTransaction::STATUS_PENDING,
'description' => 'Онлайн-пополнение баланса',
'description' => $description,
'created_at' => now(),
]);
@@ -48,10 +54,14 @@ final class OnlineTopupService
$email = $userId !== null ? User::query()->whereKey($userId)->value('email') : null;
$email = is_string($email) && $email !== '' ? $email : (string) config('mail.from.address', 'info@liderra.ru');
$receiptDescription = $creditTarget === SaasTransaction::CREDIT_TARGET_ADVERTISING
? 'Пополнение рекламного кошелька Лидерра'
: 'Пополнение баланса Лидерра';
$receipt = [
'customer' => ['email' => $email],
'items' => [[
'description' => 'Пополнение баланса Лидерра',
'description' => $receiptDescription,
'quantity' => '1.00',
'amount' => ['value' => $amountRub, 'currency' => 'RUB'],
'vat_code' => 1,
@@ -6,6 +6,7 @@ namespace App\Services\Billing;
use App\Models\PaymentGateway;
use App\Models\SaasTransaction;
use App\Services\Advertising\AdWalletService;
use App\Services\Billing\Gateway\PaymentGatewayDriver;
use App\Services\Billing\Gateway\WebhookVerifyResult;
use Illuminate\Support\Facades\DB;
@@ -29,6 +30,7 @@ final class PaymentSettlementService
public function __construct(
private readonly PaymentGatewayDriver $driver,
private readonly BillingTopupService $topupService,
private readonly AdWalletService $adWallet,
) {}
/** Спросить шлюз о судьбе платежа и довести наш учёт до соответствия. */
@@ -112,6 +114,24 @@ final class PaymentSettlementService
return; // идемпотентный no-op
}
// Развилка по назначению зачисления (рекламный кошелёк, Часть A): 'leads'
// (умолчание) — прежний путь БЕЗ ИЗМЕНЕНИЙ (баланс за лиды, ledger
// balance_transactions, provenance-связка). 'advertising' — свой кошелёк
// ad_wallets (AdWalletService), свой ledger (ad_wallet_transactions);
// balance_rub_after/balance_transaction_id относятся к ledger'у ЛИДОВ,
// поэтому для рекламной ветки не проставляются.
if ((string) ($tx->credit_target ?? SaasTransaction::CREDIT_TARGET_LEADS) === SaasTransaction::CREDIT_TARGET_ADVERTISING) {
$this->adWallet->topup(
(int) $tx->tenant_id, (string) $tx->amount_rub, 'yookassa', 'Пополнение рекламного кошелька картой'
);
SaasTransaction::where('id', $tx->id)->update([
'payment_method' => $verify->paymentMethod,
]);
return;
}
$balanceTx = $this->topupService->topup(
(int) $tx->tenant_id, (string) $tx->amount_rub, null
);
+5
View File
@@ -1,6 +1,7 @@
<?php
use App\Http\Middleware\ApiKeyAuth;
use App\Http\Middleware\CreativeRobotToken;
use App\Http\Middleware\EnsureSaasAdmin;
use App\Http\Middleware\EnsureSalesUser;
use App\Http\Middleware\ImpersonationContext;
@@ -33,6 +34,7 @@ return Application::configure(basePath: dirname(__DIR__))
'admin-db' => UseAdminConnection::class,
'sales-portal' => EnsureSalesUser::class,
'sales-integration' => SalesIntegrationToken::class,
'creative-robot' => CreativeRobotToken::class,
'apikey' => ApiKeyAuth::class,
]);
@@ -70,6 +72,9 @@ return Application::configure(basePath: dirname(__DIR__))
// Сервис-канал «Поиск → Портал» (Этап 2): защита — сервис-токен
// (SalesIntegrationToken), не CSRF. У Python-поиска нет сессии/куки.
'api/sales/integration/*',
// Служебный канал робота-грузчика креативов: у робота нет браузерной сессии
// и CSRF-токена, защита канала — сервис-токен (CreativeRobotToken).
'api/creative-robot/*',
]);
})
->withExceptions(function (Exceptions $exceptions): void {
+2
View File
@@ -1,9 +1,11 @@
<?php
use App\Providers\AdvertisingEventServiceProvider;
use App\Providers\AppServiceProvider;
use App\Providers\AutopodborServiceProvider;
return [
AppServiceProvider::class,
AutopodborServiceProvider::class,
AdvertisingEventServiceProvider::class,
];
+5 -3
View File
@@ -51,11 +51,13 @@ return [
'driver' => 'impersonation',
],
// Портал отдела продаж (Task 0.3). Sanctum Bearer-токены для sales_users.
// Портал отдела продаж (Task 0.3). Bearer-токены для sales_users.
// Отдельный guard изолирует аккаунты менеджеров от tenant-users и saas-admins.
// Драйвер 'sales-token' (AppServiceProvider::boot) — ЧИСТО токен, без захода
// в web-сессию: штатный 'sanctum' на stateful-домене lk.liderra.ru подставлял
// App\Models\User из открытого рядом обычного кабинета (инцидент 24.07.2026).
'sales' => [
'driver' => 'sanctum',
'provider' => 'sales_users',
'driver' => 'sales-token',
],
],
+20
View File
@@ -14,6 +14,12 @@ return [
|
*/
// Канал робота-грузчика креативов в веб-кабинет Яндекса. Свой токен, отдельный
// от клиентских и от токена «Поиск → Портал». Пусто → канал закрыт.
'creative_robot' => [
'token' => env('CREATIVE_ROBOT_TOKEN', ''),
],
// Рекламная аудитория кандидатов (Яндекс.Аудитории). Токен — OAuth Яндекс ID,
// выдаётся разово на аккаунт, где живёт сегмент. Пусто → синхронизация не идёт,
// джоб пишет причину в last_error и молчит.
@@ -22,6 +28,20 @@ return [
'segment_name' => env('YANDEX_AUDIENCE_SEGMENT_NAME', 'Лидерра — кандидаты'),
],
// Яндекс.Директ (B1) — рекламный кошелёк. ПЕСОЧНИЦА по умолчанию (В8): боевой —
// сменой env на api.direct.yandex.com после одобрения заявки. Денежный рубильник
// enabled: пока выкл — реальные вызовы Директа не идут (как enabled у прогрева).
'yandex_direct' => [
'base_url' => env('YANDEX_DIRECT_BASE_URL', 'https://api-sandbox.direct.yandex.com'),
'token' => env('YANDEX_DIRECT_TOKEN'),
'enabled' => env('YANDEX_DIRECT_ENABLED', false),
// Регионы показа по умолчанию (вся Россия = 225).
'region_ids' => [225],
// Предохранитель: SpendLimit в Яндексе = яндекс-бюджет × множитель (бэкстоп
// от перерасхода, НЕ клиентская цена).
'spend_limit_guard_multiplier' => (float) env('YANDEX_DIRECT_SPEND_GUARD', 1.2),
],
// Рекламная аудитория кандидатов в ВК (Task 8, 21.07.2026). Программный доступ
// запрошен 19.07, ждём проверки реквизитов — до получения токена джоб пишет
// vk_status=no_access и не делает ни одного обращения к ВК.
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ad_settings', function (Blueprint $table) {
$table->id();
$table->decimal('markup_percent', 5, 2)->default('30.00'); // глобальная наценка рекламы
$table->timestamps();
});
DB::table('ad_settings')->insert(['markup_percent' => '30.00', 'created_at' => now(), 'updated_at' => now()]);
// ГЛОБАЛЬНАЯ настройка (одна строка на всю систему), НЕ tenant-scoped: нет tenant_id,
// поэтому RLS НЕ вешаем осознанно. Читает app-роль, редактирует только админ.
DB::statement('GRANT SELECT ON ad_settings TO crm_app_user');
DB::statement('GRANT SELECT, UPDATE ON ad_settings TO crm_admin_user');
}
public function down(): void
{
Schema::dropIfExists('ad_settings');
}
};
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Рекламный кошелёк, Часть A, Task 10 (финал) счёт-фактура может пополнять
* либо баланс тенанта за лиды (по умолчанию), либо рекламный кошелёк
* (`ad_wallets`). `credit_target` машинный дискриминатор маршрутизации
* (НЕ путать со свободнотекстовым `payment_purpose` «назначение платежа»
* для банка). Значения: 'leads' (умолчание, старое поведение) | 'advertising'.
*
* Nullable/DEFAULT без NOT NULL squawk-safe, без блокировки записи.
*/
return new class extends Migration
{
public function up(): void
{
DB::statement("ALTER TABLE saas_invoices ADD COLUMN IF NOT EXISTS credit_target VARCHAR(16) NOT NULL DEFAULT 'leads'");
}
public function down(): void
{
DB::statement('ALTER TABLE saas_invoices DROP COLUMN IF EXISTS credit_target');
}
};
@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ad_campaigns', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('channel', 16)->default('yandex');
$table->string('name');
// draft|pending_moderation|running|paused|rejected|stopped_no_funds
$table->string('status', 24)->default('draft');
$table->unsignedSmallInteger('audience_days')->default(10);
$table->boolean('use_uploaded_list')->default(false);
$table->decimal('weekly_budget_rub', 14, 2);
$table->decimal('daily_budget_rub', 14, 2)->nullable();
$table->decimal('click_bid_rub', 14, 2)->nullable();
$table->unsignedBigInteger('yandex_segment_id')->nullable();
$table->unsignedBigInteger('yandex_retargeting_list_id')->nullable();
$table->unsignedBigInteger('yandex_campaign_id')->nullable();
$table->unsignedBigInteger('yandex_ad_group_id')->nullable();
$table->string('moderation_reason')->nullable();
$table->timestamp('launched_at')->nullable();
$table->timestamps();
$table->index(['tenant_id', 'status']);
});
DB::statement('ALTER TABLE ad_campaigns ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE ad_campaigns FORCE ROW LEVEL SECURITY');
DB::statement('DROP POLICY IF EXISTS tenant_isolation ON ad_campaigns');
DB::statement("CREATE POLICY tenant_isolation ON ad_campaigns USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
DB::statement('GRANT SELECT, INSERT, UPDATE ON ad_campaigns TO crm_app_user');
}
public function down(): void
{
Schema::dropIfExists('ad_campaigns');
}
};
@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ad_campaign_ads', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('campaign_id')->constrained('ad_campaigns')->cascadeOnDelete();
$table->string('title', 56);
$table->string('title2', 45)->nullable();
$table->string('text', 96);
$table->string('href', 1024);
$table->string('image_normal_hash')->nullable();
$table->string('image_wide_hash')->nullable();
$table->unsignedBigInteger('yandex_ad_id')->nullable();
$table->string('moderation_status', 16)->default('draft');
$table->string('moderation_reason')->nullable();
$table->timestamps();
$table->index(['tenant_id', 'campaign_id']);
});
DB::statement('ALTER TABLE ad_campaign_ads ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE ad_campaign_ads FORCE ROW LEVEL SECURITY');
DB::statement('DROP POLICY IF EXISTS tenant_isolation ON ad_campaign_ads');
DB::statement("CREATE POLICY tenant_isolation ON ad_campaign_ads USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
DB::statement('GRANT SELECT, INSERT, UPDATE ON ad_campaign_ads TO crm_app_user');
}
public function down(): void
{
Schema::dropIfExists('ad_campaign_ads');
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ad_campaign_phones', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('campaign_id')->constrained('ad_campaigns')->cascadeOnDelete();
$table->string('phone', 11);
$table->timestamp('expires_at')->nullable();
$table->timestamps();
$table->index(['tenant_id', 'campaign_id']);
$table->unique(['tenant_id', 'campaign_id', 'phone']);
});
DB::statement('ALTER TABLE ad_campaign_phones ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE ad_campaign_phones FORCE ROW LEVEL SECURITY');
DB::statement('DROP POLICY IF EXISTS tenant_isolation ON ad_campaign_phones');
DB::statement("CREATE POLICY tenant_isolation ON ad_campaign_phones USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
DB::statement('GRANT SELECT, INSERT, UPDATE, DELETE ON ad_campaign_phones TO crm_app_user');
}
public function down(): void
{
Schema::dropIfExists('ad_campaign_phones');
}
};
@@ -0,0 +1,20 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// crm_admin_user (BYPASSRLS) для будущего админ-экрана расход/маржа. Роль КАНОН — crm_admin_user (НЕ crm_app_admin).
DB::statement('GRANT SELECT ON ad_wallets, ad_wallet_transactions, ad_wallet_holds, ad_settings TO crm_admin_user');
DB::statement('GRANT SELECT ON ad_campaigns, ad_campaign_ads, ad_campaign_phones TO crm_admin_user');
}
public function down(): void
{
DB::statement('REVOKE SELECT ON ad_wallets, ad_wallet_transactions, ad_wallet_holds, ad_settings FROM crm_admin_user');
DB::statement('REVOKE SELECT ON ad_campaigns, ad_campaign_ads, ad_campaign_phones FROM crm_admin_user');
}
};
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// Джобы Директа (Charge/SyncAudience/SyncModeration) перечисляют и обновляют
// ad_campaigns / ad_campaign_ads через соединение pgsql_supplier (роль
// crm_supplier_worker, BYPASSRLS). BYPASSRLS обходит RLS-политику, но НЕ
// заменяет табличную привилегию — без GRANT будет permission denied на проде
// (как sales_prospects 19.07). SELECT — чтение всеми тремя джобами; UPDATE —
// запись статусов модерации SyncCampaignModerationJob. Гард на существование
// роли: на dev/test роли нет (DB_USERNAME=postgres superuser).
DB::statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
GRANT SELECT, UPDATE ON ad_campaigns TO crm_supplier_worker;
GRANT SELECT, UPDATE ON ad_campaign_ads TO crm_supplier_worker;
END IF;
END
$$;
SQL);
}
public function down(): void
{
DB::statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
REVOKE SELECT, UPDATE ON ad_campaigns FROM crm_supplier_worker;
REVOKE SELECT, UPDATE ON ad_campaign_ads FROM crm_supplier_worker;
END IF;
END
$$;
SQL);
}
};
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Рекламный кошелёк, Часть A оплата картой (ЮKassa) должна уметь зачислять
* либо баланс тенанта за лиды (по умолчанию), либо рекламный кошелёк
* (`ad_wallets`), зеркалит `saas_invoices.credit_target`
* (2026_07_24_100500_add_credit_target_to_saas_invoices.php). Машинный
* дискриминатор маршрутизации зачисления в PaymentSettlementService::settle().
* Значения: 'leads' (умолчание, старое поведение) | 'advertising'.
*
* DEFAULT 'leads' на существующих строках прежний денежный поток (карта
* баланс за лиды) не меняется байт-в-байт.
*/
return new class extends Migration
{
public function up(): void
{
DB::statement("ALTER TABLE saas_transactions ADD COLUMN IF NOT EXISTS credit_target VARCHAR(16) NOT NULL DEFAULT 'leads'");
}
public function down(): void
{
DB::statement('ALTER TABLE saas_transactions DROP COLUMN IF EXISTS credit_target');
}
};
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Плоская клиентская цена за 1000 показов (клиенту наценка НЕ видна). Дефолт 120.00.
// ad_settings — глобальная одна строка, без RLS; GRANT уже задан (SELECT app, UPDATE admin).
Schema::table('ad_settings', function (Blueprint $table) {
$table->decimal('client_cpm_rub', 8, 2)->default('120.00')->after('markup_percent');
});
DB::table('ad_settings')->update(['client_cpm_rub' => '120.00', 'updated_at' => now()]);
}
public function down(): void
{
Schema::table('ad_settings', function (Blueprint $table) {
$table->dropColumn('client_cpm_rub');
});
}
};
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('ad_campaigns', function (Blueprint $table) {
// Модель «за показы».
$table->unsignedSmallInteger('frequency')->nullable()->after('use_uploaded_list');
$table->unsignedSmallInteger('frequency_period_days')->nullable()->after('frequency');
$table->unsignedBigInteger('estimated_impressions')->nullable()->after('frequency_period_days');
$table->unsignedBigInteger('paid_impressions')->nullable()->after('estimated_impressions');
$table->unsignedBigInteger('delivered_impressions')->default(0)->after('paid_impressions');
$table->decimal('budget_rub', 14, 2)->nullable()->after('delivered_impressions');
$table->decimal('yandex_cost_rub', 14, 2)->default('0.00')->after('budget_rub');
$table->decimal('charged_client_rub', 14, 2)->default('0.00')->after('yandex_cost_rub');
// Клик-наследие больше не обязательно (клик-поля удалим отдельной уборкой позже).
$table->decimal('weekly_budget_rub', 14, 2)->nullable()->change();
});
}
public function down(): void
{
Schema::table('ad_campaigns', function (Blueprint $table) {
$table->dropColumn([
'frequency', 'frequency_period_days', 'estimated_impressions',
'paid_impressions', 'delivered_impressions', 'budget_rub',
'yandex_cost_rub', 'charged_client_rub',
]);
});
}
};
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ad_campaign_banners', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('campaign_id')->constrained('ad_campaigns')->cascadeOnDelete();
$table->unsignedSmallInteger('width');
$table->unsignedSmallInteger('height');
$table->string('path');
$table->unsignedInteger('bytes');
$table->timestamps();
$table->index(['tenant_id', 'campaign_id']);
});
DB::statement('ALTER TABLE ad_campaign_banners ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE ad_campaign_banners FORCE ROW LEVEL SECURITY');
DB::statement('DROP POLICY IF EXISTS tenant_isolation ON ad_campaign_banners');
DB::statement("CREATE POLICY tenant_isolation ON ad_campaign_banners USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
DB::statement('GRANT SELECT, INSERT, DELETE ON ad_campaign_banners TO crm_app_user');
}
public function down(): void
{
Schema::dropIfExists('ad_campaign_banners');
}
};
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('ad_campaigns', function (Blueprint $table) {
$table->timestamp('banners_approved_at')->nullable()->after('charged_client_rub');
});
}
public function down(): void
{
Schema::table('ad_campaigns', function (Blueprint $table) {
$table->dropColumn('banners_approved_at');
});
}
};
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('ad_campaign_banners', function (Blueprint $table) {
$table->boolean('included')->default(true)->after('bytes');
});
DB::statement('GRANT UPDATE ON ad_campaign_banners TO crm_app_user');
}
public function down(): void
{
Schema::table('ad_campaign_banners', fn (Blueprint $t) => $t->dropColumn('included'));
}
};
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Два режима сбора аудитории (авто/ручной) + клиент-редактируемая цена за 1000 показов.
// Аддитивно, RLS/policy не трогаем — GRANT UPDATE у crm_app_user уже есть (табличный GRANT
// из 2026_07_25_100000_create_ad_campaigns.php).
Schema::table('ad_campaigns', function (Blueprint $table) {
$table->string('mode', 10)->default('auto')->after('name'); // 'auto' | 'manual'
$table->date('snapshot_from')->nullable()->after('audience_days'); // ручной: период сделок
$table->date('snapshot_to')->nullable()->after('snapshot_from');
$table->smallInteger('run_days')->nullable()->after('snapshot_to'); // ручной: срок показа, дней
$table->decimal('client_cpm_rub', 8, 2)->nullable()->after('budget_rub'); // цена клиента; NULL → дефолт из ad_settings
});
}
public function down(): void
{
Schema::table('ad_campaigns', function (Blueprint $table) {
$table->dropColumn(['mode', 'snapshot_from', 'snapshot_to', 'run_days', 'client_cpm_rub']);
});
}
};
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Наша наценка на «за показы» (CPM-модель). Как и client_cpm_rub (2026_07_26_100000),
// покрывается табличным GRANT'ом ad_settings (SELECT app / SELECT+UPDATE admin) — доп.
// GRANT не нужен, там же не было явных per-column GRANT'ов.
Schema::table('ad_settings', fn (Blueprint $table) => $table->decimal('ad_margin_percent', 5, 2)->default(40.00));
}
public function down(): void
{
Schema::table('ad_settings', function (Blueprint $table) {
$table->dropColumn('ad_margin_percent');
});
}
};
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Номер (CreativeId) адаптивного креатива Яндекса — один на кампанию, покрывает
// все размеры баннера. Заполняет оператор/робот позже. Аддитивно, RLS/policy
// не трогаем.
Schema::table('ad_campaigns', function (Blueprint $table) {
$table->unsignedBigInteger('yandex_creative_id')->nullable()->after('yandex_ad_group_id');
});
// Оператор вписывает номер креатива в админ-экране «Рекламные кампании» под ролью
// crm_admin_user (на Managed-кластере НЕ BYPASSRLS). У неё на ad_campaigns был только
// SELECT (grant_admin_read_advertising) → нужен UPDATE. Даём ТОЧЕЧНО на одну колонку
// (least privilege) — оператор правит только номер креатива, не остальные поля кампании.
// crm_app_user (клиент) и crm_supplier_worker (робот-джобы) уже имеют табличный UPDATE.
// Гард на существование роли: на dev/test роли нет (DB_USERNAME=postgres superuser).
DB::statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
GRANT UPDATE (yandex_creative_id) ON ad_campaigns TO crm_admin_user;
END IF;
END
$$;
SQL);
}
public function down(): void
{
DB::statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
REVOKE UPDATE (yandex_creative_id) ON ad_campaigns FROM crm_admin_user;
END IF;
END
$$;
SQL);
Schema::table('ad_campaigns', function (Blueprint $table) {
$table->dropColumn('yandex_creative_id');
});
}
};
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// landing_url — адрес сайта клиента (куда ведёт баннер по клику); вписывает КЛИЕНТ в мастере,
// уходит в Директ как Href медийного объявления.
// yandex_ad_id — id единственного медийного объявления (CpmBannerAdBuilderAd) в Директе —
// для модерации/паузы (в модели показов объявление одно, поэтому храним на кампании).
// Аддитивно, RLS/policy не трогаем. crm_app_user (клиент) и crm_supplier_worker (джобы) уже
// имеют табличный UPDATE/INSERT на ad_campaigns (соседние yandex_* поля пишутся так же) —
// новые колонки покрыты, отдельный GRANT не нужен.
Schema::table('ad_campaigns', function (Blueprint $table) {
$table->string('landing_url', 1024)->nullable()->after('client_cpm_rub');
$table->unsignedBigInteger('yandex_ad_id')->nullable()->after('yandex_creative_id');
});
}
public function down(): void
{
Schema::table('ad_campaigns', function (Blueprint $table) {
$table->dropColumn(['landing_url', 'yandex_ad_id']);
});
}
};
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Конструктор креативов Яндекса закрыт 01.06.2026 — адаптивного креатива «один на все
// размеры» не существует, медийная кампания = объявление на каждый размер блока со своим
// креативом. Поэтому номера переезжают с кампании на баннер: строка баннера и есть единица
// «размер + файл + креатив + объявление». Основание — findings 2026-07-27.
//
// moderation_status повторяет словарь AdCampaignAd (draft/MODERATION/ACCEPTED/REJECTED) —
// passthrough статусов Яндекса без своей трактовки.
// Каждая колонка добавляется только если её ещё нет. На проде принят порядок выката
// «migrate --pretend --force → выполнить SQL вручную через psql», после которого
// миграция заходит второй раз уже по готовым колонкам — без гарда она бы упала
// «column already exists». Тот же приём, что в 2026_07_20_100000_ad_audience_firms_and_durations.php
// (ADD COLUMN IF NOT EXISTS) и 2026_07_23_120000_create_ad_audience_firm_channels.php.
Schema::table('ad_campaign_banners', function (Blueprint $table) {
if (! Schema::hasColumn('ad_campaign_banners', 'yandex_creative_id')) {
$table->unsignedBigInteger('yandex_creative_id')->nullable()->after('bytes');
}
if (! Schema::hasColumn('ad_campaign_banners', 'yandex_ad_id')) {
$table->unsignedBigInteger('yandex_ad_id')->nullable()->after('yandex_creative_id');
}
if (! Schema::hasColumn('ad_campaign_banners', 'moderation_status')) {
$table->string('moderation_status', 16)->default('draft')->after('yandex_ad_id');
}
if (! Schema::hasColumn('ad_campaign_banners', 'moderation_reason')) {
$table->string('moderation_reason')->nullable()->after('moderation_status');
}
});
// 🔴 На проде crm_supplier_worker и crm_admin_user НЕ BYPASSRLS. Табличные GRANT у
// ad_campaign_banners уже есть у crm_app_user; джобам модерации нужен UPDATE через
// pgsql_supplier. Кросс-тенантный доступ служебных ролей даёт db/03_service_bypass_policies.sql —
// после выката ПЕРЕзапустить его, иначе джоб молча увидит ноль строк.
//
// Гард на pg_roles — на случай окружения, где служебной роли нет: GRANT на несуществующую
// роль роняет миграцию. На liderra и liderra_testing роль есть, гард просто не срабатывает
// (тот же паттерн, что и в 2026_07_26_101000_add_yandex_creative_id_to_ad_campaigns.php).
DB::statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
GRANT SELECT, UPDATE ON ad_campaign_banners TO crm_supplier_worker;
END IF;
END
$$;
SQL);
}
public function down(): void
{
DB::statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
REVOKE SELECT, UPDATE ON ad_campaign_banners FROM crm_supplier_worker;
END IF;
END
$$;
SQL);
Schema::table('ad_campaign_banners', function (Blueprint $table) {
$table->dropColumn(['yandex_creative_id', 'yandex_ad_id', 'moderation_status', 'moderation_reason']);
});
}
};
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// Таблица ad_campaign_banners создана 26.07.2026 — позже точечных грантов админке (v9.01),
// поэтому у crm_admin_user на неё нет ни одной привилегии. Как только админ-экран начнёт
// читать статус модерации по баннерам (2026_07_27_100000 добавила moderation_status/
// moderation_reason/yandex_creative_id/yandex_ad_id), он молча увидел бы пустоту —
// «тихий ноль» при зелёных тестах. Выдаём SELECT.
//
// UPDATE выдаётся намеренно: оператор должен уметь вписать номер креатива (yandex_creative_id)
// руками, когда робот-грузчик креативов не справился, — тот же аварийный ручной путь, что и
// точечный GRANT UPDATE (yandex_creative_id) ON ad_campaigns для админки в v9.01.
//
// 🔴 На боевом кластере crm_admin_user НЕ BYPASSRLS: табличный GRANT даёт право на таблицу,
// но кросс-тенантные строки открывает политика srv_bypass из db/03_service_bypass_policies.sql —
// после выката его надо ПЕРЕзапустить, иначе админ-экран увидит ноль строк.
//
// Гард на pg_roles: GRANT на несуществующую роль роняет миграцию. На liderra и
// liderra_testing роль есть — гард просто не срабатывает; он нужен для чужого/чистого
// окружения (тот же паттерн, что в 2026_07_27_100000_add_yandex_ids_to_ad_campaign_banners.php).
DB::statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
GRANT SELECT, UPDATE ON ad_campaign_banners TO crm_admin_user;
END IF;
END
$$;
SQL);
}
public function down(): void
{
DB::statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
REVOKE SELECT, UPDATE ON ad_campaign_banners FROM crm_admin_user;
END IF;
END
$$;
SQL);
}
};
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Нумераторы (sequences) рекламных таблиц выдать право роли, которая туда пишет.
*
* ── Зачем это вообще нужно ────────────────────────────────────────────────────
* У всех рекламных таблиц первичный ключ объявлен как `$table->id()` это bigserial,
* то есть рядом с таблицей живёт отдельный объект-нумератор `<таблица>_id_seq`, который
* выдаёт следующий номер строки. В PostgreSQL это ОТДЕЛЬНЫЙ объект со своими правами:
* `GRANT INSERT ON <таблица>` разрешает вставку, но НЕ разрешает взять следующий номер.
* Без `USAGE` на нумераторе INSERT падает с «permission denied for sequence».
*
* ── Почему это не поймали раньше ──────────────────────────────────────────────
* На разработке и в тестах приложение подключается суперпользователем (`postgres`),
* а суперпользователю права не проверяются вообще. Поэтому дырка невидима при полностью
* зелёных тестах и вылезает только на бою, где ходят настоящие ограниченные роли.
* Тот же класс ошибки уже ловили раньше он прямо назван в db/CHANGELOG_schema.md
* (v8.82/v8.84): «пропущенный sequence-grant = permission denied на бою, тестам на
* dev/суперюзере невидимо».
*
* ── Корневая причина ──────────────────────────────────────────────────────────
* В `db/02_grants.sql` права раздаются двумя способами:
* 1) `GRANT ... ON ALL TABLES/SEQUENCES IN SCHEMA public` разово, только по тем
* объектам, что существуют в момент запуска файла;
* 2) `ALTER DEFAULT PRIVILEGES IN SCHEMA public ...` БЕЗ `FOR ROLE crm_migrator`.
* Из-за отсутствия `FOR ROLE` дефолтные привилегии привязаны к тому, кто запускает файл,
* а не к `crm_migrator`, под которым идут миграции. Итог: всё, что миграции создают после
* последнего запуска `02_grants.sql`, дефолтных прав НЕ наследует гранты приходится
* выдавать вручную в каждой миграции, и один из них (на нумератор) забыли.
* Системное лечение дописать `FOR ROLE crm_migrator` в `db/02_grants.sql`. Это базовый
* файл прав, он правится только по согласованию с владельцем, поэтому здесь только
* страховка по факту, а не корневой фикс.
*
* ── Кому выдаём и почему именно им ────────────────────────────────────────────
* Нумератор нужен только тому, кто делает INSERT. По фактическому состоянию прав
* (`information_schema.role_table_grants` на `liderra_testing`) INSERT на рекламные
* таблицы есть ровно у одной роли `crm_app_user` (клиентский портал):
* ad_campaigns, ad_campaign_ads, ad_campaign_phones, ad_campaign_banners,
* ad_wallets, ad_wallet_transactions, ad_wallet_holds.
* `crm_supplier_worker` и `crm_admin_user` имеют только SELECT/UPDATE им нумератор
* не нужен, лишнего не раздаём (least privilege). Джобы Директа перечисляют кампании
* через `pgsql_supplier`, но само списание (вставка в `ad_wallet_transactions`) идёт
* основным соединением под `crm_app_user` см. `ChargeCampaignSpendJob::handle()`.
* `ad_settings` в списке НЕТ: таблица однострочная, INSERT туда не выдан никому.
*
* ── Безопасность ──────────────────────────────────────────────────────────────
* Миграция строго аддитивна: ни колонок, ни таблиц, ни RLS-политик она не трогает.
* `GRANT` идемпотентен если право уже выдано (например, на бою `02_grants.sql`
* гонялся позже создания таблиц), повторная выдача ничего не меняет, это безвредный
* no-op. Гарды: на существование роли (на dev/test её может не быть там superuser)
* и на существование самого нумератора (окружение, где часть таблиц ещё/уже не создана).
*
* CHANGELOG: v9.05.
*/
return new class extends Migration
{
public function up(): void
{
DB::statement(<<<'SQL'
DO $$
DECLARE
seq TEXT;
seqs TEXT[] := ARRAY[
'ad_campaigns_id_seq',
'ad_campaign_ads_id_seq',
'ad_campaign_phones_id_seq',
'ad_campaign_banners_id_seq',
'ad_wallets_id_seq',
'ad_wallet_transactions_id_seq',
'ad_wallet_holds_id_seq'
];
BEGIN
-- Гард на роль: GRANT несуществующей роли роняет миграцию (dev/test = postgres
-- superuser). Тот же паттерн, что в 2026_07_27_100100_grant_admin_ad_campaign_banners.php.
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
RETURN;
END IF;
FOREACH seq IN ARRAY seqs LOOP
-- Гард на нумератор: таблицы на этом окружении может ещё/уже не быть.
IF EXISTS (
SELECT 1
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = seq AND c.relkind = 'S' AND n.nspname = 'public'
) THEN
EXECUTE format('GRANT USAGE, SELECT ON SEQUENCE public.%I TO crm_app_user', seq);
END IF;
END LOOP;
END
$$;
SQL);
}
public function down(): void
{
DB::statement(<<<'SQL'
DO $$
DECLARE
seq TEXT;
seqs TEXT[] := ARRAY[
'ad_campaigns_id_seq',
'ad_campaign_ads_id_seq',
'ad_campaign_phones_id_seq',
'ad_campaign_banners_id_seq',
'ad_wallets_id_seq',
'ad_wallet_transactions_id_seq',
'ad_wallet_holds_id_seq'
];
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
RETURN;
END IF;
FOREACH seq IN ARRAY seqs LOOP
IF EXISTS (
SELECT 1
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = seq AND c.relkind = 'S' AND n.nspname = 'public'
) THEN
EXECUTE format('REVOKE USAGE, SELECT ON SEQUENCE public.%I FROM crm_app_user', seq);
END IF;
END LOOP;
END
$$;
SQL);
}
};
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Очередь заданий роботу-грузчику креативов: один заказ один набор баннеров одной
* кампании. Робот загружает картиночные креативы в веб-кабинет Яндекс.Директа через
* браузер (API Директа их создавать не умеет), работает строго по одному заданию за
* раз иначе слепки `creatives.get` «до/после» перемешаются и опознать, какой номер
* креатива принадлежит какой кампании, станет невозможно.
*
* 🔴 ПОСЛЕ ВЫКАТА НА БОЙ перезапустить `db/03_service_bypass_policies.sql`. На боевом кластере
* служебные роли (`crm_supplier_worker`, `crm_admin_user`) НЕ BYPASSRLS кросс-тенантный доступ
* им даёт именно этот файл. Без его повторного прогона канал робота увидит в новой RLS-таблице
* ноль строк молча, без единой ошибки. Файл таблично-агностичный новую таблицу подхватит сам,
* перечислять её по имени внутри не надо.
*
* CHANGELOG: v9.06.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('ad_creative_jobs', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('campaign_id')->constrained('ad_campaigns')->cascadeOnDelete();
$table->string('status', 16)->default('queued'); // queued → taken → done | failed
$table->unsignedSmallInteger('attempts')->default(0);
$table->jsonb('snapshot_before')->nullable(); // номера картиночных креативов ДО загрузки: номер → [ширина, высота]
$table->string('failure_reason', 1024)->nullable();
$table->timestamp('taken_at')->nullable();
$table->timestamp('finished_at')->nullable();
$table->timestamps();
$table->index(['tenant_id', 'campaign_id']);
$table->index('status');
});
DB::statement('ALTER TABLE ad_creative_jobs ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE ad_creative_jobs FORCE ROW LEVEL SECURITY');
DB::statement('DROP POLICY IF EXISTS tenant_isolation ON ad_creative_jobs');
DB::statement("CREATE POLICY tenant_isolation ON ad_creative_jobs USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
// Гранты — внутри DO $$ с гардом на роль: на dev/test приложение ходит суперпользователем
// postgres, ролей там может не быть, и голый GRANT уронил бы миграцию (паттерн —
// 2026_07_25_100400_grant_supplier_worker_advertising.php).
DB::statement(<<<'SQL'
DO $$
BEGIN
-- crm_app_user клиентский портал, он ставит задания при запуске кампании (задача 10).
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
GRANT SELECT, INSERT, UPDATE ON ad_creative_jobs TO crm_app_user;
END IF;
-- crm_admin_user под этой ролью работает служебный канал робота-грузчика.
-- Маршруты /api/creative-robot/* идут через посредник `admin-db`
-- (App\Http\Middleware\UseAdminConnection), а он подменяет активное подключение
-- на pgsql_admin = crm_admin_user. Тот же порядок, что у канала «Поиск Портал»
-- (routes/web.php, группа api/sales/integration). Робот заданий НЕ создаёт
-- только берёт очередное (queued→taken) и отчитывается (taken→done|failed),
-- поэтому INSERT ему сознательно не даём: только SELECT/UPDATE (least privilege,
-- обоснование db/CHANGELOG_schema.md v9.06).
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
GRANT SELECT, UPDATE ON ad_creative_jobs TO crm_admin_user;
END IF;
END
$$;
SQL);
// Нумератор (bigserial) — отдельный объект `ad_creative_jobs_id_seq` со своими правами.
// GRANT INSERT на таблицу НЕ даёт право взять следующий номер — без USAGE на нумераторе
// INSERT падает на бою с «permission denied for sequence» (этот класс ошибки уже поймали
// и разобрали в 2026_07_27_100200_grant_ad_sequences.php / CHANGELOG v9.05; на dev/тестах
// дырка невидима, потому что там ходит суперпользователь postgres, которому права не
// проверяются). Нумератор выдаём только crm_app_user — только у него есть INSERT.
DB::statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
IF EXISTS (
SELECT 1
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'ad_creative_jobs_id_seq' AND c.relkind = 'S' AND n.nspname = 'public'
) THEN
GRANT USAGE, SELECT ON SEQUENCE public.ad_creative_jobs_id_seq TO crm_app_user;
END IF;
END IF;
END
$$;
SQL);
}
public function down(): void
{
// Вместе с таблицей уходят и политика, и гранты на таблицу/нумератор — отдельный REVOKE не нужен.
Schema::dropIfExists('ad_creative_jobs');
}
};
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Дата последнего дня показа кампании ровно та, что уходит в Директ параметром
* EndDate при запуске (CampaignLauncher::resolvePeriod).
*
* До этой колонки дата нигде не хранилась, считалась на лету и терялась. Из-за
* этого кампанию, не добравшую смету показов (для медийки по списку телефонов
* типовой исход), нечем было закрыть: статус вечно `running`, заморозка денег
* клиента ACTIVE навсегда. Теперь CampaignImpressionCharger закрывает такую
* кампанию по истечении срока тем же выходом, что и «смета откручена».
*
* RLS/GRANT не трогаем: колонка добавляется в существующую таблицу, политики
* и права выданы на уровне таблицы и продолжают действовать.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('ad_campaigns', function (Blueprint $table) {
$table->date('shows_until')->nullable()->after('run_days');
});
}
public function down(): void
{
Schema::table('ad_campaigns', function (Blueprint $table) {
$table->dropColumn('shows_until');
});
}
};
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Частичный уникальный индекс: заданий в работе (`taken`) в любой момент не больше одного.
*
* На этом инварианте держится ВСЁ опознание креативов. Номера креативов портал добывает
* слепками `creatives.get` по аккаунту целиком «до» и «после» загрузки. Два задания в
* работе одновременно перемешивают слепки между кампаниями, а размеры блоков у всех
* клиентов одинаковые (IAB: 300×250, 728×90, ), поэтому итог не громкий отказ, а ТИХАЯ
* привязка чужого номера креатива: картинка одного клиента уезжает в объявление другого.
*
* Проверки `exists()` в CreativeJobService::takeNext() для этого мало: она не блокирует
* строку. Две одновременные транзакции обе видят «в работе никого», первая берёт задание
* #1, вторая упирается в замок строки #1, после коммита Postgres перепроверяет условие,
* строка #1 уже не подходит — и вторая забирает задание #2. Оба оказываются `taken`.
*
* Индекс гарантия на уровне базы, которую нельзя обойти ни из кода, ни руками из psql.
* В самом сервисе дополнительно стоит advisory-замок, чтобы штатный путь не упирался в
* нарушение индекса, а спокойно отвечал роботу «работы нет».
*
* Индекс глобальный, БЕЗ tenant_id намеренно: рекламный кабинет Яндекса один на всех,
* и робот в нём один.
*
* RLS/гранты не затронуты: индекс новых объектов с правами не создаёт.
*/
return new class extends Migration
{
public function up(): void
{
DB::statement(
"CREATE UNIQUE INDEX uq_creative_job_single_taken ON ad_creative_jobs ((status)) WHERE status = 'taken'"
);
}
public function down(): void
{
DB::statement('DROP INDEX IF EXISTS uq_creative_job_single_taken');
}
};
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Уникальность слота баннера: одна кампания один баннер на каждый размер.
*
* Загрузка картинки идёт через updateOrCreate, а он без уникального индекса НЕ атомарен:
* два одновременных нажатия «загрузить» на один и тот же размер (двойной клик, повтор
* с телефона, медленная сеть) оба проходят проверку «такой строки ещё нет» и создают
* ДВЕ строки одного слота.
*
* Последствие тихое и дорогое: список слотов клиенту собирается keyBy по размеру и одну
* строку молча теряет в портале виден один баннер. А запуск идёт по ВСЕМ строкам набора
* и создаёт ДВА одинаковых объявления, которые крутятся за деньги клиента.
*
* tenant_id в ключе вместе с campaign_id: он и так однозначно определяется кампанией,
* но все запросы к таблице идут с явным tenant_id поверх RLS, и индекс той же формы
* работает и как рабочий индекс выборки.
*/
return new class extends Migration
{
public function up(): void
{
DB::statement(
'CREATE UNIQUE INDEX IF NOT EXISTS uq_ad_campaign_banner_slot
ON ad_campaign_banners (tenant_id, campaign_id, width, height)'
);
}
public function down(): void
{
DB::statement('DROP INDEX IF EXISTS uq_ad_campaign_banner_slot');
}
};
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Лента сообщений по рекламной кампании окно передачи между Яндексом и клиентом.
* Одна строка одно сообщение: пояснение модератора, ответ клиента или служебная
* отметка портала.
*
* `body` text без предела длины: модератор перечисляет претензии списком, и колонка
* на 255 знаков уже роняла обход модерации целиком (починено 28.07.2026). Обрезанное
* по-прежнему живёт в `ad_campaign_banners.moderation_reason` оно для ярлыка.
*
* 🔴 ПОСЛЕ ВЫКАТА НА БОЙ перезапустить `db/03_service_bypass_policies.sql`. На боевом
* кластере служебные роли НЕ BYPASSRLS кросс-тенантный доступ им даёт именно этот файл.
* Без повторного прогона джоб модерации увидит в новой таблице ноль строк молча.
*
* CHANGELOG: v9.10.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('ad_campaign_messages', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('campaign_id')->constrained('ad_campaigns')->cascadeOnDelete();
$table->foreignId('banner_id')->nullable()->constrained('ad_campaign_banners')->nullOnDelete();
$table->string('author', 16); // yandex | client | system
$table->text('body');
$table->string('file_path', 512)->nullable();
$table->string('file_name', 255)->nullable();
$table->unsignedInteger('file_size')->nullable();
$table->string('file_mime', 128)->nullable();
$table->timestamps();
$table->index(['tenant_id', 'campaign_id', 'id']);
});
DB::statement('ALTER TABLE ad_campaign_messages ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE ad_campaign_messages FORCE ROW LEVEL SECURITY');
DB::statement('DROP POLICY IF EXISTS tenant_isolation ON ad_campaign_messages');
DB::statement("CREATE POLICY tenant_isolation ON ad_campaign_messages USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
// Гранты внутри DO $$ с гардом на роль: на dev/test приложение ходит суперпользователем
// postgres, ролей там нет, и голый GRANT уронил бы миграцию.
DB::statement(<<<'SQL'
DO $$
BEGIN
-- crm_app_user клиентский портал: читает ленту и пишет ответы клиента.
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
GRANT SELECT, INSERT ON ad_campaign_messages TO crm_app_user;
END IF;
-- crm_supplier_worker под этой ролью бежит SyncCampaignModerationJob
-- (он перечисляет кампании через соединение pgsql_supplier). Именно он
-- кладёт в ленту пояснения Яндекса. UPDATE и DELETE не нужны: лента
-- только пополняется, сообщения не правятся и не стираются.
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
GRANT SELECT, INSERT ON ad_campaign_messages TO crm_supplier_worker;
END IF;
END
$$;
SQL);
// Нумератор — отдельный объект со своими правами: GRANT INSERT на таблицу НЕ даёт
// права взять следующий номер, и на бою INSERT упал бы с «permission denied for
// sequence». На dev дырка невидима — там суперпользователь.
DB::statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'ad_campaign_messages_id_seq' AND c.relkind = 'S' AND n.nspname = 'public'
) THEN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
GRANT USAGE, SELECT ON SEQUENCE public.ad_campaign_messages_id_seq TO crm_app_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
GRANT USAGE, SELECT ON SEQUENCE public.ad_campaign_messages_id_seq TO crm_supplier_worker;
END IF;
END IF;
END
$$;
SQL);
}
public function down(): void
{
// Вместе с таблицей уходят и политика, и гранты на таблицу/нумератор.
Schema::dropIfExists('ad_campaign_messages');
}
};
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Когда отклонённую кампанию вернули клиенту на починку кнопкой «Исправить».
*
* Замок на правке заперт признаком «в Яндексе уже что-то заведено» и открывался
* исключением по статусу `rejected`. Но само оживление переводит кампанию в черновик
* и исключение гасло в ту же секунду: мастер открывался, а сервер не давал ни сменить
* адрес сайта, ни перезалить картинку. Клиент оставался в прежнем тупике, только шагом
* дальше. Поймано живой проверкой в браузере 28.07.2026 при полностью зелёных тестах.
*
* Отметка живёт от «Исправить» до следующего запуска и гаснет при нём замок открыт
* ровно на время починки. Черновик после ОБОРВАВШЕГОСЯ запуска её не получает и остаётся
* запертым: там сегмент и кампания в Яндексе живы, а объявлений может не быть вовсе.
*
* RLS/GRANT не трогаем: колонка добавляется в существующую таблицу, политики и права
* выданы на уровне таблицы и продолжают действовать.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('ad_campaigns', function (Blueprint $table) {
$table->timestampTz('revived_at')->nullable()->after('launched_at');
});
}
public function down(): void
{
Schema::table('ad_campaigns', function (Blueprint $table) {
$table->dropColumn('revived_at');
});
}
};
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Вид задания роботу. До сих пор робот умел ровно одно отвезти картинки в кабинет,
* и сама очередь молчаливо означала именно это. Теперь видов три:
*
* upload отвезти картинки (то, что робот делает сегодня);
* inspect сходить посмотреть, что кабинет говорит про объявление (только чтение);
* deliver отвезти документ клиента и отправить объявление на повторную модерацию.
*
* Умолчание `upload` обязательно: задания, уже лежащие в очереди на момент выката, вида
* не имеют, и без умолчания робот получил бы пустое поле и не понял, что делать. Тот же
* приём, что у `status` в исходной таблице.
*
* `message_id` какой именно документ везём. Без него робот у кабинета знает только
* кампанию, а документов у неё может быть несколько, и он повезёт не тот. Ссылка гасится
* при удалении сообщения (`nullOnDelete`), а не тянет задание за собой: задание тогда
* честно провалится по «документа нет», вместо того чтобы уронить очередь ошибкой
* целостности.
*
* RLS/GRANT не трогаем: колонки добавляются в существующую таблицу, политика
* `tenant_isolation` и права выданы на уровне таблицы и продолжают действовать.
*
* 🔴 Внешний ключ НЕ защищает от чужого тенанта: проверки целостности в PostgreSQL идут
* в обход RLS, поэтому база примет ссылку на сообщение другого клиента. Портал этого
* не покажет там RLS, но робот ходит под `crm_admin_user` с кросс-тенантным доступом
* и увезёт чужой документ молча. Защита обязана быть в коде задачи 16: сообщение брать
* только через связь кампании (`$campaign->messages()->findOrFail($id)`), проверяя и
* тенанта, и кампанию. Подробности db/CHANGELOG_schema.md v9.12.
*
* CHANGELOG: v9.12.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('ad_creative_jobs', function (Blueprint $table) {
$table->string('kind', 16)->default('upload'); // upload | inspect | deliver
$table->foreignId('message_id')->nullable()->constrained('ad_campaign_messages')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('ad_creative_jobs', function (Blueprint $table) {
$table->dropConstrainedForeignId('message_id');
$table->dropColumn('kind');
});
}
};
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* 🔴 Документ, который везёт робот, обязан принадлежать той же кампании теперь это
* запрещает сама база, а не наша дисциплина в коде.
*
* Обычный внешний ключ `ad_creative_jobs.message_id → ad_campaign_messages.id` (v9.12)
* этого НЕ давал: проверки ссылочной целостности в PostgreSQL выполняются в обход RLS
* и подтверждают только, что строка существует. В портале дыра не выстрелила бы там
* RLS, чужого сообщения клиент не увидит. Но робот ходит под `crm_admin_user`, которому
* `db/03_service_bypass_policies.sql` даёт кросс-тенантный доступ: он молча увёз бы
* документ ОДНОГО клиента в модерацию кампании ДРУГОГО. Утечка персональных данных без
* единой ошибки в журнале, при полностью зелёных тестах.
*
* Ключ составлен **по кампании, а не по тенанту**: кампания принадлежит тенанту, поэтому
* проверка по кампании закрывает оба случая разом и чужого клиента, и другую кампанию
* того же клиента. Второй случай ключом по тенанту не ловился бы, а документ был бы всё
* равно не тот.
*
* 🪤 `ON DELETE SET NULL (message_id)` точечный вариант, он гасит ТОЛЬКО ссылку на
* сообщение. Обычный `ON DELETE SET NULL` у составного ключа обнулил бы и `campaign_id`,
* который NOT NULL, и удаление сообщения падало бы с ошибкой. Синтаксис требует
* **PostgreSQL 15+**; у нас 16 и на кластере, и локально. Laravel этого сам не умеет
* отсюда сырой SQL вместо Blueprint.
*
* 🪤 Правило MATCH SIMPLE (умолчание): когда `message_id` пуст, ключ не проверяется вовсе.
* Задания без документа то есть все сегодняшние не затронуты.
*
* RLS/GRANT не трогаем: ограничения целостности прав не требуют.
*
* CHANGELOG: v9.13.
*/
return new class extends Migration
{
public function up(): void
{
// Составной ключ может ссылаться только на уникальный набор колонок.
// `id` и так первичный ключ, поэтому пара (id, campaign_id) уникальна по построению —
// ограничение нужно формально, чтобы PostgreSQL разрешил ссылку.
DB::statement(<<<'SQL'
ALTER TABLE ad_campaign_messages
ADD CONSTRAINT uq_ad_campaign_message_campaign UNIQUE (id, campaign_id)
SQL);
DB::statement('ALTER TABLE ad_creative_jobs DROP CONSTRAINT ad_creative_jobs_message_id_foreign');
DB::statement(<<<'SQL'
ALTER TABLE ad_creative_jobs
ADD CONSTRAINT fk_creative_job_message_campaign
FOREIGN KEY (message_id, campaign_id)
REFERENCES ad_campaign_messages (id, campaign_id)
ON DELETE SET NULL (message_id)
SQL);
}
public function down(): void
{
DB::statement('ALTER TABLE ad_creative_jobs DROP CONSTRAINT fk_creative_job_message_campaign');
// Возвращаем простой ключ из v9.12 с его прежним именем — иначе откат той миграции
// не найдёт, что удалять.
DB::statement(<<<'SQL'
ALTER TABLE ad_creative_jobs
ADD CONSTRAINT ad_creative_jobs_message_id_foreign
FOREIGN KEY (message_id)
REFERENCES ad_campaign_messages (id)
ON DELETE SET NULL
SQL);
DB::statement('ALTER TABLE ad_campaign_messages DROP CONSTRAINT uq_ad_campaign_message_campaign');
}
};
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Право читать ленту сообщений служебным ролям иначе будет «тихий ноль».
*
* Таблица `ad_campaign_messages` создана 28.07.2026 (v9.10), и гранты в ней получили только
* `crm_app_user` и `crm_supplier_worker`. Под `crm_admin_user` работают два будущих читателя:
* служебный канал робота, который заберёт документ клиента (задача 16), и админский экран
* «ждёт разбора». Без гранта оба увидят ноль строк **молча**, без единой ошибки тот же
* класс поломки, что уже ловили на `ad_campaign_banners` (v9.02).
*
* Выдаём **только SELECT**: сообщения создаёт портал и джоб опроса модерации, служебному
* каналу писать в ленту незачем. Тот же принцип наименьших прав, что у `ad_creative_jobs`
* (v9.06), где роботу сознательно не дали INSERT.
*
* 🔴 Кросс-тенантные строки табличный GRANT не открывает: на боевом кластере `crm_admin_user`
* НЕ BYPASSRLS, их даёт политика `srv_bypass` из `db/03_service_bypass_policies.sql`.
* **В этом выкате файл перезапустить ОБЯЗАТЕЛЬНО** но не из-за этой миграции, а из-за
* новых таблиц `ad_campaign_messages` (v9.10) и `ad_creative_jobs` (v9.06): политику
* не ставит ни `CREATE TABLE`, ни какая-либо миграция, только ручной прогон этого файла.
* Без него служебные роли увидят в обеих таблицах ноль строк молча, при зелёном журнале.
*
* 🔴 Читать чужой документ этот грант не позволит: с v9.13 задание может ссылаться только
* на сообщение своей кампании запрещает составной внешний ключ. Грант и замок выкатываются
* вместе намеренно, порознь их разносить нельзя.
*
* Гард на `pg_roles`: GRANT на несуществующую роль роняет миграцию. На dev/тестах приложение
* ходит суперпользователем `postgres`, ролей там может не быть.
*
* CHANGELOG: v9.14.
*/
return new class extends Migration
{
public function up(): void
{
DB::statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
GRANT SELECT ON ad_campaign_messages TO crm_admin_user;
END IF;
END
$$;
SQL);
}
public function down(): void
{
DB::statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
REVOKE SELECT ON ad_campaign_messages FROM crm_admin_user;
END IF;
END
$$;
SQL);
}
};
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* 🔴 Замыкание защиты v9.13: клиент задания и клиент сообщения обязаны совпадать
* с клиентом их кампании.
*
* v9.13 привязала документ к кампании, и главное «бумага клиента A в кабинет клиента
* закрылось. Но `campaign_id` в обеих таблицах ссылался на кампанию **без клиента**, поэтому
* база всё ещё принимала строку, где задание числится за клиентом A, а кампания принадлежит
* клиенту B. Гарантия v9.13 держалась на неявном допущении, которого база не проверяла.
* Найдено повторной проверкой прав доступа и воспроизведено вживую.
*
* Живого пути к такой строке сегодня нет: `CreativeJobService::enqueue()` берёт клиента
* и кампанию из одной модели, сообщения создаются только от загруженной кампании. Но задача 16
* заводит задания на служебном канале, где RLS не отфильтрует, и допущение осталось бы
* висеть на аккуратности кода. Теперь его держит база.
*
* Побочный выигрыш: закрыт тихий боковой канал. Задание с чужим `tenant_id` было видно
* клиенту A по RLS, а робот пишет в него причину провала попади туда слова модератора
* чужой кампании, это капля данных B в портал A.
*
* `ON DELETE CASCADE` сохранён прежний: удаление кампании уносит её задания и её ленту.
*
* RLS/GRANT не трогаем: ограничения целостности прав не требуют.
*
* CHANGELOG: v9.15.
*/
return new class extends Migration
{
public function up(): void
{
// `id` — первичный ключ, поэтому пара (id, tenant_id) уникальна по построению;
// ограничение нужно формально, чтобы PostgreSQL разрешил составную ссылку.
DB::statement(<<<'SQL'
ALTER TABLE ad_campaigns
ADD CONSTRAINT uq_ad_campaign_tenant UNIQUE (id, tenant_id)
SQL);
DB::statement('ALTER TABLE ad_creative_jobs DROP CONSTRAINT ad_creative_jobs_campaign_id_foreign');
DB::statement(<<<'SQL'
ALTER TABLE ad_creative_jobs
ADD CONSTRAINT fk_creative_job_campaign_tenant
FOREIGN KEY (campaign_id, tenant_id)
REFERENCES ad_campaigns (id, tenant_id)
ON DELETE CASCADE
SQL);
DB::statement('ALTER TABLE ad_campaign_messages DROP CONSTRAINT ad_campaign_messages_campaign_id_foreign');
DB::statement(<<<'SQL'
ALTER TABLE ad_campaign_messages
ADD CONSTRAINT fk_ad_campaign_message_campaign_tenant
FOREIGN KEY (campaign_id, tenant_id)
REFERENCES ad_campaigns (id, tenant_id)
ON DELETE CASCADE
SQL);
}
public function down(): void
{
// Возвращаем простые ключи под прежними именами — их ищет откат исходных миграций.
DB::statement('ALTER TABLE ad_campaign_messages DROP CONSTRAINT fk_ad_campaign_message_campaign_tenant');
DB::statement(<<<'SQL'
ALTER TABLE ad_campaign_messages
ADD CONSTRAINT ad_campaign_messages_campaign_id_foreign
FOREIGN KEY (campaign_id) REFERENCES ad_campaigns (id) ON DELETE CASCADE
SQL);
DB::statement('ALTER TABLE ad_creative_jobs DROP CONSTRAINT fk_creative_job_campaign_tenant');
DB::statement(<<<'SQL'
ALTER TABLE ad_creative_jobs
ADD CONSTRAINT ad_creative_jobs_campaign_id_foreign
FOREIGN KEY (campaign_id) REFERENCES ad_campaigns (id) ON DELETE CASCADE
SQL);
DB::statement('ALTER TABLE ad_campaigns DROP CONSTRAINT uq_ad_campaign_tenant');
}
};
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Какое объявление Яндекса смотрит задание разведки (`kind = inspect`).
*
* Зачем отдельная колонка, а не «просто задание на кампанию». Опрос модерации бежит по
* расписанию, а отказ никуда не девается: объявление остаётся отклонённым, пока клиент его
* не починит. Дедуп «по кампании среди незавершённых» не спасает первая разведка
* закрывается за минуту, и следующий обход поставил бы вторую, потом третью, и так по кругу.
* Номер объявления естественный ключ: он не меняется, пока не меняется сама беда, а после
* кнопки «Исправить» объявление заводится заново и получает НОВЫЙ номер, то есть новый отказ
* честно получит свою разведку.
*
* Внешнего ключа тут нет и быть не может: это номер в чужой системе, у нас в базе такой
* таблицы не существует. Баннер по нему находится связью от кампании
* (`$campaign->banners()->where('yandex_ad_id', ...)`) чужой баннер так не подцепится.
*
* Индекс по (campaign_id, kind, yandex_ad_id) под тот самый запрос дедупа.
*
* Гранты не нужны: колонка добавляется в таблицу, права на которую уже выданы.
* Перезапуск `db/03_service_bypass_policies.sql` не требуется новых таблиц нет.
*
* CHANGELOG: v9.16.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('ad_creative_jobs', function (Blueprint $table) {
// 🪤 ->after() на PostgreSQL — пустое место: колонка встаёт в конец таблицы.
// Так же написаны все соседние миграции проекта.
$table->unsignedBigInteger('yandex_ad_id')->nullable();
$table->index(['campaign_id', 'kind', 'yandex_ad_id'], 'idx_creative_job_inspect');
});
}
public function down(): void
{
Schema::table('ad_creative_jobs', function (Blueprint $table) {
$table->dropIndex('idx_creative_job_inspect');
$table->dropColumn('yandex_ad_id');
});
}
};
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Право ПИСАТЬ в ленту кампании служебной роли `crm_admin_user`.
*
* 🔴 Почему это не «на всякий случай», а обязательное условие работы разведки.
* Доклад робота приходит на `POST /api/creative-robot/jobs/{id}/inspection`. Маршруты
* робота идут через посредник `admin-db` (`UseAdminConnection`), который подменяет
* подключение по умолчанию на `pgsql_admin` = роль `crm_admin_user`. Записью в ленту
* занимается `CampaignMessageService`, то есть INSERT уходит именно под этой ролью.
*
* Записью v9.14 роли выдали только SELECT читать ленту. На бою доклад разведки упал бы
* с «permission denied for table ad_campaign_messages», робот получил бы 500, три раза
* повторил и сдался. **Задание осталось бы навсегда «в работе», а пока хоть одно задание
* в работе, выдача отвечает «работы нет» ВСЕМ клиентам очередь встала бы колом.**
*
* Нумератор отдельный объект со своими правами: GRANT INSERT на таблицу НЕ даёт права
* взять следующий номер, и INSERT упал бы уже на «permission denied for sequence».
* Общий `GRANT ... ON ALL SEQUENCES` из `db/02_grants.sql` разовый он покрыл только те
* нумераторы, что существовали на момент создания ролей, а `ad_campaign_messages_id_seq`
* появился позже. Тот же класс ошибки уже ловили в v9.05.
*
* На dev/тестах дырка невидима: там ходит суперпользователь `postgres`, которому права
* не проверяются. Поэтому её и не поймал ни один зелёный тест.
*
* UPDATE и DELETE не даём: лента только пополняется, сообщения не правятся и не стираются.
*
* CHANGELOG: v9.17.
*/
return new class extends Migration
{
public function up(): void
{
DB::statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
GRANT INSERT ON ad_campaign_messages TO crm_admin_user;
END IF;
END
$$;
SQL);
DB::statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'ad_campaign_messages_id_seq' AND c.relkind = 'S' AND n.nspname = 'public'
) THEN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
GRANT USAGE, SELECT ON SEQUENCE public.ad_campaign_messages_id_seq TO crm_admin_user;
END IF;
END IF;
END
$$;
SQL);
}
public function down(): void
{
DB::statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
REVOKE INSERT ON ad_campaign_messages FROM crm_admin_user;
END IF;
END
$$;
SQL);
}
};
+42
View File
@@ -882,6 +882,18 @@ parameters:
count: 1
path: tests/Feature/Account/ChangePasswordTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesGuardTokenPriorityTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:withHeader\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesGuardTokenPriorityTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
@@ -1332,6 +1344,12 @@ parameters:
count: 5
path: tests/Feature/AdminVisitorsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:assertDatabaseHas\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Advertising/AdWalletTopupTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
@@ -4841,3 +4859,27 @@ parameters:
identifier: argument.type
count: 1
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 3
path: tests/Feature/Advertising/AdvertisingWalletEndpointTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Advertising/AdvertisingWalletEndpointTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Advertising/AdvertisingWalletEndpointTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 4
path: tests/Feature/Advertising/AdvertisingWalletEndpointTest.php
@@ -0,0 +1,10 @@
79001234567
79007654321
79161112233
89031234567
+79995556677
79261234500
79031239876
79051119988
79091234321
79995551234
1 79001234567
2 79007654321
3 79161112233
4 89031234567
5 +79995556677
6 79261234500
7 79031239876
8 79051119988
9 79091234321
10 79995551234
+20
View File
@@ -61,6 +61,26 @@ body {
color: #6a4504;
}
/*
* Дозор 2026-07-27: майский пересмотр закрыл success и warning, но info и error
* остались на брендовом цвете и не дотягивают. Замерено живьём на
* /advertising/yandex по .v-chip__content с учётом подложки чипа:
* info #3f7c95 3.63:1, error #b83a3a 4.31:1 при норме WCAG 2.1 AA 4.5:1.
* Подложка чипа берёт цвет из color-пропа и НЕ меняется темнеет только текст.
* Новые значения дают 5.89:1 и 5.98:1 та же глубина, что у зелёного (6.08)
* и жёлтого (6.36), чтобы все чипы статусов выглядели одинаково.
* Задеты статусы: «Готова к запуску», «Отклонено», «Остановлено (нет денег)».
*/
.v-chip--variant-tonal.bg-info .v-chip__content,
.v-chip--variant-tonal.text-info .v-chip__content {
color: #2e5a6d;
}
.v-chip--variant-tonal.bg-error .v-chip__content,
.v-chip--variant-tonal.text-error .v-chip__content {
color: #952c2c;
}
/*
* .text-warning is used both inside chips (covered above) and standalone
* (text-h6 count badges on ivory background). Vuetify defines the utility as
+107
View File
@@ -249,6 +249,113 @@ export async function listAdminBilling(search = ''): Promise<ListAdminBillingRes
return data;
}
// === SaaS-admin → Реклама: расход и маржа по тенантам ===
export type AdvertisingSpendPeriod = 'current_month' | 'last_month' | '90d' | 'all';
export interface ApiAdminAdvertisingSpendRow {
tenant_id: number;
tenant_name: string;
client_spend_rub: string;
yandex_cost_rub: string;
our_margin_rub: string;
}
export interface ApiAdminAdvertisingSpendTotals {
client_spend_rub: string;
yandex_cost_rub: string;
our_margin_rub: string;
}
export interface AdminAdvertisingSpendResponse {
data: ApiAdminAdvertisingSpendRow[];
totals: ApiAdminAdvertisingSpendTotals;
ad_margin_percent: string;
}
export async function fetchAdvertisingSpend(
period: AdvertisingSpendPeriod = 'current_month',
): Promise<AdminAdvertisingSpendResponse> {
const { data } = await apiClient.get<AdminAdvertisingSpendResponse>('/api/admin/advertising/spend', {
params: { period },
});
return data;
}
export interface AdminAdvertisingSettings {
client_cpm_rub: string;
ad_margin_percent: string;
}
export async function fetchAdSettings(): Promise<AdminAdvertisingSettings> {
const { data } = await apiClient.get<AdminAdvertisingSettings>('/api/admin/advertising/settings');
return data;
}
export async function updateAdSettings(
payload: Partial<AdminAdvertisingSettings>,
): Promise<AdminAdvertisingSettings> {
await ensureCsrfCookie();
const { data } = await apiClient.put<AdminAdvertisingSettings>('/api/admin/advertising/settings', payload);
return data;
}
// === SaaS-admin → Реклама: кампании, ждущие номер креатива Яндекса (8e) ===
export interface AdminAwaitingCampaign {
id: number;
tenant_id: number;
tenant_name: string;
name: string;
status: string;
yandex_creative_id: number | null;
landing_url: string | null;
estimated_impressions: number | null;
}
export async function fetchAdAwaitingCampaigns(): Promise<AdminAwaitingCampaign[]> {
const { data } = await apiClient.get<{ data: AdminAwaitingCampaign[] }>('/api/admin/advertising/campaigns');
return data.data ?? [];
}
// === SaaS-admin → Реклама: «ждёт разбора» — сбойные задания робота (задача 15) ===
/**
* Задание робота, которое закрылось сбоем. Это НЕ обычный отказ модерации — те клиент
* разбирает сам по причине в переписке. Это место, где цепочка встала: робот сходил
* в кабинет и не понял, что видит, либо не смог отвезти картинки.
*/
export interface AdminStuckRobotJob {
id: number;
campaign_id: number;
tenant_id: number;
tenant_name: string;
campaign_name: string;
campaign_status: string;
kind: string;
yandex_ad_id: number | null;
failure_reason: string | null;
attempts: number;
finished_at: string | null;
}
export async function fetchAdRobotStuck(): Promise<AdminStuckRobotJob[]> {
const { data } = await apiClient.get<{ data: AdminStuckRobotJob[] }>('/api/admin/advertising/robot-stuck');
return data.data ?? [];
}
export async function setAdCampaignCreative(
id: number,
yandexCreativeId: number,
): Promise<{ id: number; yandex_creative_id: number }> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<{ id: number; yandex_creative_id: number }>(
`/api/admin/advertising/campaigns/${id}/creative`,
{ yandex_creative_id: yandexCreativeId },
);
return data;
}
// === SaaS-admin → Инциденты ===
export interface ApiAdminIncident {
+418
View File
@@ -0,0 +1,418 @@
import { apiClient, ensureCsrfCookie } from './client';
/**
* API-модуль рекламного модуля «Яндекс Аудитория» (Часть B2, Task 1).
*
* Эндпоинты под [auth:sanctum, tenant], префикс /api/advertising (см. Часть B1 —
* docs/superpowers/plans/2026-07-24-yandex-kanal-chast-B1-backend.md Task 12,
* routes/web.php). GET'ы не требуют CSRF-cookie, мутации (POST/PATCH) — требуют.
*/
/** Статус кампании Директа (см. App\Models\AdCampaign константы STATUS_*). */
export type CampaignStatus =
| 'draft'
| 'queued'
// Промежуточный: запуск идёт прямо сейчас. Держится секунды и защищает от двойного
// нажатия «запустить» (иначе в кабинете Яндекса завелись бы две одинаковые кампании).
| 'launching'
| 'pending_moderation'
| 'running'
| 'paused'
| 'rejected'
| 'stopped_no_funds'
| 'completed';
/** Ответ GET /api/advertising/wallet — статус рекламного кошелька тенанта. */
export interface AdWalletStatus {
solvent: boolean;
balance_rub: string;
frozen_rub: string;
free_rub: string;
}
/**
* Кампания «за показы» (строка из GET /api/advertising/campaigns, а также
* POST/PATCH-ответ). `yandex_cost_rub` (наша маржа) сюда НИКОГДА не
* добавляется — модель прячет поле в сериализации (Ч.5b Task 1, `$hidden`),
* клиент его физически не видит.
*/
export interface Campaign {
id: number;
name: string;
status: CampaignStatus;
audience_days: number;
launched_at: string | null;
/** Частота показов на человека за период (Ч.5b). */
frequency?: number;
/** Сколько показов ожидаем — смета с сервера (Ч.5b, из audience-size). */
estimated_impressions?: number;
/** Итоговый бюджет показов клиента — та же смета, что и estimated_impressions. */
budget_rub?: string;
/** Сколько показов фактически откручено на данный момент (Ч.6, CampaignImpressionCharger). */
delivered_impressions?: number;
/**
* Приходят и в POST/PATCH-ответе, и в GET .../{id} (detail, controller::show
* отдаёт модель целиком) — но НЕ в списке GET /campaigns (index явно
* выбирает узкий набор колонок), поэтому опциональны.
*/
use_uploaded_list?: boolean;
/** Legacy-поле модели клик-кампаний — оставлено опциональным, новый мастер его не шлёт/не читает. */
weekly_budget_rub?: string;
/** Пояснение модератора Яндекса — подпись под ярлыком «Отклонено». Полный текст живёт в переписке. */
moderation_reason?: string | null;
/** Режим сбора аудитории — 'auto' (крутится постоянно, окно дней) или 'manual' (разовый снимок + свои номера). */
mode?: 'auto' | 'manual';
/** Начало периода снимка контактов (режим manual). */
snapshot_from?: string | null;
/** Конец периода снимка контактов (режим manual, >= snapshot_from). */
snapshot_to?: string | null;
/** Сколько дней показывать рекламу после снимка (режим manual). */
run_days?: number | null;
/** Клиентская цена за 1000 показов, ₽ — правится клиентом, дефолт с сервера. */
client_cpm_rub?: string | null;
/** Адрес сайта, куда ведёт баннер по клику. */
landing_url?: string | null;
}
/** Ответ GET /api/advertising/campaigns/{id}/audience-size. */
export interface AudienceSize {
size: number;
min: number;
enough: boolean;
hint: string | null;
frequency?: number;
impressions?: number;
cpm_rub?: string;
cost_rub?: string;
}
/** Тело POST/PATCH /api/advertising/campaigns (создание/правка черновика кампании «за показы»). */
export interface CampaignCreate {
name: string;
audience_days: number;
use_uploaded_list: boolean;
/** Частота показов на человека (Ч.5b, шаг «Как часто показывать»). */
frequency?: number;
frequency_period_days?: number;
/** Смета показов/бюджета — берём из ответа audience-size, фронт не считает сам (Р37). */
estimated_impressions?: number;
budget_rub?: string;
/** Режим сбора аудитории — 'auto' (окно дней, крутится постоянно) или 'manual' (снимок дат + свои номера). */
mode?: 'auto' | 'manual';
/** Начало периода снимка контактов (режим manual, YYYY-MM-DD). */
snapshot_from?: string | null;
/** Конец периода снимка контактов (режим manual, YYYY-MM-DD, >= snapshot_from). */
snapshot_to?: string | null;
/** Сколько дней показывать рекламу после снимка (режим manual, 1..365). */
run_days?: number | null;
/** Клиентская цена за 1000 показов, ₽ — правится клиентом на шаге 2. */
client_cpm_rub?: string | null;
/** Адрес сайта, куда ведёт баннер по клику. */
landing_url?: string | null;
}
/** Объявление кампании (ответ POST /api/advertising/campaigns/{id}/ads). */
export interface AdCreative {
id: number;
campaign_id: number;
title: string;
text: string;
href: string;
title2: string | null;
moderation_status: string;
/** Причина отказа модерации Яндекса — приходит вместе с moderation_status='rejected'. */
moderation_reason?: string | null;
image_normal_hash?: string | null;
}
/** Ответ GET /api/advertising/campaigns/{id} — детали кампании + объявления + расход. */
export interface CampaignDetail {
campaign: Campaign;
ads: AdCreative[];
spent_rub: string;
}
/** GET /api/advertising/wallet — статус рекламного кошелька (баланс/заморожено/свободно). */
export async function fetchWallet(): Promise<AdWalletStatus> {
const { data } = await apiClient.get<AdWalletStatus>('/api/advertising/wallet');
return data;
}
/** GET /api/advertising/campaigns — список кампаний тенанта. */
export async function fetchCampaigns(): Promise<Campaign[]> {
const { data } = await apiClient.get<{ data: Campaign[] }>('/api/advertising/campaigns');
return data.data ?? [];
}
/** GET /api/advertising/campaigns/{id} — детали кампании (объявления + расход). */
export async function fetchCampaign(id: number): Promise<CampaignDetail> {
const { data } = await apiClient.get<CampaignDetail>(`/api/advertising/campaigns/${id}`);
return data;
}
/** POST /api/advertising/campaigns — создать черновик кампании. */
export async function createCampaign(payload: CampaignCreate): Promise<Campaign> {
await ensureCsrfCookie();
const { data } = await apiClient.post<Campaign>('/api/advertising/campaigns', payload);
return data;
}
/** PATCH /api/advertising/campaigns/{id} — частичная правка кампании. */
export async function patchCampaign(id: number, payload: Partial<CampaignCreate>): Promise<Campaign> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<Campaign>(`/api/advertising/campaigns/${id}`, payload);
return data;
}
/** Параметры GET /api/advertising/campaigns/{id}/audience-size — оба режима сбора аудитории + клиентская цена. */
export interface AudienceSizeParams {
/** Окно дней (режим auto). */
days?: number;
/** Частота показов на человека — нужна для сметы показов/бюджета на шаге 2. */
frequency?: number;
/** Режим сбора аудитории — определяет, что сервер трактует days vs from/to. */
mode?: 'auto' | 'manual' | string;
/** Начало периода снимка (режим manual). */
from?: string | null;
/** Конец периода снимка (режим manual). */
to?: string | null;
/** Клиентская цена за 1000 показов — если не задана, сервер применяет дефолт. */
cpm?: string | null;
}
/** GET /api/advertising/campaigns/{id}/audience-size — живой счётчик аудитории (+ показы/цена/итого при заданных параметрах). */
export async function fetchAudienceSize(id: number, params: AudienceSizeParams = {}): Promise<AudienceSize> {
const query: Record<string, string | number> = {};
if (params.days !== undefined) query.days = params.days;
if (params.frequency !== undefined) query.frequency = params.frequency;
if (params.mode !== undefined) query.mode = params.mode;
if (params.from) query.from = params.from;
if (params.to) query.to = params.to;
if (params.cpm !== undefined && params.cpm !== null && params.cpm !== '') query.cpm = params.cpm;
const { data } = await apiClient.get<AudienceSize>(`/api/advertising/campaigns/${id}/audience-size`, { params: query });
return data;
}
/**
* Один слот баннера кампании — ровно один из 15 канонических размеров
* (BannerSizes::all()). Клиент грузит СВОЙ готовый файл на КАЖДЫЙ размер —
* автогенерации из одной картинки больше нет. `slots` в GET .../banners
* всегда содержит все 15 размеров (загруженные и пустые вперемешку).
*/
export interface BannerSlot {
width: number;
height: number;
uploaded: boolean;
banner_id: number | null;
bytes: number | null;
included: boolean;
preview_url: string | null;
}
/** Ответ GET /api/advertising/campaigns/{id}/banners. */
export interface BannerSet {
approved_at: string | null;
max_bytes: number;
formats: string[];
slots: BannerSlot[];
}
/** GET набор слотов баннеров кампании (все 15 размеров + момент утверждения). */
export async function fetchBanners(id: number): Promise<BannerSet> {
const { data } = await apiClient.get<BannerSet>(`/api/advertising/campaigns/${id}/banners`);
return data;
}
/** POST /api/advertising/campaigns/{id}/banners — загрузить готовый файл на конкретный размер (multipart width/height/file). */
export async function uploadBanner(id: number, width: number, height: number, file: File): Promise<{ slot: BannerSlot }> {
await ensureCsrfCookie();
const form = new FormData();
form.append('width', String(width));
form.append('height', String(height));
form.append('file', file);
const { data } = await apiClient.post<{ slot: BannerSlot }>(`/api/advertising/campaigns/${id}/banners`, form);
return data;
}
/** PATCH /api/advertising/campaigns/{id}/banners/{bannerId} — включить/выключить размер из показа. */
export async function toggleBannerIncluded(id: number, bannerId: number, included: boolean): Promise<{ slot: BannerSlot }> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<{ slot: BannerSlot }>(`/api/advertising/campaigns/${id}/banners/${bannerId}`, { included });
return data;
}
/** DELETE /api/advertising/campaigns/{id}/banners/{bannerId} — удалить загруженный баннер размера. */
export async function deleteBanner(id: number, bannerId: number): Promise<void> {
await ensureCsrfCookie();
await apiClient.delete(`/api/advertising/campaigns/${id}/banners/${bannerId}`);
}
/** POST утвердить набор баннеров. */
export async function approveBanners(id: number): Promise<{ approved_at: string | null }> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ approved_at: string | null }>(`/api/advertising/campaigns/${id}/banners/approve`);
return data;
}
/** POST /api/advertising/campaigns/{id}/launch — запустить кампанию (модерация Яндекса). */
export async function launchCampaign(id: number): Promise<{ status: CampaignStatus }> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ status: CampaignStatus }>(`/api/advertising/campaigns/${id}/launch`);
return data;
}
/**
* POST /api/advertising/campaigns/{id}/submit — отправить готовую кампанию «за показы» на запуск.
* Директа сейчас нет (заявка на доступ на рассмотрении, Ч.5b) — переводит кампанию в статус
* `queued` («готова к запуску, ждёт оператора»), реальный запуск в Директ — Часть 4.
*/
export async function submitCampaign(id: number): Promise<{ status: CampaignStatus }> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ status: CampaignStatus }>(`/api/advertising/campaigns/${id}/submit`);
return data;
}
/** POST /api/advertising/campaigns/{id}/pause — поставить кампанию на паузу. */
export async function pauseCampaign(id: number): Promise<{ status: CampaignStatus }> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ status: CampaignStatus }>(`/api/advertising/campaigns/${id}/pause`);
return data;
}
/** POST /api/advertising/campaigns/{id}/resume — возобновить кампанию с паузы. */
export async function resumeCampaign(id: number): Promise<{ status: CampaignStatus }> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ status: CampaignStatus }>(`/api/advertising/campaigns/${id}/resume`);
return data;
}
/**
* POST /api/advertising/campaigns/{id}/revive — вернуть отклонённую кампанию в черновик,
* чтобы клиент переделал картинки и нажал обычное «Запустить». Второго пути запуска нет.
*/
export async function reviveCampaign(id: number): Promise<{ status: CampaignStatus }> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ status: CampaignStatus }>(`/api/advertising/campaigns/${id}/revive`);
return data;
}
/** POST /api/advertising/campaigns/{id}/ads — добавить объявление (текстовый креатив). */
export async function addCreative(
id: number,
payload: { title: string; text: string; href: string; title2?: string },
): Promise<AdCreative> {
await ensureCsrfCookie();
const { data } = await apiClient.post<AdCreative>(`/api/advertising/campaigns/${id}/ads`, payload);
return data;
}
/** POST /api/advertising/campaigns/{id}/ads/{adId}/image — загрузить картинку объявления. */
export async function uploadCreativeImage(id: number, adId: number, file: File): Promise<{ hash: string }> {
await ensureCsrfCookie();
const form = new FormData();
form.append('file', file);
const { data } = await apiClient.post<{ hash: string }>(`/api/advertising/campaigns/${id}/ads/${adId}/image`, form);
return data;
}
/** DELETE /api/advertising/campaigns/{id} — удалить ЧЕРНОВИК кампании своего тенанта (409 если не draft, 404 чужой). */
export async function deleteCampaign(id: number): Promise<void> {
await ensureCsrfCookie();
await apiClient.delete(`/api/advertising/campaigns/${id}`);
}
/** Ответ POST /api/advertising/campaigns/{id}/phones — сколько номеров распознано / отброшено. */
export interface UploadPhonesResult {
recognized: number;
skipped: number;
}
/**
* POST /api/advertising/campaigns/{id}/phones — загрузить «мой список номеров» кампании
* (файл csv/txt и/или текст, оба необязательны по отдельности — но хотя бы один нужен серверу).
*/
export async function uploadCampaignPhones(
id: number,
payload: { file?: File | null; text?: string },
): Promise<UploadPhonesResult> {
await ensureCsrfCookie();
const form = new FormData();
if (payload.file) form.append('file', payload.file);
if (payload.text && payload.text.trim() !== '') form.append('text', payload.text);
const { data } = await apiClient.post<UploadPhonesResult>(`/api/advertising/campaigns/${id}/phones`, form);
return data;
}
/**
* Счёт на пополнение РЕКЛАМНОГО кошелька (POST /api/billing/invoices, credit_target=advertising).
* Тот же эндпоинт, что и обычный счёт за лиды (api/billing.ts::createInvoice) —
* InvoicePaymentService зачисляет ad_wallets вместо tenants.balance_rub (см.
* app/Http/Controllers/Api/InvoiceController.php::store, min:100/max:1000000).
*/
export interface AdvertisingInvoice {
id: number;
invoice_number: string;
amount_total: string;
pdf_url: string;
}
/** POST /api/billing/invoices с credit_target='advertising' — счёт по реквизитам для пополнения рекламного кошелька. */
export async function createAdvertisingInvoice(amountRub: number): Promise<AdvertisingInvoice> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ invoice: AdvertisingInvoice }>('/api/billing/invoices', {
amount_rub: amountRub,
credit_target: 'advertising',
});
return data.invoice;
}
/**
* Результат POST /api/billing/topup с credit_target='advertising' — две формы (зеркалит
* api/billing.ts::TopupResult / BillingController::topup):
* • реальный шлюз (флаг billing_yookassa_enabled ВКЛ): confirmation_url — редирект на оплату
* ЮKassa, кошелёк зачислится позже по webhook;
* • заглушка (флаг ВЫКЛ): ok:true — AdWalletService зачисляет рекламный кошелёк мгновенно.
*/
export interface AdvertisingCardTopupResult {
confirmation_url?: string;
ok?: boolean;
}
/** POST /api/billing/topup с credit_target='advertising' — оплата картой рекламного кошелька. */
export async function topupAdvertisingByCard(amountRub: string | number): Promise<AdvertisingCardTopupResult> {
await ensureCsrfCookie();
const { data } = await apiClient.post<AdvertisingCardTopupResult>('/api/billing/topup', {
amount_rub: amountRub,
credit_target: 'advertising',
});
return data;
}
/** Сообщение ленты кампании — окно передачи между Яндексом и клиентом. */
export interface CampaignMessage {
id: number;
author: 'yandex' | 'client' | 'system';
banner_id: number | null;
body: string;
file_name: string | null;
file_size: number | null;
created_at: string | null;
}
/** GET /api/advertising/campaigns/{id}/messages — лента сообщений кампании. */
export async function fetchCampaignMessages(id: number): Promise<CampaignMessage[]> {
const { data } = await apiClient.get<{ messages: CampaignMessage[] }>(`/api/advertising/campaigns/${id}/messages`);
return data.messages;
}
/**
* POST /api/advertising/campaigns/{id}/messages — ответ клиента, можно с файлом.
* Мутация, поэтому сначала CSRF-cookie — как у всех POST'ов этого модуля.
*/
export async function sendCampaignMessage(id: number, body: string, file: File | null): Promise<void> {
await ensureCsrfCookie();
const form = new FormData();
if (body !== '') form.append('body', body);
if (file !== null) form.append('file', file);
await apiClient.post(`/api/advertising/campaigns/${id}/messages`, form);
}
@@ -109,7 +109,7 @@ function close(): void {
было <span class="num">{{ currentBalanceRub.toFixed(2) }} </span> станет
<span class="num">{{ targetNormalized }} </span> (<span
class="num"
:class="Number(delta) < 0 ? 'text-error' : 'text-success'"
:class="Number(delta) < 0 ? 'text-error' : 'text-success-strong'"
>
{{ Number(delta) > 0 ? '+' : '' }}{{ delta }} </span
>)
@@ -59,7 +59,7 @@ const emit = defineEmits<{
<td>{{ tx.description }}</td>
<td
class="text-end num"
:class="{ 'text-error': tx.amount < 0, 'text-success': tx.amount > 0 }"
:class="{ 'text-error': tx.amount < 0, 'text-success-strong': tx.amount > 0 }"
>
{{ formatRub(tx.amount) }}
</td>
@@ -39,7 +39,7 @@ function formatRub(v: number): string {
>
<span class="sep">·</span>
<span
><span class="num text-warning">{{ stats.overdue }}</span> просрочка</span
><span class="num text-warning-strong">{{ stats.overdue }}</span> просрочка</span
>
<span class="sep">·</span>
<span
@@ -90,7 +90,7 @@ function formatRub(v: number): string {
font-weight: 500;
}
.stat-success {
/* WCAG2AA: #1b5e20 ≈ 7.1:1 на ivory #F6F3EC (Vuetify text-success ≈ 3.83:1). */
/* WCAG2AA: #1b5e20 ≈ 7.1:1 на ivory #F6F3EC (брендовый text-success ≈ 3.83:1). */
color: #1b5e20;
}
</style>
@@ -0,0 +1,142 @@
<script setup lang="ts">
/**
* Шапка-кошелёк рекламного модуля «Яндекс Аудитория» (Часть B2, задача B2-3).
*
* Показывает баланс / заморожено / свободно рекламного кошелька тенанта
* (GET /api/advertising/wallet, api/advertising.ts::fetchWallet). Если
* solvent=false — реклама остановлена из-за нехватки средств (Р14/Р15:
* заметное оповещение прямо в кабинете). Кнопка «Пополнить» открывает
* AdWalletTopupDialog (Часть A, Task 3) — счёт с credit_target=advertising,
* НЕ router.push('/billing') (тот балансировал за лиды, не рекламу).
*/
import { ref, onMounted } from 'vue';
import { fetchWallet, type AdWalletStatus } from '../../api/advertising';
import { extractErrorMessage } from '../../api/client';
import { formatPlain } from '../../composables/billingFormatters';
import AdWalletTopupDialog from './AdWalletTopupDialog.vue';
const wallet = ref<AdWalletStatus | null>(null);
const loading = ref(true);
const loadError = ref<string | null>(null);
const showTopupDialog = ref(false);
/** «18400.00» (bcmath-строка) → «18 400 ₽» — переиспользуем формат портала (BillingView). */
function formatMoney(value: string): string {
return formatPlain(Number(value));
}
async function loadWallet(): Promise<void> {
loading.value = true;
loadError.value = null;
try {
wallet.value = await fetchWallet();
} catch (e) {
// Ошибка загрузки не должна рушить экран — тихий inline-текст, без wallet.
wallet.value = null;
loadError.value = extractErrorMessage(e, 'Не удалось загрузить данные рекламного кошелька.');
} finally {
loading.value = false;
}
}
function goTopup(): void {
showTopupDialog.value = true;
}
onMounted(() => {
void loadWallet();
});
defineExpose({ loadWallet, wallet });
</script>
<template>
<v-card variant="flat" class="ad-wallet-header pa-4 mb-4" data-testid="ad-wallet-header">
<div class="d-flex align-center justify-space-between flex-wrap ga-3">
<div class="d-flex align-center">
<v-icon icon="mdi-wallet-outline" color="primary" size="22" class="mr-2" />
<span class="text-subtitle-2 text-medium-emphasis">Рекламный кошелёк</span>
</div>
<v-btn
color="primary"
variant="flat"
size="small"
prepend-icon="mdi-plus"
data-testid="wallet-topup-btn"
@click="goTopup"
>
Пополнить рекламный кошелёк
</v-btn>
</div>
<div v-if="loading" class="d-flex justify-center py-4">
<v-progress-circular indeterminate color="primary" size="24" />
</div>
<p v-else-if="loadError" class="text-body-2 text-error mt-3 mb-0" data-testid="wallet-load-error">
{{ loadError }}
</p>
<template v-else-if="wallet">
<v-alert
v-if="!wallet.solvent"
type="error"
variant="tonal"
density="comfortable"
class="mt-3"
icon="mdi-alert-circle-outline"
data-testid="wallet-insolvent-alert"
>
Пополните рекламный кошелёк реклама остановлена.
</v-alert>
<v-row class="mt-2" dense>
<v-col cols="12" sm="4">
<div class="wallet-stat">
<span class="wallet-stat-label text-caption text-medium-emphasis">Кошелёк</span>
<span class="wallet-stat-value num" data-testid="wallet-balance">{{
formatMoney(wallet.balance_rub)
}}</span>
</div>
</v-col>
<v-col cols="12" sm="4">
<div class="wallet-stat">
<span class="wallet-stat-label text-caption text-medium-emphasis">Заморожено</span>
<span class="wallet-stat-value num" data-testid="wallet-frozen">{{
formatMoney(wallet.frozen_rub)
}}</span>
</div>
</v-col>
<v-col cols="12" sm="4">
<div class="wallet-stat">
<span class="wallet-stat-label text-caption text-medium-emphasis">Свободно</span>
<span class="wallet-stat-value num text-primary" data-testid="wallet-free">{{
formatMoney(wallet.free_rub)
}}</span>
</div>
</v-col>
</v-row>
</template>
</v-card>
<AdWalletTopupDialog v-model="showTopupDialog" @topped-up="loadWallet" />
</template>
<style scoped>
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-weight: 500;
}
.wallet-stat {
display: flex;
flex-direction: column;
gap: 4px;
}
.wallet-stat-value {
font-size: 20px;
line-height: 1.2;
}
</style>

Some files were not shown because too many files have changed in this diff Show More