merge: сведение ветки «Реклама Телеграм» с боевым main
Слияние 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:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Набор баннеров задания: включённые в показ и БЕЗ номера креатива. Один и тот же список
|
||||
* и выдаётся роботу, и сопоставляется при отчёте — иначе стороны разойдутся.
|
||||
*
|
||||
* Почему только без номера. Баннер, чей креатив уже лежит в кабинете, возить незачем:
|
||||
* повторная заливка оставляет там второй такой же креатив навсегда, вычистить его можно
|
||||
* только руками. Хуже другое — при отчёте такой баннер попал бы в список ожидаемых
|
||||
* размеров, а нового креатива его размера в разнице слепков нет; опознание падало бы
|
||||
* «не появился креатив размера X» на безупречной работе робота. Дальше задание ставится
|
||||
* заново, слепок снимается свежий — и так по кругу, каждый круг плодит дубли в кабинете.
|
||||
*
|
||||
* @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
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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',
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+32
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+44
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+77
@@ -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);
|
||||
}
|
||||
};
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 🔴 Замыкание защиты v9.13: клиент задания и клиент сообщения обязаны совпадать
|
||||
* с клиентом их кампании.
|
||||
*
|
||||
* v9.13 привязала документ к кампании, и главное — «бумага клиента A в кабинет клиента B» —
|
||||
* закрылось. Но `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);
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user