diff --git a/.gitleaks.toml b/.gitleaks.toml index 9d6ad67e..650469d5 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -104,6 +104,15 @@ commits = [ # никуда — проверено, из main и рабочих веток недостижима. Светит только потому, # что проверка обходит всё хранилище целиком. "3b5fc99caf36763872bcc0f4a7e81d2797ac1c10", # 19.07.2026 YandexAudienceClient.php + # ── Разбор 28.07.2026 ──────────────────────────────────────────────────── + # Три коммита от 26.07 из ПАРАЛЛЕЛЬНЫХ веток («смс-клиент», «реклама-показы»), + # в других ветках недостижимы. Находки — номера-ОБРАЗЦЫ в файле-примере для + # клиента и в демо-данных экрана: показывают, что номер можно писать в любом + # формате. Владелец подтвердил 28.07.2026, что номера выдуманные, не ПДн. + # Первый уже на gitea, два других — локальные, лежат в своих ветках. + "5a4c0e0235a8c8b322c667f623a9ecb188bac035", # 26.07.2026 пример списка номеров + "bc573a99ebbebbb27c4f3dd7e202b85a71225028", # 26.07.2026 генератор файла-примера + "c47c3fb4d5402323166eace53484935b7dea1a9d", # 26.07.2026 экран смс-рассылки ] paths = [ @@ -144,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) @@ -194,7 +208,13 @@ paths = [ # (напр. +7 495 000-00-00) для проверки извлечения номера из кода сайта, и # публичный ИНН для проверки резолва. Не реальные ПДн; та же категория, что # app/tests/*.php и app/tests/fixtures/*. Токены живут в secrets/ (.gitignore). - '''моя/sales-finder/tests/.*''' + '''моя/sales-finder/tests/.*''', + # TDD-тесты бота Telegram Ads (bots/mts-telegram-ads/test/) — синтетические + # телефоны-фикстуры для проверки нормализации номеров: 345-67-89 (сплошная + # возрастающая последовательность) и 111-22-33 (шаблон). Таких номеров не + # существует в реальности; не клиентские ПДн. Только каталог test/ (src/ + # сканируется штатно). Та же категория, что app/tests/*.php. + '''bots/mts-telegram-ads/test/.*''' ] regexTarget = "match" regexes = [ diff --git a/CLAUDE.md b/CLAUDE.md index 85e4aa40..950ab037 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ # CLAUDE.md — техконтекст Лидерры -**Версия:** 2.48 от 01.07.2026 — в §ГЛАВНОЕ добавлен горящий баннер «БОЕВОЙ ПРОД» (доступ только с разрешения владельца, БД по умолчанию только чтение, ЛК поставщика на проде = crm.lead.store, снос базы только по PROD-DESTROY-OK); прод очищен «с нуля» и взведён для боевой работы 01.07.2026 (см. `ПИЛОТ.md` + план `docs/superpowers/plans/2026-07-01-prod-cleanup-supplier-lk-swap.md`). Прежняя запись: 2.47 от 15.06.2026 — структурная компактизация: история версий и журнал фаз вынесены в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md); разделы про «мозг» (router / наставник / observer / enforcement / разработка реестра инструментов) убраны — управляющий слой выделен в отдельный репозиторий **claude-brain** (ADR-020). Правила, нормативка и состав продукта **не изменены** — только структура файла. Полная история — в CHANGELOG. (Прежняя ремарка про рассинхрон cross-ref квинтета на 2.47 снята — закрыто в PSR v3.24 / Tooling v2.25 от 14.06.2026.) +**Версия:** 2.49 от 28.07.2026 — зарегистрирован инструмент **#90 `grilling`** (вендоренный скил `mattpocock/skills`, MIT, user-level `~/.claude/skills/grilling/` — вне репозитория): безжалостный допрос по **уже имеющемуся** решению, обход дерева развилок по одному вопросу с рекомендуемым ответом на каждый, протокол `docs/grilling/` (Решили / Отрезали и почему / Осталось открытым — переживает компакт контекста). Вторая позиция подкатегории discovery-tooling рядом с #55 `discovery-interview`; граница ADR-021 GR1 — **по наличию решения**: grilling куёт готовое, discovery-interview вскрывает проблему. §0 версии квинтета синхронизированы (Pravila v1.45 / PSR_v1 v3.25 / Прил. Н v2.26), §3.4 +#90. Счётчики не дублируются — канон в Прил. Н §0. Прежняя запись: 2.48 от 01.07.2026 — в §ГЛАВНОЕ добавлен горящий баннер «БОЕВОЙ ПРОД» (доступ только с разрешения владельца, БД по умолчанию только чтение, ЛК поставщика на проде = crm.lead.store, снос базы только по PROD-DESTROY-OK); прод очищен «с нуля» и взведён для боевой работы 01.07.2026 (см. `ПИЛОТ.md` + план `docs/superpowers/plans/2026-07-01-prod-cleanup-supplier-lk-swap.md`). Прежняя запись: 2.47 от 15.06.2026 — структурная компактизация: история версий и журнал фаз вынесены в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md); разделы про «мозг» (router / наставник / observer / enforcement / разработка реестра инструментов) убраны — управляющий слой выделен в отдельный репозиторий **claude-brain** (ADR-020). Правила, нормативка и состав продукта **не изменены** — только структура файла. Полная история — в CHANGELOG. (Прежняя ремарка про рассинхрон cross-ref квинтета на 2.47 снята — закрыто в квинтете 14.06.2026.) **Назначение:** оперативная карта для Claude Code. Не первоисточник — первоисточники указаны в §0. **Владелец и режим правок:** все изменения этого файла — **только** через плагин `claude-md-management` (skills `/claude-md-management:claude-md-improver` для audit/targeted-updates и `/claude-md-management:revise-claude-md` для capture session-learnings). Прямые правки запрещены — см. §5 п.11. @@ -32,9 +32,9 @@ | Тема | Документ (текущая версия) | |---|---| -| Продуктовые правила работы Claude | [docs/Pravila_raboty_Claude_v1_1.md](docs/Pravila_raboty_Claude_v1_1.md) (v1.44 от 14.06.2026) | -| Правила совместного использования плагинов Claude | [docs/Plugin_stack_rules_v1.md](docs/Plugin_stack_rules_v1.md) (v3.24 от 14.06.2026) | -| Полный реестр позиций тулчейна (счётчики — канон в Прил. Н §0) | [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) (Прил. Н v2.25 от 14.06.2026) | +| Продуктовые правила работы Claude | [docs/Pravila_raboty_Claude_v1_1.md](docs/Pravila_raboty_Claude_v1_1.md) (v1.45 от 28.07.2026) | +| Правила совместного использования плагинов Claude | [docs/Plugin_stack_rules_v1.md](docs/Plugin_stack_rules_v1.md) (v3.25 от 28.07.2026) | +| Полный реестр позиций тулчейна (счётчики — канон в Прил. Н §0) | [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) (Прил. Н v2.26 от 28.07.2026) | | Главное ТЗ | [docs/CRM_bp-gr_Инструкция_v8_5.md](docs/CRM_bp-gr_Инструкция_v8_5.md) (v8.5 от 07.05.2026) | | Схема БД | [db/schema.sql](db/schema.sql) — метрики и версия схемы **канон в header файла** + [db/CHANGELOG_schema.md](db/CHANGELOG_schema.md); CLAUDE.md числа не дублирует | | Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (v1.83 от 13.05.2026) | @@ -145,7 +145,7 @@ | 24 | Каталог компонентов | Histoire (НЕ Storybook) | `npm run story` | | 30 | Доменная база UI (компоненты, паттерны, состояния, a11y-принципы) | **Frontend Design plugin** (Anthropic, paired со Superpowers) | автоматически через `~/.claude/settings.json`; **обязательный стек-фильтр** Vue+Vuetify (см. [Plugin_stack_rules_v1.md](docs/Plugin_stack_rules_v1.md) Правило 6) | -**Off-phase инструменты (#31–#89, 20 подкатегорий)** — полный реестр, команды, конфликты и счётчики — канон в [Tooling Прил. Н §0](docs/Tooling_v8_3.md). Routing-аид «триггер задачи → off-phase узел» + канонические связки — [docs/routing-off-phase.md](docs/routing-off-phase.md). Ключевые: #33 `claude-md-management` (обязательный канал правок CLAUDE.md, §5 п.10), #34 Sentry MCP / #35 Redis MCP (READ-ONLY отладка прод-runtime), #60 context7 (актуальная документация библиотек), #86 graphifyy (граф проекта, §5 п.14), #87–89 perplexity/exa/firecrawl (веб-разведка, READ-ONLY). +**Off-phase инструменты (#31–#90, 20 подкатегорий)** — полный реестр, команды, конфликты и счётчики — канон в [Tooling Прил. Н §0](docs/Tooling_v8_3.md). Routing-аид «триггер задачи → off-phase узел» + канонические связки — [docs/routing-off-phase.md](docs/routing-off-phase.md). Ключевые: #33 `claude-md-management` (обязательный канал правок CLAUDE.md, §5 п.10), #34 Sentry MCP / #35 Redis MCP (READ-ONLY отладка прод-runtime), #60 context7 (актуальная документация библиотек), #86 graphifyy (граф проекта, §5 п.14), #87–89 perplexity/exa/firecrawl (веб-разведка, READ-ONLY), #55 `discovery-interview` / #90 `grilling` (интервью: вскрыть проблему, когда решения нет — vs обстрелять решение, которое уже есть; граница ADR-021 GR1). ### 3.4. Фаза 3 — pre-production (+5, итого 29) @@ -291,4 +291,6 @@ trivy image liderra:latest Полная история — [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md) (туда же 15.06.2026 дописан полный снимок прежнего CLAUDE.md перед компактизацией — без потерь). Здесь — последняя запись: +- **v2.49 от 28.07.2026 — регистрация #90 `grilling`** — вендоренный скил из `mattpocock/skills` (MIT, `skills/productivity/grilling`), установлен user-level `~/.claude/skills/grilling/SKILL.md` — вне репозитория, единственная копия (проектная удалена во избежание задвоения). Роль: допрос по **уже имеющемуся** плану/решению — обход дерева развилок по одному вопросу за раз, к каждому вопросу свой рекомендуемый ответ, факты ищутся самостоятельно, работа не начинается до подтверждения заказчика. Надстройка проекта поверх немодифицированного апстрима: протокол `docs/grilling/ГГГГ-ММ-ДД-<тема>.md` (Решили / Отрезали и почему / Осталось открытым — пишется по ходу, перечитывается после компакта, закрывает класс «договорённость сгорела при компакте»), порядок «сначала необратимое», остановка по пустому фронту развилок с объявлением вслух. Новой подкатегории не заводит — вторая позиция 12-й (discovery-tooling) рядом с #55 `discovery-interview`; разрез ADR-021 GR1 по **наличию решения**. Врезан в связку L1 между `brainstorming` и `writing-plans`, классификация `planning` вес 0.8. Синхронизировано: Прил. Н v2.26 (§4.63 #90, §0 счётчик 87→88), Pravila v1.45 (§13.2), PSR_v1 v3.25 (R10.1 Блок 1 note), реестр `docs/registry/nodes.yaml` + контракт, `docs/routing-off-phase.md`, `tools/observer-chain-map.json`, ADR-021. Через `claude-md-management`. NB: запись о v2.48 в этом разделе отсутствует (пропущена 01.07.2026) — содержание v2.48 сохранено в строке версии в шапке. + - **v2.47 от 15.06.2026 — структурная компактизация** — история версий (v1.80…v2.46), цепочки «наследие» (строка версии + ячейки §0) и журнал фаз (§6) вынесены в CHANGELOG; вырезаны разделы про «мозг» (router / наставник / observer / enforcement / разработка реестра инструментов) — управляющий слой выделен в отдельный репозиторий **claude-brain** (ADR-020). Правила (§1, §5), нормативка (§0 квинтет, версии не тронуты) и состав продукта (§2, §7) **не изменены** — только структура. Файл сокращён с 347 КБ (точный новый размер — командой `wc -c` после применения). Cross-ref версии CLAUDE.md в Pravila/PSR/Tooling указывают 2.46 — синхронизация квинтета на 2.47 — отдельный follow-up. Через `claude-md-management`. diff --git a/app/.env.example b/app/.env.example index 5e0348be..d994b5f9 100644 --- a/app/.env.example +++ b/app/.env.example @@ -121,3 +121,15 @@ SMS_SMSC_PRICE_KOP=0 SMS_MTS_ENABLED=false SMS_MTS_TOKEN= SMS_MTS_PRICE_KOP=0 + +# Реклама в Телеграме по своей базе (клиентский модуль, робот-в-браузере — у МТС нет API). +# Песочница по умолчанию ВКЛ — робот доводит только до черновика, деньги не списываются. +# TG_SANDBOX=false открывается осознанно (Сессия 6) после проверки в песочнице. +TG_SANDBOX=true +# Node-робот кабинета МТС (bots/mts-telegram-ads). Воркер живёт на Windows-машине с сессией МТС. +TG_ROBOT_NODE=node +TG_ROBOT_SCRIPT= +TG_ROBOT_CWD= +TG_ROBOT_TIMEOUT=300 +# Канал робота-грузчика креативов в веб-кабинет Яндекса. Пусто → канал закрыт. +CREATIVE_ROBOT_TOKEN= diff --git a/app/app/Console/Commands/ReapStuckCreativeJobs.php b/app/app/Console/Commands/ReapStuckCreativeJobs.php new file mode 100644 index 00000000..642a1bbb --- /dev/null +++ b/app/app/Console/Commands/ReapStuckCreativeJobs.php @@ -0,0 +1,82 @@ +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; + } +} diff --git a/app/app/Exceptions/Advertising/AudienceNotReadyException.php b/app/app/Exceptions/Advertising/AudienceNotReadyException.php new file mode 100644 index 00000000..d4a9365c --- /dev/null +++ b/app/app/Exceptions/Advertising/AudienceNotReadyException.php @@ -0,0 +1,32 @@ +json([ + 'tariffs' => Tariff::orderBy('min_qty')->get(), + ]); + } + + /** PUT /api/admin/telegram/tariffs — заменяет весь набор ступеней. */ + public function updateTariffs(Request $request): JsonResponse + { + $validated = $request->validate([ + 'rows' => ['required', 'array', 'min:1'], + 'rows.*.min_qty' => ['required', 'integer', 'min:1'], + 'rows.*.price_rub' => ['required', 'numeric', 'gt:0'], + ]); + + DB::transaction(function () use ($validated): void { + Tariff::query()->delete(); + + $now = now(); + $rows = array_map( + fn (array $row): array => [ + 'min_qty' => $row['min_qty'], + 'price_rub' => $row['price_rub'], + 'created_at' => $now, + 'updated_at' => $now, + ], + $validated['rows'], + ); + + Tariff::query()->insert($rows); + }); + + return response()->json([ + 'tariffs' => Tariff::orderBy('min_qty')->get(), + ]); + } +} diff --git a/app/app/Http/Controllers/Api/AdminAdvertisingController.php b/app/app/Http/Controllers/Api/AdminAdvertisingController.php index 191d37f7..ccd5136a 100644 --- a/app/app/Http/Controllers/Api/AdminAdvertisingController.php +++ b/app/app/Http/Controllers/Api/AdminAdvertisingController.php @@ -5,6 +5,8 @@ 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; @@ -13,14 +15,14 @@ use Illuminate\Support\Facades\DB; /** * SaaS-admin: «Реклама: расход и наша маржа» — расход тенантов в Яндексе * (ad_wallet_transactions, type=charge, channel=yandex) и наша наценка сверху - * (ad_settings.markup_percent — глобальная настройка, не tenant-scoped). + * (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 + markup_percent/100) — сколько отдали Яндексу; - * our_margin = client_spend − yandex_cost. + * 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 { @@ -33,9 +35,9 @@ class AdminAdvertisingController extends Controller $period = 'current_month'; } - $markupPercent = (string) (DB::table('ad_settings')->value('markup_percent') ?? '30.00'); - // factor = 1 + markup/100, scale повышенный (10) — знаменатель деления ниже. - $factor = bcadd('1', bcdiv($markupPercent, '100', 10), 10); + $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') @@ -59,8 +61,8 @@ class AdminAdvertisingController extends Controller foreach ($rows as $r) { // bcadd(..., '0', 2) — нормализация к money-строке scale=2, без float. $clientSpend = bcadd((string) $r->client_spend_rub, '0', 2); - // Усечение bcdiv (без округления) — безопасная сторона, см. ТЗ T4a. - $yandexCost = bcdiv($clientSpend, $factor, 2); + // Усечение bcmul (без округления) — безопасная сторона, см. ТЗ T4a. + $yandexCost = bcmul($clientSpend, $share, 2); $margin = bcsub($clientSpend, $yandexCost, 2); $data[] = [ @@ -83,7 +85,163 @@ class AdminAdvertisingController extends Controller 'yandex_cost_rub' => $totalYandexCost, 'our_margin_rub' => $totalMargin, ], - 'markup_percent' => $markupPercent, + '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'], ]); } diff --git a/app/app/Http/Controllers/Api/AdvertisingCampaignController.php b/app/app/Http/Controllers/Api/AdvertisingCampaignController.php index 6f0bf150..7f659882 100644 --- a/app/app/Http/Controllers/Api/AdvertisingCampaignController.php +++ b/app/app/Http/Controllers/Api/AdvertisingCampaignController.php @@ -4,20 +4,35 @@ declare(strict_types=1); namespace App\Http\Controllers\Api; +use App\Exceptions\Advertising\AudienceNotReadyException; use App\Exceptions\Advertising\AudienceTooSmallException; +use App\Exceptions\Advertising\BudgetBelowYandexMinimumException; +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) — тонкий @@ -36,7 +51,10 @@ class AdvertisingCampaignController extends Controller $campaigns = AdCampaign::where('tenant_id', $tenantId) ->orderByDesc('id') - ->get(['id', 'name', 'status', 'weekly_budget_rub', 'audience_days', 'launched_at']); + // 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]); } @@ -47,25 +65,40 @@ class AdvertisingCampaignController extends Controller $data = $request->validate([ 'name' => ['required', 'string', 'max:255'], - // TODO(бизнес): уточнить минимальный недельный бюджет клиента (после ÷1.3 - // у Яндекса — фактический минимум площадки), Р8/В-open — НЕ хардкодить - // выдуманное число. Пока только "> 0". 'audience_days' => ['required', 'integer', 'min:1', 'max:90'], 'use_uploaded_list' => ['boolean'], - 'weekly_budget_rub' => ['required', 'numeric', 'min:1'], - 'daily_budget_rub' => ['nullable', 'numeric', 'min:1'], - 'click_bid_rub' => ['nullable', 'numeric', 'min:1'], + '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'], - 'use_uploaded_list' => $data['use_uploaded_list'] ?? false, - 'weekly_budget_rub' => $data['weekly_budget_rub'], - 'daily_budget_rub' => $data['daily_budget_rub'] ?? null, - 'click_bid_rub' => $data['click_bid_rub'] ?? null, + '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); @@ -109,11 +142,66 @@ class AdvertisingCampaignController extends Controller 'name' => ['sometimes', 'required', 'string', 'max:255'], 'audience_days' => ['sometimes', 'required', 'integer', 'min:1', 'max:90'], 'use_uploaded_list' => ['sometimes', 'boolean'], - 'weekly_budget_rub' => ['sometimes', 'required', 'numeric', 'min:1'], - 'daily_budget_rub' => ['sometimes', 'nullable', 'numeric', 'min:1'], - 'click_bid_rub' => ['sometimes', 'nullable', 'numeric', 'min:1'], + '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) — здесь только сохраняем поле, // текущий прогон кампании её не подхватывает. Бюджет — применяется сразу. @@ -122,7 +210,29 @@ class AdvertisingCampaignController extends Controller return response()->json($campaign->fresh()); } - public function audienceSize(Request $request, int $id, CampaignAudienceBuilder $builder): JsonResponse + /** + * 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; @@ -130,21 +240,46 @@ class AdvertisingCampaignController extends Controller $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): временно проставляем audience_days на инстансе, - // НЕ сохраняя в БД (нет вызова ->save()). + // Живой счётчик (Р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; - return response()->json([ + $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 @@ -153,12 +288,80 @@ class AdvertisingCampaignController extends Controller $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 (AudienceNotReadyException $e) { + // Это не ошибка клиента и не поломка: рекламная система ещё сводит загруженные + // номера с людьми. На бою 30.07.2026 это заняло около 15 часов. Отвечаем как + // на «подождите» — тем же 202, что и ожидание картинок: кампания осталась + // черновиком, деньги не тронуты, в рекламном кабинете ничего не создано. + Log::info('Запуск отложен: аудитория ещё готовится', [ + 'campaign_id' => $campaign->id, + 'tenant_id' => $tenantId, + 'segment_status' => $e->segmentStatus, + 'matched' => $e->matched, + ]); + + return response()->json([ + 'status' => 'audience_pending', + 'message' => 'Аудитория ещё готовится — рекламная система сверяет ваш список с людьми. Обычно это занимает несколько часов. Настройки и картинки сохранены, зайдите позже и нажмите «Запустить» ещё раз.', + ], 202); + } catch (BudgetBelowYandexMinimumException $e) { + // Наружу — ТОЛЬКО клиентские числа. Ни минимума рекламной площадки, ни нашей + // наценки: по паре «минимум площадки / цена клиенту» долю можно вычислить делением. + return response()->json([ + 'status' => 'budget_too_small', + 'message' => "Слишком маленькая смета показов — с такой рекламная площадка кампанию не возьмёт. Минимум для запуска: {$e->minImpressions} показов, это {$e->minClientRub} ₽. Увеличьте количество показов в мастере кампании.", + ], 422); } catch (RuntimeException $e) { return response()->json(['message' => $e->getMessage()], 409); } @@ -166,6 +369,63 @@ class AdvertisingCampaignController extends Controller 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; @@ -178,10 +438,24 @@ class AdvertisingCampaignController extends Controller ], 409); } - $this->callDirect($campaign, fn (YandexDirectClient $direct, int $yandexCampaignId) => $direct->suspendCampaign($yandexCampaignId)); + // Пауза, которая не дошла до Директа, — не пауза. Раньше ошибку глотали в журнал, + // ставили статус «на паузе» и БЕЗУСЛОВНО размораживали деньги: реклама в Яндексе + // продолжала крутиться и тратить, портал показывал «на паузе», а деньги за неё уже + // были свободны и могли уйти на другую кампанию. Клиент уходил в минус молча. + $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]); } @@ -197,6 +471,19 @@ class AdvertisingCampaignController extends Controller ], 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]); @@ -205,15 +492,41 @@ class AdvertisingCampaignController extends Controller } /** - * Вызывает Директ (suspend/resume) под рубильником, если у кампании уже есть - * yandex_campaign_id. Деньги не трогает. Если Директ недоступен — логируем и - * всё равно продолжаем менять локальный статус (клиент ждёт паузу/возобновление - * здесь и сейчас, синхронизация с Директом — не блокер). + * Неоткрученный остаток сметы в клиентских рублях: полная стоимость оплаченных + * показов минус уже списанное. Для кампаний без сметы (старые, «за клики») — 0. */ - private function callDirect(AdCampaign $campaign, callable $action): void + private function remainingBudgetRub(AdCampaign $campaign): string { - if (config('services.yandex_direct.enabled') !== true || $campaign->yandex_campaign_id === null) { - return; + $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( @@ -228,7 +541,11 @@ class AdvertisingCampaignController extends Controller 'campaign_id' => $campaign->id, 'error' => $e->getMessage(), ]); + + return $e->getMessage(); } + + return null; } public function storeAd(Request $request, int $id, CreativeValidator $validator): JsonResponse @@ -289,7 +606,11 @@ class AdvertisingCampaignController extends Controller return response()->json(['errors' => $errors], 422); } - if (config('services.yandex_direct.enabled') === false) { + // 🪤 Была проверка `=== false`. Значение приходит из env, и `YANDEX_DIRECT_ENABLED=0` + // в .env даёт СТРОКУ «0»: рубильник считает её выключенным, а строгое сравнение — + // включённым, и запрос уходил бы в живой Яндекс при выключенном рубильнике. + // Остальные места проверяют именно так — приводим к общему виду. + if (! config('services.yandex_direct.enabled')) { return response()->json(['message' => 'Яндекс.Директ выключен — картинку пока не загрузить.'], 409); } @@ -305,4 +626,347 @@ class AdvertisingCampaignController extends Controller 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 */ + 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", + ]; + } } diff --git a/app/app/Http/Controllers/Api/AdvertisingCampaignMessageController.php b/app/app/Http/Controllers/Api/AdvertisingCampaignMessageController.php new file mode 100644 index 00000000..4e78c942 --- /dev/null +++ b/app/app/Http/Controllers/Api/AdvertisingCampaignMessageController.php @@ -0,0 +1,168 @@ +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); + } +} diff --git a/app/app/Http/Controllers/Api/Client/ClientSmsSenderController.php b/app/app/Http/Controllers/Api/Client/ClientSmsSenderController.php index 63d4c12f..a730c9f7 100644 --- a/app/app/Http/Controllers/Api/Client/ClientSmsSenderController.php +++ b/app/app/Http/Controllers/Api/Client/ClientSmsSenderController.php @@ -135,7 +135,9 @@ class ClientSmsSenderController extends Controller if ($party === 'individual') { $ownerName = ($data['owner_name'] ?? '') !== '' ? $data['owner_name'] - : ($clientSubject === 'individual' ? (string) $req?->contact_name : ''); + // Знак вопроса здесь не нужен: до сюда доходим, только если + // $clientSubject === 'individual', а он взят из $req — значит $req есть. + : ($clientSubject === 'individual' ? (string) $req->contact_name : ''); $reqArr = ['legal_name' => '', 'contact_name' => $ownerName, 'inn' => '']; $prefilled = $ownerName !== ''; } else { diff --git a/app/app/Http/Controllers/Api/ClientTg/AutoRuleController.php b/app/app/Http/Controllers/Api/ClientTg/AutoRuleController.php new file mode 100644 index 00000000..1efdf28d --- /dev/null +++ b/app/app/Http/Controllers/Api/ClientTg/AutoRuleController.php @@ -0,0 +1,92 @@ +user(). + */ +class AutoRuleController extends Controller +{ + public function show(Request $request): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + return response()->json($this->present( + AutoRule::where('tenant_id', $tenantId)->first(), + )); + } + + public function update(Request $request): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $data = $request->validate([ + 'enabled' => 'required|boolean', + 'ad_text' => 'required_if:enabled,true|nullable|string|max:1000', + 'ad_link' => 'nullable|string|max:500', + 'ord_category' => 'nullable|string|max:200', + 'budget_cap_rub' => 'required|numeric|min:1', + 'daily_limit_rub' => 'required|numeric|min:0', + 'batch_threshold' => 'nullable|integer|min:367', + ]); + + $rule = AutoRule::updateOrCreate( + ['tenant_id' => $tenantId], + [ + 'enabled' => $data['enabled'], + 'ad_text' => $data['ad_text'] ?? '', + 'ad_link' => $data['ad_link'] ?? null, + 'ord_category' => $data['ord_category'] ?? 'Размещение рекламы', + 'budget_cap_rub' => $data['budget_cap_rub'], + 'daily_limit_rub' => $data['daily_limit_rub'], + 'batch_threshold' => $data['batch_threshold'] ?? null, + 'updated_by' => (int) $request->user()->id, + ], + ); + + return response()->json($this->present($rule->fresh())); + } + + /** + * Плоский снимок правила для кабинета. Без правила — безопасные значения по + * умолчанию (авто выключено, дневной лимит 0). Десятичные — строками, как модель. + * + * @return array + */ + private function present(?AutoRule $rule): array + { + if ($rule === null) { + return [ + 'enabled' => false, + 'ad_text' => '', + 'ad_link' => '', + 'ord_category' => 'Размещение рекламы', + 'budget_cap_rub' => '0.00', + 'daily_limit_rub' => '0.00', + 'batch_threshold' => null, + ]; + } + + return [ + 'enabled' => (bool) $rule->enabled, + 'ad_text' => $rule->ad_text, + 'ad_link' => $rule->ad_link ?? '', + 'ord_category' => $rule->ord_category, + 'budget_cap_rub' => (string) $rule->budget_cap_rub, + 'daily_limit_rub' => (string) $rule->daily_limit_rub, + 'batch_threshold' => $rule->batch_threshold, + ]; + } +} diff --git a/app/app/Http/Controllers/Api/ClientTg/CampaignController.php b/app/app/Http/Controllers/Api/ClientTg/CampaignController.php new file mode 100644 index 00000000..6be2dae5 --- /dev/null +++ b/app/app/Http/Controllers/Api/ClientTg/CampaignController.php @@ -0,0 +1,386 @@ +user()->tenant_id; каждый запрос — с явным + * ->where('tenant_id', ...) поверх RLS (defense-in-depth, паттерн ClientSmsController). + */ +class CampaignController extends Controller +{ + public function __construct( + private readonly TelegramAudienceService $audience, + private readonly TelegramTariffService $tariff, + ) {} + + public function index(Request $request): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + // Баланс — общий баланс тенанта (как СМС): экран гасит «Запустить» при нехватке + // средств ДО обращения к серверу. Заморозки больше нет (frozen всегда 0). + $tenant = Tenant::find($tenantId); + + return response()->json([ + 'campaigns' => Campaign::where('tenant_id', $tenantId) + ->orderByDesc('id') + ->limit(50) + ->get(), + 'sandbox' => (bool) config('client_tg.sandbox', true), + 'balance_rub' => $tenant !== null ? (string) $tenant->balance_rub : '0.00', + 'frozen_rub' => '0.00', + ]); + } + + /** + * Создаёт ЧЕРНОВИК кампании: сохраняет объявление/аудиторию, считает кандидатов + * и смету-потолок. Ни робот, ни деньги не трогаются — это делает `launch`. + */ + public function store(Request $request): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + $data = $this->validatePayload($request); + + $campaign = Campaign::create([ + 'tenant_id' => $tenantId, + 'status' => Campaign::STATUS_DRAFT, + 'ad_text' => $data['ad_text'], + 'ad_link' => $data['ad_link'], + 'ad_headline' => $data['ad_headline'] ?? null, + 'ord_category' => $data['ord_category'] ?? 'Размещение рекламы', + 'budget_cap_rub' => $data['budget_cap_rub'], + 'audience_kind' => $data['audience_kind'], + 'audience_params' => $data['audience_kind'] === Campaign::AUDIENCE_DEALS + ? ['days' => (int) $data['audience_days']] + : null, + 'planned_count' => 0, + 'estimated_cost_rub' => '0.00', + 'created_by' => $request->user()->id, + ]); + + // Список номеров (audience_kind=list) — сохраняем как строки кампании; их + // потом читает TelegramAudienceService::fromList по campaign_id. + $dropped = 0; + if ($data['audience_kind'] === Campaign::AUDIENCE_LIST) { + $dropped = $this->storePhones($tenantId, $campaign->id, $data['phones'] ?? []); + } + + // Кандидаты (нормализованы, без дублей/стоп-листа) + смета-потолок для UI. + $candidates = $this->audience->build($campaign)->candidatesCount; + $campaign->update([ + 'planned_count' => $candidates, + // Клиентская цена (с наценкой) — её клиент видит и платит при запуске. + 'estimated_cost_rub' => $this->tariff->clientEstimateRub($candidates), + ]); + + $payload = $campaign->fresh()->toArray(); + $payload['dropped_count'] = $dropped; + + return response()->json($payload, 201); + } + + public function show(Request $request, int $id): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = Campaign::where('tenant_id', $tenantId)->findOrFail($id); + + return response()->json(['campaign' => $campaign]); + } + + /** + * Запуск кампании: draft → queued + джоб робота. В бою СПИСЫВАЕТ клиентскую смету + * (с наценкой) с общего баланса; не хватает денег → 409, кампания остаётся + * черновиком. Песочница — без денег. Не-черновик → 422 (повторно не жмём). + */ + public function launch(Request $request, int $id): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = Campaign::where('tenant_id', $tenantId)->findOrFail($id); + + if (! $campaign->canTransitionTo(Campaign::STATUS_QUEUED)) { + return response()->json(['message' => 'Кампанию уже запускали'], 422); + } + + $sandbox = (bool) config('client_tg.sandbox', true); + + // Предстартовый гейт аудитории (находка #2): в бою НЕ списываем деньги под + // заведомо непроходную кампанию. Минимум у МТС Маркетолог — 367 кандидатов; + // <367 или пусто — робот всё равно упал бы, но деньги уже ушли бы. В песочнице + // гейта нет — денег нет, а сам робот отсеет по MIN_NON_MTS. + $candidates = 0; + if (! $sandbox) { + $candidates = $this->audience->build($campaign)->candidatesCount; + $min = (int) config('client_tg.auto_batch_threshold', 367); + if ($candidates === 0) { + return response()->json(['message' => 'В аудитории нет номеров — добавьте контакты или расширьте условия'], 422); + } + if ($candidates < $min) { + return response()->json(['message' => "Недостаточно номеров: нужно не меньше {$min}, а набралось {$candidates}"], 422); + } + } + + try { + DB::transaction(function () use ($campaign, $sandbox, $candidates): void { + if (! $sandbox) { + // Пересчёт клиентской сметы по свежему числу кандидатов — клиент видит + // и платит одно и то же. Списание с общего баланса; нехватка бросает + // InsufficientBalanceException → откат транзакции → кампания в draft. + $clientCost = $this->tariff->clientEstimateRub($candidates); + $campaign->update(['estimated_cost_rub' => $clientCost]); + app(TelegramCampaignChargeService::class)->charge($campaign, $clientCost); + } + $campaign->transitionTo(Campaign::STATUS_QUEUED); + }); + } catch (InsufficientBalanceException $e) { + return response()->json(['message' => 'Не хватает денег на балансе — пополните счёт'], 409); + } + + RunTelegramCampaignJob::dispatch($campaign->id, $tenantId)->afterCommit(); + + return response()->json($campaign->fresh()); + } + + /** + * Отмена кампании ДО запуска (draft/queued → cancelled). В бою возвращает списанную + * смету на общий баланс (refund) — из queued (уже списали на launch) вернёт деньги, + * из draft (не списывали) refund — no-op. Песочница денег не трогала. Из running/ + * терминальных отмена запрещена (422) по машине статусов модели. + */ + public function cancel(Request $request, int $id): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = Campaign::where('tenant_id', $tenantId)->findOrFail($id); + + if (! $campaign->canTransitionTo(Campaign::STATUS_CANCELLED)) { + return response()->json(['message' => 'Эту кампанию нельзя отменить'], 422); + } + + $sandbox = (bool) config('client_tg.sandbox', true); + + DB::transaction(function () use ($campaign, $sandbox): void { + if (! $sandbox) { + app(TelegramCampaignChargeService::class)->refund($campaign); + } + $campaign->transitionTo(Campaign::STATUS_CANCELLED); + }); + + return response()->json($campaign->fresh()); + } + + /** + * Пересдача отклонённой модерацией кампании (задача 3.6 + связка mode:'resubmit'): + * rejected → queued с правками объявления и (опц.) документом модератору. Робот + * ЧИНИТ ТУ ЖЕ кампанию в кабинете через «Исправить» (по mts_campaign_id) — + * поэтому id кабинета СОХРАНЯЕМ (не создаём новую), чистим только причину отказа. + * Аудитория не меняется. В бою бронь ставится ЗАНОВО (при отказе её вернул + * опросчик 3.4; freeze идемпотентен по ACTIVE-холду). Не-rejected → 422; нет + * mts_campaign_id (нечего «Исправить») → 422. + */ + public function resubmit(Request $request, int $id): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = Campaign::where('tenant_id', $tenantId)->findOrFail($id); + + if ($campaign->status !== Campaign::STATUS_REJECTED) { + return response()->json(['message' => 'Пересдать можно только отклонённую кампанию'], 422); + } + + // Пересдача чинит СУЩЕСТВУЮЩУЮ кампанию кабинета через «Исправить» — без id + // кабинета чинить нечего (в норме у отклонённой он всегда есть; защита от края). + if ($campaign->mts_campaign_id === null || $campaign->mts_campaign_id === '') { + return response()->json(['message' => 'Эту кампанию нельзя пересдать: она не заведена в кабинете'], 422); + } + + $data = $request->validate([ + 'ad_text' => 'required|string|max:1000', + 'ad_link' => 'required|url|max:500', // = колонка client_tg_campaigns.ad_link varchar(500) + 'ord_category' => 'nullable|string|max:200', // = колонка client_tg_campaigns.ord_category varchar(200) + 'moderator_file' => 'nullable|file|mimes:png,jpg,jpeg,pdf|max:10240', + ]); + + // Документ модератору (лицензия/договор) — сохраняем ДО смены статуса; путь + // отдадим роботу как moderatorFile. Файл не приложили — старый путь очищаем. + $moderatorPath = $request->hasFile('moderator_file') + ? $request->file('moderator_file')->store('client_tg/moderator', 'local') + : null; + + $sandbox = (bool) config('client_tg.sandbox', true); + + // Предстартовый гейт аудитории (как launch): в бою не списываем деньги под + // заведомо непроходную кампанию. Песочница — без гейта (денег нет, робот отсеет). + $candidates = 0; + if (! $sandbox) { + $candidates = $this->audience->build($campaign)->candidatesCount; + $min = (int) config('client_tg.auto_batch_threshold', 367); + if ($candidates === 0) { + return response()->json(['message' => 'В аудитории нет номеров — добавьте контакты или расширьте условия'], 422); + } + if ($candidates < $min) { + return response()->json(['message' => "Недостаточно номеров: нужно не меньше {$min}, а набралось {$candidates}"], 422); + } + } + + try { + DB::transaction(function () use ($campaign, $data, $moderatorPath, $sandbox, $candidates): void { + // Пересдача платится ЗАНОВО: на отказе модерации смету вернул опросчик + // (refund), сальдо выровнялось — новое списание проходит. Не хватает → + // InsufficientBalanceException → откат → кампания остаётся rejected. + if (! $sandbox) { + $clientCost = $this->tariff->clientEstimateRub($candidates); + $campaign->update(['estimated_cost_rub' => $clientCost]); + app(TelegramCampaignChargeService::class)->charge($campaign, $clientCost); + } + + $campaign->fill([ + 'ad_text' => $data['ad_text'], + 'ad_link' => $data['ad_link'], + 'ord_category' => $data['ord_category'] ?? $campaign->ord_category, + 'moderator_file_path' => $moderatorPath, + // Чиним ту же кампанию кабинета — id СОХРАНЯЕМ, чистим только причину отказа. + 'status_reason' => null, + ]); + $campaign->save(); + + $campaign->transitionTo(Campaign::STATUS_QUEUED); + }); + } catch (InsufficientBalanceException $e) { + return response()->json(['message' => 'Не хватает денег на балансе — пополните счёт'], 409); + } + + ResubmitTelegramCampaignJob::dispatch($campaign->id, $tenantId)->afterCommit(); + + return response()->json($campaign->fresh()); + } + + /** + * Прикладывает картинку/видео к объявлению ЧЕРНОВИКА (у МТС объявление может быть + * с медиа). Файл кладём на локальный диск; путь в `media_path` робот отдаёт кабинету + * МТС при запуске (RunTelegramCampaignJob → mediaFile → cabinet.js `fillAdMedia`). + * Только для черновика — после запуска объявление уже ушло в кабинет. Замена медиа + * удаляет прежний файл, чтобы не копить мусор на диске. + */ + public function attachMedia(Request $request, int $id): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = Campaign::where('tenant_id', $tenantId)->findOrFail($id); + + if ($campaign->status !== Campaign::STATUS_DRAFT) { + return response()->json(['message' => 'Медиа можно приложить только к черновику — до запуска'], 422); + } + + $request->validate([ + // Картинка (png/jpg/gif) или видео (mp4); лимит 50 МБ (кабинет МТС жмёт крупные сам). + 'media' => 'required|file|mimes:png,jpg,jpeg,gif,mp4|max:51200', + ]); + + $path = $request->file('media')->store('client_tg/media', 'local'); + + $old = $campaign->media_path; + $campaign->update(['media_path' => $path]); + + // Прежний файл больше не нужен — убираем с диска (после успешной записи пути). + if ($old !== null && $old !== '' && $old !== $path) { + Storage::disk('local')->delete($old); + } + + return response()->json($campaign->fresh()); + } + + /** + * Валидация полей кампании. Для «по сделкам» срок обязателен — иначе выборка + * ушла бы по ВСЕЙ истории (и сканировала бы все партиции deals). + * + * @return array + */ + private function validatePayload(Request $request): array + { + // 🔴 Заголовок обязателен ТОЛЬКО для рекламы сайта: кабинет МТС рисует поле + // «Заголовок объявления» лишь тогда, а без него молча не пускает дальше шага + // «Объявление» — уже ПОСЛЕ списания денег. Для ссылки на канал/бота поля нет, + // и требовать заголовок было бы лишним вопросом клиенту. Проверено глазами + // 31.07.2026 (кампании 2234454 и 2234462). + $headlineRule = TelegramLink::isTelegram((string) $request->input('ad_link', '')) + ? 'nullable|string|max:40' + : 'required|string|max:40'; // = колонка client_tg_campaigns.ad_headline varchar(40) + + return $request->validate([ + 'ad_text' => 'required|string|max:1000', + 'ad_link' => 'required|url|max:500', // = колонка client_tg_campaigns.ad_link varchar(500) + 'ad_headline' => $headlineRule, + 'audience_kind' => 'required|in:deals,base,list', + 'audience_days' => 'required_if:audience_kind,deals|nullable|integer|min:1|max:365', + 'budget_cap_rub' => 'required|numeric|min:1', + 'ord_category' => 'nullable|string|max:200', // = колонка client_tg_campaigns.ord_category varchar(200) + 'phones' => 'nullable|array|max:200000', + 'phones.*' => 'string|max:32', + ]); + } + + /** + * Нормализует и складывает номера списка в строки кампании. Нераспознанные — + * молча пропускаем (клиент видит итоговое число кандидатов), но СЧИТАЕМ их — + * `store` возвращает `dropped_count`, чтобы клиент видел, что часть номеров + * не распозналась. ПДн: голый 7XXXX. + * + * @param array $rawPhones + * @return int число нераспознанных (отброшенных) номеров + */ + private function storePhones(int $tenantId, int $campaignId, array $rawPhones): int + { + $rows = []; + $dropped = 0; + foreach ($rawPhones as $raw) { + $normalized = PhoneNormalizer::normalize((string) $raw); + if ($normalized === null) { + $dropped++; + + continue; + } + $rows[] = [ + 'tenant_id' => $tenantId, + 'campaign_id' => $campaignId, + 'phone' => substr($normalized, 1), // "+7..." → "7..." + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + if ($rows !== []) { + CampaignPhone::insert($rows); + } + + return $dropped; + } +} diff --git a/app/app/Http/Controllers/Api/CreativeRobotController.php b/app/app/Http/Controllers/Api/CreativeRobotController.php new file mode 100644 index 00000000..7c584aeb --- /dev/null +++ b/app/app/Http/Controllers/Api/CreativeRobotController.php @@ -0,0 +1,267 @@ +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]); + } +} diff --git a/app/app/Http/Controllers/Api/ProjectController.php b/app/app/Http/Controllers/Api/ProjectController.php index ab8e9bcf..87fa089b 100644 --- a/app/app/Http/Controllers/Api/ProjectController.php +++ b/app/app/Http/Controllers/Api/ProjectController.php @@ -38,7 +38,7 @@ class ProjectController extends Controller public function index(Request $request): JsonResponse { $query = Project::query() - ->with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1 in aggregation helpers + ->with(['supplierProjects', 'supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1 in aggregation helpers ->withCount('supplierProjects') // ProjectResource::source_locked — анти-N+1 (hasLinks без per-row запроса) ->where('tenant_id', $request->user()->tenant_id); @@ -185,7 +185,7 @@ class ProjectController extends Controller /** GET /api/projects/{id} */ public function show(Request $request, int $id): JsonResponse { - $project = Project::with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1 + $project = Project::with(['supplierProjects', 'supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1 ->withCount('supplierProjects') // ProjectResource::source_locked — анти-N+1 ->where('tenant_id', $request->user()->tenant_id) ->findOrFail($id); diff --git a/app/app/Http/Controllers/Api/Sales/SalesProspectController.php b/app/app/Http/Controllers/Api/Sales/SalesProspectController.php index 1c6181bf..130df6a7 100644 --- a/app/app/Http/Controllers/Api/Sales/SalesProspectController.php +++ b/app/app/Http/Controllers/Api/Sales/SalesProspectController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\Sales; +use App\Http\Controllers\Concerns\ResolvesSalesPeriod; use App\Http\Controllers\Controller; use App\Models\SalesAdAudienceFirm; use App\Models\SalesAdAudienceWarmingEpisode; @@ -15,8 +16,10 @@ use App\Services\DaData\PartyLookup; use App\Services\Sales\SalesAttachmentService; use App\Support\InnValidator; use App\Support\PhoneNormalizer; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; /** @@ -29,9 +32,33 @@ use Illuminate\Validation\ValidationException; */ class SalesProspectController extends Controller { - /** Все стадии в порядке колонок канбана. */ + use ResolvesSalesPeriod; + + /** Стадии, по которым делать уже нечего — в фильтр «что надо сделать» не попадают. */ + private const DEAD_STAGES = ['rejected', 'trash']; + + /** Сроки режима «что надо сделать» — смотрят ВПЕРЁД плюс «просроченные». */ + private const TODO_PERIODS = ['overdue', 'today', 'tomorrow', 'next7', 'next30', 'custom']; + + /** Сроки режима «что менялось» — смотрят НАЗАД: будущего в истории не бывает. */ + private const CHANGED_PERIODS = ['today', 'yesterday', 'd7', 'd30', 'custom']; + + /** Все стадии в порядке колонок канбана. Должно совпадать с PROSPECT_STAGES (фронт). */ private const STAGES = [ - 'new', 'in_work', 'negotiation', 'registered', 'testing', 'topped_up', 'user', 'rejected', 'no_answer', + 'new', 'in_work', 'negotiation', 'manual_testing', 'registered', 'testing', + 'topped_up', 'user', 'kp_sent', 'rejected', 'no_answer', 'trash', + ]; + + /** + * Человеческие названия каналов КП — для записи в журнал разговоров. + * Ключи должны совпадать с CHECK sales_prospects_kp_channel_check. + */ + private const KP_CHANNEL_TITLES = [ + 'email' => 'почта', + 'whatsapp' => 'ватсап', + 'telegram' => 'телеграм', + 'max' => 'макс', + 'other' => 'другое', ]; public function __construct(private readonly SalesAttachmentService $attachments) {} @@ -64,6 +91,8 @@ class SalesProspectController extends Controller $query->where('source', (string) $request->query('source')); } + $this->applyDateFilter($query, $request); + $all = $query->get(); $warmingByProspect = $this->warmingByProspect($all->pluck('id')->all()); $prospects = $all->map(fn (SalesProspect $p) => $this->row($p, $warmingByProspect))->all(); @@ -94,6 +123,93 @@ class SalesProspectController extends Controller ]); } + /** + * Фильтр доски по датам. Два режима, они взаимоисключающие по смыслу: + * + * todo — «что надо сделать»: срок созвона попадает в выбранный отрезок. + * Просроченное — ОТДЕЛЬНЫЙ пункт срока (period=overdue), а не + * добавка к каждому: владелец просил, чтобы каждый пункт списка + * показывал ровно то, что написано (01.08.2026). Отказ и корзина + * не показываются никогда — по ним делать нечего. + * changed — «что менялось»: у карточки есть движение по стадиям ИЛИ запись + * в журнале разговоров за период. Движения включают автоматические + * (деньги → тестирование/пополнил/пользователь) — владелец просил + * считать ЛЮБОЕ движение, а не только разговоры. + * + * Сроки у режимов РАЗНЫЕ и не пересекаются: «что надо сделать» смотрит вперёд + * (просрочено / сегодня / завтра / ближайшие 7–30 дней), «что менялось» — назад + * (сегодня / вчера / прошедшие 7–30 дней). Срок не из своего режима — 422: + * «что менялось завтра» не бывает, и молча подменять его на месяц нельзя. + * + * Без date_mode доска отдаёт всё, как раньше. + * + * @param Builder $query + */ + private function applyDateFilter(Builder $query, Request $request): void + { + $mode = (string) $request->query('date_mode', ''); + if (! in_array($mode, ['todo', 'changed'], true)) { + return; + } + + $kind = (string) $request->query('period', $mode === 'todo' ? 'today' : 'd30'); + $this->assertPeriodFitsMode($mode, $kind); + + if ($mode === 'todo') { + $query->whereNotIn('stage', self::DEAD_STAGES)->whereNotNull('next_call_at'); + + // «Просроченные» — не отрезок календаря, а всё, что раньше «сейчас». + if ($kind === 'overdue') { + $query->where('next_call_at', '<', now()); + + return; + } + + $range = $this->resolvePeriod($request, 'today'); + $query->whereBetween('next_call_at', [$range->start, $range->end]); + + return; + } + + $range = $this->resolvePeriod($request); + + $query->where(function ($q) use ($range) { + $q->whereExists(function ($sub) use ($range) { + $sub->selectRaw('1') + ->from('sales_prospect_moves') + ->whereColumn('sales_prospect_moves.prospect_id', 'sales_prospects.id') + ->whereBetween('sales_prospect_moves.created_at', [$range->start, $range->end]); + })->orWhereExists(function ($sub) use ($range) { + $sub->selectRaw('1') + ->from('sales_prospect_notes') + ->whereColumn('sales_prospect_notes.prospect_id', 'sales_prospects.id') + ->whereBetween('sales_prospect_notes.created_at', [$range->start, $range->end]); + }); + }); + } + + /** + * Срок должен подходить режиму: «что менялось завтра» и «просроченные за + * вчера» — бессмыслица. Раньше чужой срок молча падал в резолвере на + * «текущий месяц», и доска показывала совсем не то, что выбрано. + * + * @throws ValidationException + */ + private function assertPeriodFitsMode(string $mode, string $kind): void + { + $allowed = $mode === 'todo' + ? self::TODO_PERIODS + : self::CHANGED_PERIODS; + + if (in_array($kind, $allowed, true)) { + return; + } + + throw ValidationException::withMessages([ + 'period' => 'Этот срок не подходит к выбранному фильтру по датам.', + ]); + } + /** * Менеджер заводит СВОЕГО кандидата (инициатива, не из поиска). * Карточка всегда создаётся автору (sales_user_id из тела игнорируется), @@ -104,15 +220,22 @@ class SalesProspectController extends Controller /** @var SalesUser $user */ $user = $request->user('sales'); + // Пустая строка ИНН = «не знаю» → null (иначе проверка контрольной суммы + // ругнётся на пустоту вместо того, чтобы пропустить карточку без ИНН). + if (is_string($request->input('inn')) && trim((string) $request->input('inn')) === '') { + $request->merge(['inn' => null]); + } + $data = $request->validate([ 'firm_name' => ['required', 'string', 'max:500'], 'legal_name' => ['nullable', 'string', 'max:500'], 'city' => ['nullable', 'string', 'max:255'], 'phone' => ['nullable', 'string', 'max:64'], 'site' => ['nullable', 'string', 'max:500'], - // ИНН обязателен при ручном заведении (§16.1); в БД колонка остаётся - // nullable — карточки из поиска и старые строки могут быть без ИНН. - 'inn' => ['required', 'string', function ($attr, $value, $fail) { + // ИНН НЕ обязателен: обязательно только название (§16.1, правка 28.07.2026). + // Если ИНН всё же указали — проверяем контрольную сумму, чтобы мусор + // не попал в дедуп-индекс. В БД колонка и так nullable. + 'inn' => ['nullable', 'string', function ($attr, $value, $fail) { if (! InnValidator::isValidAny((string) $value)) { $fail('ИНН выглядит неправильно: нужно 10 или 12 цифр.'); } @@ -127,14 +250,19 @@ class SalesProspectController extends Controller // Дедуп «одна фирма — один раз одному менеджеру»: проверяем ДО вставки, // иначе уникальный индекс uq_prospect_user_inn отдаст 500 вместо понятного текста. - $duplicate = SalesProspect::query() - ->where('sales_user_id', $user->id) - ->where('inn', $data['inn']) - ->exists(); - if ($duplicate) { - throw ValidationException::withMessages([ - 'inn' => 'Эта фирма уже есть в вашей воронке.', - ]); + // Без ИНН сравнивать не по чему — карточки без ИНН друг друга не блокируют + // (индекс тоже частичный: WHERE inn IS NOT NULL). + $inn = $data['inn'] ?? null; + if ($inn !== null) { + $duplicate = SalesProspect::query() + ->where('sales_user_id', $user->id) + ->where('inn', $inn) + ->exists(); + if ($duplicate) { + throw ValidationException::withMessages([ + 'inn' => 'Эта фирма уже есть в вашей воронке.', + ]); + } } $contacts = $this->cleanContacts($data['contacts'] ?? []); @@ -151,7 +279,7 @@ class SalesProspectController extends Controller // «Главный телефон» карточки — первый телефон первого контакта (§16.2). 'phone' => $data['phone'] ?? ($contacts[0]['phones'][0] ?? null), 'site' => $data['site'] ?? null, - 'inn' => $data['inn'], + 'inn' => $inn, 'notes' => $data['notes'] ?? null, 'payload' => [], ]); @@ -279,6 +407,43 @@ class SalesProspectController extends Controller return $out; } + /** + * Значение «куда слали тест»: телефон приводим к 79XXXXXXXXX тем же нормализатором, + * что и контактные лица — иначе один номер ляжет в базу тремя разными видами. + * Адрес сайта не проверяем: менеджер может записать и домен, и полный адрес страницы. + */ + private function testTarget(string $channel, string $raw): string + { + if ($channel !== 'phone') { + return trim($raw); + } + + $normalized = PhoneNormalizer::normalize($raw); + if ($normalized === null) { + throw ValidationException::withMessages([ + 'test_target' => 'Номер телефона выглядит неправильно.', + ]); + } + + // normalize() отдаёт «+7…», а по всему проекту телефоны хранятся без плюса + // («79…» — формат Яндекс Аудиторий и контактов карточки, см. cleanContactList). + return substr($normalized, 1); + } + + /** + * Клиент уже платит реальными деньгами — ручной откат его карточки в + * «Ручное тестирование» или «Выслано КП» стёр бы с доски факт оплаты. + * Тот же принцип уже действует для «Отказа» (запрещён на 'user'). + */ + private function assertNotPaying(SalesProspect $prospect): void + { + if (in_array($prospect->stage, ['topped_up', 'user'], true)) { + throw ValidationException::withMessages([ + 'action' => 'Клиент уже платит — этот результат ему не подходит.', + ]); + } + } + /** * Записать результат разговора. Карточка переезжает по стадии сама. * Авто-стадии (testing/topped_up/user) здесь не ставятся — только Этап 3. @@ -332,6 +497,62 @@ class SalesProspectController extends Controller $logEntry = 'Договорились на созвон '.$prospect->next_call_at->format('d.m.Y H:i'); break; + case 'manual_testing': + $this->assertNotPaying($prospect); + $data = $request->validate([ + 'next_call_at' => ['required', 'date'], + 'test_channel' => ['required', 'string', Rule::in(['phone', 'site'])], + 'test_target' => ['required', 'string', 'max:500'], + ]); + $target = $this->testTarget($data['test_channel'], $data['test_target']); + $prospect->stage = 'manual_testing'; + $prospect->next_call_at = $data['next_call_at']; + $prospect->test_channel = $data['test_channel']; + $prospect->test_target = $target; + $prospect->reason = null; + $logEntry = 'Ручное тестирование: ' + .($data['test_channel'] === 'phone' ? 'телефон ' : 'сайт ').$target + .'. Следующий созвон '.$prospect->next_call_at->format('d.m.Y H:i'); + break; + + case 'kp_sent': + $this->assertNotPaying($prospect); + $data = $request->validate([ + // Дата созвона НЕобязательна (правка 01.08.2026): бывает «пришлите, + // созвонимся через пару дней» — дата есть, и бывает «пришлите на + // почту, если интересно — перезвоню» — договорённости о созвоне нет. + 'next_call_at' => ['nullable', 'date'], + 'kp_channel' => ['required', 'string', Rule::in(array_keys(self::KP_CHANNEL_TITLES))], + // Обязательно и при «другое»: запись «КП ушло куда-то» бесполезна. + 'kp_target' => ['required', 'string', 'max:500'], + ]); + $prospect->stage = 'kp_sent'; + $prospect->kp_channel = $data['kp_channel']; + // Телеграм-ник, почта и номер — разные вещи, к одному виду не приводим. + $prospect->kp_target = trim($data['kp_target']); + $prospect->reason = null; + $logEntry = 'Выслано КП: '.self::KP_CHANNEL_TITLES[$data['kp_channel']].' '.$prospect->kp_target; + if (! empty($data['next_call_at'])) { + $prospect->next_call_at = $data['next_call_at']; + $logEntry .= '. Следующий созвон '.$prospect->next_call_at->format('d.m.Y H:i'); + } + break; + + case 'trash': + // Карточка, с которой больше не работаем: мусор, дубль, не наш профиль. + // Платящего выбросить нельзя — тот же запрет, что у «Отказа». + $this->assertNotPaying($prospect); + $data = $request->validate([ + 'reason' => ['required', 'string', 'max:2000'], + ]); + $prospect->stage = 'trash'; + $prospect->reason = $data['reason']; + // Дату созвона стираем: иначе выброшенная карточка продолжала бы + // всплывать в фильтре «что надо сделать» и красным на доске. + $prospect->next_call_at = null; + $logEntry = 'В корзину: '.$data['reason']; + break; + case 'no_answer': $data = $request->validate([ 'reason' => ['required', 'string', 'max:2000'], @@ -598,6 +819,8 @@ class SalesProspectController extends Controller 'id' => $p->id, 'sales_user_id' => $p->sales_user_id, 'stage' => $p->stage, + // Откуда карточка приехала — из него доска считает дробь «69/1» у «Отказа». + 'prev_stage' => $p->prev_stage, 'source' => $p->source, 'firm_name' => $p->firm_name, 'legal_name' => $p->legal_name, @@ -611,6 +834,10 @@ class SalesProspectController extends Controller 'next_call_at' => $p->next_call_at?->toIso8601String(), 'reason' => $p->reason, 'registered_email' => $p->registered_email, + 'test_channel' => $p->test_channel, + 'test_target' => $p->test_target, + 'kp_channel' => $p->kp_channel, + 'kp_target' => $p->kp_target, 'notes' => $p->notes, 'warming' => $warmingByProspect[$p->id] ?? ['active' => false, 'channels' => []], ]; diff --git a/app/app/Http/Controllers/Api/SupplierWebhookController.php b/app/app/Http/Controllers/Api/SupplierWebhookController.php index da7eb571..024026ed 100644 --- a/app/app/Http/Controllers/Api/SupplierWebhookController.php +++ b/app/app/Http/Controllers/Api/SupplierWebhookController.php @@ -10,6 +10,7 @@ use App\Models\SupplierLead; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\RateLimiter; use Symfony\Component\HttpFoundation\IpUtils; @@ -60,13 +61,13 @@ class SupplierWebhookController extends Controller && hash_equals(hash_hmac('sha256', $request->getContent(), $expectedSecret), $sig); if (! $this->verifySecret($secret) && ! $hmacValid) { - $this->logSupplierWebhook($request, null, 'rejected_secret'); + $this->logSupplierWebhook($request, null, 'rejected_secret', $secret !== '' ? $secret : $sig); return response()->json(['message' => 'Not found.'], 404); } if (! $this->verifyIpAllowlist($request->ip())) { - $this->logSupplierWebhook($request, null, 'rejected_ip'); + $this->logSupplierWebhook($request, null, 'rejected_ip', $secret); return response()->json(['message' => 'Not found.'], 404); } @@ -77,7 +78,7 @@ class SupplierWebhookController extends Controller $rateKey = 'supplier-webhook:'.($request->ip() ?? 'unknown'); if (RateLimiter::tooManyAttempts($rateKey, self::RATE_LIMIT_PER_MINUTE)) { $retryAfter = RateLimiter::availableIn($rateKey); - $this->logSupplierWebhook($request, null, 'rate_limited'); + $this->logSupplierWebhook($request, null, 'rate_limited', $secret); return response()->json([ 'message' => 'Превышен лимит запросов.', @@ -124,7 +125,7 @@ class SupplierWebhookController extends Controller ]); RouteSupplierLeadJob::dispatch($lead->id); - $this->logSupplierWebhook($request, $lead->id, 'received'); + $this->logSupplierWebhook($request, $lead->id, 'received', $secret); return response()->json([ 'status' => 'accepted', @@ -133,28 +134,42 @@ class SupplierWebhookController extends Controller } /** - * Audit-fix: log all supplier webhook outcomes to webhook_log. - * Covers 4 exit points: received / rejected_secret / rejected_ip / rate_limited. - * Silently skips if table does not exist (safe for migrations in progress). + * Журнал входящих вызовов вебхука: received / rejected_secret / rejected_ip / rate_limited. + * + * 🔴 До 31.07.2026 этот метод писал в таблицу `webhook_log`, снесённую 24.05.2026 вместе + * с legacy-каналом, и молча выходил по guard'у hasTable — журнала не было ВООБЩЕ. Из-за + * этого 70 отказов 404 за 10 дней (поставщик слал со старым паролем на голый IP после + * ротации секрета 01.07) были невидимы и нашлись случайно в логе nginx. + * + * Пишем через pgsql_supplier: в вебе приложение ходит как crm_app_user, а системные + * таблицы (без tenant_id) принадлежат служебной роли — тот же приём, что у site_visitors. + * + * `secret_fingerprint` — первые 8 символов md5 присланного секрета: отвечает «наш ключ + * или чужой», сам секретом не является. Отказы дополнительно кладём в лог уровнем + * warning: на бою LOG_LEVEL=warning, info туда не попадает (мина 14.07.2026). */ - private function logSupplierWebhook(Request $request, ?int $leadId, string $status): void + private function logSupplierWebhook(Request $request, ?int $leadId, string $status, string $providedSecret = ''): void { - if (! \Schema::hasTable('webhook_log')) { - return; - } - try { - DB::table('webhook_log')->insert([ - 'tenant_id' => null, - 'raw_payload' => '{}', - 'source' => 'supplier', + DB::connection('pgsql_supplier')->table('supplier_webhook_log')->insert([ 'status' => $status, - 'lead_id' => $leadId, + 'secret_fingerprint' => $providedSecret !== '' ? substr(md5($providedSecret), 0, 8) : null, 'ip_address' => $request->ip(), + 'target_host' => substr((string) $request->getHost(), 0, 255), + 'supplier_lead_id' => $leadId, 'created_at' => now(), ]); } catch (\Throwable) { - // Never let logging failure break the primary response + // Журнал не имеет права ронять приём лида (revenue-critical). + } + + if ($status !== 'received') { + Log::warning('supplier_webhook.rejected', [ + 'status' => $status, + 'ip' => $request->ip(), + 'target_host' => $request->getHost(), + 'secret_fingerprint' => $providedSecret !== '' ? substr(md5($providedSecret), 0, 8) : null, + ]); } } diff --git a/app/app/Http/Controllers/Api/TgRobotController.php b/app/app/Http/Controllers/Api/TgRobotController.php new file mode 100644 index 00000000..ea6a41ab --- /dev/null +++ b/app/app/Http/Controllers/Api/TgRobotController.php @@ -0,0 +1,138 @@ +mode, [RobotJob::MODE_DRAFT, RobotJob::MODE_LIVE], true); + } + + /** Выдать роботу одно задание. */ + public function next(): JsonResponse + { + $job = $this->queue->takeNext(); + + if ($job === null) { + return response()->json(['job' => null]); + } + + $payload = [ + 'id' => $job->id, + 'campaign_id' => $job->campaign_id, + 'mode' => $job->mode, + 'task' => $job->payload, + ]; + + // 🔴 ПДн. Адрес за номерами кладём ТОЛЬКО тем работам, которым номера нужны. + // Чтению вердикта и пересдаче аудитория не требуется — она у кампании в кабинете + // уже есть, и лишний повод сходить за номерами роботу давать незачем. + if ($this->needsPhones($job)) { + $payload['phones_url'] = url("/api/tg-robot/jobs/{$job->id}/phones"); + } + + return response()->json(['job' => $payload]); + } + + /** + * Отдать роботу номера кампании — построчно, обычным текстом. + * + * 🔴 ПДн. Отдаём ТОЛЬКО по заданию, которое сейчас в работе, и только по тому номеру + * задания, который робот прислал в адресе. Иначе утёкший токен позволил бы перебором + * номеров вычерпать клиентские базы. Проверка держится на самом запросе, а не на + * внешнем условии «в работе кто-то один». + */ + public function phones(int $jobId, TelegramAudienceService $audience): Response + { + $job = RobotJob::where('id', $jobId) + ->where('status', RobotJob::STATUS_TAKEN) + ->whereIn('mode', [RobotJob::MODE_DRAFT, RobotJob::MODE_LIVE]) + ->first(); + + abort_if($job === null, 404, 'Задание не в работе или ему номера не положены.'); + + $phones = $audience->phonesForCampaign($job->tenant_id, $job->campaign_id); + + return response(implode("\n", $phones), 200, [ + 'Content-Type' => 'text/plain; charset=UTF-8', + 'Cache-Control' => 'no-store', + ]); + } + + /** + * Принять отчёт робота о задании. + * + * Отчёт принимается ТОЛЬКО по заданию в работе: повторный или чужой отчёт по уже + * закрытому заданию не должен второй раз двигать кампанию и трогать деньги. + */ + public function done(int $jobId, Request $request): JsonResponse + { + $job = RobotJob::where('id', $jobId) + ->where('status', RobotJob::STATUS_TAKEN) + ->first(); + + abort_if($job === null, 404, 'Задание не в работе — отчёт по нему не принимается.'); + + /** @var array $payload */ + $payload = $request->all(); + $result = RobotResult::fromRobotJson($payload); + + $campaign = Campaign::where('tenant_id', $job->tenant_id) + ->where('id', $job->campaign_id) + ->firstOrFail(); + + $sandbox = (bool) config('client_tg.sandbox', true); + + // Итог разный у разных работ: у запуска — номер кампании в кабинете и статус, + // у чтения вердикта — вердикт модерации с возвратом денег при отказе, у пересдачи — + // своя осторожность с деньгами. Без развилки отчёт о чтении вердикта применился бы + // как отчёт о запуске и сдвинул кампанию не туда. + match ($job->mode) { + RobotJob::MODE_READ_STATUS => app(TelegramModerationVerdictApplier::class) + ->apply($job->tenant_id, $job->campaign_id, $result, $sandbox), + RobotJob::MODE_RESUBMIT => app(TelegramResubmitResultApplier::class) + ->apply($job->tenant_id, $campaign, $result, $sandbox), + default => app(TelegramCampaignResultApplier::class) + ->apply($job->tenant_id, $campaign, $result, $sandbox), + }; + + $job->update([ + 'status' => $result->ok ? RobotJob::STATUS_DONE : RobotJob::STATUS_FAILED, + 'result' => $payload, + 'failure_reason' => $result->ok ? null : mb_substr((string) $result->reason, 0, 1024), + 'finished_at' => now(), + ]); + + return response()->json(['accepted' => true]); + } +} diff --git a/app/app/Http/Controllers/Concerns/ResolvesSalesPeriod.php b/app/app/Http/Controllers/Concerns/ResolvesSalesPeriod.php index 9032fc6f..501802f8 100644 --- a/app/app/Http/Controllers/Concerns/ResolvesSalesPeriod.php +++ b/app/app/Http/Controllers/Concerns/ResolvesSalesPeriod.php @@ -26,15 +26,16 @@ trait ResolvesSalesPeriod * Период из query-параметров запроса. * * По умолчанию — последние 30 дней: тот же период, что PeriodPicker - * показывает при первом заходе. + * показывает при первом заходе. Экраны, где «назад на 30 дней» бессмысленно + * (воронка «что надо сделать» смотрит вперёд), передают свой $default. * * @throws ValidationException при неполном или перевёрнутом произвольном периоде */ - protected function resolvePeriod(Request $request): SalesPeriodRange + protected function resolvePeriod(Request $request, string $default = 'd30'): SalesPeriodRange { try { return app(SalesPeriodResolver::class)->resolve([ - 'kind' => (string) $request->query('period', 'd30'), + 'kind' => (string) $request->query('period', $default), 'from' => $request->query('from'), 'to' => $request->query('to'), ]); diff --git a/app/app/Http/Middleware/CreativeRobotToken.php b/app/app/Http/Middleware/CreativeRobotToken.php new file mode 100644 index 00000000..002c7cc4 --- /dev/null +++ b/app/app/Http/Middleware/CreativeRobotToken.php @@ -0,0 +1,32 @@ +header('X-Creative-Robot-Token', ''); + + if ($expected === '' || ! hash_equals($expected, $given)) { + abort(401, 'Неверный сервис-токен робота.'); + } + + return $next($request); + } +} diff --git a/app/app/Http/Middleware/TgRobotToken.php b/app/app/Http/Middleware/TgRobotToken.php new file mode 100644 index 00000000..5026f224 --- /dev/null +++ b/app/app/Http/Middleware/TgRobotToken.php @@ -0,0 +1,31 @@ +header('X-Tg-Robot-Token', ''); + + if ($expected === '' || ! hash_equals($expected, $given)) { + abort(401, 'Неверный сервис-токен телеграм-робота.'); + } + + return $next($request); + } +} diff --git a/app/app/Jobs/ChargeCampaignSpendJob.php b/app/app/Jobs/ChargeCampaignSpendJob.php index 3def97a3..4a908d5a 100644 --- a/app/app/Jobs/ChargeCampaignSpendJob.php +++ b/app/app/Jobs/ChargeCampaignSpendJob.php @@ -5,44 +5,45 @@ declare(strict_types=1); namespace App\Jobs; use App\Models\AdCampaign; -use App\Services\Advertising\AdMarkup; use App\Services\Advertising\AdStopAllService; use App\Services\Advertising\AdWalletGate; -use App\Services\Advertising\AdWalletService; +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\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Throwable; /** - * Суточное списание расхода Директа по кликам (Cost × наценка) со всех - * тенантов сразу. + * Суточное списание расхода Директа по ПОКАЗАМ (плоская цена ₽/1000, наценка + * вычитанием) со всех тенантов сразу. * * 🔴 На проде очередь бежит под ролью `crm_app_user` (НЕ BYPASSRLS) на дефолтном * соединении, БЕЗ tenant-контекста. Перечисление кампаний через дефолтное * соединение без контекста дало бы 0 строк по RLS (молчаливый сбой), а чтение - * кошелька внутри AdWalletService/AdWalletGate/AdStopAllService вообще упало бы - * ModelNotFound. Поэтому: + * кошелька внутри CampaignImpressionCharger/AdWalletGate/AdStopAllService вообще + * упало бы ModelNotFound. Поэтому: * — кампании перечисляем плоскими строками через `pgsql_supplier` (BYPASSRLS, * аналог SyncSupplierProjectsJob/SendNewLeadsDigestJob), БЕЗ открытой * транзакции — не держим транзакцию во время сетевого запроса к Директу; - * — денежная операция (charge/isSolvent/stopAll) — под tenant-контекстом - * (`SET LOCAL app.current_tenant_id`) на ДЕФОЛТНОМ соединении, где живут - * AdWallet/AdWalletTransaction; тогда crm_app_user с контекстом проходит - * RLS корректно. Денежные операции всегда принимают tenant_id явным - * аргументом — никакого смешения кошельков между тенантами. + * — денежная операция (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 — джоб не делает ни * одного обращения к Яндексу и ничего не списывает. * - * Идемпотентность — через AdWalletService::charge по external_key - * "yandex:{campaign->id}:{date}" (дата — вчерашние сутки, которые списываем). + * Идемпотентность — через CampaignImpressionCharger → AdWalletService::charge + * по external_key "yandex-imp:{campaign->id}:{billable}" (billable — число + * оплачиваемых показов на момент списания, счётчик сам считает дельту от + * уже списанного charged_client_rub). */ class ChargeCampaignSpendJob implements ShouldQueue { @@ -54,12 +55,10 @@ class ChargeCampaignSpendJob implements ShouldQueue return; } - $wallet = app(AdWalletService::class); + $charger = app(CampaignImpressionCharger::class); $gate = app(AdWalletGate::class); $stopAll = app(AdStopAllService::class); - $date = Carbon::now()->subDay()->toDateString(); - $markup = new AdMarkup((string) (DB::table('ad_settings')->value('markup_percent') ?? '30.00')); $direct = new YandexDirectClient( $this->configString('services.yandex_direct.base_url'), $this->configString('services.yandex_direct.token'), @@ -72,26 +71,21 @@ class ChargeCampaignSpendJob implements ShouldQueue foreach ($rows as $row) { try { - $spend = $direct->getCampaignSpend((int) $row->yandex_campaign_id, $date); - $clientCost = $markup->clientFromYandex($spend['cost']); - - if (bccomp($clientCost, '0', 2) === 0) { - continue; - } - + $delivered = $direct->getCampaignImpressions((int) $row->yandex_campaign_id); $tenantId = (int) $row->tenant_id; - DB::transaction(function () use ($row, $clientCost, $date, $tenantId, $wallet, $gate, $stopAll): void { + DB::transaction(function () use ($row, $delivered, $tenantId, $charger, $gate, $stopAll): void { DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); - $wallet->charge( - $tenantId, - 'yandex', - 'campaign', - (int) $row->id, - $clientCost, - "yandex:{$row->id}:{$date}", - ); + // Замок строки обязателен. Идемпотентность списания держится на ключе + // «yandex-imp:{кампания}:{показы}», а число показов приходит из отчёта + // Директа: два прогона, начавшихся одновременно (ручной запуск поверх + // расписания, повтор упавшей задачи), получат чуть разные числа — значит + // разные ключи, и уникальный индекс по ключу дубль уже не остановит. + // Клиента списали бы дважды. Под замком прогоны выстраиваются в очередь: + // второй увидит уже обновлённый charged_client_rub и спишет только дельту. + $campaign = AdCampaign::where('id', $row->id)->lockForUpdate()->firstOrFail(); + $charger->charge($campaign, $delivered); if (! $gate->isSolvent($tenantId)) { $stopAll->stopAll($tenantId); diff --git a/app/app/Jobs/ClientTg/AccumulateTelegramLeadJob.php b/app/app/Jobs/ClientTg/AccumulateTelegramLeadJob.php new file mode 100644 index 00000000..40241460 --- /dev/null +++ b/app/app/Jobs/ClientTg/AccumulateTelegramLeadJob.php @@ -0,0 +1,66 @@ +tenantId); + + $deal = Deal::where('tenant_id', $this->tenantId)->find($this->dealId); + if ($deal === null) { + return; + } + + // Накопитель работает под уже установленным tenant-контекстом транзакции. + // Невалидный/пустой номер отсекает сам накопитель (PhoneNormalizer → null). + $accumulator->accumulate($this->tenantId, (string) $deal->phone); + }); + } catch (Throwable $e) { + Log::warning('client_tg.auto_job_failed', [ + 'deal_id' => $this->dealId, + 'tenant_id' => $this->tenantId, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/app/Jobs/ClientTg/PollTelegramModerationJob.php b/app/app/Jobs/ClientTg/PollTelegramModerationJob.php new file mode 100644 index 00000000..6942452c --- /dev/null +++ b/app/app/Jobs/ClientTg/PollTelegramModerationJob.php @@ -0,0 +1,122 @@ +table('client_tg_campaigns') + ->where('status', Campaign::STATUS_MODERATING) + ->whereNotNull('mts_campaign_id') + ->get(['id', 'tenant_id', 'mts_campaign_id']); + + foreach ($rows as $row) { + $tenantId = (int) $row->tenant_id; + $campaignId = (int) $row->id; + $mtsId = (string) $row->mts_campaign_id; + + try { + // Канал «опрос»: робот живёт не на этой машине (МТС не пускает адреса + // дата-центров), поэтому вместо запуска кладём задание — робот придёт + // за ним сам. Номеров в задании нет и не нужно: чтобы прочитать вердикт, + // аудитория не требуется. + if (config('client_tg.robot.transport') === 'poll') { + $this->tenantTx($tenantId, fn () => app(TelegramRobotQueue::class)->enqueue( + tenantId: $tenantId, + campaignId: $campaignId, + mode: RobotJob::MODE_READ_STATUS, + payload: ['campaignId' => $mtsId], + )); + + Log::info('client_tg.read_status_job_enqueued', [ + 'campaign_id' => $campaignId, + 'tenant_id' => $tenantId, + ]); + + continue; + } + + // Чтение вердикта роботом — сетевая/браузерная работа ВНЕ транзакции. + // Зовём РОВНО ОДИН РАЗ на кампанию (каждый вызов = заход в кабинет). + $result = $runner->readModeration($mtsId); + + // Применение вердикта (статус, возврат сметы при отказе, уведомление) + // вынуто в TelegramModerationVerdictApplier: тот же итог приходит и + // отчётом робота на опросе, и применять его должны оба пути одинаково. + app(TelegramModerationVerdictApplier::class)->apply($tenantId, $campaignId, $result, $sandbox); + } catch (Throwable $e) { + Log::warning('PollTelegramModerationJob: сбой обработки кампании на модерации', [ + 'campaign_id' => $campaignId, + 'tenant_id' => $tenantId, + 'error' => $e->getMessage(), + ]); + } + } + } + + /** + * Выполняет $fn в транзакции с установленным tenant-контекстом $tenantId. + * + * @template T + * + * @param callable(): T $fn + * @return T + */ + private function tenantTx(int $tenantId, callable $fn) + { + return DB::transaction(function () use ($tenantId, $fn) { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + + return $fn(); + }); + } +} diff --git a/app/app/Jobs/ClientTg/ResubmitTelegramCampaignJob.php b/app/app/Jobs/ClientTg/ResubmitTelegramCampaignJob.php new file mode 100644 index 00000000..18886102 --- /dev/null +++ b/app/app/Jobs/ClientTg/ResubmitTelegramCampaignJob.php @@ -0,0 +1,223 @@ +tenantTx(function (): ?Campaign { + $campaign = Campaign::where('tenant_id', $this->tenantId) + ->where('id', $this->campaignId) + ->lockForUpdate() + ->first(); + + if ($campaign === null || $campaign->status !== Campaign::STATUS_QUEUED) { + return null; + } + + $campaign->transitionTo(Campaign::STATUS_RUNNING); + + return $campaign; + }); + + if ($campaign === null) { + Log::info('client_tg.resubmit_noop', [ + 'campaign_id' => $this->campaignId, + 'tenant_id' => $this->tenantId, + ]); + + return; + } + + // Канал «опрос»: робот живёт не на этой машине (МТС не пускает адреса + // дата-центров), поэтому вместо запуска кладём задание — робот придёт за ним сам. + // Номера не нужны: аудитория у кампании в кабинете уже есть. + // Кампания остаётся в running намеренно — робот ещё не отработал; за зависшими + // следят уборщик SweepStuckTelegramCampaignsJob и срок аренды задания. + if (config('client_tg.robot.transport') === 'poll') { + $this->tenantTx(fn () => app(TelegramRobotQueue::class)->enqueue( + tenantId: $this->tenantId, + campaignId: $campaign->id, + mode: RobotJob::MODE_RESUBMIT, + payload: [ + 'campaignId' => (string) $campaign->mts_campaign_id, + 'submitMode' => $sandbox ? 'draft' : 'live', + 'adText' => $campaign->ad_text, + 'buttonUrl' => $campaign->ad_link, + 'ordCategory' => $campaign->ord_category, + 'moderatorFile' => $campaign->moderator_file_path, + ], + )); + + Log::info('client_tg.resubmit_job_enqueued', [ + 'campaign_id' => $campaign->id, + 'tenant_id' => $this->tenantId, + ]); + + return; + } + + // Сетевой/браузерный запуск робота — ВНЕ транзакции. Пересдача правит + // существующую кампанию кабинета (по mts_campaign_id) через «Исправить». + // В песочнице submitMode:'draft' (не отправляем), в бою — 'live' (без оплаты). + $result = $runner->run([ + 'mode' => 'resubmit', + 'campaignId' => $campaign->mts_campaign_id, + 'submitMode' => $sandbox ? 'draft' : 'live', + 'adText' => $campaign->ad_text, + 'buttonUrl' => $campaign->ad_link, + 'ordCategory' => $campaign->ord_category, + 'moderatorFile' => $campaign->moderator_file_path, + ]); + + // Применение итога (статус, деньги, уведомление) вынуто в + // TelegramResubmitResultApplier: тот же итог приходит и отчётом робота на опросе, + // и применять его должны оба пути одинаково. + app(TelegramResubmitResultApplier::class)->apply($this->tenantId, $campaign, $result, $sandbox); + } + + public function failed(Throwable $e): void + { + Log::error('client_tg.resubmit_failed_permanently', [ + 'campaign_id' => $this->campaignId, + 'tenant_id' => $this->tenantId, + 'error' => $e->getMessage(), + ]); + + // Осторожно с деньгами (F5): трогаем ТОЛЬКО застрявшую до итога (running/queued). + // + // 🔑 Отличие пересдачи от RunTelegramCampaignJob: у неё queued ВСЕГДА с + // mts_campaign_id. Но в статусе `queued` Фаза A ещё НЕ закоммитила `running` → + // робот кабинет НЕ трогал → кампания в кабинете осталась прежней (rejected), + // смету безопасно вернуть. Поэтому queued → failed + refund (переход + // queued→needs_review в TRANSITIONS отсутствует; без этой ветки кампания зависла + // бы в queued со списанными деньгами — уборщик метёт только running/moderating). + // А вот `running` мог тронуть кабинет → прежняя осторожная логика по mts_campaign_id. + $campaignToRefund = null; + try { + $this->tenantTx(function () use (&$campaignToRefund): void { + $campaign = Campaign::where('tenant_id', $this->tenantId)->find($this->campaignId); + if ($campaign === null) { + return; + } + + if ($campaign->status === Campaign::STATUS_QUEUED) { + if ($campaign->canTransitionTo(Campaign::STATUS_FAILED)) { + $campaign->transitionTo(Campaign::STATUS_FAILED); + $campaignToRefund = $campaign; + } + + return; + } + + if ($campaign->status !== Campaign::STATUS_RUNNING) { + return; // finalize уже терминализовал — не трогаем + } + + // running: робот мог войти в кабинет. Есть mts_campaign_id → needs_review + // без возврата (могла уйти на пере-модерацию); нет id → failed + refund. + $hasMtsId = $campaign->mts_campaign_id !== null && $campaign->mts_campaign_id !== ''; + if ($hasMtsId) { + if ($campaign->canTransitionTo(Campaign::STATUS_NEEDS_REVIEW)) { + $campaign->transitionTo(Campaign::STATUS_NEEDS_REVIEW); + } + + return; + } + + if ($campaign->canTransitionTo(Campaign::STATUS_FAILED)) { + $campaign->transitionTo(Campaign::STATUS_FAILED); + $campaignToRefund = $campaign; + } + }); + } catch (Throwable $inner) { + Log::error('client_tg.resubmit_finalize_failed', [ + 'campaign_id' => $this->campaignId, + 'error' => $inner->getMessage(), + ]); + } + + if ($campaignToRefund !== null && ! (bool) config('client_tg.sandbox', true)) { + try { + $this->tenantTx(fn () => app(TelegramCampaignChargeService::class)->refund($campaignToRefund)); + } catch (Throwable $inner) { + Log::warning('client_tg.refund_failed', [ + 'campaign_id' => $this->campaignId, + 'error' => $inner->getMessage(), + ]); + } + } + } + + /** + * Выполняет $fn в транзакции с установленным tenant-контекстом. + * + * @template T + * + * @param callable(): T $fn + * @return T + */ + private function tenantTx(callable $fn) + { + return DB::transaction(function () use ($fn) { + DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId); + + return $fn(); + }); + } +} diff --git a/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php b/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php new file mode 100644 index 00000000..f59164a4 --- /dev/null +++ b/app/app/Jobs/ClientTg/RunTelegramCampaignJob.php @@ -0,0 +1,238 @@ +}|null $prepared */ + $prepared = $this->tenantTx(function () use ($audience): ?array { + // Блокировка строки (lockForUpdate) сериализует конкурирующих воркеров: + // второй воркер ждёт снятия блокировки, видит уже running и делает no-op + // (находка #14 — защита от гонки двух воркеров на одной queued-кампании). + $campaign = Campaign::where('tenant_id', $this->tenantId) + ->where('id', $this->campaignId) + ->lockForUpdate() + ->first(); + + if ($campaign === null || $campaign->status !== Campaign::STATUS_QUEUED) { + return null; + } + + $campaign->transitionTo(Campaign::STATUS_RUNNING); + + // build() открывает свою SET-LOCAL транзакцию (вложенный savepoint). + $candidates = $audience->build($campaign)->phones; + + return [$campaign, $candidates]; + }); + + if ($prepared === null) { + Log::info('client_tg.job_noop', [ + 'campaign_id' => $this->campaignId, + 'tenant_id' => $this->tenantId, + ]); + + return; + } + + [$campaign, $candidates] = $prepared; + + // Опросный канал: робот живёт на ДРУГОЙ машине (МТС не пускает адреса дата-центров), + // запустить его отсюда физически нельзя. Кладём задание и выходим — итог придёт + // отдельным запросом в /api/tg-robot/jobs/{id}/done. Номера в задание НЕ кладём (ПДн): + // робот заберёт их отдельным запросом, пока задание в работе. + if (config('client_tg.robot.transport') === 'poll') { + $this->tenantTx(fn () => app(TelegramRobotQueue::class)->enqueue( + tenantId: $this->tenantId, + campaignId: $campaign->id, + mode: $sandbox ? RobotJob::MODE_DRAFT : RobotJob::MODE_LIVE, + payload: [ + 'adText' => $campaign->ad_text, + 'buttonUrl' => $campaign->ad_link, + // Заголовок нужен кабинету только для рекламы сайта; у телеграм- + // ссылки он null, и робот его не трогает (поля на странице нет). + 'adHeadline' => $campaign->ad_headline, + 'budgetRub' => (string) $campaign->budget_cap_rub, + 'ordCategory' => $campaign->ord_category, + 'mediaFile' => $campaign->media_path, + 'moderatorFile' => $campaign->moderator_file_path, + 'clientTag' => 'tg:'.$campaign->id, + ], + )); + + Log::info('client_tg.robot_job_enqueued', [ + 'campaign_id' => $campaign->id, + 'tenant_id' => $this->tenantId, + ]); + + return; + } + + // Файл номеров для робота (ПДн) — временный, чистим в finally. + $phonesFile = tempnam(sys_get_temp_dir(), 'tg-phones-'); + + try { + file_put_contents($phonesFile, implode("\n", $candidates)); + + // Сетевой/браузерный запуск робота — ВНЕ транзакции. В песочнице всегда draft. + $result = $runner->run([ + 'mode' => $sandbox ? 'draft' : 'live', + 'phonesFile' => $phonesFile, + 'adText' => $campaign->ad_text, + 'buttonUrl' => $campaign->ad_link, + 'adHeadline' => $campaign->ad_headline, + 'budgetRub' => (string) $campaign->budget_cap_rub, + 'ordCategory' => $campaign->ord_category, + 'mediaFile' => $campaign->media_path, + 'moderatorFile' => $campaign->moderator_file_path, + 'clientTag' => 'tg:'.$campaign->id, + ]); + + // Фаза B — итог под tenant-контекстом. Деньги в песочнице/черновике не трогаем + // (живое списание — Сессия 6, когда известен источник фактической цены). + // Тот же сервис зовёт и опросный путь из канала робота — код один на оба. + app(TelegramCampaignResultApplier::class)->apply($this->tenantId, $campaign, $result, $sandbox); + } finally { + if (is_string($phonesFile) && file_exists($phonesFile)) { + @unlink($phonesFile); + } + } + } + + public function failed(Throwable $e): void + { + Log::error('client_tg.campaign_failed_permanently', [ + 'campaign_id' => $this->campaignId, + 'tenant_id' => $this->tenantId, + 'error' => $e->getMessage(), + ]); + + // Осторожно с деньгами (ревью-фикс F5, симметрия с уборщиком/finalize): трогаем + // ТОЛЬКО кампанию, застрявшую до старта (running/queued) — если finalize уже + // увёл её дальше (moderating/…), статус не меняем и деньги не возвращаем. Есть + // mts_campaign_id → могла уйти на модерацию → needs_review без возврата; нет id → + // failed + возврат сметы. refund делаем ТОЛЬКО когда реально ушли в failed. + $campaignToRefund = null; + try { + $this->tenantTx(function () use (&$campaignToRefund): void { + $campaign = Campaign::where('tenant_id', $this->tenantId)->find($this->campaignId); + if ($campaign === null) { + return; + } + if (! in_array($campaign->status, [Campaign::STATUS_RUNNING, Campaign::STATUS_QUEUED], true)) { + return; // finalize уже терминализовал — не трогаем + } + + $hasMtsId = $campaign->mts_campaign_id !== null && $campaign->mts_campaign_id !== ''; + if ($hasMtsId) { + if ($campaign->canTransitionTo(Campaign::STATUS_NEEDS_REVIEW)) { + $campaign->transitionTo(Campaign::STATUS_NEEDS_REVIEW); + } + + return; // деньги не трогаем — черновик мог уйти на модерацию + } + + if ($campaign->canTransitionTo(Campaign::STATUS_FAILED)) { + $campaign->transitionTo(Campaign::STATUS_FAILED); + $campaignToRefund = $campaign; + } + }); + } catch (Throwable $inner) { + Log::error('client_tg.finalize_failed', [ + 'campaign_id' => $this->campaignId, + 'error' => $inner->getMessage(), + ]); + } + + // Перманентный сбой джоба возвращает списанную смету (в бою) — но ТОЛЬКО для + // заведомо не ушедших (failed). Отдельный tenantTx в своём try/catch — сбой + // возврата не должен ронять обработчик failed. + if ($campaignToRefund !== null && ! (bool) config('client_tg.sandbox', true)) { + try { + $this->tenantTx(fn () => app(TelegramCampaignChargeService::class)->refund($campaignToRefund)); + } catch (Throwable $inner) { + Log::warning('client_tg.refund_failed', [ + 'campaign_id' => $this->campaignId, + 'error' => $inner->getMessage(), + ]); + } + } + } + + /** + * Выполняет $fn в транзакции с установленным tenant-контекстом. + * + * @template T + * + * @param callable(): T $fn + * @return T + */ + private function tenantTx(callable $fn) + { + return DB::transaction(function () use ($fn) { + DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId); + + return $fn(); + }); + } +} diff --git a/app/app/Jobs/ClientTg/SweepStuckTelegramCampaignsJob.php b/app/app/Jobs/ClientTg/SweepStuckTelegramCampaignsJob.php new file mode 100644 index 00000000..c6f75424 --- /dev/null +++ b/app/app/Jobs/ClientTg/SweepStuckTelegramCampaignsJob.php @@ -0,0 +1,268 @@ +subMinutes(self::STUCK_AFTER_MINUTES); + + // Кросс-тенантное перечисление зависших running через BYPASSRLS-роль, + // БЕЗ открытой транзакции (как PollTelegramModerationJob). + $rows = DB::connection('pgsql_supplier')->table('client_tg_campaigns') + ->where('status', Campaign::STATUS_RUNNING) + ->where('updated_at', '<', $cutoff) + ->get(['id', 'tenant_id', 'mts_campaign_id']); + + foreach ($rows as $row) { + $tenantId = (int) $row->tenant_id; + $campaignId = (int) $row->id; + $hasMtsId = $row->mts_campaign_id !== null && $row->mts_campaign_id !== ''; + + try { + // Возвращаем true, только если реально перевели в failed (нужен возврат). + $needsRefund = $this->tenantTx($tenantId, function () use ($campaignId, $hasMtsId): bool { + // Блокировка + повторная проверка статуса: между перечислением и + // этим моментом finalize мог увести кампанию из running (гонка) — + // тогда не трогаем (идемпотентность). + $campaign = Campaign::where('id', $campaignId)->lockForUpdate()->first(); + if ($campaign === null || $campaign->status !== Campaign::STATUS_RUNNING) { + return false; + } + + if ($hasMtsId) { + // Черновик в кабинете есть → мог уйти на модерацию → ручной разбор, + // деньги НЕ трогаем. + $campaign->status_reason = 'Прогон оборвался; черновик в кабинете МТС создан — нужна ручная сверка статуса'; + $campaign->transitionTo(Campaign::STATUS_NEEDS_REVIEW); + + Log::warning('client_tg.sweep_needs_review', [ + 'campaign_id' => $campaignId, + ]); + + return false; + } + + // Черновика нет → заведомо не ушла → добиваем в failed, смету вернём. + $campaign->status_reason = 'Прогон оборвался до создания черновика — кампания не запущена'; + $campaign->transitionTo(Campaign::STATUS_FAILED); + + Log::warning('client_tg.sweep_failed', [ + 'campaign_id' => $campaignId, + ]); + + return true; + }); + + // Возврат сметы — только для заведомо не ушедших (failed) и только в бою + // (в песочнице денег не списывали, refund найдёт сальдо 0). Отдельный + // tenantTx: сбой возврата не должен откатить уже проставленный статус. + if ($needsRefund && ! $sandbox) { + try { + $this->tenantTx($tenantId, function () use ($campaignId): void { + $campaign = Campaign::where('id', $campaignId)->first(); + if ($campaign !== null) { + app(TelegramCampaignChargeService::class)->refund($campaign); + } + }); + } catch (Throwable $e) { + Log::warning('client_tg.sweep_refund_failed', [ + 'campaign_id' => $campaignId, + 'error' => $e->getMessage(), + ]); + } + } + } catch (Throwable $e) { + Log::warning('SweepStuckTelegramCampaignsJob: сбой обработки зависшей кампании', [ + 'campaign_id' => $campaignId, + 'tenant_id' => $tenantId, + 'error' => $e->getMessage(), + ]); + } + } + + $this->sweepStuckQueued(); + $this->sweepStuckModeration(); + } + + /** + * Зависшие `queued` (деньги списаны на launch, но джоб не стартовал). queued не имеет + * `mts_campaign_id` (его ставит робот в running) → робот кабинет не трогал → `failed` + * + возврат сметы. lockForUpdate + повторная проверка статуса: если джоб УЖЕ подхватил + * (стал running) — не трогаем (гонка решается блокировкой; кто первый, того и статус). + */ + private function sweepStuckQueued(): void + { + $sandbox = (bool) config('client_tg.sandbox', true); + $cutoff = Carbon::now()->subMinutes(self::QUEUED_STUCK_AFTER_MINUTES); + + $rows = DB::connection('pgsql_supplier')->table('client_tg_campaigns') + ->where('status', Campaign::STATUS_QUEUED) + ->where('updated_at', '<', $cutoff) + ->get(['id', 'tenant_id']); + + foreach ($rows as $row) { + $tenantId = (int) $row->tenant_id; + $campaignId = (int) $row->id; + + try { + $needsRefund = $this->tenantTx($tenantId, function () use ($campaignId): bool { + $campaign = Campaign::where('id', $campaignId)->lockForUpdate()->first(); + if ($campaign === null || $campaign->status !== Campaign::STATUS_QUEUED) { + return false; // джоб уже подхватил (running) / ушёл дальше — не трогаем + } + + $campaign->status_reason = 'Кампания зависла в очереди — воркер не подхватил задачу; запуск отменён, деньги возвращены'; + $campaign->transitionTo(Campaign::STATUS_FAILED); + + Log::warning('client_tg.sweep_queued_failed', ['campaign_id' => $campaignId]); + + return true; + }); + + // Возврат сметы (в бою). Отдельный tenantTx: сбой возврата не откатит статус. + if ($needsRefund && ! $sandbox) { + try { + $this->tenantTx($tenantId, function () use ($campaignId): void { + $campaign = Campaign::where('id', $campaignId)->first(); + if ($campaign !== null) { + app(TelegramCampaignChargeService::class)->refund($campaign); + } + }); + } catch (Throwable $e) { + Log::warning('client_tg.sweep_queued_refund_failed', [ + 'campaign_id' => $campaignId, + 'error' => $e->getMessage(), + ]); + } + } + } catch (Throwable $e) { + Log::warning('SweepStuckTelegramCampaignsJob: сбой обработки зависшей queued-кампании', [ + 'campaign_id' => $campaignId, + 'tenant_id' => $tenantId, + 'error' => $e->getMessage(), + ]); + } + } + } + + /** + * Ревью-фикс F4: кампания на модерации дольше окна `client_tg.moderation_stuck_hours` + * — робот не смог прочитать вердикт (сломались селекторы кабинета / id протух). + * Уводим в `needs_review` для ручного разбора, БЕЗ возврата сметы: кампания могла + * реально показываться, деньги вслепую не возвращаем (как ветка «есть mts_id» выше). + * Опросчик не трогает строку, пока вердикта нет, поэтому `updated_at` честно растёт. + */ + private function sweepStuckModeration(): void + { + $hours = (int) config('client_tg.moderation_stuck_hours', 48); + $cutoff = Carbon::now()->subHours($hours); + + $rows = DB::connection('pgsql_supplier')->table('client_tg_campaigns') + ->where('status', Campaign::STATUS_MODERATING) + ->where('updated_at', '<', $cutoff) + ->get(['id', 'tenant_id']); + + foreach ($rows as $row) { + $tenantId = (int) $row->tenant_id; + $campaignId = (int) $row->id; + + try { + $this->tenantTx($tenantId, function () use ($campaignId): void { + // Блокировка + повторная проверка: между перечислением и этим моментом + // опросчик мог применить вердикт (гонка) — тогда не трогаем. + $campaign = Campaign::where('id', $campaignId)->lockForUpdate()->first(); + if ($campaign === null || $campaign->status !== Campaign::STATUS_MODERATING) { + return; + } + + $campaign->status_reason = 'Вердикт модерации не удалось получить в срок — нужна ручная сверка статуса в кабинете МТС'; + $campaign->transitionTo(Campaign::STATUS_NEEDS_REVIEW); + + Log::warning('client_tg.sweep_moderation_stuck', ['campaign_id' => $campaignId]); + }); + } catch (Throwable $e) { + Log::warning('SweepStuckTelegramCampaignsJob: сбой обработки зависшей модерации', [ + 'campaign_id' => $campaignId, + 'tenant_id' => $tenantId, + 'error' => $e->getMessage(), + ]); + } + } + } + + /** + * Выполняет $fn в транзакции с установленным tenant-контекстом $tenantId. + * + * @template T + * + * @param callable(): T $fn + * @return T + */ + private function tenantTx(int $tenantId, callable $fn) + { + return DB::transaction(function () use ($tenantId, $fn) { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + + return $fn(); + }); + } +} diff --git a/app/app/Jobs/Supplier/CsvReconcileJob.php b/app/app/Jobs/Supplier/CsvReconcileJob.php index 13678616..60eb9485 100644 --- a/app/app/Jobs/Supplier/CsvReconcileJob.php +++ b/app/app/Jobs/Supplier/CsvReconcileJob.php @@ -31,15 +31,17 @@ use Throwable; * * Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md * - * Алгоритм: + * Алгоритм (актуален на 31.07.2026): * 1. Cache::lock — overlap-защита. * 2. INSERT supplier_csv_reconcile_log (status='running'). - * 3. Заказать отчёт «Запрос номеров» за окно (2 кал. дня) → дождаться → скачать. - * 4. Parse CSV (Name;Tag;Phone). - * 5. Дедуп по (phone, project): SELECT existing supplier_leads за окно. - * 6. Diff = missing → INSERT supplier_leads (vid=NULL, source='csv_recovery') + RouteJob. - * 7. UPDATE log + drift; drift > 5% → CsvDriftAlertMail. - * 8. На exception — status='failed', throw (cron повторит через 30 мин). + * 3. Прочитать журнал ОТДАННОГО «Мои сделки» за окно (2 кал. дня) — fetchDeliveredLeads, + * ключ = vid. НЕ пул «Запрос номеров» (переработка 09.07.2026, иначе фантомы). + * 4. Дедуп по vid глобально: SELECT supplier_leads WHERE vid IN (отданные). + * 5. Недостача → отсрочка (splitByGrace): увиденное впервые уходит в карантин и ждёт + * опаздывающий вебхук; добираем только висящее дольше GRACE_MINUTES (31.07.2026). + * 6. Просроченное → INSERT supplier_leads (настоящий vid, source='csv_recovery') + RouteJob. + * 7. UPDATE log + drift (считается только по просроченному); drift > 5% → CsvDriftAlertMail. + * 8. На exception — Log::error, status='failed', throw (cron повторит через 30 мин). */ final class CsvReconcileJob implements ShouldQueue { @@ -62,6 +64,27 @@ final class CsvReconcileJob implements ShouldQueue private const LOCK_TTL_SECONDS = 600; + /** + * Отсрочка добора (31.07.2026). Вебхук поставщика приходит на 2–8 минут ПОЗЖЕ, + * чем строка появляется в журнале отданного. Сверка (каждые 30 мин) попадала в это + * окно и добирала лид, который уже был в пути: инцидент 31.07 — 7 «потерянных», + * вебхук по тем же семи пришёл через 2,5 минуты и получил «уже есть». Тревога ложная, + * а добранная карточка беднее живой (в журнале отданного нет tag/time/phones → + * у 4 из 7 не определился регион). + * + * Правило: недостача, увиденная ВПЕРВЫЕ, кладётся в карантин и добирается только + * следующим прогоном, если провисела дольше GRACE_MINUTES. При цикле 30 мин + * реальная потеря доезжает максимум через полчаса — это дешевле ложных тревог + * и обеднённых карточек. + */ + private const GRACE_MINUTES = 15; + + /** Карантин: cache-ключ с картой vid => unixtime первого обнаружения недостачи. */ + private const PENDING_CACHE_KEY = 'supplier:csv_reconcile:pending'; + + /** Живучесть карантина: старше — чистим (лид давно закрыт/отозван поставщиком). */ + private const PENDING_TTL_HOURS = 24; + /** UI-аудит 21.06: не чаще 1 алерта о падении сверки за это окно (анти-спам). */ private const FAILURE_ALERT_THROTTLE_HOURS = 6; @@ -122,12 +145,17 @@ final class CsvReconcileJob implements ShouldQueue }); } - // Недостача = отданные поставщиком vid'ы, которых у нас нет (webhook потерял). + // Недостача = отданные поставщиком vid'ы, которых у нас нет. $missing = array_diff_key($delivered, $existingVids); + // Отсрочка (31.07.2026): недостачу делим на «в пути» (увидели только сейчас — + // вебхук по ней, скорее всего, ещё летит) и «просроченную» (висит дольше + // GRACE_MINUTES → вебхук её действительно потерял). Добираем только вторую. + [$overdue, $pendingCount] = $this->splitByGrace($missing); + $recoveredCount = 0; $unparseableCount = 0; - foreach ($missing as $row) { + foreach ($overdue as $row) { $platform = $this->extractPlatform((string) $row['project']); if ($platform === null) { // Поставщик иногда кладёт в `project` нестандартные имена (телефон, URL). @@ -146,15 +174,23 @@ final class CsvReconcileJob implements ShouldQueue // vid — НАСТОЯЩИЙ (из журнала отданного). idx_supplier_leads_vid_unique делает // recovery идемпотентным с поздним webhook: SupplierWebhookController при том же // vid вернёт существующий лид, второй сделки не будет. + // tag — регион лида по версии поставщика. Кладём его в карточку: когда + // ДаData не знает номер (виртуальные операторы), тег — единственная опора + // резолвера, иначе сделка уходит клиенту без региона и города (31.07.2026). + $payload = [ + 'project' => $row['project'], + 'phone' => (string) $row['phone'], + 'vid' => $row['vid'], + ]; + if (($row['tag'] ?? null) !== null && $row['tag'] !== '') { + $payload['tag'] = $row['tag']; + } + $lead = SupplierLead::create([ 'vid' => $row['vid'], 'platform' => $platform, 'phone' => (string) $row['phone'], - 'raw_payload' => [ - 'project' => $row['project'], - 'phone' => (string) $row['phone'], - 'vid' => $row['vid'], - ], + 'raw_payload' => $payload, 'received_at' => now(), 'recovered_from_csv_at' => now(), 'source' => 'csv_recovery', @@ -174,12 +210,14 @@ final class CsvReconcileJob implements ShouldQueue } $matchedCount = $totalCsvRows - count($missing); - // drift считается только по «реальным» пропускам (parseable, не junk): - // real_missing = count(missing) - unparseable (всегда ≥ 0) + // drift считается только по «реальным» пропускам (parseable, не junk) и только + // по ПРОСРОЧЕННЫМ (лиды «в пути» — не потеря, тревожить по ним нельзя): + // real_missing = count(overdue) - unparseable (всегда ≥ 0) // parseable_tot = total_csv_rows - unparseable // Это убирает класс «поставщик кладёт телефон/URL в поле project → - // строки скипаются → drift искусственно завышен» (см. ПИЛОТ 22.05, 25.05). - $realMissing = max(0, count($missing) - $unparseableCount); + // строки скипаются → drift искусственно завышен» (см. ПИЛОТ 22.05, 25.05) + // и класс «вебхук опоздал на 2-8 минут → ложное "потеряно N"» (31.07). + $realMissing = max(0, count($overdue) - $unparseableCount); $parseableTotal = max(0, $totalCsvRows - $unparseableCount); $driftRatio = $parseableTotal > 0 ? $realMissing / $parseableTotal : 0.0; $status = $driftRatio > self::DRIFT_THRESHOLD ? 'drift_alert' : 'ok'; @@ -190,6 +228,7 @@ final class CsvReconcileJob implements ShouldQueue 'matched_count' => $matchedCount, 'recovered_count' => $recoveredCount, 'unparseable_count' => $unparseableCount, + 'pending_count' => $pendingCount, 'drift_ratio' => $driftRatio, 'status' => $status, ]; @@ -199,7 +238,8 @@ final class CsvReconcileJob implements ShouldQueue ->send(new CsvDriftAlertMail( reconcileLogId: $logId, totalCsvRows: $totalCsvRows, - missingCount: count($missing), + missingCount: count($overdue), + pendingCount: $pendingCount, recoveredCount: $recoveredCount, driftRatio: $driftRatio, windowStart: $windowStart, @@ -221,6 +261,14 @@ final class CsvReconcileJob implements ShouldQueue $this->detectAndAlertBusinessDrift($mailer, $windowStart, $windowEnd); } catch (Throwable $e) { + // Первым делом — в журнал, ДО любых обращений к БД. Если упавшая транзакция + // испорчена (25P02), следующие запросы в этом же catch бросят своё исключение + // и настоящая причина потеряется (напоролся при разборе 31.07.2026). + Log::error('csv_reconcile.failed', [ + 'exception' => get_class($e), + 'message' => substr($e->getMessage(), 0, 1000), + ]); + // UI-аудит 21.06: раньше падение сверки писалось в лог status=failed, // но НИКОГО не уведомляло (алерт слался только на drift) — а heartbeat // показывал «OK» (Schedule::job меряет постановку в очередь, не результат). @@ -259,6 +307,61 @@ final class CsvReconcileJob implements ShouldQueue } } + /** + * Делит недостачу на просроченную (добираем) и «в пути» (ждём опаздывающий вебхук). + * + * Карантин лежит одним cache-ключом (карта vid => unixtime первого обнаружения) и + * ПЕРЕПИСЫВАЕТСЯ целиком каждым прогоном: vid, которого больше нет в недостаче + * (вебхук донёс / поставщик отозвал), исчезает сам. Просроченные сохраняют своё + * первое время — иначе неудачный добор (нераспознанный project, гонка) сбрасывал бы + * им отсчёт и они болтались бы в карантине вечно. + * + * Потеря карантина (сброс Redis) безопасна: в худшем случае реальная потеря доедет + * на один прогон позже. + * + * @param array> $missing + * @return array{0: array>, 1: int} [просроченные, сколько в пути] + */ + private function splitByGrace(array $missing): array + { + $store = Cache::store('redis'); + $seen = $store->get(self::PENDING_CACHE_KEY, []); + $seen = is_array($seen) ? $seen : []; + + $now = now()->getTimestamp(); + $graceEdge = $now - self::GRACE_MINUTES * 60; + + $overdue = []; + $stillPending = 0; + $nextSeen = []; + + foreach ($missing as $vid => $row) { + $firstSeen = isset($seen[$vid]) ? (int) $seen[$vid] : $now; + $nextSeen[$vid] = $firstSeen; + + if ($firstSeen <= $graceEdge) { + $overdue[$vid] = $row; + + continue; + } + $stillPending++; + } + + $store->put(self::PENDING_CACHE_KEY, $nextSeen, self::PENDING_TTL_HOURS * 3600); + + if ($stillPending > 0) { + // Уровень warning осознанно: на бою LOG_LEVEL=warning, info в журнал не попадает + // (мина 14.07.2026), а без этой строки отсрочка невидима при разборе. + Log::warning('csv_reconcile.pending_grace', [ + 'pending' => $stillPending, + 'overdue' => count($overdue), + 'grace_minutes' => self::GRACE_MINUTES, + ]); + } + + return [$overdue, $stillPending]; + } + /** Был ли алерт о падении сверки за последнее окно троттла (анти-спам). */ private function failureAlertRecentlySent(): bool { diff --git a/app/app/Jobs/SyncCampaignModerationJob.php b/app/app/Jobs/SyncCampaignModerationJob.php index bd831425..fee915ad 100644 --- a/app/app/Jobs/SyncCampaignModerationJob.php +++ b/app/app/Jobs/SyncCampaignModerationJob.php @@ -5,12 +5,18 @@ 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\Collection; use Illuminate\Support\Facades\Log; use Throwable; @@ -18,16 +24,20 @@ use Throwable; * Опрос модерации объявлений Яндекс.Директа — passthrough статусов (Р27): * что сказал Яндекс про объявление, то и записываем себе, без своей трактовки. * - * Агрегация на уровне кампании: одно REJECTED объявление останавливает всю - * кампанию (rejected), все ACCEPTED — переводит в running, иначе кампания - * остаётся pending_moderation (ждём). + * Единица модерации — БАННЕР набора (ad_campaign_banners): у медийной кампании + * своё объявление на каждый размер блока, и статус модерации живёт на баннере. + * + * Агрегация на уровне кампании (спека §6): кампания РАБОТАЕТ, если принято хотя бы + * одно объявление — отклонённые у Яндекса просто не показываются, остальные крутятся. + * В rejected (со снятием заморозки) уходим, только когда отклонены ВСЕ. Пока хоть одно + * объявление не получило финального вердикта — ждём, статус кампании не трогаем. * * 🔴 На проде очередь бежит под ролью `crm_app_user` (НЕ BYPASSRLS) на дефолтном * соединении, БЕЗ tenant-контекста — без явного перечисления через BYPASSRLS- * соединение RLS-политика при пустом контексте даёт 0 строк, и джоб молча ничего * не делает. Перечисляем кампании через `pgsql_supplier` (BYPASSRLS, аналог * SyncCampaignAudienceJob/ChargeCampaignSpendJob); модели загружены через это - * соединение — их ->update() (ad и campaign) идут по BYPASSRLS, пишем строго + * соединение — их ->update() (баннер и кампания) идут по BYPASSRLS, пишем строго * по загруженным строкам (без доп. tenant-фильтра). * * Рубильник: services.yandex_direct.enabled — пока выключен, джоб не делает @@ -51,65 +61,232 @@ class SyncCampaignModerationJob implements ShouldQueue $campaigns = AdCampaign::on('pgsql_supplier')->whereIn('status', [ AdCampaign::STATUS_RUNNING, AdCampaign::STATUS_PENDING_MODERATION, - ])->whereNotNull('yandex_campaign_id')->with('ads')->get(); + ])->whereNotNull('yandex_campaign_id')->with('banners')->get(); foreach ($campaigns as $campaign) { - $ads = $campaign->ads->filter(fn ($ad) => $ad->yandex_ad_id !== null); + $banners = $campaign->banners->filter(fn ($b) => $b->yandex_ad_id !== null); - if ($ads->isEmpty()) { + if ($banners->isEmpty()) { continue; } + // Под защитой не только поход в сеть, но и запись ответа: беда на одной кампании + // не должна срывать обход остальных клиентов. Сорванный обход — это чужая реклама, + // про которую никто не узнал, что её приняли или отклонили, и не вернувшиеся деньги. try { - $moderation = $direct->getAdsModeration($ads->pluck('yandex_ad_id')->all()); + $moderation = $direct->getAdsModeration( + $banners->pluck('yandex_ad_id')->map(fn ($v) => (int) $v)->all() + ); + + // 🔴 Включение пробуем ДО разбора баннеров, а не после. Отказ включения — + // единственный машинный признак «Яндекс придержал показ», и он нужен уже + // при составлении текста для клиента. Сделай наоборот — сообщение о придержке + // легло бы ПОВЕРХ доклада разведчика, а следующий обход начал бы чередовать + // их по кругу: защита от дублей смотрит на последнее сообщение. + $otkazyVklyucheniya = $this->probuemVklyuchit($direct, $banners, $moderation, (int) $campaign->id); + + 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; + + // 🔴 Яндекс принял объявление, но включить не дал — значит показов нет, + // а `ACCEPTED` в ответе выдал бы их за идущие. Статус не трогаем + // (passthrough Р27: что сказал Яндекс, то и записали), но клиенту + // говорим правду вместо машинного «Принято на модерации.». + $priderzhan = isset($otkazyVklyucheniya[(int) $banner->yandex_ad_id]); + + $reason = match (true) { + $status === AdCampaignBanner::MOD_REJECTED => ModerationReason::forRejected($raw), + $priderzhan => ModerationReason::POKAZ_PRIDERZHAN, + default => 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 = in_array($reason, [ + ModerationReason::PRICHINA_NEIZVESTNA, + ModerationReason::POKAZ_PRIDERZHAN, + ], true); + + 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 || $priderzhan) { + 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; } - $hasRejected = false; - $rejectedReason = null; - $allAccepted = true; + // Перечитывать баннеры не нужно: ->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 + )); - foreach ($ads as $ad) { - $info = $moderation[(int) $ad->yandex_ad_id] ?? null; - if ($info === null) { - $allAccepted = false; - - continue; - } - - $status = $info['status'] ?? null; - $reason = $info['reason'] ?? null; - - $ad->update([ - 'moderation_status' => $status, - 'moderation_reason' => $reason, - ]); - - if ($status === 'REJECTED') { - if (! $hasRejected) { - $hasRejected = true; - $rejectedReason = $reason; - } - } elseif ($status !== 'ACCEPTED') { - $allAccepted = false; - } + // Правило спеки §6: кампания работает, если принято ХОТЯ БЫ ОДНО объявление — + // отклонённые просто не показываются. В «отклонено» с разморозкой денег уходим, + // только когда отклонены ВСЕ. Так не появляется пятый выход кампании: существующие + // четыре выхода снятия заморозки остаются как есть. + if ($anyPending) { + continue; // ждём вердикта по остальным, статус кампании не трогаем } - $newStatus = $hasRejected - ? AdCampaign::STATUS_REJECTED - : ($allAccepted ? AdCampaign::STATUS_RUNNING : AdCampaign::STATUS_PENDING_MODERATION); + $newStatus = $anyAccepted ? AdCampaign::STATUS_RUNNING : AdCampaign::STATUS_REJECTED; - if ($newStatus !== $campaign->status) { - $campaign->update([ - 'status' => $newStatus, - 'moderation_reason' => $hasRejected ? $rejectedReason : $campaign->moderation_reason, + 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, + ); + } + } + } + + /** + * Поднять показ у принятых, но выключенных объявлений — и вернуть тех, кого Яндекс + * поднять не дал. + * + * 🔴 У объявления два независимых признака: `Status` — вердикт модерации, `State` — + * идёт ли показ. Созданное программой объявление рождается выключенным и таким + * остаётся, пока его не включить; включение кампании объявления не поднимает. + * + * Собираем по СОСТОЯНИЮ из ответа Яндекса, а не по переходу статуса: у давно + * запущенной кампании «принято» записано неделю назад, перехода уже не будет, + * и по переходу она осталась бы выключенной навсегда. + * + * Отдельный try: показ — вещь важная, но неудача включения не должна стоить кампании + * записанного вердикта модерации и сорвать обход остальных клиентов. + * + * @param Collection $banners + * @param array> $moderation + * @return array номер объявления → слова Яндекса, почему не включил + */ + private function probuemVklyuchit( + YandexDirectClient $direct, + Collection $banners, + array $moderation, + int $campaignId, + ): array { + $vyklyuchennye = []; + + foreach ($banners as $banner) { + $info = $moderation[(int) $banner->yandex_ad_id] ?? null; + + if (($info['status'] ?? null) === AdCampaignBanner::MOD_ACCEPTED && ($info['state'] ?? null) === 'OFF') { + $vyklyuchennye[] = (int) $banner->yandex_ad_id; + } + } + + if ($vyklyuchennye === []) { + return []; + } + + try { + $otkazy = $direct->resumeAds($vyklyuchennye); + + if ($otkazy !== []) { + Log::warning('Яндекс не дал включить показ принятых объявлений', [ + 'campaign' => $campaignId, + 'ads' => array_keys($otkazy), + 'слова Яндекса' => array_values(array_unique($otkazy)), ]); } + + return $otkazy; + } catch (Throwable $e) { + Log::warning('Не смогли включить показ принятых объявлений: '.$e->getMessage(), [ + 'campaign' => $campaignId, 'ads' => count($vyklyuchennye), + ]); + + return []; } } diff --git a/app/app/Listeners/PauseCampaignsOnAdStop.php b/app/app/Listeners/PauseCampaignsOnAdStop.php index d6793d34..89898e3f 100644 --- a/app/app/Listeners/PauseCampaignsOnAdStop.php +++ b/app/app/Listeners/PauseCampaignsOnAdStop.php @@ -6,12 +6,15 @@ 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). @@ -38,6 +41,11 @@ final class PauseCampaignsOnAdStop } } $campaign->update(['status' => AdCampaign::STATUS_STOPPED_NO_FUNDS]); + + // ВЫХОД 3 — реклама заглушена из-за нехватки денег. Держать заморозку на + // мёртвой кампании нельзя: она занижает свободный остаток и не даёт клиенту + // распорядиться пополнением (в т.ч. перезапустить рекламу). + $this->wallet->release((int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id); } } } diff --git a/app/app/Mail/AdDocumentAttachedMail.php b/app/app/Mail/AdDocumentAttachedMail.php new file mode 100644 index 00000000..1409b2d7 --- /dev/null +++ b/app/app/Mail/AdDocumentAttachedMail.php @@ -0,0 +1,59 @@ +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, + ], + ); + } +} diff --git a/app/app/Mail/AdModerationMessageMail.php b/app/app/Mail/AdModerationMessageMail.php new file mode 100644 index 00000000..53f14a13 --- /dev/null +++ b/app/app/Mail/AdModerationMessageMail.php @@ -0,0 +1,44 @@ +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, + ], + ); + } +} diff --git a/app/app/Mail/CsvDriftAlertMail.php b/app/app/Mail/CsvDriftAlertMail.php index 58f39c67..cc33b5f8 100644 --- a/app/app/Mail/CsvDriftAlertMail.php +++ b/app/app/Mail/CsvDriftAlertMail.php @@ -12,7 +12,14 @@ use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; /** - * Email алерт админу Лидерры о расхождении CSV-сверки > 5%. + * Email алерт админу Лидерры: поставщик отдал номера, которых у нас нет, и они не + * доехали даже с отсрочкой (CsvReconcileJob::GRACE_MINUTES) — то есть вебхук их + * действительно потерял, добрала сверка. + * + * Формулировки правлены 31.07.2026: прежний текст «пропущено вебхуком» вешал ярлык + * потери на любой номер, которого не было в базе НА МОМЕНТ СВЕРКИ, включая летящие + * в этот момент вебхуки (инцидент 31.07 — 7 «потерянных», вебхук по ним пришёл через + * 2,5 минуты). Теперь «потеряно» = только просроченное, «в пути» показывается отдельно. * * Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.6 */ @@ -25,6 +32,7 @@ final class CsvDriftAlertMail extends Mailable public readonly int $reconcileLogId, public readonly int $totalCsvRows, public readonly int $missingCount, + public readonly int $pendingCount, public readonly int $recoveredCount, public readonly float $driftRatio, public readonly CarbonInterface $windowStart, @@ -37,7 +45,7 @@ final class CsvDriftAlertMail extends Mailable $window = $this->windowStart->format('Y-m-d H:i').' — '.$this->windowEnd->format('Y-m-d H:i'); return new Envelope( - subject: "Лидерра ↔ Поставщик: расхождение CSV > 5% за {$window} ({$pct}%)", + subject: "Лидерра ↔ Поставщик: вебхук потерял {$this->missingCount} — добрали сверкой ({$pct}%, {$window})", ); } diff --git a/app/app/Models/AdCampaign.php b/app/app/Models/AdCampaign.php index 7f18bb5b..737c6a2a 100644 --- a/app/app/Models/AdCampaign.php +++ b/app/app/Models/AdCampaign.php @@ -8,6 +8,7 @@ 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; /** * Рекламная кампания клиента в Яндекс.Директе (по своей аудитории телефонов). @@ -15,6 +16,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany; * Tenant-aware с RLS. * * Источник: db/schema.sql, table `ad_campaigns`. + * + * @mixin IdeHelperAdCampaign */ class AdCampaign extends Model { @@ -32,57 +35,157 @@ class AdCampaign extends Model 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', - 'weekly_budget_rub', + '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', - 'click_bid_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', - 'weekly_budget_rub' => 'decimal:2', + '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', - 'click_bid_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 */ public function ads(): HasMany { return $this->hasMany(AdCampaignAd::class, 'campaign_id'); } + /** Набор баннеров кампании: строка = «размер + файл + креатив + объявление». + * + * @return HasMany + */ + public function banners(): HasMany + { + return $this->hasMany(AdCampaignBanner::class, 'campaign_id'); + } + /** @return HasMany */ public function phones(): HasMany { return $this->hasMany(AdCampaignPhone::class, 'campaign_id'); } + /** + * Лента переписки по кампании — слова Яндекса, ответы клиента, служебные отметки. + * + * 🔴 Это **единственный допустимый путь** к сообщению, когда по нему принимается + * решение: искать сообщение сырым номером нельзя. Служебный канал робота ходит под + * ролью с кросс-тенантным доступом, где RLS не отфильтрует, и поиск по номеру отдал бы + * чужой документ. Связь привязывает выборку к кампании, а кампания — к её владельцу. + * + * @return HasMany + */ + public function messages(): HasMany + { + return $this->hasMany(AdCampaignMessage::class, 'campaign_id'); + } + /** @return BelongsTo */ public function tenant(): BelongsTo { diff --git a/app/app/Models/AdCampaignAd.php b/app/app/Models/AdCampaignAd.php index 98f0bf3e..992c19b3 100644 --- a/app/app/Models/AdCampaignAd.php +++ b/app/app/Models/AdCampaignAd.php @@ -14,6 +14,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * Tenant-aware с RLS. * * Источник: db/schema.sql, table `ad_campaign_ads`. + * + * @mixin IdeHelperAdCampaignAd */ class AdCampaignAd extends Model { diff --git a/app/app/Models/AdCampaignBanner.php b/app/app/Models/AdCampaignBanner.php new file mode 100644 index 00000000..cd4f7c09 --- /dev/null +++ b/app/app/Models/AdCampaignBanner.php @@ -0,0 +1,50 @@ + 'integer', + 'campaign_id' => 'integer', + 'width' => 'integer', + 'height' => 'integer', + 'bytes' => 'integer', + 'included' => 'boolean', + 'yandex_creative_id' => 'integer', + 'yandex_ad_id' => 'integer', + ]; + } + + /** @return BelongsTo */ + public function campaign(): BelongsTo + { + return $this->belongsTo(AdCampaign::class, 'campaign_id'); + } +} diff --git a/app/app/Models/AdCampaignMessage.php b/app/app/Models/AdCampaignMessage.php new file mode 100644 index 00000000..1d92fc41 --- /dev/null +++ b/app/app/Models/AdCampaignMessage.php @@ -0,0 +1,46 @@ + 'integer', + 'campaign_id' => 'integer', + 'banner_id' => 'integer', + 'file_size' => 'integer', + ]; + } + + /** @return BelongsTo */ + public function campaign(): BelongsTo + { + return $this->belongsTo(AdCampaign::class, 'campaign_id'); + } +} diff --git a/app/app/Models/AdCampaignPhone.php b/app/app/Models/AdCampaignPhone.php index 7c318d0e..0e62f92e 100644 --- a/app/app/Models/AdCampaignPhone.php +++ b/app/app/Models/AdCampaignPhone.php @@ -14,6 +14,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * Tenant-aware с RLS. * * Источник: db/schema.sql, table `ad_campaign_phones`. + * + * @mixin IdeHelperAdCampaignPhone */ class AdCampaignPhone extends Model { diff --git a/app/app/Models/AdCreativeJob.php b/app/app/Models/AdCreativeJob.php new file mode 100644 index 00000000..34c0c902 --- /dev/null +++ b/app/app/Models/AdCreativeJob.php @@ -0,0 +1,71 @@ + 'integer', + 'campaign_id' => 'integer', + 'message_id' => 'integer', + 'yandex_ad_id' => 'integer', + 'attempts' => 'integer', + 'snapshot_before' => 'array', + 'taken_at' => 'datetime', + 'finished_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function campaign(): BelongsTo + { + return $this->belongsTo(AdCampaign::class, 'campaign_id'); + } + + /** + * Сообщение ленты, документ из которого везёт задание вида `deliver`. + * + * @return BelongsTo + */ + public function message(): BelongsTo + { + return $this->belongsTo(AdCampaignMessage::class, 'message_id'); + } +} diff --git a/app/app/Models/AdWallet.php b/app/app/Models/AdWallet.php index cd5eb61b..a2b0f976 100644 --- a/app/app/Models/AdWallet.php +++ b/app/app/Models/AdWallet.php @@ -14,6 +14,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * Tenant-aware с RLS. Один кошелёк на тенанта (unique tenant_id). * * Источник: db/schema.sql, table `ad_wallets`. + * + * @mixin IdeHelperAdWallet */ class AdWallet extends Model { diff --git a/app/app/Models/AdWalletHold.php b/app/app/Models/AdWalletHold.php index 0fbbc37a..a9f6d8d2 100644 --- a/app/app/Models/AdWalletHold.php +++ b/app/app/Models/AdWalletHold.php @@ -14,6 +14,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * Tenant-aware с RLS. Статус переключается active→released. * * Источник: db/schema.sql, table `ad_wallet_holds`. + * + * @mixin IdeHelperAdWalletHold */ class AdWalletHold extends Model { diff --git a/app/app/Models/AdWalletTransaction.php b/app/app/Models/AdWalletTransaction.php index 905ea1eb..7510f0e7 100644 --- a/app/app/Models/AdWalletTransaction.php +++ b/app/app/Models/AdWalletTransaction.php @@ -14,6 +14,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * Tenant-aware с RLS. Только SELECT/INSERT на уровне БД — без UPDATE/DELETE. * * Источник: db/schema.sql, table `ad_wallet_transactions`. + * + * @mixin IdeHelperAdWalletTransaction */ class AdWalletTransaction extends Model { diff --git a/app/app/Models/BalanceTransaction.php b/app/app/Models/BalanceTransaction.php index fb3f23d7..e144d472 100644 --- a/app/app/Models/BalanceTransaction.php +++ b/app/app/Models/BalanceTransaction.php @@ -46,6 +46,10 @@ class BalanceTransaction extends Model public const TYPE_SMS_CHARGE = 'sms_charge'; + public const TYPE_TG_AD_CHARGE = 'tg_ad_charge'; + + public const TYPE_TG_AD_REFUND = 'tg_ad_refund'; + public $timestamps = false; protected $fillable = [ diff --git a/app/app/Models/ClientSmsAutoRule.php b/app/app/Models/ClientSmsAutoRule.php index 7ea7931c..cb5a6018 100644 --- a/app/app/Models/ClientSmsAutoRule.php +++ b/app/app/Models/ClientSmsAutoRule.php @@ -12,6 +12,8 @@ use Illuminate\Database\Eloquent\Model; * Tenant-aware с RLS. * * Источник: db/schema.sql, table `client_sms_auto_rule`. + * + * @mixin IdeHelperClientSmsAutoRule */ class ClientSmsAutoRule extends Model { diff --git a/app/app/Models/ClientSmsCampaign.php b/app/app/Models/ClientSmsCampaign.php index 7165328b..58d45601 100644 --- a/app/app/Models/ClientSmsCampaign.php +++ b/app/app/Models/ClientSmsCampaign.php @@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; * (каст `array`). Без этой пометки статанализ читает тип из миграции и видит строку. * * @property array|null $audience_statuses + * @mixin IdeHelperClientSmsCampaign */ class ClientSmsCampaign extends Model { diff --git a/app/app/Models/ClientSmsCampaignPhone.php b/app/app/Models/ClientSmsCampaignPhone.php index 80422a12..7a768381 100644 --- a/app/app/Models/ClientSmsCampaignPhone.php +++ b/app/app/Models/ClientSmsCampaignPhone.php @@ -26,6 +26,8 @@ use Illuminate\Database\Eloquent\Model; * Tenant-aware с RLS. * * Источник: db/schema.sql, table `client_sms_campaign_phones`. + * + * @mixin IdeHelperClientSmsCampaignPhone */ class ClientSmsCampaignPhone extends Model { diff --git a/app/app/Models/ClientSmsContact.php b/app/app/Models/ClientSmsContact.php index fbba9233..82908b4c 100644 --- a/app/app/Models/ClientSmsContact.php +++ b/app/app/Models/ClientSmsContact.php @@ -12,6 +12,8 @@ use Illuminate\Database\Eloquent\Model; * Tenant-aware с RLS. * * Источник: db/schema.sql, table `client_sms_contacts`. + * + * @mixin IdeHelperClientSmsContact */ class ClientSmsContact extends Model { diff --git a/app/app/Models/ClientSmsMessage.php b/app/app/Models/ClientSmsMessage.php index 79c4eb1d..28d70737 100644 --- a/app/app/Models/ClientSmsMessage.php +++ b/app/app/Models/ClientSmsMessage.php @@ -13,6 +13,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * Tenant-aware с RLS. * * Источник: db/schema.sql, table `client_sms_messages`. + * + * @mixin IdeHelperClientSmsMessage */ class ClientSmsMessage extends Model { diff --git a/app/app/Models/ClientSmsOptout.php b/app/app/Models/ClientSmsOptout.php index b1c5ac5a..9da177e7 100644 --- a/app/app/Models/ClientSmsOptout.php +++ b/app/app/Models/ClientSmsOptout.php @@ -12,6 +12,8 @@ use Illuminate\Database\Eloquent\Model; * Tenant-aware с RLS. * * Источник: db/schema.sql, table `client_sms_optouts`. + * + * @mixin IdeHelperClientSmsOptout */ class ClientSmsOptout extends Model { diff --git a/app/app/Models/ClientSmsSender.php b/app/app/Models/ClientSmsSender.php index 882242c7..5d08a757 100644 --- a/app/app/Models/ClientSmsSender.php +++ b/app/app/Models/ClientSmsSender.php @@ -13,6 +13,8 @@ use Illuminate\Database\Eloquent\Model; * Tenant-aware с RLS, один отправитель на тенанта (UNIQUE tenant_id). * * Источник: db/schema.sql, table `client_sms_senders`. + * + * @mixin IdeHelperClientSmsSender */ class ClientSmsSender extends Model { diff --git a/app/app/Models/ClientSmsSettings.php b/app/app/Models/ClientSmsSettings.php index 11f28295..22d1a0ca 100644 --- a/app/app/Models/ClientSmsSettings.php +++ b/app/app/Models/ClientSmsSettings.php @@ -23,6 +23,7 @@ use Illuminate\Database\Eloquent\Model; * @property int $stuck_after_minutes * @property array|null $allowed_operators * @property int $max_upload_phones + * @mixin IdeHelperClientSmsSettings */ class ClientSmsSettings extends Model { diff --git a/app/app/Models/ClientSmsTariff.php b/app/app/Models/ClientSmsTariff.php index 8a9608c6..b344a4c4 100644 --- a/app/app/Models/ClientSmsTariff.php +++ b/app/app/Models/ClientSmsTariff.php @@ -12,6 +12,8 @@ use Illuminate\Database\Eloquent\Model; * Общая справочная таблица (не tenant-aware, без RLS). * * Источник: db/schema.sql, table `client_sms_tariffs`. + * + * @mixin IdeHelperClientSmsTariff */ class ClientSmsTariff extends Model { diff --git a/app/app/Models/ClientSmsTemplate.php b/app/app/Models/ClientSmsTemplate.php index 5f83af46..d6af5872 100644 --- a/app/app/Models/ClientSmsTemplate.php +++ b/app/app/Models/ClientSmsTemplate.php @@ -12,6 +12,8 @@ use Illuminate\Database\Eloquent\Model; * Tenant-aware с RLS. * * Источник: db/schema.sql, table `client_sms_templates`. + * + * @mixin IdeHelperClientSmsTemplate */ class ClientSmsTemplate extends Model { diff --git a/app/app/Models/ClientTg/AutoRule.php b/app/app/Models/ClientTg/AutoRule.php new file mode 100644 index 00000000..e0f8d1d1 --- /dev/null +++ b/app/app/Models/ClientTg/AutoRule.php @@ -0,0 +1,50 @@ + 'integer', + 'enabled' => 'boolean', + 'budget_cap_rub' => 'decimal:2', + 'daily_limit_rub' => 'decimal:2', + 'batch_threshold' => 'integer', + 'spent_today_rub' => 'decimal:2', + 'spent_date' => 'date', + 'updated_by' => 'integer', + ]; + } +} diff --git a/app/app/Models/ClientTg/Campaign.php b/app/app/Models/ClientTg/Campaign.php new file mode 100644 index 00000000..3e6ca358 --- /dev/null +++ b/app/app/Models/ClientTg/Campaign.php @@ -0,0 +1,168 @@ +|null $audience_params + * @mixin IdeHelperCampaign + */ +class Campaign extends Model +{ + protected $table = 'client_tg_campaigns'; + + public const STATUS_DRAFT = 'draft'; + + public const STATUS_QUEUED = 'queued'; + + public const STATUS_RUNNING = 'running'; + + public const STATUS_DRAFT_READY = 'draft_ready'; + + public const STATUS_MODERATING = 'moderating'; + + public const STATUS_LAUNCHED = 'launched'; + + public const STATUS_FAILED = 'failed'; + + public const STATUS_REJECTED = 'rejected'; + + public const STATUS_CANCELLED = 'cancelled'; + + public const STATUS_NEEDS_REVIEW = 'needs_review'; + + public const AUDIENCE_DEALS = 'deals'; + + public const AUDIENCE_BASE = 'base'; + + public const AUDIENCE_LIST = 'list'; + + /** + * Разрешённые переходы статуса (план §Сессия 2 задача 2.1 + Этап 3 задачи 3.2/3.3): + * draft → queued → running → (draft_ready | moderating | launched | failed + * | rejected | needs_review). + * Живая отправка робота ставит `moderating` («на модерации МТС»), а не сразу + * `launched`: одобрение придёт от опросчика вердикта (задача 3.4), тогда + * moderating → launched; отказ модерации — moderating → rejected. Отклонённую + * кампанию можно пересдать: rejected → queued (задача 3.6). Отмена (cancelled) + * разрешена только ДО запуска — из draft и из queued; из running отмену не + * пускаем. + * + * queued → failed: обработчик `failed()` джоба при перманентном сбое до старта + * робота (задача 3.3) добивает статус зависшей queued-кампании. + * + * running → needs_review: уборщик зависших (задача 3.3) помечает так кампанию, + * которая застряла в `running`, НО уже имеет `mts_campaign_id` (черновик в + * кабинете реально создан) — она могла уйти на модерацию, поэтому деньги вслепую + * не возвращаем, ждём ручного разбора / сверки статуса в кабинете (3.4). + * + * moderating → needs_review (ревью-фикс F4): кампания вечно висит на модерации, + * если робот не может прочитать вердикт (сломались селекторы / id протух). Уборщик + * по возрасту (client_tg.moderation_stuck_hours) уводит её в needs_review для + * ручного разбора — БЕЗ возврата брони (могла реально показываться). + * + * Терминальные — draft_ready/launched/failed/cancelled/needs_review. + * + * @var array> + */ + public const TRANSITIONS = [ + self::STATUS_DRAFT => [self::STATUS_QUEUED, self::STATUS_CANCELLED], + self::STATUS_QUEUED => [self::STATUS_RUNNING, self::STATUS_CANCELLED, self::STATUS_FAILED], + self::STATUS_RUNNING => [ + self::STATUS_DRAFT_READY, + self::STATUS_MODERATING, + self::STATUS_LAUNCHED, + self::STATUS_FAILED, + self::STATUS_REJECTED, + self::STATUS_NEEDS_REVIEW, + ], + self::STATUS_MODERATING => [self::STATUS_LAUNCHED, self::STATUS_REJECTED, self::STATUS_NEEDS_REVIEW], + self::STATUS_DRAFT_READY => [], + self::STATUS_LAUNCHED => [], + self::STATUS_FAILED => [], + self::STATUS_REJECTED => [self::STATUS_QUEUED], + self::STATUS_CANCELLED => [], + self::STATUS_NEEDS_REVIEW => [], + ]; + + protected $fillable = [ + 'tenant_id', + 'status', + 'status_reason', + 'mts_campaign_id', + 'ad_text', + 'ad_link', + 'ad_headline', + 'media_path', + 'moderator_file_path', + 'ord_category', + 'budget_cap_rub', + 'audience_kind', + 'audience_params', + 'planned_count', + 'matched_count', + 'estimated_cost_rub', + 'actual_cost_rub', + 'created_by', + ]; + + protected function casts(): array + { + return [ + 'tenant_id' => 'integer', + 'planned_count' => 'integer', + 'matched_count' => 'integer', + 'created_by' => 'integer', + 'audience_params' => 'array', + 'budget_cap_rub' => 'decimal:2', + 'estimated_cost_rub' => 'decimal:2', + 'actual_cost_rub' => 'decimal:2', + ]; + } + + /** @return HasMany */ + public function phones(): HasMany + { + return $this->hasMany(CampaignPhone::class, 'campaign_id'); + } + + /** Допустим ли переход из текущего статуса в $to (без броска). */ + public function canTransitionTo(string $to): bool + { + return in_array($to, self::TRANSITIONS[$this->status] ?? [], true); + } + + /** + * Перевести кампанию в статус $to и сохранить. Недопустимый переход — + * DomainException (статус при этом НЕ меняется). Бросаем глобальное + * исключение, а не доменный класс: слой Model не должен зависеть от слоя + * Exception (deptrac Model: [], ADR-005). Кто ловит (контроллер/джоб) — + * может ловить DomainException. + */ + public function transitionTo(string $to): void + { + if (! $this->canTransitionTo($to)) { + throw new DomainException( + "Недопустимый переход статуса кампании: {$this->status} → {$to}", + ); + } + + $this->status = $to; + $this->save(); + } +} diff --git a/app/app/Models/ClientTg/CampaignPhone.php b/app/app/Models/ClientTg/CampaignPhone.php new file mode 100644 index 00000000..248a3c66 --- /dev/null +++ b/app/app/Models/ClientTg/CampaignPhone.php @@ -0,0 +1,45 @@ + 'integer', + 'campaign_id' => 'integer', + 'expires_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function campaign(): BelongsTo + { + return $this->belongsTo(Campaign::class, 'campaign_id'); + } +} diff --git a/app/app/Models/ClientTg/Contact.php b/app/app/Models/ClientTg/Contact.php new file mode 100644 index 00000000..7afe8486 --- /dev/null +++ b/app/app/Models/ClientTg/Contact.php @@ -0,0 +1,35 @@ + 'integer', + ]; + } +} diff --git a/app/app/Models/ClientTg/Optout.php b/app/app/Models/ClientTg/Optout.php new file mode 100644 index 00000000..17f290f9 --- /dev/null +++ b/app/app/Models/ClientTg/Optout.php @@ -0,0 +1,34 @@ + 'integer', + ]; + } +} diff --git a/app/app/Models/ClientTg/RobotJob.php b/app/app/Models/ClientTg/RobotJob.php new file mode 100644 index 00000000..c4e0b185 --- /dev/null +++ b/app/app/Models/ClientTg/RobotJob.php @@ -0,0 +1,54 @@ + 'array', + 'result' => 'array', + 'attempts' => 'integer', + 'taken_at' => 'datetime', + 'finished_at' => 'datetime', + ]; + } +} diff --git a/app/app/Models/ClientTg/Tariff.php b/app/app/Models/ClientTg/Tariff.php new file mode 100644 index 00000000..484ef854 --- /dev/null +++ b/app/app/Models/ClientTg/Tariff.php @@ -0,0 +1,35 @@ + 'integer', + 'price_rub' => 'decimal:2', + ]; + } +} diff --git a/app/app/Models/EmailVerification.php b/app/app/Models/EmailVerification.php index 7f2e2997..6df64c06 100644 --- a/app/app/Models/EmailVerification.php +++ b/app/app/Models/EmailVerification.php @@ -25,7 +25,6 @@ use Illuminate\Support\Carbon; * @property Carbon $expires_at * @property Carbon|null $verified_at * @property Carbon $created_at - * * @mixin IdeHelperEmailVerification */ class EmailVerification extends Model diff --git a/app/app/Models/ImpersonationToken.php b/app/app/Models/ImpersonationToken.php index 909d501c..cf019bb3 100644 --- a/app/app/Models/ImpersonationToken.php +++ b/app/app/Models/ImpersonationToken.php @@ -33,7 +33,6 @@ use Illuminate\Support\Carbon; * @property Carbon|null $second_approval_at * @property Carbon $created_at * @property string|null $session_token_hash - * * @mixin IdeHelperImpersonationToken */ class ImpersonationToken extends Model diff --git a/app/app/Models/PdSubjectRequest.php b/app/app/Models/PdSubjectRequest.php index 6094c1bd..6d84f550 100644 --- a/app/app/Models/PdSubjectRequest.php +++ b/app/app/Models/PdSubjectRequest.php @@ -29,7 +29,6 @@ use Illuminate\Support\Facades\DB; * @property string $deadline_at * @property string|null $completed_at * @property bool $processing_restricted - * * @mixin IdeHelperPdSubjectRequest */ class PdSubjectRequest extends Model diff --git a/app/app/Models/Project.php b/app/app/Models/Project.php index e4ec8410..4728bcf5 100644 --- a/app/app/Models/Project.php +++ b/app/app/Models/Project.php @@ -154,20 +154,32 @@ class Project extends Model } /** - * Все связанные SupplierProject из eager-loaded BelongsTo отношений. + * Все связанные SupplierProject: pivot project_supplier_links ПЛЮС три legacy-слота + * supplier_b{1,2,3}_project_id, объединение без дублей. * - * Используется внутри aggregateSyncStatus(), aggregateLastSyncedAt(), - * getSupplierLinks() — устраняет N+1 (каждый из трёх методов вызывал - * SupplierProject::find() независимо; теперь читает из уже загруженных - * $this->supplierB1 / supplierB2 / supplierB3). + * 🔴 Почему pivot обязателен (прод-баг 29.07.2026): ночной SyncSupplierProjectsJob — + * единственный, кто в режиме batch реально заводит заказ у поставщика — пишет ТОЛЬКО + * в pivot и legacy-колонок не касается. Заполняет их лишь SyncSupplierProjectJob, и + * только если заказ УЖЕ существует в момент запуска; при создании проекта заказа ещё + * нет (он появится в 18:00), поэтому колонки остаются пустыми навсегда — пока клиент + * сам не дёрнет проект (пауза / снятие с паузы / «Синхронизировать» / правка). + * Пока статус читался только из колонок, 5 из 9 работающих проектов на бою показывали + * жёлтое «Готовим к запуску» при живом заказе (самый старый — 13 дней). * - * Требует eager-load: Project::with(['supplierB1', 'supplierB2', 'supplierB3']). + * Legacy-слоты продолжаем читать: handleBatch пишет колонку без pivot-строки, такие + * проекты терять нельзя. + * + * Требует eager-load: Project::with(['supplierProjects', 'supplierB1', 'supplierB2', 'supplierB3']). * * @return Collection */ private function resolvedSupplierProjects(): Collection { - return collect([$this->supplierB1, $this->supplierB2, $this->supplierB3])->filter()->values(); + return collect([$this->supplierB1, $this->supplierB2, $this->supplierB3]) + ->filter() + ->merge($this->supplierProjects) + ->unique('id') + ->values(); } /** @@ -223,10 +235,9 @@ class Project extends Model */ public function getSupplierLinks(): array { - return collect(['b1' => $this->supplierB1, 'b2' => $this->supplierB2, 'b3' => $this->supplierB3]) - ->filter() - ->map(fn (SupplierProject $sp, string $platform) => [ - 'platform' => $platform, + return $this->resolvedSupplierProjects() + ->map(fn (SupplierProject $sp) => [ + 'platform' => strtolower((string) $sp->platform), 'supplier_project_id' => $sp->id, 'sync_status' => $sp->sync_status, 'last_synced_at' => $sp->last_synced_at?->toIso8601String(), diff --git a/app/app/Models/SaasAdminAuditLog.php b/app/app/Models/SaasAdminAuditLog.php index 74abde0c..5fcdf999 100644 --- a/app/app/Models/SaasAdminAuditLog.php +++ b/app/app/Models/SaasAdminAuditLog.php @@ -30,7 +30,6 @@ use Illuminate\Support\Carbon; * @property int|null $approved_by * @property Carbon|null $approved_at * @property Carbon $created_at - * * @mixin IdeHelperSaasAdminAuditLog */ class SaasAdminAuditLog extends Model diff --git a/app/app/Models/SaasInvoice.php b/app/app/Models/SaasInvoice.php index ee69a976..ecb976f3 100644 --- a/app/app/Models/SaasInvoice.php +++ b/app/app/Models/SaasInvoice.php @@ -37,7 +37,6 @@ use Illuminate\Support\Carbon; * @property Carbon|null $expires_at * @property Carbon|null $paid_at * @property Carbon|null $cancelled_at - * * @mixin IdeHelperSaasInvoice */ class SaasInvoice extends Model diff --git a/app/app/Models/SaasInvoiceItem.php b/app/app/Models/SaasInvoiceItem.php index 6a7e08af..a3ed6270 100644 --- a/app/app/Models/SaasInvoiceItem.php +++ b/app/app/Models/SaasInvoiceItem.php @@ -20,7 +20,6 @@ use Illuminate\Database\Eloquent\Model; * @property string|null $vat_rate * @property string|null $vat_amount * @property string $amount_total - * * @mixin IdeHelperSaasInvoiceItem */ class SaasInvoiceItem extends Model diff --git a/app/app/Models/SaasUpdDocument.php b/app/app/Models/SaasUpdDocument.php index e1aa90b4..7e23a7e3 100644 --- a/app/app/Models/SaasUpdDocument.php +++ b/app/app/Models/SaasUpdDocument.php @@ -33,7 +33,6 @@ use Illuminate\Support\Carbon; * @property string|null $pdf_path * @property string $status * @property Carbon|null $issued_at - * * @mixin IdeHelperSaasUpdDocument */ class SaasUpdDocument extends Model diff --git a/app/app/Models/SalesAdAudienceFirm.php b/app/app/Models/SalesAdAudienceFirm.php index 794062a3..4b185db6 100644 --- a/app/app/Models/SalesAdAudienceFirm.php +++ b/app/app/Models/SalesAdAudienceFirm.php @@ -42,6 +42,7 @@ use Illuminate\Support\Carbon; * @property Carbon $updated_at * @property-read Collection $phones * @property-read Collection $firmChannels + * @mixin IdeHelperSalesAdAudienceFirm */ class SalesAdAudienceFirm extends Model { diff --git a/app/app/Models/SalesAdAudienceFirmChannel.php b/app/app/Models/SalesAdAudienceFirmChannel.php index 98d7a897..998e9f7d 100644 --- a/app/app/Models/SalesAdAudienceFirmChannel.php +++ b/app/app/Models/SalesAdAudienceFirmChannel.php @@ -25,6 +25,7 @@ use Illuminate\Support\Carbon; * @property int $warmed_times * @property Carbon $created_at * @property Carbon $updated_at + * @mixin IdeHelperSalesAdAudienceFirmChannel */ class SalesAdAudienceFirmChannel extends Model { diff --git a/app/app/Models/SalesAdAudiencePhone.php b/app/app/Models/SalesAdAudiencePhone.php index b1bce727..a5798b66 100644 --- a/app/app/Models/SalesAdAudiencePhone.php +++ b/app/app/Models/SalesAdAudiencePhone.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * Номер директора в рекламной аудитории. Живёт до expires_at, потом выходит из сегмента. + * * removed_at ставится, когда номер реально убран из Яндекса (не когда истёк срок). * * @property int $id @@ -27,6 +28,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property Carbon|null $resume_at * @property string|null $operator * @property string|null $phone_type + * @mixin IdeHelperSalesAdAudiencePhone */ class SalesAdAudiencePhone extends Model { diff --git a/app/app/Models/SalesAdAudiencePlatform.php b/app/app/Models/SalesAdAudiencePlatform.php index ea10b759..6aab3e41 100644 --- a/app/app/Models/SalesAdAudiencePlatform.php +++ b/app/app/Models/SalesAdAudiencePlatform.php @@ -30,6 +30,7 @@ use Illuminate\Database\Eloquent\Model; * @property int $registered_days * @property int $testing_days * @property int $days + * @mixin IdeHelperSalesAdAudiencePlatform */ class SalesAdAudiencePlatform extends Model { diff --git a/app/app/Models/SalesAdAudienceState.php b/app/app/Models/SalesAdAudienceState.php index 7d9cd7e6..2b9c9580 100644 --- a/app/app/Models/SalesAdAudienceState.php +++ b/app/app/Models/SalesAdAudienceState.php @@ -32,6 +32,7 @@ use Illuminate\Database\Eloquent\Model; * @property Carbon|null $vk_last_synced_at * @property string|null $vk_last_error * @property string $vk_status + * @mixin IdeHelperSalesAdAudienceState */ class SalesAdAudienceState extends Model { diff --git a/app/app/Models/SalesAdAudienceWarmingEpisode.php b/app/app/Models/SalesAdAudienceWarmingEpisode.php index c9d09587..9b0278fb 100644 --- a/app/app/Models/SalesAdAudienceWarmingEpisode.php +++ b/app/app/Models/SalesAdAudienceWarmingEpisode.php @@ -21,6 +21,7 @@ use Illuminate\Support\Carbon; * @property Carbon|null $ended_at * @property Carbon $created_at * @property Carbon $updated_at + * @mixin IdeHelperSalesAdAudienceWarmingEpisode */ class SalesAdAudienceWarmingEpisode extends Model { diff --git a/app/app/Models/SalesAttachmentRequest.php b/app/app/Models/SalesAttachmentRequest.php index dde307f5..61cd277d 100644 --- a/app/app/Models/SalesAttachmentRequest.php +++ b/app/app/Models/SalesAttachmentRequest.php @@ -30,7 +30,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property int|null $decided_by * @property Carbon|null $decided_at * @property Carbon $created_at - * * @mixin IdeHelperSalesAttachmentRequest */ class SalesAttachmentRequest extends Model diff --git a/app/app/Models/SalesClientAssignment.php b/app/app/Models/SalesClientAssignment.php index f3715091..b3c57408 100644 --- a/app/app/Models/SalesClientAssignment.php +++ b/app/app/Models/SalesClientAssignment.php @@ -30,7 +30,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property array $tariff_params * @property Carbon $assigned_at * @property Carbon $created_at - * * @mixin IdeHelperSalesClientAssignment */ class SalesClientAssignment extends Model diff --git a/app/app/Models/SalesPayout.php b/app/app/Models/SalesPayout.php index b6c276bb..35177d2c 100644 --- a/app/app/Models/SalesPayout.php +++ b/app/app/Models/SalesPayout.php @@ -27,7 +27,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property string|null $comment * @property int $created_by * @property Carbon $created_at - * * @mixin IdeHelperSalesPayout */ class SalesPayout extends Model diff --git a/app/app/Models/SalesProspect.php b/app/app/Models/SalesProspect.php index 1880b0b4..75cfcf4d 100644 --- a/app/app/Models/SalesProspect.php +++ b/app/app/Models/SalesProspect.php @@ -34,13 +34,17 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property string|null $rating_label * @property array $payload * @property Carbon|null $next_call_at + * @property string|null $prev_stage * @property string|null $reason * @property string|null $registered_email + * @property string|null $test_channel + * @property string|null $test_target + * @property string|null $kp_channel + * @property string|null $kp_target * @property int|null $linked_tenant_id * @property string|null $notes * @property Carbon $created_at * @property Carbon $updated_at - * * @mixin IdeHelperSalesProspect */ class SalesProspect extends Model @@ -52,6 +56,7 @@ class SalesProspect extends Model 'sales_user_id', 'assigned_by', 'stage', 'source', 'firm_name', 'legal_name', 'contacts', 'city', 'phone', 'site', 'inn', 'rating_label', 'payload', 'next_call_at', 'reason', 'registered_email', 'linked_tenant_id', 'notes', + 'test_channel', 'test_target', 'kp_channel', 'kp_target', 'prev_stage', ]; /** Дефолты in-memory (совпадают с DB DEFAULT), чтобы поля были доступны до refresh. */ @@ -77,4 +82,54 @@ class SalesProspect extends Model { return $this->belongsTo(SalesUser::class, 'sales_user_id'); } + + /** + * Журнал движений пишется ЗДЕСЬ, а не в контроллере и джобе по отдельности. + * + * Стадию меняют два разных места: результат разговора менеджера + * (SalesProspectController) и автопереезд по деньгам (SalesProspectsAdvanceJob). + * Джоба раньше не оставляла следа вообще — половина движений была невидима. + * Событие модели ловит обе стороны и любую будущую третью: забыть его нельзя, + * потому что стадия без save() не меняется. + */ + protected static function booted(): void + { + // prev_stage снимаем ДО записи: после save() getOriginal('stage') уже + // равен новой стадии — прежнюю в событии `updated` взять было бы неоткуда. + static::saving(function (self $prospect): void { + if ($prospect->exists && $prospect->isDirty('stage')) { + $prospect->prev_stage = (string) $prospect->getOriginal('stage'); + } + }); + + // Заведение карточки — тоже движение: из ниоткуда в первую стадию. + static::created(fn (self $prospect) => $prospect->logMove(null)); + + // Именно `updated`, а не `saved`: у только что созданного объекта + // wasRecentlyCreated остаётся true до конца его жизни, и второе + // сохранение (даже без смены стадии) писало бы лишнее движение. + static::updated(function (self $prospect): void { + if ($prospect->wasChanged('stage')) { + $prospect->logMove($prospect->prev_stage); + } + }); + } + + /** + * Строка в журнале движений. + * + * Соединение берём у самой карточки: джоба живёт на pgsql_admin, портал — + * на дефолтном. Жёстко зашитое соединение сломало бы одну из сторон. + */ + private function logMove(?string $fromStage): void + { + $this->getConnection()->table('sales_prospect_moves')->insert([ + 'prospect_id' => $this->id, + // Двигал не человек (джоба по деньгам аккаунта) → автора нет. + 'sales_user_id' => auth('sales')->id(), + 'from_stage' => $fromStage, + 'to_stage' => $this->stage, + 'created_at' => now(), + ]); + } } diff --git a/app/app/Models/SalesProspectMove.php b/app/app/Models/SalesProspectMove.php new file mode 100644 index 00000000..8d5ef9e6 --- /dev/null +++ b/app/app/Models/SalesProspectMove.php @@ -0,0 +1,49 @@ + 'datetime']; + } + + /** @return BelongsTo */ + public function prospect(): BelongsTo + { + return $this->belongsTo(SalesProspect::class, 'prospect_id'); + } +} diff --git a/app/app/Models/SalesProspectNote.php b/app/app/Models/SalesProspectNote.php index b17ae3ca..c376f6fc 100644 --- a/app/app/Models/SalesProspectNote.php +++ b/app/app/Models/SalesProspectNote.php @@ -24,6 +24,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property string|null $title * @property string $body * @property Carbon $created_at + * @mixin IdeHelperSalesProspectNote */ class SalesProspectNote extends Model { diff --git a/app/app/Models/SalesSmsCampaign.php b/app/app/Models/SalesSmsCampaign.php index 4a335108..8668dbd6 100644 --- a/app/app/Models/SalesSmsCampaign.php +++ b/app/app/Models/SalesSmsCampaign.php @@ -30,6 +30,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; * @property Carbon|null $started_at * @property Carbon|null $finished_at * @property string|null $last_error + * @mixin IdeHelperSalesSmsCampaign */ class SalesSmsCampaign extends Model { diff --git a/app/app/Models/SalesSmsMessage.php b/app/app/Models/SalesSmsMessage.php index 66f53591..d8262dc5 100644 --- a/app/app/Models/SalesSmsMessage.php +++ b/app/app/Models/SalesSmsMessage.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * Строка журнала: что случилось с одним номером в одной рассылке. + * * Статусы skipped_* означают «до провайдера не дошло, деньги не потрачены». * * @property int $id @@ -26,6 +27,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property Carbon|null $sent_at * @property Carbon|null $delivered_at * @property string|null $error + * @mixin IdeHelperSalesSmsMessage */ class SalesSmsMessage extends Model { diff --git a/app/app/Models/SalesSmsOptout.php b/app/app/Models/SalesSmsOptout.php index 5c6a8f82..5955d0f8 100644 --- a/app/app/Models/SalesSmsOptout.php +++ b/app/app/Models/SalesSmsOptout.php @@ -16,6 +16,7 @@ use Illuminate\Database\Eloquent\Model; * @property string $phone * @property string $reason * @property Carbon $created_at + * @mixin IdeHelperSalesSmsOptout */ class SalesSmsOptout extends Model { diff --git a/app/app/Models/SalesSmsSender.php b/app/app/Models/SalesSmsSender.php index 1230c1c6..604782c4 100644 --- a/app/app/Models/SalesSmsSender.php +++ b/app/app/Models/SalesSmsSender.php @@ -23,6 +23,7 @@ use Illuminate\Support\Collection; * @property Carbon|null $submitted_at * @property Carbon|null $approved_at * @property string|null $rejected_reason + * @mixin IdeHelperSalesSmsSender */ class SalesSmsSender extends Model { diff --git a/app/app/Models/SalesTariff.php b/app/app/Models/SalesTariff.php index 619ab18d..3dd4078f 100644 --- a/app/app/Models/SalesTariff.php +++ b/app/app/Models/SalesTariff.php @@ -26,7 +26,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany; * @property string $kind * @property array $params * @property bool $is_active - * * @mixin IdeHelperSalesTariff */ class SalesTariff extends Model diff --git a/app/app/Models/SalesUser.php b/app/app/Models/SalesUser.php index 4fe48c10..a50bcb11 100644 --- a/app/app/Models/SalesUser.php +++ b/app/app/Models/SalesUser.php @@ -34,7 +34,6 @@ use Laravel\Sanctum\HasApiTokens; * @property int|null $created_by * @property CarbonInterface $created_at * @property CarbonInterface|null $updated_at - * * @mixin IdeHelperSalesUser */ class SalesUser extends Authenticatable diff --git a/app/app/Models/SmsGlobalOptout.php b/app/app/Models/SmsGlobalOptout.php index 53af6dff..d3c32319 100644 --- a/app/app/Models/SmsGlobalOptout.php +++ b/app/app/Models/SmsGlobalOptout.php @@ -18,6 +18,8 @@ use Illuminate\Database\Eloquent\Model; * причина словами и дата — этого хватает и для разбора жалобы, и для договора с МТС. * * Источник: db/schema.sql, table `sms_global_optouts`. + * + * @mixin IdeHelperSmsGlobalOptout */ final class SmsGlobalOptout extends Model { diff --git a/app/app/Models/SupplierLead.php b/app/app/Models/SupplierLead.php index e87845d5..cbca246e 100644 --- a/app/app/Models/SupplierLead.php +++ b/app/app/Models/SupplierLead.php @@ -25,7 +25,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property string|null $phone_operator * @property string|null $region_source * @property int|null $resolved_subject_code - * * @mixin IdeHelperSupplierLead */ class SupplierLead extends Model diff --git a/app/app/Models/SupplierLeadDelivery.php b/app/app/Models/SupplierLeadDelivery.php index 0eb09923..5f7a870a 100644 --- a/app/app/Models/SupplierLeadDelivery.php +++ b/app/app/Models/SupplierLeadDelivery.php @@ -15,7 +15,6 @@ use Illuminate\Database\Eloquent\Model; * @property int $tenant_id * @property int|null $deal_id * @property string $created_at - * * @mixin IdeHelperSupplierLeadDelivery */ class SupplierLeadDelivery extends Model diff --git a/app/app/Models/SupplierManualSyncQueue.php b/app/app/Models/SupplierManualSyncQueue.php index d06606e6..39b0975c 100644 --- a/app/app/Models/SupplierManualSyncQueue.php +++ b/app/app/Models/SupplierManualSyncQueue.php @@ -25,7 +25,6 @@ use Illuminate\Support\Carbon; * @property int|null $resolved_by_user_id * @property Carbon|null $created_at * @property Carbon|null $resolved_at - * * @mixin IdeHelperSupplierManualSyncQueue */ class SupplierManualSyncQueue extends Model diff --git a/app/app/Models/SupportRequest.php b/app/app/Models/SupportRequest.php index 9dfcde45..fa0bf60f 100644 --- a/app/app/Models/SupportRequest.php +++ b/app/app/Models/SupportRequest.php @@ -17,7 +17,6 @@ use Illuminate\Support\Carbon; * @property string $contact * @property string $message * @property Carbon $created_at - * * @mixin IdeHelperSupportRequest */ class SupportRequest extends Model diff --git a/app/app/Models/SystemSetting.php b/app/app/Models/SystemSetting.php index 873af8e3..1670cffe 100644 --- a/app/app/Models/SystemSetting.php +++ b/app/app/Models/SystemSetting.php @@ -18,7 +18,6 @@ use Illuminate\Support\Carbon; * @property string|null $description * @property Carbon $updated_at * @property int|null $updated_by - * * @mixin IdeHelperSystemSetting */ class SystemSetting extends Model diff --git a/app/app/Models/TenantRequisites.php b/app/app/Models/TenantRequisites.php index 69c3795e..0c80a626 100644 --- a/app/app/Models/TenantRequisites.php +++ b/app/app/Models/TenantRequisites.php @@ -14,7 +14,6 @@ use Illuminate\Support\Carbon; * интроспекции (стаб IdeHelperTenantRequisites не генерируется), поэтому * * @property-аннотации заданы вручную по db/schema.sql (tenant_requisites). - * * @property int $id * @property int $tenant_id * @property string $subject_type @@ -34,7 +33,6 @@ use Illuminate\Support\Carbon; * @property Carbon|null $requisites_completed_at * @property Carbon|null $created_at * @property Carbon|null $updated_at - * * @mixin IdeHelperTenantRequisites */ class TenantRequisites extends Model diff --git a/app/app/Models/UserRecoveryCode.php b/app/app/Models/UserRecoveryCode.php index 1318478d..e4319bc4 100644 --- a/app/app/Models/UserRecoveryCode.php +++ b/app/app/Models/UserRecoveryCode.php @@ -19,7 +19,6 @@ use Illuminate\Support\Carbon; * @property string $code_hash * @property Carbon|null $used_at * @property Carbon|null $created_at - * * @mixin IdeHelperUserRecoveryCode */ class UserRecoveryCode extends Model diff --git a/app/app/Observers/DealTelegramObserver.php b/app/app/Observers/DealTelegramObserver.php new file mode 100644 index 00000000..929fe251 --- /dev/null +++ b/app/app/Observers/DealTelegramObserver.php @@ -0,0 +1,49 @@ + now()). + if (Carbon::parse($deal->received_at)->lt(now()->subDay())) { + return; + } + + AccumulateTelegramLeadJob::dispatch((int) $deal->id, (int) $deal->tenant_id)->afterCommit(); + } catch (Throwable $e) { + Log::warning('client_tg.auto_observer_failed', [ + 'deal_id' => $deal->id ?? null, + 'tenant_id' => $deal->tenant_id ?? null, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/app/Providers/AppServiceProvider.php b/app/app/Providers/AppServiceProvider.php index fb173f2a..35288d45 100644 --- a/app/app/Providers/AppServiceProvider.php +++ b/app/app/Providers/AppServiceProvider.php @@ -2,10 +2,13 @@ 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\DealSmsObserver; +use App\Observers\DealTelegramObserver; use App\Services\Billing\Gateway\PaymentGatewayDriver; use App\Services\Billing\Gateway\PaymentGatewayManager; use App\Services\Bot\AitunnelChatClient; @@ -171,7 +174,7 @@ class AppServiceProvider extends ServiceProvider // Task 15: авто-СМС на каждый новый лид — observer поверх боевого потока // создания сделок (freshness-guard внутри; тело обёрнуто в try/catch, лид // не роняет). См. App\Observers\DealSmsObserver. - \App\Models\Deal::observe(\App\Observers\DealSmsObserver::class); + Deal::observe(DealSmsObserver::class); // FN-RESET (приёмка 22.06.2026): дефолтное Laravel-уведомление ResetPassword // строит ссылку через route('password.reset'), которого в SPA нет — роут @@ -199,6 +202,11 @@ class AppServiceProvider extends ServiceProvider ); } + // Telegram-модуль, задача 4.2: авто-режим кормит накопитель каждым новым лидом. + // Observer поверх боевого потока приёма лидов — best-effort, лид не роняет + // (try/catch внутри). См. App\Observers\DealTelegramObserver. + Deal::observe(DealTelegramObserver::class); + // apiv1-rate (приёмка 21.06): публичный read-API сделок (/api/v1/deals) // прикрыт per-источник лимитом 120/мин ПЕРЕД ApiKeyAuth — режет brute/DoS // по ключам и снимает нагрузку bcrypt/DB до аутентификации. Ключ лимитера — diff --git a/app/app/Services/Advertising/AdImpressionPricing.php b/app/app/Services/Advertising/AdImpressionPricing.php new file mode 100644 index 00000000..6b6ced61 --- /dev/null +++ b/app/app/Services/Advertising/AdImpressionPricing.php @@ -0,0 +1,49 @@ +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); + } +} diff --git a/app/app/Services/Advertising/AdMarkup.php b/app/app/Services/Advertising/AdMarkup.php deleted file mode 100644 index f079b440..00000000 --- a/app/app/Services/Advertising/AdMarkup.php +++ /dev/null @@ -1,29 +0,0 @@ -percent, '100', 6), 6); - } - - /** Цена/сумма для КЛИЕНТА из яндексовой: × (1 + наценка). */ - public function clientFromYandex(string $yandexRub): string - { - return bcmul($yandexRub, $this->factor(), 2); - } - - /** Сумма для ЯНДЕКСА из клиентской: ÷ (1 + наценка). */ - public function yandexFromClient(string $clientRub): string - { - return bcdiv($clientRub, $this->factor(), 2); - } -} diff --git a/app/app/Services/Advertising/AdWalletService.php b/app/app/Services/Advertising/AdWalletService.php index b5e9cf77..d8b1dcff 100644 --- a/app/app/Services/Advertising/AdWalletService.php +++ b/app/app/Services/Advertising/AdWalletService.php @@ -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,10 +96,18 @@ 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()]); - AdWalletHold::create([ - 'tenant_id' => $tenantId, 'channel' => $channel, 'source_type' => $sourceType, - 'source_id' => $sourceId, 'amount_rub' => $amountRub, 'status' => AdWalletHold::STATUS_ACTIVE, - ]); + // Уникальный ключ брони — (tenant, channel, source_type, source_id) БЕЗ статуса, + // а release() строку не удаляет, а метит 'released'. Активной брони тут уже нет + // (выше ранний return), значит существующая строка — released: оживляем её. + // Через create() второй заход упал бы на дубле ключа (23505) при паузе→возобновлении + // и при пересдаче отклонённой кампании. + AdWalletHold::updateOrCreate( + [ + 'tenant_id' => $tenantId, 'channel' => $channel, + 'source_type' => $sourceType, 'source_id' => $sourceId, + ], + ['amount_rub' => $amountRub, 'status' => AdWalletHold::STATUS_ACTIVE], + ); AdWalletTransaction::create([ 'tenant_id' => $tenantId, 'type' => AdWalletTransaction::TYPE_FREEZE, @@ -89,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(); @@ -113,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) @@ -125,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, diff --git a/app/app/Services/Advertising/BannerGenerator.php b/app/app/Services/Advertising/BannerGenerator.php new file mode 100644 index 00000000..771fe492 --- /dev/null +++ b/app/app/Services/Advertising/BannerGenerator.php @@ -0,0 +1,65 @@ + $maxBytes && $quality >= 40); + + imagedestroy($src); + imagedestroy($dst); + + // Качество упёрлось в пол, а вес всё равно больше предела. Раньше такой файл уходил + // наружу молча — кабинет Яндекса его не примет, и узнали бы мы об этом уже роботом, + // стоящим перед окном загрузки. Отказ вслух лучше заведомо негодного файла. + if (strlen($bytes) > $maxBytes) { + throw new RuntimeException( + "Картинка не ужимается до {$maxBytes} байт для размера {$width}×{$height} — нужна другая." + ); + } + + return $bytes; + } +} diff --git a/app/app/Services/Advertising/BannerSizes.php b/app/app/Services/Advertising/BannerSizes.php new file mode 100644 index 00000000..5223c022 --- /dev/null +++ b/app/app/Services/Advertising/BannerSizes.php @@ -0,0 +1,28 @@ + */ + 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 */ + public static function all(): array + { + return self::SIZES; + } +} diff --git a/app/app/Services/Advertising/BannerUploadPolicy.php b/app/app/Services/Advertising/BannerUploadPolicy.php new file mode 100644 index 00000000..6576bbd8 --- /dev/null +++ b/app/app/Services/Advertising/BannerUploadPolicy.php @@ -0,0 +1,21 @@ + */ + public const FORMATS = ['jpg', 'jpeg', 'png', 'gif']; + + public static function humanMaxSize(): string + { + return (int) round(self::MAX_BYTES / 1024).' КБ'; + } +} diff --git a/app/app/Services/Advertising/CampaignAudienceBuilder.php b/app/app/Services/Advertising/CampaignAudienceBuilder.php index d69ce394..f5b77444 100644 --- a/app/app/Services/Advertising/CampaignAudienceBuilder.php +++ b/app/app/Services/Advertising/CampaignAudienceBuilder.php @@ -10,7 +10,16 @@ use Illuminate\Support\Facades\DB; final class CampaignAudienceBuilder { - /** @return list уникальные телефоны 79XXXXXXXXX для кампании. */ + /** + * @return list уникальные телефоны 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 { @@ -21,15 +30,32 @@ final class CampaignAudienceBuilder // (crm_supplier_worker) — BYPASSRLS, SET LOCAL не фильтрует // (00_create_roles.sql §5; тот же паттерн — HistoricalImportService::loadStatusOverrides). // Без фильтра — cross-tenant утечка телефонов. - $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(); + $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 ($campaign->use_uploaded_list) { + if ($isManual) { $listPhones = DB::table('ad_campaign_phones') ->where('tenant_id', $campaign->tenant_id) ->where('campaign_id', $campaign->id) diff --git a/app/app/Services/Advertising/CampaignBannerService.php b/app/app/Services/Advertising/CampaignBannerService.php new file mode 100644 index 00000000..f2ac0278 --- /dev/null +++ b/app/app/Services/Advertising/CampaignBannerService.php @@ -0,0 +1,53 @@ +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; + } +} diff --git a/app/app/Services/Advertising/CampaignEstimateService.php b/app/app/Services/Advertising/CampaignEstimateService.php new file mode 100644 index 00000000..d71959e9 --- /dev/null +++ b/app/app/Services/Advertising/CampaignEstimateService.php @@ -0,0 +1,37 @@ +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, + ]; + } +} diff --git a/app/app/Services/Advertising/CampaignImpressionCharger.php b/app/app/Services/Advertising/CampaignImpressionCharger.php new file mode 100644 index 00000000..671b9942 --- /dev/null +++ b/app/app/Services/Advertising/CampaignImpressionCharger.php @@ -0,0 +1,99 @@ +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); + } + } +} diff --git a/app/app/Services/Advertising/CampaignLauncher.php b/app/app/Services/Advertising/CampaignLauncher.php index 5eb7471a..f78a98d2 100644 --- a/app/app/Services/Advertising/CampaignLauncher.php +++ b/app/app/Services/Advertising/CampaignLauncher.php @@ -4,25 +4,38 @@ declare(strict_types=1); namespace App\Services\Advertising; +use App\Exceptions\Advertising\AudienceNotReadyException; use App\Exceptions\Advertising\AudienceTooSmallException; +use App\Exceptions\Advertising\BudgetBelowYandexMinimumException; use App\Models\AdCampaign; +use App\Models\AdCampaignBanner; use App\Services\Sales\YandexAudienceClient; +use Carbon\CarbonImmutable; +use DateTimeInterface; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use RuntimeException; +use Throwable; /** - * Оркестратор запуска рекламной кампании: аудитория → сегмент Аудиторий → - * Директ (retargeting → campaign → adgroup → target → ads) → заморозка недельного - * бюджета в кошельке → статус «на модерации». + * Оркестратор запуска МЕДИЙНОЙ рекламной кампании «за показы»: аудитория → сегмент + * Аудиторий → Директ (retargeting → CPM-кампания → CPM-группа → медиа-таргет → баннер + * по готовому креативу) → заморозка клиентской суммы за смету показов в кошельке → + * статус «на модерации». * - * MONEY: суммы для Директа считаются через AdMarkup + bcmath, заморозка — - * в клиентских рублях через AdWalletService::freeze (уже атомарна и идемпотентна). + * 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 @@ -31,57 +44,417 @@ final class CampaignLauncher 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)); + } + + /** + * Спрашивает у Яндекса, свёл ли он уже загруженные номера с людьми. Пока не свёл — + * сегмент есть, но для рекламы негоден, и строить на нём в Директе нельзя. + * + * 🔑 Судим ТОЛЬКО по `can_create_dependent`. Статус `is_processed` — это + * «обрабатывается» (так и написано в кабинете), а НЕ «готов»: у готового статус + * `processed` и заполнен `matched_quantity`. На боевом 30.07.2026 сведение 1693 + * номеров заняло около 15 часов, всё это время сегмент существовал и был негоден. + */ + private function trebuetGotovnostiAuditorii(int $segmentId): void + { + $segment = (new YandexAudienceClient((string) config('services.yandex_audience.token'))) + ->segment($segmentId); + + if (($segment['can_create_dependent'] ?? false) === true) { + return; + } + + throw new AudienceNotReadyException( + (int) ($segment['matched_quantity'] ?? 0), + (string) ($segment['status'] ?? 'нет такого сегмента'), + ); + } + + /** Сам запуск. Вызывается только из 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 } - $markup = new AdMarkup((string) (DB::table('ad_settings')->value('markup_percent') ?? '30.00')); + // Период показа нужен ДО денег: минимум, ниже которого Директ не берёт кампанию, + // считается именно от длины периода (см. проверку минимума ниже). + [$startDate, $endDate] = $this->resolvePeriod($campaign); + + // Деньги: только 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); // клиентские ₽ + + // Директ не берёт кампанию, у которой бюджет за период ниже его минимума. Живой + // отказ 30.07.2026: «Budget for this period cannot be less than 600 rub.» — клиент + // видел его как есть, по-английски, и не понимал, что от него хотят. + // + // 🔴 Минимум НЕ постоянный: он считается от длины периода — 300 ₽ за каждый + // календарный день, считая оба края. Первый живой отказ мы приняли за постоянные + // 600 ₽, и на бою тут же напоролись на второй: та же кампания на неделю потребовала + // 2400 ₽ и снова показала клиенту английский текст. Дни считаем ровно так, как их + // считает Яндекс: от StartDate до EndDate включительно. + // + // Проверяем ЗДЕСЬ — до первого обращения к Яндексу: в кабинете ничего не создаётся, + // деньги не морозятся, кампания остаётся черновиком. + $dneyVPeriode = CarbonImmutable::parse($startDate)->diffInDays(CarbonImmutable::parse($endDate)) + 1; + $minSpendRub = bcmul( + (string) config('services.yandex_direct.min_spend_limit_rub_per_day'), + (string) $dneyVPeriode, + 2, + ); + $spendLimitRub = bcdiv((string) $spendLimitMicros, '1000000', 2); + if (bccomp($spendLimitRub, $minSpendRub, 2) < 0) { + // Обратный счёт: сколько показов нужно, чтобы дотянуть до минимума. + $rubZaPokaz = bcdiv(bcmul($yandexCpm, $guard, 6), '1000', 8); + $minImpressions = (int) ceil((float) bcdiv($minSpendRub, $rubZaPokaz, 6)); + + throw new BudgetBelowYandexMinimumException( + $minImpressions, + $this->pricing->clientCostRub($minImpressions, $clientCpm), + ); + } + + $freqImpr = (int) ($campaign->frequency ?? 1); + $freqPeriod = (int) ($campaign->frequency_period_days ?? 1); + + // Запуск ВОЗОБНОВЛЯЕМЫЙ: номер каждой созданной в Яндексе сущности пишется на + // кампанию СРАЗУ. Если связь оборвётся на середине (например, на третьем объявлении + // из пятнадцати), портал будет знать, что уже создано, и повторный запуск дочертит + // недостающее, а не заведёт вторую кампанию-сироту, которая начнёт крутить деньги. // 1) Сегмент Аудиторий. - $audienceClient = new YandexAudienceClient((string) config('services.yandex_audience.token')); - $segmentId = (int) $campaign->yandex_segment_id - ?: $audienceClient->createSegment('Лидерра кампания #'.$campaign->id, $phones); + $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 → campaign → adgroup → audience target → ads. + // 2) Директ: retargeting → CPM-кампания → CPM-группа → медиа-таргет → баннер. $direct = new YandexDirectClient( (string) config('services.yandex_direct.base_url'), (string) config('services.yandex_direct.token'), ); - $retId = $direct->addRetargetingList('Лидерра #'.$campaign->id, $segmentId); - // Недельный бюджет в Директ: клиентский ÷ наценку → микросы. - $weeklyYandexRub = $markup->yandexFromClient((string) $campaign->weekly_budget_rub); - $weeklyMicros = (int) bcmul($weeklyYandexRub, '1000000', 0); - $campaignId = $direct->addCampaign('Лидерра #'.$campaign->id, now()->toDateString(), $weeklyMicros); - $adGroupId = $direct->addAdGroup($campaignId, 'Группа #'.$campaign->id, config('services.yandex_direct.region_ids')); + $retId = (int) $campaign->yandex_retargeting_list_id; + if ($retId === 0) { + // Сегмент может существовать и при этом быть НЕГОДНЫМ: Яндекс ещё сводит + // загруженные номера с людьми. Раньше портал этого не спрашивал и лез + // создавать условие ретаргетинга — Директ отвечал «объект не найден», и + // клиент получал ту же ошибку, что и на несуществующий сегмент. + $this->trebuetGotovnostiAuditorii($segmentId); - $bidMicros = (int) bcmul($markup->yandexFromClient((string) ($campaign->click_bid_rub ?? '10.00')), '1000000', 0); - $direct->addAudienceTarget($adGroupId, $retId, $bidMicros); - - foreach ($campaign->ads as $ad) { - $textAd = ['Title' => $ad->title, 'Text' => $ad->text, 'Href' => $ad->href]; - if ($ad->title2) { - $textAd['Title2'] = $ad->title2; - } - if ($ad->image_normal_hash) { - $textAd['AdImageHash'] = $ad->image_normal_hash; - } - $yandexAdId = $direct->addTextAd($adGroupId, $textAd); - $ad->update(['yandex_ad_id' => $yandexAdId, 'moderation_status' => 'MODERATION']); + // Срок хранения в сегменте берём из настроек кампании — на сколько дней назад + // клиент собирал аудиторию. Без него Яндекс отказывает, см. addRetargetingList. + $retId = $direct->addRetargetingList('Лидерра #'.$campaign->id, $segmentId, (int) $campaign->audience_days); + $campaign->update(['yandex_retargeting_list_id' => $retId]); } - // 3) Заморозить недельный бюджет (клиентские ₽) в кошельке (Часть A). - $this->wallet->freeze((int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id, (string) $campaign->weekly_budget_rub); + $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]); + } - // 4) Записать id и статус. + // Группа + аудитория к ней — ОДИН неделимый шаг. Номер группы пишется на кампанию + // ПОСЛЕДНИМ, только после успешного 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); + + $sozdannyeAds = []; + foreach ($banners as $banner) { + if ((int) $banner->yandex_ad_id > 0) { + continue; + } + + $adId = $direct->addCpmBannerAd($adGroupId, (int) $banner->yandex_creative_id, $href); + $sozdannyeAds[] = $adId; + $banner->update([ + 'yandex_ad_id' => $adId, + 'moderation_status' => AdCampaignBanner::MOD_MODERATION, + ]); + } + + // 2б) Отдать объявления на проверку и включить показ. + // + // 🔴 Создать объявление НЕ значит запустить рекламу. Яндекс кладёт всё созданное + // черновиком и сам проверку не начинает: кампания, группа и объявления остаются + // DRAFT/OFF, показов нет, деньги не тратятся — и портал при этом рапортует + // «на модерации». Поймано живой приёмкой 30.07.2026: кабинет показал 15 объявлений + // в черновиках через двадцать минут после «успешного» запуска. Класс тот же, что и + // у остальных молчаливых сбоев: успех в журнале, пусто в жизни. + // + // Порядок обязателен: пока кампания черновик, включить её нельзя — Директ отвечает + // «кампания является черновиком и не может быть остановлена». Сначала модерация, + // она переводит кампанию в MODERATION, и только потом включение показа. + // + // На модерацию отправляем объявления, созданные ИМЕННО этим заходом: повторная + // отправка уже проверяемого объявления — отказ по позиции, и он порвал бы + // возобновляемый запуск. Включение показа безобидно при любом повторе: на уже + // включённой кампании Яндекс отвечает предупреждением, а не отказом. + $direct->moderateAds($sozdannyeAds); + $direct->resumeCampaign($campaignId); + + // 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([ - 'yandex_segment_id' => $segmentId, - 'yandex_retargeting_list_id' => $retId, - 'yandex_campaign_id' => $campaignId, - 'yandex_ad_group_id' => $adGroupId, + 'paid_impressions' => $estImpr, + 'client_cpm_rub' => $clientCpm, 'status' => AdCampaign::STATUS_PENDING_MODERATION, 'launched_at' => now(), + 'revived_at' => null, ]); } + + /** + * Сверяет настоящий размер каждого креатива с размером баннера ПЕРЕД созданием объявлений. + * + * Номер креатива попадает на баннер двумя путями: от робота-грузчика (опознание по разнице + * слепков) и руками оператора. Ошибка на любом из них — объявление 300×250, в котором + * крутится картинка 728×90: клиент платит за показы битого или вовсе чужого баннера, + * а увидеть это можно только глазами в кабинете. Одна проверка закрывает весь класс. + * + * Один запрос на весь набор, только по баннерам, которым объявление ещё предстоит создать: + * у остальных объявление уже живёт в кабинете, и сверять там нечего. + * + * @param Collection $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]; + } } diff --git a/app/app/Services/Advertising/CampaignMessageService.php b/app/app/Services/Advertising/CampaignMessageService.php new file mode 100644 index 00000000..5c4a2e6d --- /dev/null +++ b/app/app/Services/Advertising/CampaignMessageService.php @@ -0,0 +1,180 @@ +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 */ + 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, + ]); + } + } +} diff --git a/app/app/Services/Advertising/CampaignReviveService.php b/app/app/Services/Advertising/CampaignReviveService.php new file mode 100644 index 00000000..8b08693e --- /dev/null +++ b/app/app/Services/Advertising/CampaignReviveService.php @@ -0,0 +1,79 @@ +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(), + ]); + }); + } +} diff --git a/app/app/Services/Advertising/CreativeIdMatcher.php b/app/app/Services/Advertising/CreativeIdMatcher.php new file mode 100644 index 00000000..410d4678 --- /dev/null +++ b/app/app/Services/Advertising/CreativeIdMatcher.php @@ -0,0 +1,69 @@ + $before слепок до загрузки: номер → [ш, в] + * @param array $after слепок после загрузки + * @param list $expectedSizes размеры, которые робот должен был залить + * @return array «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; + } +} diff --git a/app/app/Services/Advertising/CreativeJobService.php b/app/app/Services/Advertising/CreativeJobService.php new file mode 100644 index 00000000..18f5f83b --- /dev/null +++ b/app/app/Services/Advertising/CreativeJobService.php @@ -0,0 +1,384 @@ +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 + */ + 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 $snapshot + * @return array + */ + 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'), + ); + } +} diff --git a/app/app/Services/Advertising/ModerationReason.php b/app/app/Services/Advertising/ModerationReason.php new file mode 100644 index 00000000..2258e142 --- /dev/null +++ b/app/app/Services/Advertising/ModerationReason.php @@ -0,0 +1,93 @@ +call('v5/retargetinglists', 'add', [ 'RetargetingLists' => [[ @@ -27,7 +46,11 @@ final class YandexDirectClient 'Type' => 'AUDIENCE', 'Rules' => [[ 'Operator' => 'ALL', - 'Arguments' => [['ExternalId' => $segmentExternalId]], + 'Arguments' => [[ + 'ExternalId' => (int) ('20'.$segmentExternalId), + // Яндекс принимает от 1 до 540 дней. + 'MembershipLifeSpan' => max(1, min(540, $membershipDays)), + ]], ]], ]], ]); @@ -35,61 +58,6 @@ final class YandexDirectClient return (int) $res['result']['AddResults'][0]['Id']; } - /** Campaigns.add: TEXT_CAMPAIGN, ручная стратегия HIGHEST_POSITION, недельный бюджет (микросы). */ - public function addCampaign(string $name, string $startDate, int $weeklySpendLimitMicros): int - { - // TODO(verify): SERVING_OFF / NetworkHighestPosition — сверить add-text-campaign.md перед боем - $res = $this->call('v5/campaigns', 'add', [ - 'Campaigns' => [[ - 'Name' => mb_substr($name, 0, 255), - 'StartDate' => $startDate, // YYYY-MM-DD - 'TextCampaign' => [ - 'BiddingStrategy' => [ - // Показ только в сетях (там ретаргетинг). ⚠️ verify: гашение Search = SERVING_OFF. - 'Search' => ['BiddingStrategyType' => 'SERVING_OFF'], - 'Network' => [ - 'BiddingStrategyType' => 'HIGHEST_POSITION', - 'NetworkHighestPosition' => ['WeeklySpendLimit' => $weeklySpendLimitMicros], - ], - ], - ], - ]], - ]); - - return (int) $res['result']['AddResults'][0]['Id']; - } - - /** AdGroups.add: группа без ключевых слов, регионы показа. - * - * @param array $regionIds - */ - public function addAdGroup(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), - ]], - ]); - - return (int) $res['result']['AddResults'][0]['Id']; - } - - /** AudienceTargets.add: привязать условие ретаргетинга к группе + ставка (₽ × 1e6). */ - public function addAudienceTarget(int $adGroupId, int $retargetingListId, int $contextBidMicros): int - { - $res = $this->call('v5/audiencetargets', 'add', [ - 'AudienceTargets' => [[ - 'AdGroupId' => $adGroupId, - 'RetargetingListId' => $retargetingListId, - 'ContextBid' => $contextBidMicros, - ]], - ]); - - return (int) $res['result']['AddResults'][0]['Id']; - } - /** AdImages.upload (json/v501): base64 → AdImageHash. */ public function uploadAdImage(string $name, string $base64Data): string { @@ -100,19 +68,6 @@ final class YandexDirectClient return (string) $res['result']['AddResults'][0]['AdImageHash']; } - /** Ads.add: TextAd в группе. Возвращает Id объявления. - * - * @param array $textAd - */ - public function addTextAd(int $adGroupId, array $textAd): int - { - $res = $this->call('v5/ads', 'add', [ - 'Ads' => [['AdGroupId' => $adGroupId, 'TextAd' => $textAd]], - ]); - - return (int) $res['result']['AddResults'][0]['Id']; - } - /** Ads.get: статусы модерации по id. Возвращает [adId => ['status'=>..,'state'=>..,'reason'=>..]]. * * @param array $adIds @@ -137,6 +92,289 @@ final class YandexDirectClient 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 $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 + */ + public function listImageCreativeIds(): array + { + $out = []; + $offset = 0; + + do { + $res = $this->call('v5/creatives', 'get', [ + // 🔴 Пустой ОБЪЕКТ, а не пустой массив. Отбора здесь нет — просим весь + // кабинет, — но в JSON пустой массив PHP уезжает как `[]`, и живой Яндекс + // отвечает отказом 8000 «SelectionCriteria cannot contain an array». + // Тогда портал не может снять слепок, задание роботу не выдаётся, а клиент + // видит вечное «готовим картинки». Поймано живым запуском 29.07.2026: + // прежде ходили в песочницу, а она отвергала запрос раньше, ещё на входе. + 'SelectionCriteria' => new \stdClass, + '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 $ids + * @return array + */ + 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 $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]]); + } + + /** + * Ads.moderate — отправить объявления на модерацию Яндекса. + * + * 🔴 Без этого вызова созданное объявление остаётся ЧЕРНОВИКОМ и не показывается + * никогда: Яндекс не начинает проверку сам. Живая приёмка 30.07.2026 — портал + * отрапортовал «на модерации», а в кабинете лежали кампания, группа и 15 объявлений + * в статусе DRAFT/OFF. Отбор строго по `Ids`: на `CampaignIds` Директ отвечает + * «отсутствует обязательный параметр Ids». + * + * @param list $adIds + */ + public function moderateAds(array $adIds): void + { + if ($adIds === []) { + return; + } + + $this->call('v5/ads', 'moderate', ['SelectionCriteria' => ['Ids' => $adIds]]); + } + + /** + * Ads.resume — включить показ объявлений. + * + * 🔴 У объявления ДВА независимых признака: `Status` — вердикт модерации, `State` — + * идёт ли показ. Модерация приняла — это ещё не показ. Созданное программой + * объявление рождается со `State: OFF` и таким остаётся, пока его не включить. + * Включение кампании (`campaigns.resume`) объявления НЕ поднимает. + * + * Живая приёмка 31.07.2026: кампания 713175197 включена, Яндекс на уровне кампании + * пишет «Идут показы», а внутри все 15 объявлений выключены — показов ноль при + * замороженных у клиента деньгах. Команды включить объявления не было в коде вообще. + * + * 🔴🔴 Отказ включения Яндекс кладёт в «предупреждения», а НЕ в «ошибки» — и проверка + * по ошибкам его не видит. Замер 01.08.2026 боевым ключом, ответ целиком: + * + * {"ResumeResults":[{"Id":17787641513, + * "Warnings":[{"Code":10201,"Message":"Объявление не остановлено"}],"Errors":[]}]} + * + * `Errors` пуст, исключения нет, портал считает, что справился. На бою это шло двое + * суток: обход каждые два часа, около двадцати попыток поднять 13 объявлений, ноль + * записей в журнале, показов ноль — а клиент видел «Идут показы». Поэтому отказы + * возвращаем наружу: вызывающий обязан узнать, кого включить не дали. + * + * @param list $adIds + * @return array номер объявления → слова Яндекса, почему не включил + */ + public function resumeAds(array $adIds): array + { + if ($adIds === []) { + return []; + } + + $res = $this->call('v5/ads', 'resume', ['SelectionCriteria' => ['Ids' => $adIds]]); + + $otkazy = []; + foreach ($res['result']['ResumeResults'] ?? [] as $item) { + $preduprezhdenie = $item['Warnings'][0]['Message'] ?? null; + + if (isset($item['Id']) && is_string($preduprezhdenie) && $preduprezhdenie !== '') { + $otkazy[(int) $item['Id']] = $preduprezhdenie; + } + } + + return $otkazy; + } + /** Campaigns.suspend — пауза показа. */ public function suspendCampaign(int $campaignId): void { @@ -150,20 +388,19 @@ final class YandexDirectClient } /** - * Reports CAMPAIGN_PERFORMANCE_REPORT за дату. Возвращает ['clicks'=>int,'cost'=>string ₽]. + * Reports CAMPAIGN_PERFORMANCE_REPORT за ВСЁ ВРЕМЯ кампании (накопительно). Возвращает + * суммарное число показов — источник для CampaignImpressionCharger, который сам считает + * дельту от уже списанного и идемпотентен по external_key. * * ⚠️ TODO(verify): Reports часто отдаёт TSV (не JSON) и может строиться асинхронно - * (HTTP 201/202 + заголовок retryIn) — перед боем сверить формат/async/НДС по + * (HTTP 201/202 + заголовок retryIn) — перед боем сверить формат/async по * yandex.ru/dev/direct/doc/ru/reports/* и при необходимости добавить ретрай-поллинг. * Здесь — минимальный синхронный путь (для песочницы/тестов). - * - * @return array{clicks: int, cost: string} */ - public function getCampaignSpend(int $campaignId, string $date): array + public function getCampaignImpressions(int $campaignId): int { $resp = Http::withToken($this->token) ->withHeaders([ - 'returnMoneyInMicros' => 'false', 'skipReportHeader' => 'true', 'skipReportSummaryRow' => 'true', 'skipColumnHeader' => 'true', @@ -171,29 +408,28 @@ final class YandexDirectClient ->acceptJson() ->post(rtrim($this->baseUrl, '/').'/json/v5/reports', ['params' => [ 'SelectionCriteria' => [ - 'DateFrom' => $date, - 'DateTo' => $date, 'Filter' => [[ 'Field' => 'CampaignId', 'Operator' => 'EQUALS', 'Values' => [(string) $campaignId], ]], ], - 'FieldNames' => ['Clicks', 'Cost'], - 'ReportName' => 'spend-'.$campaignId.'-'.$date, + 'FieldNames' => ['Impressions'], + // ReportName УНИКАЛЕН на каждый вызов: Яндекс кэширует подготовленный отчёт + // по имени, а нам при ALL_TIME нужны свежие накопительные показы каждый прогон — + // иначе повторный запрос вернёт устаревший кэш и списание замрёт (недобор). + 'ReportName' => 'impressions-'.$campaignId.'-'.now()->format('YmdHis'), 'ReportType' => 'CAMPAIGN_PERFORMANCE_REPORT', - 'DateRangeType' => 'CUSTOM_DATE', + 'DateRangeType' => 'ALL_TIME', 'Format' => 'TSV', - 'IncludeVAT' => 'YES', // ⚠️ verify: влияет на Cost ]]); $body = trim((string) $resp->body()); if ($body === '') { - return ['clicks' => 0, 'cost' => '0.00']; + return 0; } - [$clicks, $cost] = array_pad(explode("\t", $body), 2, '0'); - return ['clicks' => (int) trim($clicks), 'cost' => bcadd(trim($cost) !== '' ? trim($cost) : '0', '0', 2)]; + return (int) $body; } /** @@ -205,6 +441,14 @@ final class YandexDirectClient private function call(string $servicePath, string $method, array $params): array { $resp = Http::withToken($this->token) + // 🔴 Язык ответа спрашиваем ЯВНО. Без этого заголовка Яндекс отвечает + // по-английски, и его слова уезжают клиенту в переписку и письмом: + // «Rejected at moderation.», «Accepted at moderation.». Замер 01.08.2026 + // боевым ключом, два одинаковых запроса подряд по объявлению 17787641507: + // без заголовка — «\nRejected at moderation.», с ним — «\nОтклонено на модерации.». + // 🪤 Записанный 28.07 русский ответ (cabinet-flow.md §7.7) снят ДРУГИМ + // инструментом, который язык просил, — прибором, которым продукт не пользуется. + ->withHeaders(['Accept-Language' => 'ru']) ->acceptJson() ->asJson() ->post(rtrim($this->baseUrl, '/').'/json/'.$servicePath, [ @@ -218,6 +462,23 @@ final class YandexDirectClient throw new RuntimeException('Yandex Direct '.$servicePath.'/'.$method.' error: '.json_encode($err, JSON_UNESCAPED_UNICODE)); } + // 🔴 Второй способ отказа: сам запрос успешен, а отказ лежит ВНУТРИ ответа, + // по отдельной позиции. Тогда нужного нам `Id` в ней просто нет. + // + // Поймано живым запуском 29.07.2026: RetargetingLists.add вернул позицию без `Id`, + // и портал упал ошибкой PHP «Undefined array key "Id"» — ни клиенту, ни в журнал + // не попало ни слова о том, ЧТО сказал Яндекс. Теперь слова Яндекса выходят наружу. + foreach (['AddResults', 'UpdateResults', 'DeleteResults', 'ModerateResults', 'ResumeResults'] as $key) { + foreach ($json['result'][$key] ?? [] as $item) { + if (! empty($item['Errors'])) { + throw new RuntimeException( + 'Yandex Direct '.$servicePath.'/'.$method.' отказал по позиции: ' + .json_encode($item['Errors'], JSON_UNESCAPED_UNICODE) + ); + } + } + } + return $json; } } diff --git a/app/app/Services/ClientSms/ClientSmsPhoneFileReader.php b/app/app/Services/ClientSms/ClientSmsPhoneFileReader.php index 37f5c6dc..b9797643 100644 --- a/app/app/Services/ClientSms/ClientSmsPhoneFileReader.php +++ b/app/app/Services/ClientSms/ClientSmsPhoneFileReader.php @@ -47,6 +47,8 @@ class ClientSmsPhoneFileReader array_shift($values); } - return array_values($values); + // Перенумеровывать нечего: $values набирается через [] и после array_shift + // сам остаётся сплошным списком. + return $values; } } diff --git a/app/app/Services/ClientTg/RobotResult.php b/app/app/Services/ClientTg/RobotResult.php new file mode 100644 index 00000000..68c10a46 --- /dev/null +++ b/app/app/Services/ClientTg/RobotResult.php @@ -0,0 +1,72 @@ + $json + */ + public static function fromRobotJson(array $json): self + { + $ok = ($json['ok'] ?? false) === true; + + return new self( + ok: $ok, + matched: (int) ($json['matched'] ?? 0), + launched: (bool) ($json['launched'] ?? false), + campaignId: isset($json['campaignId']) ? (string) $json['campaignId'] : null, + step: isset($json['step']) ? (string) $json['step'] : null, + reason: isset($json['reason']) ? (string) $json['reason'] : null, + actualCostRub: isset($json['actualCostRub']) ? (string) $json['actualCostRub'] : null, + moderationStatus: isset($json['moderationStatus']) ? (string) $json['moderationStatus'] : null, + resubmitted: (bool) ($json['resubmitted'] ?? false), + ); + } + + /** + * Обёрточный отказ (таймаут, непарсабельный вывод, сбой процесса). + * $campaignId — id черновика кабинета, восстановленный из stderr-маркера робота + * (задача 3.1): робот создаёт черновик рано, и его id нужен даже при провале. + */ + public static function failed(string $reason, ?string $step = null, ?string $campaignId = null): self + { + return new self(ok: false, campaignId: $campaignId, step: $step, reason: $reason); + } +} diff --git a/app/app/Services/ClientTg/TelegramAudience.php b/app/app/Services/ClientTg/TelegramAudience.php new file mode 100644 index 00000000..b3320c50 --- /dev/null +++ b/app/app/Services/ClientTg/TelegramAudience.php @@ -0,0 +1,22 @@ + $phones */ + public function __construct( + public array $phones, + public int $candidatesCount, + ) {} +} diff --git a/app/app/Services/ClientTg/TelegramAudienceService.php b/app/app/Services/ClientTg/TelegramAudienceService.php new file mode 100644 index 00000000..6b4e1546 --- /dev/null +++ b/app/app/Services/ClientTg/TelegramAudienceService.php @@ -0,0 +1,149 @@ +tenant_id; + + // RLS-контекст сервис-слоя (см. ClientSmsAudienceBuilder). Явные where(tenant_id) + // ниже — defense-in-depth: prod-воркер (crm_supplier_worker) BYPASSRLS. + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + + $rawPhones = match ($campaign->audience_kind) { + Campaign::AUDIENCE_DEALS => $this->fromDeals($campaign, $tenantId), + Campaign::AUDIENCE_BASE => $this->fromBase($tenantId), + Campaign::AUDIENCE_LIST => $this->fromList($campaign, $tenantId), + default => [], + }; + + $candidates = $this->removeOptouts($this->normalizeAndDedupe($rawPhones), $tenantId); + + return new TelegramAudience($candidates, count($candidates)); + }); + } + + /** + * Номера кандидатов кампании — для выдачи роботу. + * + * Отдельный вход рядом с build(): каналу робота нужен готовый список, а не пересчёт + * состава кампании. Tenant-контекст ставит вызывающая сторона. + * + * @return list + */ + public function phonesForCampaign(int $tenantId, int $campaignId): array + { + $campaign = Campaign::where('tenant_id', $tenantId) + ->where('id', $campaignId) + ->firstOrFail(); + + return $this->build($campaign)->phones; + } + + /** @return list */ + private function fromDeals(Campaign $campaign, int $tenantId): array + { + $params = $campaign->audience_params ?? []; + $days = $params['days'] ?? null; + + $query = Deal::query() + ->where('tenant_id', $tenantId) + ->whereNull('duplicate_of_id'); + + if ($days !== null) { + // received_at в where — и окно по смыслу, и partition pruning (deals партиционирована). + $query->where('received_at', '>=', now()->subDays((int) $days)); + } + + return $query->pluck('phone')->map(fn ($p) => (string) $p)->all(); + } + + /** @return list */ + private function fromBase(int $tenantId): array + { + return Contact::query() + ->where('tenant_id', $tenantId) + ->pluck('phone') + ->map(fn ($p) => (string) $p) + ->all(); + } + + /** @return list */ + private function fromList(Campaign $campaign, int $tenantId): array + { + return CampaignPhone::query() + ->where('tenant_id', $tenantId) + ->where('campaign_id', $campaign->id) + ->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now())) + ->pluck('phone') + ->map(fn ($p) => (string) $p) + ->all(); + } + + /** + * Нормализует к «голому» 11-значному «7XXXXXXXXXX» и схлопывает дубли (порядок + * первого вхождения сохраняем). Нераспознанные номера отбрасываются. + * + * @param list $phones + * @return list + */ + private function normalizeAndDedupe(array $phones): array + { + $out = []; + $seen = []; + foreach ($phones as $raw) { + $norm = PhoneNormalizer::normalize($raw); + if ($norm === null) { + continue; + } + $bare = substr($norm, 1); // "+7..." → "7..." + if (isset($seen[$bare])) { + continue; + } + $seen[$bare] = true; + $out[] = $bare; + } + + return $out; + } + + /** + * Список «не показывать этим номерам»: вырезаем из аудитории номера, которые клиент + * пометил как исключаемые (client_tg_optouts). В показах рекламы «отписок» нет — это + * ручной exclude-лист тенанта. + * + * @param list $phones + * @return list + */ + private function removeOptouts(array $phones, int $tenantId): array + { + $optouts = []; + foreach (Optout::query()->where('tenant_id', $tenantId)->pluck('phone') as $raw) { + $norm = PhoneNormalizer::normalize((string) $raw); + if ($norm !== null) { + $optouts[substr($norm, 1)] = true; // "+7..." → "7..." — как у кандидатов + } + } + + return array_values(array_filter($phones, fn ($p) => ! isset($optouts[$p]))); + } +} diff --git a/app/app/Services/ClientTg/TelegramAutoAccumulator.php b/app/app/Services/ClientTg/TelegramAutoAccumulator.php new file mode 100644 index 00000000..99e654d0 --- /dev/null +++ b/app/app/Services/ClientTg/TelegramAutoAccumulator.php @@ -0,0 +1,192 @@ +first(); + if ($rule === null || ! $rule->enabled) { + return; // авто-режим выключен + } + + $normalized = PhoneNormalizer::normalize($phone); + if ($normalized === null) { + return; // слать некуда + } + $bare = substr($normalized, 1); // "+7…" → "7…" + + $campaign = $this->openBatch($tenantId, $rule); + + // Дедуп на входе: тот же номер в пачке не задваиваем. + $already = CampaignPhone::where('tenant_id', $tenantId) + ->where('campaign_id', $campaign->id) + ->where('phone', $bare) + ->exists(); + if (! $already) { + CampaignPhone::create([ + 'tenant_id' => $tenantId, + 'campaign_id' => $campaign->id, + 'phone' => $bare, + ]); + } + + // Кандидаты (нормализованы, без дублей/стоп-листа) + клиентская смета (с наценкой). + $candidates = $this->audience->build($campaign)->candidatesCount; + $campaign->update([ + 'planned_count' => $candidates, + 'estimated_cost_rub' => $this->tariff->clientEstimateRub($candidates), + ]); + + // Порог пачки — из правила (клиент мог поднять), иначе дефолт из конфига. + $threshold = $rule->batch_threshold ?? (int) config('client_tg.auto_batch_threshold', 367); + if ($candidates < $threshold) { + return; // ещё копим + } + + if (! $this->passesSafeguard($tenantId, $rule, $campaign)) { + return; // предохранитель: нет денег / упёрлись в дневной лимит — держим черновиком + } + + $campaign->transitionTo(Campaign::STATUS_QUEUED); + RunTelegramCampaignJob::dispatch($campaign->id, $tenantId)->afterCommit(); + } + + /** + * Предохранитель авто-рекламы (план §Этап 5, задача 5.1 + ревью-фикс F3). В + * песочнице деньги не трогаются — гейта нет (как ручной launch). В бою: + * - смета пачки (клиентская, с наценкой) + трата за сегодня не должна превышать + * дневной лимит правила (дефолт 0 → авто выключено); + * - клиентская смета СПИСЫВАЕТСЯ с общего баланса (charge, как ручной launch) — + * не хватило → мягко пропускаем (приём лидов не должен падать). + * Всё под `lockForUpdate` строки правила — счётчик `spent_today_rub` не гоняется + * двумя параллельными джобами одного тенанта. Не прошла — держим черновиком и + * пишем причину; прошла — списываем смету и учитываем трату за сегодня. + */ + private function passesSafeguard(int $tenantId, AutoRule $rule, Campaign $campaign): bool + { + if ((bool) config('client_tg.sandbox', true)) { + return true; + } + + $cost = (string) $campaign->estimated_cost_rub; + $today = Carbon::now()->toDateString(); + + return DB::transaction(function () use ($tenantId, $rule, $campaign, $cost, $today): bool { + $locked = AutoRule::where('id', $rule->id)->lockForUpdate()->first(); + if ($locked === null) { + return false; + } + + $sameDay = $locked->spent_date !== null + && Carbon::parse($locked->spent_date)->toDateString() === $today; + $spentToday = $sameDay ? (string) $locked->spent_today_rub : '0.00'; + + if (bccomp(bcadd($spentToday, $cost, 2), (string) $locked->daily_limit_rub, 2) > 0) { + Log::info('client_tg.auto_skipped', [ + 'reason' => 'daily_limit', + 'tenant_id' => $tenantId, + 'campaign_id' => $campaign->id, + 'cost_rub' => $cost, + 'spent_today_rub' => $spentToday, + 'daily_limit_rub' => (string) $locked->daily_limit_rub, + ]); + + return false; + } + + // Списываем клиентскую смету с общего баланса симметрично ручному launch. + // Не хватило — мягко пропускаем (кампания остаётся черновиком, приём лидов + // не падает). Идемпотентность charge по сальдо: повтор не задвоит. + try { + $this->charger->charge($campaign, $cost); + } catch (InsufficientBalanceException $e) { + Log::info('client_tg.auto_skipped', [ + 'reason' => 'insufficient_balance', + 'tenant_id' => $tenantId, + 'campaign_id' => $campaign->id, + 'cost_rub' => $cost, + ]); + + return false; + } + + $locked->update([ + 'spent_today_rub' => bcadd($spentToday, $cost, 2), + 'spent_date' => $today, + ]); + + return true; + }); + } + + /** + * Открытый черновик авто-кампании тенанта (created_by IS NULL, draft, список). + * Нет — создаём новый по объявлению из правила. Так после запуска пачки + * следующий лид открывает свежую пачку. + */ + private function openBatch(int $tenantId, AutoRule $rule): Campaign + { + $open = Campaign::where('tenant_id', $tenantId) + ->whereNull('created_by') + ->where('status', Campaign::STATUS_DRAFT) + ->where('audience_kind', Campaign::AUDIENCE_LIST) + ->orderByDesc('id') + ->first(); + + if ($open !== null) { + return $open; + } + + return Campaign::create([ + 'tenant_id' => $tenantId, + 'status' => Campaign::STATUS_DRAFT, + 'ad_text' => $rule->ad_text, + 'ad_link' => $rule->ad_link, + 'ord_category' => $rule->ord_category ?? 'Размещение рекламы', + 'budget_cap_rub' => (string) $rule->budget_cap_rub, + 'audience_kind' => Campaign::AUDIENCE_LIST, + 'audience_params' => null, + 'planned_count' => 0, + 'estimated_cost_rub' => '0.00', + 'created_by' => null, + ]); + } +} diff --git a/app/app/Services/ClientTg/TelegramCampaignChargeService.php b/app/app/Services/ClientTg/TelegramCampaignChargeService.php new file mode 100644 index 00000000..f9c94ddf --- /dev/null +++ b/app/app/Services/ClientTg/TelegramCampaignChargeService.php @@ -0,0 +1,147 @@ +tenant_id)->lockForUpdate()->firstOrFail(); + + // Идемпотентность по сальдо — строго ПОСЛЕ взятия замка. Есть непогашенное + // списание (charges > refunds) → повтор не списывает. После возврата сальдо + // выравнивается и новое списание (пересдача) проходит. + if ($this->hasOutstandingCharge($campaign)) { + return; + } + + if (bccomp($priceRub, '0', 2) === 0) { + return; + } + + if (bccomp((string) $tenant->balance_rub, $priceRub, 2) < 0) { + throw new InsufficientBalanceException( + priceKopecks: (int) bcmul($priceRub, '100', 0), + balanceRub: (string) $tenant->balance_rub, + ); + } + + $newBalance = bcsub((string) $tenant->balance_rub, $priceRub, 2); + + DB::table('tenants')->where('id', $tenant->id)->update(['balance_rub' => $newBalance]); + + BalanceTransaction::create([ + 'tenant_id' => $tenant->id, + 'type' => BalanceTransaction::TYPE_TG_AD_CHARGE, + 'amount_rub' => '-'.$priceRub, + 'amount_leads' => null, + 'balance_rub_after' => $newBalance, + 'balance_leads_after' => null, + 'related_type' => Campaign::class, + 'related_id' => $campaign->id, + 'created_at' => now(), + ]); + }); + } + + /** + * Вернуть ранее списанную смету на общий баланс тенанта. Идемпотентно: + * если списания не было или возврат уже сделан — ничего не делает. + */ + public function refund(Campaign $campaign): void + { + DB::transaction(function () use ($campaign): void { + /** @var Tenant $tenant */ + $tenant = Tenant::whereKey($campaign->tenant_id)->lockForUpdate()->firstOrFail(); + + // Нечего возвращать — непогашенного списания нет (списаний не было или все + // уже возвращены). + if (! $this->hasOutstandingCharge($campaign)) { + return; + } + + /** @var BalanceTransaction $chargeTx */ + $chargeTx = BalanceTransaction::query() + ->where('type', BalanceTransaction::TYPE_TG_AD_CHARGE) + ->where('related_type', Campaign::class) + ->where('related_id', $campaign->id) + ->orderByDesc('id') + ->firstOrFail(); + + // Сумма возврата = модуль ПОСЛЕДНЕГО (непогашенного) списания + // (amount_rub у charge отрицательный). + $amount = ltrim((string) $chargeTx->amount_rub, '-'); + + if (bccomp($amount, '0', 2) === 0) { + return; + } + + $newBalance = bcadd((string) $tenant->balance_rub, $amount, 2); + + DB::table('tenants')->where('id', $tenant->id)->update(['balance_rub' => $newBalance]); + + BalanceTransaction::create([ + 'tenant_id' => $tenant->id, + 'type' => BalanceTransaction::TYPE_TG_AD_REFUND, + 'amount_rub' => $amount, + 'amount_leads' => null, + 'balance_rub_after' => $newBalance, + 'balance_leads_after' => null, + 'related_type' => Campaign::class, + 'related_id' => $campaign->id, + 'created_at' => now(), + ]); + }); + } + + /** Есть ли по кампании непогашенное списание: count(tg_ad_charge) > count(tg_ad_refund). */ + private function hasOutstandingCharge(Campaign $campaign): bool + { + $charges = BalanceTransaction::query() + ->where('type', BalanceTransaction::TYPE_TG_AD_CHARGE) + ->where('related_type', Campaign::class) + ->where('related_id', $campaign->id) + ->count(); + + $refunds = BalanceTransaction::query() + ->where('type', BalanceTransaction::TYPE_TG_AD_REFUND) + ->where('related_type', Campaign::class) + ->where('related_id', $campaign->id) + ->count(); + + return $charges > $refunds; + } +} diff --git a/app/app/Services/ClientTg/TelegramCampaignResultApplier.php b/app/app/Services/ClientTg/TelegramCampaignResultApplier.php new file mode 100644 index 00000000..59061365 --- /dev/null +++ b/app/app/Services/ClientTg/TelegramCampaignResultApplier.php @@ -0,0 +1,121 @@ +tenantId` → параметр `$tenantId`. Логика стоит на бою и трогает деньги, любая + * правка по дороге запрещена — только отдельным коммитом после того, как оба пути зелёные. + */ +final class TelegramCampaignResultApplier +{ + public function apply(int $tenantId, Campaign $campaign, RobotResult $result, bool $sandbox): void + { + // Сессия 5: при провале робота причина сохраняется в status_reason и клиент + // уведомляется. Тенант/кампанию грузим ВНУТРИ tenant-контекста (иначе RLS на + // проде вернёт 0), уведомление шлём ПОСЛЕ транзакции (notifyInApp ставит свой + // SET LOCAL в отдельной транзакции — вложенности избегаем). + $failedTenant = null; + $failedCampaign = null; + + $this->tenantTx($tenantId, function () use ($tenantId, $campaign, $result, &$failedTenant, &$failedCampaign): void { + /** @var Campaign $fresh */ + $fresh = Campaign::where('tenant_id', $tenantId)->findOrFail($campaign->id); + + // Ранний/надёжный захват id кампании МТС: сохраняем при любом исходе (успех/отказ), + // не затирая уже сохранённый ранее id, если робот на этот раз id не вернул. + if ($result->campaignId !== null && $result->campaignId !== '' && $result->campaignId !== 'unknown') { + $fresh->mts_campaign_id = $result->campaignId; + } + + if (! $result->ok) { + Log::warning('client_tg.robot_failed', [ + 'campaign_id' => $fresh->id, + 'step' => $result->step, + 'reason' => $result->reason, + ]); + $fresh->status_reason = $result->reason; + + // Осторожно с деньгами (ревью-фикс F5, как уборщик, находка #2): есть + // mts_campaign_id (черновик в кабинете реально создан) → кампания МОГЛА + // уйти на модерацию → needs_review БЕЗ возврата брони, ждём ручной сверки + // / опросчика. Без id — заведомо не ушла → failed + возврат брони. + if ($fresh->mts_campaign_id !== null && $fresh->mts_campaign_id !== '') { + $fresh->transitionTo(Campaign::STATUS_NEEDS_REVIEW); + Log::warning('client_tg.robot_failed_needs_review', ['campaign_id' => $fresh->id]); + + return; // деньги не трогаем, уведомление об отказе не шлём (не отклонена) + } + + $fresh->transitionTo(Campaign::STATUS_FAILED); + $failedCampaign = $fresh; + $failedTenant = Tenant::find($tenantId); + + return; + } + + $fresh->matched_count = $result->matched; + // launched=true (живой режим) = кампания УШЛА НА МОДЕРАЦИЮ МТС, а не + // «запущена»: ставим `moderating`, одобрение переведёт в `launched` + // опросчик вердикта. Песочница (черновик) → `draft_ready`. + $fresh->transitionTo($result->launched ? Campaign::STATUS_MODERATING : Campaign::STATUS_DRAFT_READY); + }); + + // При отказе робота (заведомо не ушла в кабинет — id нет) возвращаем списанную + // смету на общий баланс, иначе клиент заплатил за несделанную работу. Отдельный + // tenantTx ПОСЛЕ записи статуса — чтобы сбой возврата не откатил FAILED. В + // песочнице денег не списывали — refund просто ничего не найдёт (сальдо 0). + if ($failedCampaign !== null && ! $sandbox) { + try { + $this->tenantTx($tenantId, fn () => app(TelegramCampaignChargeService::class)->refund($failedCampaign)); + } catch (Throwable $e) { + Log::warning('client_tg.refund_failed', [ + 'campaign_id' => $failedCampaign->id, + 'error' => $e->getMessage(), + ]); + } + } + + if ($failedCampaign !== null && $failedTenant !== null) { + try { + app(NotificationService::class)->notifyTelegramCampaignRejected($failedTenant, $failedCampaign); + } catch (Throwable $e) { + // Сбой канала уведомления не должен ронять итог кампании. + Log::warning('client_tg.reject_notify_failed', [ + 'campaign_id' => $failedCampaign->id, + 'error' => $e->getMessage(), + ]); + } + } + } + + /** + * @template T + * + * @param callable(): T $fn + * @return T + */ + private function tenantTx(int $tenantId, callable $fn) + { + return DB::transaction(function () use ($tenantId, $fn) { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + + return $fn(); + }); + } +} diff --git a/app/app/Services/ClientTg/TelegramModerationVerdictApplier.php b/app/app/Services/ClientTg/TelegramModerationVerdictApplier.php new file mode 100644 index 00000000..224f4ee4 --- /dev/null +++ b/app/app/Services/ClientTg/TelegramModerationVerdictApplier.php @@ -0,0 +1,132 @@ +moderationStatus; + + // Ещё на модерации / робот не смог прочитать → ждём следующего цикла. + if ($verdict !== 'approved' && $verdict !== 'rejected') { + return; + } + + // Применяем вердикт под tenant-контекстом; получаем модели для пост- + // транзакционных действий (возврат брони, уведомление) либо null (гонка). + $applied = $this->applyVerdict($tenantId, $campaignId, $result); + + if ($applied === null) { + return; + } + + [$outcome, $campaign, $tenant] = $applied; + + // Возврат сметы — только при отказе и только в бою (в песочнице денег не + // списывали, refund найдёт сальдо 0). Отдельный tenantTx: сбой возврата + // не должен откатить уже проставленный статус. + if ($outcome === Campaign::STATUS_REJECTED && ! $sandbox) { + try { + $this->tenantTx($tenantId, fn () => app(TelegramCampaignChargeService::class)->refund($campaign)); + } catch (Throwable $e) { + Log::warning('client_tg.poll_refund_failed', [ + 'campaign_id' => $campaignId, + 'error' => $e->getMessage(), + ]); + } + } + + // Уведомление клиенту — ПОСЛЕ транзакции (notifyInApp ставит свой SET LOCAL + // в отдельной транзакции; вложенности избегаем). Сбой канала не должен ронять + // применение вердикта. + if ($tenant !== null) { + try { + if ($outcome === Campaign::STATUS_REJECTED) { + app(NotificationService::class)->notifyTelegramCampaignRejected($tenant, $campaign); + } else { + app(NotificationService::class)->notifyTelegramCampaignApproved($tenant, $campaign); + } + } catch (Throwable $e) { + Log::warning('client_tg.poll_notify_failed', [ + 'campaign_id' => $campaignId, + 'error' => $e->getMessage(), + ]); + } + } + } + + /** + * Применяет вердикт `$result` к кампании под tenant-контекстом и возвращает + * [терминальный статус, campaign, tenant] для пост-транзакционных действий, либо + * null, если кампания уже ушла из moderating (гонка с параллельным опросом). + * + * @return array{0: string, 1: Campaign, 2: Tenant|null}|null + */ + private function applyVerdict(int $tenantId, int $campaignId, RobotResult $result): ?array + { + return $this->tenantTx($tenantId, function () use ($campaignId, $tenantId, $result): ?array { + // Блокировка + повторная проверка статуса: между перечислением и этим + // моментом статус мог измениться (гонка) — тогда не трогаем. + $campaign = Campaign::where('id', $campaignId)->lockForUpdate()->first(); + if ($campaign === null || $campaign->status !== Campaign::STATUS_MODERATING) { + return null; + } + + if ($result->moderationStatus === 'rejected') { + $campaign->status_reason = $result->reason; + $campaign->transitionTo(Campaign::STATUS_REJECTED); + + Log::warning('client_tg.poll_rejected', ['campaign_id' => $campaignId]); + } else { + $campaign->transitionTo(Campaign::STATUS_LAUNCHED); + + Log::info('client_tg.poll_approved', ['campaign_id' => $campaignId]); + } + + return [$campaign->status, $campaign, Tenant::find($tenantId)]; + }); + } + + /** + * Выполняет $fn в транзакции с установленным tenant-контекстом $tenantId. + * + * @template T + * + * @param callable(): T $fn + * @return T + */ + private function tenantTx(int $tenantId, callable $fn) + { + return DB::transaction(function () use ($tenantId, $fn) { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + + return $fn(); + }); + } +} diff --git a/app/app/Services/ClientTg/TelegramResubmitResultApplier.php b/app/app/Services/ClientTg/TelegramResubmitResultApplier.php new file mode 100644 index 00000000..a41500fa --- /dev/null +++ b/app/app/Services/ClientTg/TelegramResubmitResultApplier.php @@ -0,0 +1,119 @@ +tenantTx($tenantId, function () use ($tenantId, $campaign, $result, $sandbox, &$failedTenant, &$failedCampaign): void { + /** @var Campaign $fresh */ + $fresh = Campaign::where('tenant_id', $tenantId)->findOrFail($campaign->id); + + // mts_campaign_id НЕ трогаем — чиним ту же кампанию, id прежний. + if (! $result->ok) { + Log::warning('client_tg.resubmit_failed', [ + 'campaign_id' => $fresh->id, + 'step' => $result->step, + 'reason' => $result->reason, + ]); + $fresh->status_reason = $result->reason; + + // Осторожно с деньгами (F5): есть mts_campaign_id → кампания в кабинете + // реальна и могла уйти на пере-модерацию → needs_review БЕЗ возврата сметы. + // Без id (край — контроллер это отсекает) → failed + возврат сметы. + if ($fresh->mts_campaign_id !== null && $fresh->mts_campaign_id !== '') { + $fresh->transitionTo(Campaign::STATUS_NEEDS_REVIEW); + + return; + } + + $fresh->transitionTo(Campaign::STATUS_FAILED); + $failedCampaign = $fresh; + $failedTenant = Tenant::find($tenantId); + + return; + } + + // Успех: resubmitted=true (live) = кампания УШЛА НА ПЕРЕ-МОДЕРАЦИЮ → moderating + // (одобрение переведёт в launched опросчик 3.4). Песочница (draft — не + // отправляли) → draft_ready. Причину прошлого отказа чистим. + $fresh->status_reason = null; + if ($result->resubmitted) { + $fresh->transitionTo(Campaign::STATUS_MODERATING); + } elseif ($sandbox) { + $fresh->transitionTo(Campaign::STATUS_DRAFT_READY); + } else { + // Аномалия контракта: бой (submitMode:'live') ДОЛЖЕН давать resubmitted=true. + // Если робот вернул ok, но не отправил — смета списана, а кампания на + // модерацию НЕ ушла → needs_review (списание держим, ручная сверка), не + // терминальный draft_ready (иначе деньги зависнут навсегда). + $fresh->transitionTo(Campaign::STATUS_NEEDS_REVIEW); + } + }); + + // Возврат сметы — ТОЛЬКО для заведомо не ушедших (failed без id), как в + // TelegramCampaignResultApplier. Отдельный tenantTx, чтобы сбой возврата не откатил статус. + if ($failedCampaign !== null && ! $sandbox) { + try { + $this->tenantTx($tenantId, fn () => app(TelegramCampaignChargeService::class)->refund($failedCampaign)); + } catch (Throwable $e) { + Log::warning('client_tg.refund_failed', [ + 'campaign_id' => $failedCampaign->id, + 'error' => $e->getMessage(), + ]); + } + } + + if ($failedCampaign !== null && $failedTenant !== null) { + try { + app(NotificationService::class)->notifyTelegramCampaignRejected($failedTenant, $failedCampaign); + } catch (Throwable $e) { + Log::warning('client_tg.reject_notify_failed', [ + 'campaign_id' => $failedCampaign->id, + 'error' => $e->getMessage(), + ]); + } + } + } + + /** + * Выполняет $fn в транзакции с установленным tenant-контекстом $tenantId. + * + * @template T + * + * @param callable(): T $fn + * @return T + */ + private function tenantTx(int $tenantId, callable $fn) + { + return DB::transaction(function () use ($tenantId, $fn) { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + + return $fn(); + }); + } +} diff --git a/app/app/Services/ClientTg/TelegramRobotQueue.php b/app/app/Services/ClientTg/TelegramRobotQueue.php new file mode 100644 index 00000000..17cdd246 --- /dev/null +++ b/app/app/Services/ClientTg/TelegramRobotQueue.php @@ -0,0 +1,85 @@ + $payload + */ + public function enqueue(int $tenantId, int $campaignId, string $mode, array $payload): RobotJob + { + $pending = RobotJob::where('campaign_id', $campaignId) + ->whereIn('status', [RobotJob::STATUS_QUEUED, RobotJob::STATUS_TAKEN]) + ->first(); + + if ($pending !== null) { + return $pending; + } + + return RobotJob::create([ + 'tenant_id' => $tenantId, + 'campaign_id' => $campaignId, + 'mode' => $mode, + 'payload' => $payload, + 'status' => RobotJob::STATUS_QUEUED, + ]); + } + + /** + * Выдаёт очередное задание, переводя его в работу. + * + * Замок берём ПЕРВОЙ строкой транзакции и именно advisory, а не по строке: защищать + * надо саму операцию выдачи, а строк с нужным статусом в этот момент может не быть + * вовсе. Без замка две одновременные выдачи обе прошли бы проверку «в работе никого». + */ + public function takeNext(): ?RobotJob + { + return DB::transaction(function (): ?RobotJob { + DB::select("SELECT pg_advisory_xact_lock(hashtext('client_tg_robot_jobs_take'))"); + + $leaseMinutes = (int) config('client_tg.robot.poll_lease_minutes', 20); + $deadline = now()->subMinutes($leaseMinutes); + + // Робот мог умереть, не отчитавшись. Без возврата по сроку аренды такое + // задание держало бы очередь навсегда, и все кампании встали бы молча. + RobotJob::where('status', RobotJob::STATUS_TAKEN) + ->where('taken_at', '<', $deadline) + ->update(['status' => RobotJob::STATUS_QUEUED, 'taken_at' => null]); + + if (RobotJob::where('status', RobotJob::STATUS_TAKEN)->exists()) { + return null; + } + + $job = RobotJob::where('status', RobotJob::STATUS_QUEUED) + ->orderBy('id')->lockForUpdate()->first(); + + if ($job === null) { + return null; + } + + $job->update([ + 'status' => RobotJob::STATUS_TAKEN, + 'taken_at' => now(), + 'attempts' => $job->attempts + 1, + ]); + + return $job->refresh(); + }); + } +} diff --git a/app/app/Services/ClientTg/TelegramRobotRunner.php b/app/app/Services/ClientTg/TelegramRobotRunner.php new file mode 100644 index 00000000..e304a9f0 --- /dev/null +++ b/app/app/Services/ClientTg/TelegramRobotRunner.php @@ -0,0 +1,184 @@ +` через + * Symfony Process и разбирает JSON-вывод в RobotResult. + * + * 🔴 Реальный робот идёт ТОЛЬКО в mode:'draft' до Сессии 6 (live закрыт). Обёртка + * mode не навязывает — его задаёт джоб (в песочнице всегда 'draft'). + * + * Устойчивость воркера: таймаут и непарсабельный вывод НЕ бросаются наружу, а + * превращаются в аккуратный RobotResult::failed — очередь не должна падать из-за + * робота. Сетевую/браузерную работу робот делает сам вне какой-либо транзакции. + */ +// Не final: джоб-тесты подменяют раннер моком (Mockery), чтобы не гонять реальный +// node/браузер в очереди. +class TelegramRobotRunner +{ + /** + * Нормализует параметры кампании в task.json робота (поля — как в + * bots/mts-telegram-ads/src/task.js). Обязательные: mode, phonesFile, adText, + * budgetRub, buttonUrl; опциональные: mediaFile, moderatorFile (документ модератору + * для пересдачи, задача 3.6), clientTag, ordCategory, campaignId (id кампании + * кабинета для режимов read-status/resubmit), submitMode (для mode:'resubmit' — + * draft|live; робот в других режимах игнорирует). + * + * @param array $params + * @return array + */ + public function taskPayload(array $params): array + { + return [ + 'mode' => $params['mode'] ?? 'draft', + 'phonesFile' => $params['phonesFile'] ?? null, + 'adText' => $params['adText'] ?? null, + 'budgetRub' => $params['budgetRub'] ?? null, + 'buttonUrl' => $params['buttonUrl'] ?? null, + 'mediaFile' => $params['mediaFile'] ?? null, + 'moderatorFile' => $params['moderatorFile'] ?? null, + 'clientTag' => $params['clientTag'] ?? null, + 'ordCategory' => $params['ordCategory'] ?? 'Размещение рекламы', + 'campaignId' => $params['campaignId'] ?? null, + 'submitMode' => $params['submitMode'] ?? null, + ]; + } + + /** + * Читает вердикт модерации кампании в кабинете по её id (задача 3.4). + * Робот открывает кампанию `$mtsCampaignId` в режиме read-status и возвращает + * RobotResult с `moderationStatus` ('approved'|'rejected'|'moderating') и, при + * отказе, `reason` (текст причины из слайд-модалки «Причины»). Селекторы Part B + * — в bots/mts-telegram-ads (режим read-status, задача 3.5). Сетевую/браузерную + * работу робот делает сам, вне транзакции; ошибки → RobotResult::failed (опросчик + * оставит кампанию на модерации до следующего цикла). + */ + public function readModeration(string $mtsCampaignId): RobotResult + { + return $this->run(['mode' => 'read-status', 'campaignId' => $mtsCampaignId]); + } + + /** + * Запускает робота с заданием и возвращает разобранный результат. + * + * @param array $params + */ + public function run(array $params): RobotResult + { + $payload = $this->taskPayload($params); + $taskFile = tempnam(sys_get_temp_dir(), 'tg-task-'); + + // tempnam отдаёт файл без .json — переименовываем ради предсказуемого glob + // и совпадения с ожиданием робота. При сбое переименования работаем с исходным. + $jsonPath = $taskFile.'.json'; + if (@rename($taskFile, $jsonPath)) { + $taskFile = $jsonPath; + } + + try { + file_put_contents($taskFile, json_encode($payload, JSON_UNESCAPED_UNICODE)); + + $process = new Process( + [$this->node(), $this->script(), '--task', $taskFile], + $this->cwd(), + ); + $process->setTimeout($this->timeout()); + + $timedOut = false; + try { + $process->run(); + } catch (ProcessTimedOutException $e) { + Log::warning('client_tg.robot_timeout', ['reason' => $e->getMessage()]); + $timedOut = true; + } + + // Робот печатает маркер MTS_CAMPAIGN_ID= в stderr сразу после создания + // черновика — восстанавливаем id даже при таймауте/непарсабельном выводе + // (задача 3.1), чтобы опросчик/уборщик нашли реально ушедшую кампанию. + $markerId = null; + if (preg_match('/MTS_CAMPAIGN_ID=(\d+)/', $process->getErrorOutput(), $mm)) { + $markerId = $mm[1]; + } + + if ($timedOut) { + return RobotResult::failed('Робот не ответил за отведённое время (таймаут)', 'timeout', $markerId); + } + + $stdout = trim($process->getOutput()); + /** @var mixed $decoded */ + $decoded = json_decode($stdout, true); + + if (! is_array($decoded)) { + $stderr = trim($process->getErrorOutput()); + Log::warning('client_tg.robot_unparsable', [ + 'stdout' => mb_substr($stdout, 0, 500), + 'stderr' => mb_substr($stderr, 0, 500), + ]); + + return RobotResult::failed( + $stderr !== '' ? mb_substr($stderr, 0, 255) : 'Робот вернул непонятный ответ', + 'parse', + $markerId, + ); + } + + // Если робот вернул валидный JSON, но БЕЗ campaignId (например упал после + // создания черновика и ветка отказа не донесла id) — подставляем маркер. + if (! isset($decoded['campaignId']) && $markerId !== null) { + $decoded['campaignId'] = $markerId; + } + + /** @var array $decoded */ + return RobotResult::fromRobotJson($decoded); + } catch (Throwable $e) { + Log::error('client_tg.robot_crash', ['reason' => $e->getMessage()]); + + return RobotResult::failed(mb_substr($e->getMessage(), 0, 255), 'crash'); + } finally { + // task.json — не ПДн (номера лежат в phonesFile, который пишет/чистит джоб), + // но временный файл убираем в любом случае. + if (is_string($taskFile) && file_exists($taskFile)) { + @unlink($taskFile); + } + } + } + + private function node(): string + { + $v = config('client_tg.robot.node', 'node'); + + return is_string($v) && $v !== '' ? $v : 'node'; + } + + private function script(): string + { + $v = config('client_tg.robot.script'); + + return is_string($v) && $v !== '' ? $v : base_path('bots/mts-telegram-ads/bin/run.js'); + } + + private function cwd(): string + { + $v = config('client_tg.robot.cwd'); + + return is_string($v) && $v !== '' ? $v : base_path('bots/mts-telegram-ads'); + } + + private function timeout(): float + { + $v = config('client_tg.robot.timeout', 300); + + return is_numeric($v) ? (float) $v : 300.0; + } +} diff --git a/app/app/Services/ClientTg/TelegramTariffService.php b/app/app/Services/ClientTg/TelegramTariffService.php new file mode 100644 index 00000000..fb9066cb --- /dev/null +++ b/app/app/Services/ClientTg/TelegramTariffService.php @@ -0,0 +1,44 @@ +orderByDesc('min_qty') + ->first(); + + return $row !== null ? (string) $row->price_rub : '0.00'; + } + + /** Смета: объём показов × цена ступени, bcmath scale 2. */ + public function estimateRub(int $impressions): string + { + return bcmul((string) max($impressions, 0), $this->pricePerImpressionRub($impressions), 2); + } + + /** + * Клиентская смета = базовая смета × наценка (config client_tg.markup). + * Это цена, которую клиент ВИДИТ и ПЛАТИТ (базовый тариф — себестоимость-оценка). + */ + public function clientEstimateRub(int $impressions): string + { + $markup = (string) config('client_tg.markup', '1.40'); + + return bcmul($this->estimateRub($impressions), $markup, 2); + } +} diff --git a/app/app/Services/NotificationService.php b/app/app/Services/NotificationService.php index dedb57e6..e2744089 100644 --- a/app/app/Services/NotificationService.php +++ b/app/app/Services/NotificationService.php @@ -8,6 +8,7 @@ use App\Mail\InvoicePaidNotification; use App\Mail\NewLeadsDigestMail; use App\Mail\TopupSuccessNotification; use App\Mail\ZeroBalancePausedMail; +use App\Models\ClientTg\Campaign; use App\Models\Deal; use App\Models\InAppNotification; use App\Models\Project; @@ -57,6 +58,12 @@ class NotificationService /** Эпик 6: объяснение правил сбора (хвост источника, вступление правок и т.п.). */ public const EVENT_PROJECT_RULE = 'project_rule'; + /** Сессия 5: Telegram-кампания отклонена модерацией МТС / не запущена. */ + public const EVENT_TG_CAMPAIGN_REJECTED = 'tg_campaign_rejected'; + + /** Задача 3.4: Telegram-кампания одобрена модерацией МТС и запущена. */ + public const EVENT_TG_CAMPAIGN_APPROVED = 'tg_campaign_approved'; + public const ALL_EVENTS = [ self::EVENT_NEW_LEAD, self::EVENT_LOW_BALANCE, @@ -212,6 +219,66 @@ class NotificationService } } + /** + * Сессия 5, задача 5.2: клиента уведомляем, что его Telegram-реклама отклонена + * модерацией МТС (или не запустилась). In-app всем активным пользователям + * тенанта, БЕЗ pref-гейта (важное операционное сообщение доходит всегда, как + * notifyProjectRule — иначе клиент не узнает, почему реклама не пошла). + * + * Текст причины — из campaign.status_reason (свободный текст МТС/робота); если + * пусто — нейтральный текст. Заголовок зависит от терминального статуса. + */ + public function notifyTelegramCampaignRejected(Tenant $tenant, Campaign $campaign): void + { + $title = $campaign->status === Campaign::STATUS_REJECTED + ? 'Реклама в Телеграме отклонена модерацией' + : 'Реклама в Телеграме: не удалось запустить'; + $body = (string) $campaign->status_reason !== '' + ? (string) $campaign->status_reason + : 'Причина не указана — откройте кабинет для деталей.'; + + $users = User::query() + ->where('tenant_id', $tenant->id) + ->where('is_active', true) + ->whereNull('deleted_at') + ->get(); + + foreach ($users as $user) { + $this->notifyInApp($user, self::EVENT_TG_CAMPAIGN_REJECTED, $title, $body, [ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + ]); + } + } + + /** + * Задача 3.4: клиента уведомляем, что его Telegram-реклама ОДОБРЕНА модерацией + * МТС и запущена. Зеркало notifyTelegramCampaignRejected: in-app всем активным + * пользователям тенанта, БЕЗ pref-гейта (важное операционное сообщение доходит + * всегда — клиент должен знать, что реклама пошла). + */ + public function notifyTelegramCampaignApproved(Tenant $tenant, Campaign $campaign): void + { + $users = User::query() + ->where('tenant_id', $tenant->id) + ->where('is_active', true) + ->whereNull('deleted_at') + ->get(); + + foreach ($users as $user) { + $this->notifyInApp( + $user, + self::EVENT_TG_CAMPAIGN_APPROVED, + 'Реклама в Телеграме одобрена и запущена', + 'Модерация МТС одобрила кампанию — показы пошли.', + [ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + ], + ); + } + } + /** * INSERT в `in_app_notifications` для bell-icon UI. RLS требует * `app.current_tenant_id` = user.tenant_id, поэтому SET LOCAL внутри diff --git a/app/app/Services/Sales/AdAudienceScheduler.php b/app/app/Services/Sales/AdAudienceScheduler.php index 0b78dbc2..81ea9cba 100644 --- a/app/app/Services/Sales/AdAudienceScheduler.php +++ b/app/app/Services/Sales/AdAudienceScheduler.php @@ -54,7 +54,19 @@ class AdAudienceScheduler return $this->stopped('client_won'); } - if ($stage === 'negotiation') { + // Корзина — карточку выбросили, греть некого. Стоп СРАЗУ, в отличие от + // «Отказа», который догревается ещё rejected_days: отказавший может + // передумать, а выброшенный мусор — нет. Ветка явная не для красоты: + // без неё стадия попала бы в 'unknown_stage' и реклама встала бы молча, + // по случайности, а не по решению. + if ($stage === 'trash') { + return $this->stopped('trashed'); + } + + // Три стадии с обязательной датой созвона живут по одному правилу: крутим + // до созвона, потом ещё negotiation_overdue_days, дальше стоп. Без этой + // строки новые стадии попали бы в 'unknown_stage' и реклама МОЛЧА встала бы. + if (in_array($stage, ['negotiation', 'manual_testing', 'kp_sent'], true)) { return $this->negotiation($firm, $now); } @@ -72,8 +84,8 @@ class AdAudienceScheduler } /** - * Переговоры — единственная стадия, где дата созвона обязательна, - * поэтому логика владельца («просрочена → ещё три дня → стоп») тут полная. + * Правило для стадий с обязательной датой созвона — «Переговоры», «Тестирование + * ручное» и «Выслано КП»: логика владельца («просрочена → ещё три дня → стоп»). * * @param array $firm * @return array{state: string, reason: ?string, resume_at: ?CarbonImmutable} diff --git a/app/app/Services/Sales/SalesPeriodResolver.php b/app/app/Services/Sales/SalesPeriodResolver.php index cee97054..0bae976e 100644 --- a/app/app/Services/Sales/SalesPeriodResolver.php +++ b/app/app/Services/Sales/SalesPeriodResolver.php @@ -12,9 +12,12 @@ use InvalidArgumentException; * * Поддерживаемые kind: * 'today' — сегодня. + * 'tomorrow' — завтра (нужен фильтру воронки «что надо сделать»). * 'yesterday' — вчера. * 'd7' — последние 7 дней, включая сегодня. * 'd30' — последние 30 дней, включая сегодня. + * 'next7' — ближайшие 7 дней, включая сегодня (вперёд). + * 'next30' — ближайшие 30 дней, включая сегодня (вперёд). * 'this' — текущий месяц целиком. * 'prev' — предыдущий месяц целиком. * 'prev2' — месяц перед предыдущим целиком. @@ -40,9 +43,12 @@ final class SalesPeriodResolver return match ($kind) { 'today' => $this->lastDays(1), + 'tomorrow' => $this->dayRange(1), 'yesterday' => $this->dayRange(-1), 'd7' => $this->lastDays(7), 'd30' => $this->lastDays(30), + 'next7' => $this->nextDays(7), + 'next30' => $this->nextDays(30), 'prev' => $this->monthRange(-1), 'prev2' => $this->monthRange(-2), 'custom' => $this->customRange($period), @@ -86,6 +92,22 @@ final class SalesPeriodResolver ); } + /** + * Ближайшие $days дней, ВКЛЮЧАЯ сегодня: next7 = сегодня и 6 следующих. + * + * Зеркало lastDays() для фильтра «что надо сделать»: там срок смотрит + * ВПЕРЁД (когда звонить), а не назад (что уже случилось). + */ + private function nextDays(int $days): SalesPeriodRange + { + $today = CarbonImmutable::now(self::TZ)->startOfDay(); + + return new SalesPeriodRange( + $today, + $today->addDays($days - 1)->setTime(23, 59, 59), + ); + } + /** Один день, смещённый на $offset от сегодняшнего (0 — сегодня, -1 — вчера). */ private function dayRange(int $offset): SalesPeriodRange { diff --git a/app/app/Services/Supplier/SupplierPortalClient.php b/app/app/Services/Supplier/SupplierPortalClient.php index 75e8179d..05a3e079 100644 --- a/app/app/Services/Supplier/SupplierPortalClient.php +++ b/app/app/Services/Supplier/SupplierPortalClient.php @@ -90,7 +90,7 @@ class SupplierPortalClient * Verified live 2026-07-09: строка = `name="visit-checbox" value=""` + * `B{1,2,3}_` (проект) + `7\d{10}` (телефон). date_from/date_to формат d.m.Y. * - * @return array ключ = vid (дедуп) + * @return array ключ = vid (дедуп) */ public function fetchDeliveredLeads(CarbonInterface $from, CarbonInterface $to): array { @@ -117,10 +117,10 @@ class SupplierPortalClient } /** - * Парсит строки таблицы «Мои сделки» → [vid, phone, project]. + * Парсит строки таблицы «Мои сделки» → [vid, phone, project, tag]. * Строки без vid (шапка/служебные) или без телефона пропускаются. * - * @return list + * @return list */ private function parseDeliveredRows(string $html): array { @@ -131,6 +131,20 @@ class SupplierPortalClient } $project = preg_match('#(B[123]_[^\s<"]+)#', $row, $proj) === 1 ? $proj[1] : ''; + // Тег поставщика (регион лида либо «РФ») — ячейка СРАЗУ после td.crm-domain-column + // с именем проекта. Разметка списана с живого кабинета 31.07.2026. + // Зачем: у добранного из журнала лида нет тега, и когда ДаData не знает номер + // (виртуальные операторы), региону взяться неоткуда — карточка выходит без региона + // и города (инцидент 31.07, 5 сделок). Тег — штатная подстраховка RegionTagResolver. + // Не нашли ячейку или там прочерк — tag=null, поведение как раньше (регресс исключён). + $tag = null; + if (preg_match('#crm-domain-column.*?\s*]*>(.*?)#si', $row, $tg) === 1) { + $candidate = trim(html_entity_decode(strip_tags($tg[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8')); + if ($candidate !== '' && $candidate !== '-') { + $tag = mb_substr($candidate, 0, 255); + } + } + // B2/B3 (Билайн/МТС) проекты названы номером-ловушкой (7\d{10}), который в строке // «Мои сделки» идёт РАНЬШЕ телефона звонившего. Берём первый 7\d{10}, НЕ равный // номеру проекта, иначе номер-ловушка попадёт в сделку вместо звонившего @@ -155,6 +169,7 @@ class SupplierPortalClient 'vid' => (int) $vid[1], 'phone' => $phone, 'project' => $project, + 'tag' => $tag, ]; } diff --git a/app/app/Support/TelegramLink.php b/app/app/Support/TelegramLink.php new file mode 100644 index 00000000..8f5f9d26 --- /dev/null +++ b/app/app/Support/TelegramLink.php @@ -0,0 +1,39 @@ + UseAdminConnection::class, 'sales-portal' => EnsureSalesUser::class, 'sales-integration' => SalesIntegrationToken::class, + 'creative-robot' => CreativeRobotToken::class, + 'tg-robot' => TgRobotToken::class, 'apikey' => ApiKeyAuth::class, ]); @@ -70,6 +74,13 @@ return Application::configure(basePath: dirname(__DIR__)) // Сервис-канал «Поиск → Портал» (Этап 2): защита — сервис-токен // (SalesIntegrationToken), не CSRF. У Python-поиска нет сессии/куки. 'api/sales/integration/*', + // Служебный канал робота-грузчика креативов: у робота нет браузерной сессии + // и CSRF-токена, защита канала — сервис-токен (CreativeRobotToken). + 'api/creative-robot/*', + // Служебный канал телеграм-робота кабинета МТС: робот живёт на другой машине, + // браузерной сессии и CSRF-токена у него нет — защита канала сервис-токеном + // (TgRobotToken). + 'api/tg-robot/*', ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/app/config/client_tg.php b/app/config/client_tg.php new file mode 100644 index 00000000..57348d9c --- /dev/null +++ b/app/config/client_tg.php @@ -0,0 +1,78 @@ + filter_var(env('TG_SANDBOX', true), FILTER_VALIDATE_BOOL), + + /* + |-------------------------------------------------------------------------- + | Наценка на клиентскую цену Telegram-рекламы + |-------------------------------------------------------------------------- + | Клиент платит цену С наценкой: клиентская смета = базовая смета тарифа + | (себестоимость-оценка) × markup. 1.40 = +40%. Строка для bcmath (scale 2). + | Меняется без правки кода через env TG_MARKUP. + */ + 'markup' => (string) env('TG_MARKUP', '1.40'), + + /* + |-------------------------------------------------------------------------- + | Порог авто-пачки + |-------------------------------------------------------------------------- + | Авто-режим копит новых лидов и запускает кампанию, когда наберётся столько + | кандидатов (МТС показывает рекламу базе, а не по одному номеру). Минимальная + | осмысленная пачка у МТС Маркетолог — 367 (спека §5). + */ + 'auto_batch_threshold' => (int) env('TG_AUTO_BATCH', 367), + + /* + |-------------------------------------------------------------------------- + | Окно «зависшей модерации» + |-------------------------------------------------------------------------- + | Кампания в статусе `moderating` дольше стольких ЧАСОВ считается зависшей + | (робот не смог прочитать вердикт: сломались селекторы кабинета / id протух). + | Уборщик уводит её в `needs_review` для ручного разбора, БЕЗ возврата брони + | (могла реально показываться). МТС проверяет ~4ч; окно с большим запасом. + */ + 'moderation_stuck_hours' => (int) env('TG_MODERATION_STUCK_HOURS', 48), + + /* + |-------------------------------------------------------------------------- + | Node-робот кабинета МТС (bots/mts-telegram-ads) + |-------------------------------------------------------------------------- + | У МТС нет API — кампанию доводит браузер-робот. TelegramRobotRunner + | запускает `node @@ -175,7 +265,9 @@ defineExpose({ loadCampaigns });
- {{ campaign.name }} + {{ campaign.name }} #{{ campaign.id }}
-
- Бюджет в неделю - {{ - formatMoney(campaign.weekly_budget_rub) - }} +
+ {{ firstLine(campaign.moderation_reason) }} +
+ +
+ Показов + ~{{ campaign.estimated_impressions }} +
+ +
+ Бюджет показов + {{ formatMoney(campaign.budget_rub) }}
@@ -208,8 +311,9 @@ defineExpose({ loadCampaigns }); size="small" variant="outlined" color="primary" + :loading="busyId === campaign.id" data-testid="campaign-edit-btn" - @click="emit('edit', campaign.id)" + @click="onEdit(campaign)" > {{ campaign.status === 'rejected' ? 'Исправить' : 'Изменить' }} @@ -240,6 +344,7 @@ defineExpose({ loadCampaigns }); Отчёт + + + Удалить +
@@ -255,6 +373,29 @@ defineExpose({ loadCampaigns }); + + + + Удалить кампанию? + + Черновик «{{ campaignToDelete?.name }} #{{ campaignToDelete?.id }}» будет удалён без возможности + восстановления. + + + + Отмена + + Удалить + + + +
diff --git a/app/resources/js/components/advertising/CampaignMessages.vue b/app/resources/js/components/advertising/CampaignMessages.vue new file mode 100644 index 00000000..d3f183da --- /dev/null +++ b/app/resources/js/components/advertising/CampaignMessages.vue @@ -0,0 +1,104 @@ + + + diff --git a/app/resources/js/components/advertising/CampaignReportDialog.vue b/app/resources/js/components/advertising/CampaignReportDialog.vue index 1bed2d4f..4c3b18a7 100644 --- a/app/resources/js/components/advertising/CampaignReportDialog.vue +++ b/app/resources/js/components/advertising/CampaignReportDialog.vue @@ -1,15 +1,17 @@ diff --git a/app/resources/js/components/advertising/CampaignWizard.vue b/app/resources/js/components/advertising/CampaignWizard.vue index 9bf8345d..f5553882 100644 --- a/app/resources/js/components/advertising/CampaignWizard.vue +++ b/app/resources/js/components/advertising/CampaignWizard.vue @@ -1,52 +1,57 @@