merge: подтянул общую ветку в клиентские СМС — 227 записей отставания закрыты

Ветка шла отдельно почти неделю и отставала на 227 записей, отставание росло
каждый день. Направление сведения — общая В ветку: перевод main владелец
отклонил, значит вливать в него нечего.

Девять столкновений, каждое разобрано по существу.

Журнал схемы: столкнулись НЕ три номера, как ожидалось, а ВСЕ - обе ветки
независимо заняли v8.96-v9.25 и v9.32 разным содержимым. Обе стороны
настоящие, выбросить нельзя ни одну, поэтому перенумерована ветка, а не
общая: 31 запись уехала в свободный диапазон v9.33-v9.63. Содержание не
тронуто - доказано сверкой с исходной версией через git, посимвольно.
Соответствие старых номеров новым вписано в сам журнал, чтобы старые
документы ветки оставались читаемыми. Прежняя пометка про "запас v9.32"
заменена: запас не спас, v9.32 в общей ветке тоже был занят.

Сборка тестовой базы: взята версия общей ветки. Она позже и доказана
замером - двумя шагами вместо migrate:fresh, который спотыкался на
типе-призраке и оставлял схему неполной.

Список слов орфографии сведён объединением: 2106 наших + 2169 общих дали
2173, ни одно слово ни с одной стороны не потеряно - проверено сравнением.

Расписание работ, маршруты экранов и админский слой: обе стороны добавляли
своё в одно место, оставлены обе.

Витрина рекламных каналов: каждая ветка сделала настоящим СВОЙ канал -
ветка СМС свой, общая Телеграм. После сведения настоящих три, заглушки
исключают все три. Сторож витрины принят вырезанием: убрал СМС из списка
настоящих - покраснел, вернул - позеленел.

СТОЛКНОВЕНИЕ ИМЁН, созданное самим сведением. Оба набора тестов объявляли
глобального помощника pollCampaign - свой в СМС (один довод) и свой в
Телеграме (от двух до четырёх). Две функции с одним именем в одном языке
не живут: пока ветки шли врозь, этого не видел никто. Помощник СМС
переименован в pollSmsCampaign. Проверено, что других таких пар в PHP-тестах
нет ни одной.

Статанализ ветки доведён с 674 замечаний до НУЛЯ, уровень не понижен и в
baseline не заметено ничего.
  - 616 из 674 - ложный класс Pest, закрытый тремя узкими правилами; правила
    перенесены из рабочей ветки, где владелец их уже принял;
  - остальные 42 - свои, в новом коде ветки, и починены по существу:
    задвоенный ключ массива в трёх тестах (след копирования - комментарий
    оторвался от своей строки), врущие описания двух помощников (PHP сам
    делает из ключа-номера число), сужение типа возврата, прятавшее от
    анализатора свойства подставного отправителя, лишний знак вопроса и
    четыре бесполезных перенумерования списка.
  - Приёмка вырезанием: подложил несуществующий метод - анализатор назвал его
    поимённо и покраснел; убрал - ноль.

Шапки 59 моделей обновлены пересборкой подсказчика и ОСТАВЛЕНЫ намеренно
(правка только в комментариях, проверено): без них анализатор не связывает
модель с описанием и не знает, что дата - это дата, а не строка. Откатил их
сперва по привычке - получил 15 замечаний про даты, вернул - ноль.

Орфография: 15 файлов проверено, 0 замечаний (смотрел и на число
проверенных файлов, не только на число ошибок). Добавлены три слова из имён
миграций ветки.

Заодно: алиас ruflo-core в списке имён сторожа реестра - плагин описан в
реестре групповым именем, сторож видел только машинное. Мостится ТОЛЬКО имя;
🔴 содержательный долг остаётся - реестр до сих пор зовёт ruflo изолированным,
хотя его разморозили 28.07. Это чинить отдельно, через claude-md-management.

Проверено: три фронтовых сторожа рекламы 18/18, статанализ 0, разметка 0,
орфография 0, синтаксис PHP чист. Полный прогон тестов ветки - отдельным
шагом, он ещё ни разу не делался.

NB: в журнале схемы есть задвоенные номера v8.26 (пять раз) и v8.64 (два) -
это досталось по наследству из общей ветки, ровно столько же их там и было.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-08-01 19:50:41 +03:00
610 changed files with 68128 additions and 1415 deletions
+21 -1
View File
@@ -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 = [
+7 -5
View File
@@ -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), #8789 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), #8789 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`.
+12
View File
@@ -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=
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\AdCreativeJob;
use Illuminate\Console\Command;
use Illuminate\Contracts\Database\Query\Builder;
use Illuminate\Support\Facades\Log;
/**
* Сторож зависших заданий робота-грузчика креативов.
*
* Задание «в работе» пробка на всю очередь: пока хоть одно из них живо, выдача заданий
* отвечает «работы нет» ВСЕМ, и ни одна кампания больше не стартует. Робот умирает молча
* (упал процесс, перезагрузили сервер, портал не принял отчёт) и без сторожа пробка
* вечная: `taken_at` до сих пор никто не читал, а число попыток ничем не ограничивалось.
*
* 🔴 Ходим через `pgsql_admin` (роль crm_admin_user, у неё разрешающая политика srv_bypass).
* На дефолтной роли `crm_app_user` расписание работает БЕЗ tenant-контекста, и RLS отдал бы
* ноль строк сторож бодро рапортовал бы об успехе, ничего не разбирая. Ровно тот самый
* «тихий ноль», от которого этот сторож и защищает.
*/
class ReapStuckCreativeJobs extends Command
{
protected $signature = 'creative-jobs:reap';
protected $description = 'Разобрать пробку: вернуть в очередь задания робота, зависшие «в работе»';
/** Столько минут молчания — и считаем, что робот задание бросил. */
private const STUCK_MINUTES = 30;
/** Столько всего попыток даём одному заданию, дальше закрываем сбоем. */
private const MAX_ATTEMPTS = 3;
public function handle(): int
{
$deadline = now()->subMinutes(self::STUCK_MINUTES);
// taken_at без значения тоже считаем зависшим: такое задание иначе не разобрать
// ничем, а в работе оно висит и очередь держит.
$stuck = AdCreativeJob::on('pgsql_admin')
->where('status', AdCreativeJob::STATUS_TAKEN)
->where(fn (Builder $q) => $q->whereNull('taken_at')->orWhere('taken_at', '<', $deadline))
->get();
foreach ($stuck as $job) {
if ($job->attempts >= self::MAX_ATTEMPTS) {
$job->update([
'status' => AdCreativeJob::STATUS_FAILED,
'failure_reason' => sprintf(
'Робот не отчитался за %d минут, и попытки исчерпаны (%d). Задание закрыто сторожем очереди.',
self::STUCK_MINUTES,
$job->attempts,
),
'finished_at' => now(),
]);
Log::warning('Задание робота закрыто сторожем: попытки исчерпаны', [
'job_id' => $job->id, 'campaign_id' => $job->campaign_id, 'attempts' => $job->attempts,
]);
$this->warn("Задание #{$job->id}: попытки исчерпаны, закрыто сбоем.");
continue;
}
$job->update(['status' => AdCreativeJob::STATUS_QUEUED, 'taken_at' => null]);
Log::warning('Задание робота возвращено в очередь: робот не отчитался', [
'job_id' => $job->id, 'campaign_id' => $job->campaign_id, 'attempts' => $job->attempts,
]);
$this->info("Задание #{$job->id}: возвращено в очередь.");
}
if ($stuck->isEmpty()) {
$this->info('Зависших заданий нет.');
}
return self::SUCCESS;
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Advertising;
use RuntimeException;
/**
* Сегмент в Аудиториях есть, но Яндекс ещё сводит загруженные номера с людьми
* для рекламы он пока не годен.
*
* 🔑 Готовность определяет ТОЛЬКО `can_create_dependent`. Статус `is_processed` это
* «обрабатывается», кабинет так и пишет; готовый сегмент имеет статус `processed`
* и заполненный `matched_quantity`. На боевом 30.07.2026 сведение заняло около 15 часов.
*
* До этой проверки портал в такой момент лез создавать условие ретаргетинга и получал
* от Директа «объект не найден» тот же отказ, что и на несуществующий сегмент.
*/
final class AudienceNotReadyException extends RuntimeException
{
public function __construct(
/** Сколько человек Яндекс уже опознал (0, пока не начал). */
public readonly int $matched,
/** Что Яндекс отвечает про состояние сегмента — для журнала, не для клиента. */
public readonly string $segmentStatus,
) {
parent::__construct(
"Аудитория ещё готовится на стороне рекламной площадки (состояние: {$segmentStatus}, опознано {$matched})."
);
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Advertising;
use RuntimeException;
/**
* Смета показов слишком мала: бюджет кампании за период не дотягивает до минимума Директа.
*
* Живой отказ 30.07.2026: «Budget for this period cannot be less than 600 rub.». Клиент
* видел его как есть по-английски, про чужие 600 , без единой подсказки, что делать.
*
* Наружу отдаём ТОЛЬКО клиентские числа: сколько показов нужно и сколько это стоит ему.
* Ни минимум Яндекса, ни наша наценка в клиентский ответ не попадают иначе долю
* Яндекса можно вычислить делением.
*/
final class BudgetBelowYandexMinimumException extends RuntimeException
{
public function __construct(
/** Минимальная смета показов, при которой Директ возьмёт кампанию. */
public readonly int $minImpressions,
/** Во сколько эта смета обойдётся клиенту, ₽ с копейками. */
public readonly string $minClientRub,
) {
parent::__construct(
"Слишком маленькая смета показов: рекламная площадка не берёт такие кампании. Минимум — {$minImpressions} показов ({$minClientRub} ₽)."
);
}
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Advertising;
use RuntimeException;
/**
* Слепок креативов «до/после» не сошёлся с ожидаемым набором размеров: загрузилось не то
* количество, не те размеры или два креатива одного размера. Привязку не делаем наугад
* задание уходит в «требует внимания», кампания остаётся черновиком.
*/
final class CreativeMatchFailedException extends RuntimeException {}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\ClientTg\Tariff;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* SaaS-admin Клиентская Telegram-реклама: тарифная сетка (client_tg_tariffs).
* План §Сессия 4, задача 4.4.
*
* Зона saas-admin/admin-db (crm_admin_user); GRANT миграция client_tg_tariffs.
* Таблица глобальная (без RLS, не tenant-scoped) единый прайс на всех тенантов.
* updateTariffs заменяет весь набор ступеней целиком (owner правит сетку списком).
*/
class TgTariffController extends Controller
{
/** GET /api/admin/telegram/tariffs */
public function index(Request $request): JsonResponse
{
return response()->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(),
]);
}
}
@@ -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'],
]);
}
@@ -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<array{width:int,height:int,uploaded:bool,banner_id:?int,bytes:?int,included:bool,preview_url:?string}> */
private function bannerSlots(AdCampaign $campaign): array
{
$existing = AdCampaignBanner::where('tenant_id', $campaign->tenant_id)
->where('campaign_id', $campaign->id)
->get()
->keyBy(fn ($b) => $b->width.'x'.$b->height);
$slots = [];
foreach (BannerSizes::all() as [$w, $h]) {
$banner = $existing->get($w.'x'.$h);
$slots[] = $this->bannerSlot($campaign, $w, $h, $banner);
}
return $slots;
}
/** @return array{width:int,height:int,uploaded:bool,banner_id:?int,bytes:?int,included:bool,preview_url:?string} */
private function bannerSlot(AdCampaign $campaign, int $w, int $h, ?AdCampaignBanner $banner): array
{
if ($banner === null) {
return [
'width' => $w,
'height' => $h,
'uploaded' => false,
'banner_id' => null,
'bytes' => null,
'included' => true,
'preview_url' => null,
];
}
return [
'width' => $w,
'height' => $h,
'uploaded' => true,
'banner_id' => (int) $banner->id,
'bytes' => (int) $banner->bytes,
'included' => (bool) $banner->included,
'preview_url' => "/api/advertising/campaigns/{$campaign->id}/banners/{$banner->id}/preview",
];
}
}
@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Mail\AdDocumentAttachedMail;
use App\Models\AdCampaign;
use App\Models\AdCampaignMessage;
use App\Services\Advertising\CampaignMessageService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
/**
* Лента сообщений по рекламной кампании окно передачи между Яндексом и клиентом.
* Всё строго в пределах своего тенанта: чужая переписка это чужие бумаги.
*
* Наценки в ленте быть не может: наружу отдаются только поля сообщения, ни
* `yandex_cost_rub`, ни `ad_margin_percent` тут не появляются никогда.
*/
class AdvertisingCampaignMessageController extends Controller
{
public function index(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
$messages = AdCampaignMessage::where('tenant_id', $tenantId)
->where('campaign_id', $campaign->id)
->orderBy('id')
->get()
->map(fn (AdCampaignMessage $m) => [
'id' => (int) $m->id,
'author' => $m->author,
'banner_id' => $m->banner_id,
'body' => $m->body,
'file_name' => $m->file_name,
'file_size' => $m->file_size,
'created_at' => $m->created_at?->toIso8601String(),
]);
return response()->json(['messages' => $messages]);
}
/**
* Ответ клиента. Пустое сообщение без файла принимать бессмысленно окно передачи
* должно что-то передавать.
*
* Что принимаем: pdf, jpg, png до 10 МБ. Проверяем и расширение, и настоящий тип
* файла переименованный exe правилом `mimes` не пройдёт. Проверка идёт ДО записи
* на диск: чужой исполняемый файл на боевом сервере это не «неудобство», а дыра.
*/
public function store(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
$data = $request->validate([
'body' => ['nullable', 'string', 'max:4000'],
'file' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:10240'],
]);
$body = trim((string) ($data['body'] ?? ''));
$file = $request->file('file');
if ($body === '' && ! $file instanceof UploadedFile) {
return response()->json([
'message' => 'Напишите сообщение или приложите документ.',
'errors' => ['body' => ['Напишите сообщение или приложите документ.']],
], 422);
}
$attributes = [
'tenant_id' => $tenantId,
'campaign_id' => (int) $campaign->id,
'author' => AdCampaignMessage::AUTHOR_CLIENT,
'body' => $body === '' ? 'Приложен документ' : $body,
];
if ($file instanceof UploadedFile) {
// Приватный диск: наружу файл уходит только через ручку ниже, с проверкой тенанта.
$path = $file->store("ad-messages/{$tenantId}/{$campaign->id}", 'local');
$attributes += [
'file_path' => $path,
'file_name' => mb_substr($file->getClientOriginalName(), 0, 255),
'file_size' => $file->getSize(),
'file_mime' => $file->getMimeType(),
];
}
$message = AdCampaignMessage::create($attributes);
if ($file instanceof UploadedFile) {
$this->tellTruthAboutDocument($campaign, $message, $body);
}
return response()->json(['id' => (int) $message->id], 201);
}
/**
* Правда про приложенный документ клиенту в ленту, владельцу письмом.
*
* 🔴 Замысел предполагал, что документ отвезёт робот прямо в кабинет Яндекса. **Такой
* дороги нет** проверено двумя нарочными отказами 28.07.2026, обычной тематикой
* и лицензируемой: в окне отказа ноль полей для файла, документы Яндекс принимает
* только снаружи кабинета (чат поддержки, форма обратной связи).
*
* Молчать про это нельзя. Клиент, приложивший лицензию, будет ждать ответа Яндекса,
* которого не будет: файл просто ляжет на диск. Поэтому говорим прямо и зовём живого
* человека иначе «разберёмся вручную» было бы пустым обещанием.
*
* Всё внутри под Throwable: отметка и письмо дело второстепенное, а принятый документ
* клиента нет. Беда с почтой не должна возвращать клиенту отказ на успешно принятый файл.
*/
private function tellTruthAboutDocument(AdCampaign $campaign, AdCampaignMessage $message, string $comment): void
{
try {
app(CampaignMessageService::class)->postSystem(
$campaign,
'Документ получен и сохранён у нас. Передать его Яндексу автоматически нельзя — '
.'он принимает документы только от человека. Если по вашему отказу документ нужен, '
.'мы отнесём его сами и напишем здесь.',
);
$to = (string) config('services.monitoring.alert_email');
if ($to !== '') {
Mail::to($to)->queue(new AdDocumentAttachedMail(
(string) $campaign->name,
(int) $campaign->id,
(int) $campaign->tenant_id,
(string) $message->file_name,
$comment,
));
}
} catch (Throwable $e) {
Log::warning('Не смогли отметить приложенный документ: '.$e->getMessage(), [
'campaign' => $campaign->id, 'message' => $message->id,
]);
}
}
/** Файл отдаём только своему тенанту и только через портал — диск закрытый. */
public function file(Request $request, int $id, int $messageId): StreamedResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail();
$message = AdCampaignMessage::where('tenant_id', $tenantId)
->where('campaign_id', $campaign->id)
->where('id', $messageId)
->whereNotNull('file_path')
->firstOrFail();
return Storage::disk('local')->download((string) $message->file_path, (string) $message->file_name);
}
}
@@ -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 {
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\ClientTg;
use App\Http\Controllers\Controller;
use App\Models\ClientTg\AutoRule;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Клиентское HTTP-API правила авторассылки Telegram (план §Этап 5, задача 5.4).
*
* Одно правило на тенанта. Клиент из кабинета включает/выключает авто, задаёт
* объявление, порог пачки (batch_threshold, ≥367 минимум МТС), бюджет на кампанию
* и дневной лимит трат (daily_limit_rub, дефолт 0 = авто выключено). Предохранители
* трат в TelegramAutoAccumulator (задача 5.1). Scope тенантом из $request->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<string, mixed>
*/
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,
];
}
}
@@ -0,0 +1,386 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\ClientTg;
use App\Exceptions\Billing\InsufficientBalanceException;
use App\Http\Controllers\Controller;
use App\Jobs\ClientTg\ResubmitTelegramCampaignJob;
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\CampaignPhone;
use App\Models\Tenant;
use App\Services\ClientTg\TelegramAudienceService;
use App\Services\ClientTg\TelegramCampaignChargeService;
use App\Services\ClientTg\TelegramTariffService;
use App\Support\PhoneNormalizer;
use App\Support\TelegramLink;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
/**
* HTTP-API клиентской Telegram-рекламы «по своей базе» (план §Сессия 3, задача 3.1).
* Тонкий слой над ядром: TelegramAudienceService (кандидаты), TelegramTariffService
* (смета-потолок), RunTelegramCampaignJob (робот кабинета МТС).
*
* 🔴 Отличие от СМС-близнеца: создание и запуск РАЗДЕЛЕНЫ. `store` создаёт ЧЕРНОВИК
* (деньги/робот не трогаются) клиент видит клиентскую смету (с наценкой) и число
* кандидатов; `launch` переводит draft→queued, в бою СПИСЫВАЕТ клиентскую смету с
* общего баланса и ставит джоб. Песочница (TG_SANDBOX): денег не трогаем, робот черновиком.
*
* tenant_id всегда из $request->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<string, mixed>
*/
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<int, string> $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;
}
}
@@ -0,0 +1,267 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Exceptions\Advertising\CreativeMatchFailedException;
use App\Http\Controllers\Controller;
use App\Models\AdCampaign;
use App\Models\AdCampaignBanner;
use App\Models\AdCreativeJob;
use App\Services\Advertising\CampaignMessageService;
use App\Services\Advertising\CreativeJobService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
/**
* Служебный канал робота-грузчика креативов. Три действия: взять задание, скачать файл
* баннера, отчитаться о результате.
*
* Робот ходит по сервис-токену, без пользователя и без tenant-контекста. Кросс-тенантный
* доступ даёт посредник `admin-db`: он подменяет активное подключение на pgsql_admin
* (роль crm_admin_user, у неё разрешающая политика srv_bypass). Поэтому внутри работаем
* обычным подключением по умолчанию прибивать модели к конкретному соединению НЕ надо.
*
* Запустить кампанию, потратить деньги или изменить смету через этот канал нельзя
* таких действий в нём просто нет.
*/
class CreativeRobotController extends Controller
{
public function __construct(private readonly CreativeJobService $jobs) {}
/** Выдать роботу одно задание с перечнем файлов. */
public function next(): JsonResponse
{
$job = $this->jobs->takeNext();
if ($job === null) {
return response()->json(['job' => null]);
}
// Разведка — это «сходить посмотреть», а не «отвезти картинки»: роботу нужен только
// номер объявления в кабинете. Списка файлов у неё нет и быть не может, а вызывать
// ради неё подбор баннеров — значит выдать роботу работу, которой ему не поручали.
if ($job->kind !== AdCreativeJob::KIND_UPLOAD) {
// Номер кампании в Яндексе роботу нужен: список объявлений открывается только
// по нему. Наш внутренний номер кабинету ничего не говорит.
$campaign = AdCampaign::find($job->campaign_id);
return response()->json(['job' => [
'id' => $job->id,
'campaign_id' => $job->campaign_id,
'kind' => $job->kind,
'yandex_ad_id' => $job->yandex_ad_id,
'yandex_campaign_id' => $campaign?->yandex_campaign_id,
'banners' => [],
]]);
}
// Ровно тот же набор, который потом сопоставляется при отчёте: включённые баннеры
// без номера креатива. Возить в кабинет то, что там уже лежит, — значит плодить
// дубли, которые вычищаются только руками.
$banners = $this->jobs->bannersToUpload($job);
return response()->json(['job' => [
'id' => $job->id,
'campaign_id' => $job->campaign_id,
'kind' => $job->kind,
'banners' => $banners->map(fn ($b) => [
'banner_id' => $b->id,
'width' => (int) $b->width,
'height' => (int) $b->height,
'file_url' => url("/api/creative-robot/jobs/{$job->id}/banners/{$b->id}/file"),
])->values()->all(),
]]);
}
/**
* Отдать роботу файл баннера.
*
* Отдаём ТОЛЬКО баннеры задания, номер которого робот прислал в адресе, и только пока
* это задание в работе. Иначе утёкший токен позволил бы перебором номеров вычерпать
* картинки всех клиентов.
*
* Раньше задание искалось как «какое-нибудь в работе». Пока в работе строго одно
* задание, результат совпадал, но защита держалась на внешнем условии, а не на самом
* запросе. Теперь «в работе не больше одного» обеспечивает частичный уникальный индекс
* `uq_creative_job_single_taken` в базе, а выдача файла ни на что постороннее не
* опирается она проверяет ровно то задание, о котором спросили.
*/
public function file(int $jobId, int $bannerId): StreamedResponse
{
$job = AdCreativeJob::where('id', $jobId)
->where('status', AdCreativeJob::STATUS_TAKEN)
->first();
abort_if($job === null, 404, 'Задание не в работе — файлы по нему не выдаются.');
$banner = AdCampaignBanner::where('id', $bannerId)
->where('campaign_id', $job->campaign_id)
->where('included', true)
->firstOrFail();
abort_unless(Storage::disk('local')->exists($banner->path), 404, 'Файл баннера не найден.');
// Расширение и тип — НАСТОЯЩИЕ, из самого файла. Клиенту разрешены jpg, png и gif,
// а отдавали мы всё под именем «.jpg»: робот сохранял PNG как «картинка.jpg» и таким
// же скармливал кабинету Яндекса. Кабинет либо отвергнет файл, либо примет с
// искажением — и разбираться придётся человеку по письму «не смог загрузить».
// Имя начинается с номера баннера: он уникален, размер — нет.
$ext = strtolower(pathinfo($banner->path, PATHINFO_EXTENSION)) ?: 'jpg';
$contentType = match ($ext) {
'png' => 'image/png',
'gif' => 'image/gif',
default => 'image/jpeg',
};
return Storage::disk('local')->download(
$banner->path,
"{$banner->id}-{$banner->width}x{$banner->height}.{$ext}",
['Content-Type' => $contentType],
);
}
/**
* Принять доклад разведки: что робот прочитал на экране кабинета про отклонённое
* объявление, и снимок этого экрана.
*
* 🔑 Отдельная ручка, а не `done`, потому что у разведки другой смысл слова «готово».
* У заливки готово = креативы в кабинете, портал идёт за слепком. У разведки готово =
* робот принёс ТЕКСТ, и без текста доклада не бывает. Причина отказа единственное,
* ради чего разведка затевалась: программный интерфейс Яндекса её не отдаёт вовсе.
*
* Текст ложится в ленту от имени `yandex` и слово в слово: портал ничего не толкует
* и не сокращает. Клиенту уходит письмо и колокольчик этим занимается сервис ленты.
*/
public function inspection(Request $request, int $jobId): JsonResponse
{
$data = $request->validate([
// Пустой доклад — это «ничего не выяснил», а не причина отказа. Такой доклад
// клиенту в ленту класть нельзя: он выглядит как ответ Яндекса, а им не является.
'report' => ['required', 'string', 'min:1', 'max:20000'],
'screenshot' => ['nullable', 'file', 'mimes:png,jpg,jpeg', 'max:5120'],
]);
if (trim($data['report']) === '') {
return response()->json([
'message' => 'Пустой доклад разведки не принимается.',
'errors' => ['report' => ['Пустой доклад разведки не принимается.']],
], 422);
}
$job = AdCreativeJob::findOrFail($jobId);
abort_if(
$job->status !== AdCreativeJob::STATUS_TAKEN,
409,
"Задание #{$job->id} не в работе (статус «{$job->status}») — доклад по нему не принимается."
);
// Разошлись в том, какую работу робот делал. Принять такой доклад — значит положить
// клиенту в ленту неизвестно что от имени Яндекса.
abort_if(
$job->kind !== AdCreativeJob::KIND_INSPECT,
409,
"Задание #{$job->id} — не разведка (вид «{$job->kind}»), доклад разведки по нему не принимается."
);
$campaign = AdCampaign::findOrFail($job->campaign_id);
// Баннер ищем связью ОТ КАМПАНИИ: номер объявления — чужой, из системы Яндекса,
// и брать по нему что-либо в обход кампании нельзя. Не нашёлся — доклад всё равно
// кладём, просто без привязки к размеру блока: причина важнее привязки.
$banner = $campaign->banners()
->where('yandex_ad_id', $job->yandex_ad_id)
->first();
$file = null;
$screenshot = $request->file('screenshot');
if ($screenshot instanceof UploadedFile) {
// Приватный диск: снимок экрана кабинета — чужая внутренняя кухня, наружу он
// уходит только через ручку портала с проверкой тенанта.
$file = [
'path' => $screenshot->store("ad-messages/{$campaign->tenant_id}/{$campaign->id}", 'local'),
'name' => mb_substr($screenshot->getClientOriginalName(), 0, 255),
'size' => (int) $screenshot->getSize(),
'mime' => (string) $screenshot->getMimeType(),
];
}
// 🔴 Именно postFromRobot, а НЕ postFromYandex: у второго стоит защита от дублей
// по последнему сообщению, и повторный отказ с той же формулировкой после починки
// она бы съела — клиент не узнал бы, что его опять не пустили. Уникальность
// разведки обеспечена на входе: одно задание на номер объявления.
app(CampaignMessageService::class)->postFromRobot(
$campaign,
$banner === null ? null : (int) $banner->id,
$data['report'],
$file,
);
$job->update(['status' => AdCreativeJob::STATUS_DONE, 'finished_at' => now()]);
return response()->json(['status' => AdCreativeJob::STATUS_DONE]);
}
/** Принять отчёт робота: готово или сбой. */
public function done(Request $request, int $jobId): JsonResponse
{
$data = $request->validate([
'ok' => ['required', 'boolean'],
'reason' => ['nullable', 'string', 'max:1024'],
]);
// Отчёт принимаем ТОЛЬКО по заданию, которое сейчас в работе. Номер задания робот
// присылает в адресе, и без этой проверки он брался как есть: «готово» по чужому
// ЕЩЁ НЕ выданному заданию разложило бы номера креативов чужой кампании по её
// баннерам (картинка одного клиента уехала бы в объявление другого), а «сбой» по
// уже закрытому заданию переписал бы правильный результат на failed.
$job = AdCreativeJob::findOrFail($jobId);
abort_if(
$job->status !== AdCreativeJob::STATUS_TAKEN,
409,
"Задание #{$job->id} не в работе (статус «{$job->status}») — отчёт по нему не принимается."
);
if ($data['ok'] === false) {
$this->jobs->fail($job, (string) ($data['reason'] ?? 'Робот не сообщил причину.'));
return response()->json(['status' => AdCreativeJob::STATUS_FAILED]);
}
try {
$this->jobs->complete($job);
} catch (CreativeMatchFailedException $e) {
// Задание уже помечено сбойным внутри complete(). Роботу отвечаем 200: свою
// работу он сделал, разошёлся слепок креативов — это наша сторона, не его.
return response()->json(['status' => AdCreativeJob::STATUS_FAILED, 'message' => $e->getMessage()]);
} catch (Throwable $e) {
// Приём отчёта ходит в живой Яндекс за слепком креативов. Любая другая беда
// (API недоступен, лимит, оборвалась сеть) раньше улетала наружу: робот получал
// 500, а задание НАВСЕГДА оставалось «в работе». А пока хоть одно задание в
// работе, выдача отвечает «работы нет» ВСЕМ — очередь встаёт колом для всех
// клиентов сразу. Поэтому закрываем задание сбойным: возобновляемый запуск
// поставит новое, и работа продолжится.
Log::error('Не смогли принять отчёт робота о креативах', [
'job_id' => $job->id,
'campaign_id' => $job->campaign_id,
'error' => $e->getMessage(),
]);
$fresh = $job->fresh();
if ($fresh !== null && $fresh->status === AdCreativeJob::STATUS_TAKEN) {
$this->jobs->fail($fresh, 'Портал не смог принять отчёт: '.$e->getMessage());
}
return response()->json(['status' => AdCreativeJob::STATUS_FAILED, 'message' => $e->getMessage()]);
}
return response()->json(['status' => AdCreativeJob::STATUS_DONE]);
}
}
@@ -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);
@@ -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 «что менялось»: у карточки есть движение по стадиям ИЛИ запись
* в журнале разговоров за период. Движения включают автоматические
* (деньги тестирование/пополнил/пользователь) владелец просил
* считать ЛЮБОЕ движение, а не только разговоры.
*
* Сроки у режимов РАЗНЫЕ и не пересекаются: «что надо сделать» смотрит вперёд
* (просрочено / сегодня / завтра / ближайшие 730 дней), «что менялось» назад
* (сегодня / вчера / прошедшие 730 дней). Срок не из своего режима 422:
* «что менялось завтра» не бывает, и молча подменять его на месяц нельзя.
*
* Без date_mode доска отдаёт всё, как раньше.
*
* @param Builder<SalesProspect> $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' => []],
];
@@ -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,
]);
}
}
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\RobotJob;
use App\Services\ClientTg\RobotResult;
use App\Services\ClientTg\TelegramAudienceService;
use App\Services\ClientTg\TelegramCampaignResultApplier;
use App\Services\ClientTg\TelegramModerationVerdictApplier;
use App\Services\ClientTg\TelegramResubmitResultApplier;
use App\Services\ClientTg\TelegramRobotQueue;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Служебный канал телеграм-робота кабинета МТС.
*
* Робот ходит по сервис-токену, без пользователя и без tenant-контекста. Кросс-тенантный
* доступ даёт посредник `admin-db`: он подменяет подключение на pgsql_admin (роль
* crm_admin_user с разрешающей политикой srv_bypass). Внутри работаем обычным
* подключением по умолчанию прибивать модели к соединению НЕ надо.
*
* 🔴 Номера телефонов в задании не отдаются: за ними отдельный запрос (метод phones),
* и только пока задание в работе.
*/
class TgRobotController extends Controller
{
public function __construct(private readonly TelegramRobotQueue $queue) {}
/** Нужны ли роботу номера для этой работы. Запуск — да; чтение вердикта и пересдача — нет. */
private function needsPhones(RobotJob $job): bool
{
return in_array($job->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<string, mixed> $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]);
}
}
@@ -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'),
]);
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Сервис-токен канала «Робот-грузчик креативов Портал» (/api/creative-robot/*).
*
* Проверяет заголовок X-Creative-Robot-Token против config('services.creative_robot.token').
* Это НЕ пользовательская аутентификация: робот ходит от своего имени, без tenant-контекста.
* Пустой токен в настройках канал закрыт (401), чтобы его нельзя было случайно открыть
* без секрета тот же порядок, что у SalesIntegrationToken.
*/
class CreativeRobotToken
{
public function handle(Request $request, Closure $next): Response
{
$expected = (string) config('services.creative_robot.token', '');
$given = (string) $request->header('X-Creative-Robot-Token', '');
if ($expected === '' || ! hash_equals($expected, $given)) {
abort(401, 'Неверный сервис-токен робота.');
}
return $next($request);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Сервис-токен канала «Телеграм-робот кабинета МТС Портал» (/api/tg-robot/*).
*
* Это НЕ пользовательский вход: робот ходит от своего имени, без tenant-контекста.
* Пустой токен в настройках канал закрыт (401), чтобы его нельзя было случайно
* открыть без секрета. Тот же порядок, что у CreativeRobotToken.
*/
class TgRobotToken
{
public function handle(Request $request, Closure $next): Response
{
$expected = (string) config('services.tg_robot.token', '');
$given = (string) $request->header('X-Tg-Robot-Token', '');
if ($expected === '' || ! hash_equals($expected, $given)) {
abort(401, 'Неверный сервис-токен телеграм-робота.');
}
return $next($request);
}
}
+27 -33
View File
@@ -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);
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Jobs\ClientTg;
use App\Models\Deal;
use App\Services\ClientTg\TelegramAutoAccumulator;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Кормит накопитель авто-режима Telegram одним новым лидом (план §Сессия 4, задача 4.2).
*
* Ставится DealTelegramObserver'ом на КАЖДЫЙ свежий лид (freshness-guard в observer'е
* ограничивает поток настоящими новыми лидами). Аналог SendAutoSmsForDealJob.
*
* 🔴 На проде очередь бежит под ролью `crm_app_user` (НЕ BYPASSRLS). tenant_id приходит
* явным аргументом; джоб ставит `SET LOCAL app.current_tenant_id` перед работой, поэтому
* все записи накопителя (черновик авто-кампании, номера) проходят RLS. Best-effort:
* ЛЮБОЙ сбой логируем и выходим, НИКОГДА не роняем (под sync-очередью джоб может
* исполниться прямо в запросе приёма лида исключение сломало бы приём).
*
* ПДн: телефон в payload джоба НЕ кладём перечитываем по deal_id под tenant-контекстом.
*/
class AccumulateTelegramLeadJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public function __construct(
private readonly int $dealId,
private readonly int $tenantId,
) {}
public function handle(TelegramAutoAccumulator $accumulator): void
{
try {
DB::transaction(function () use ($accumulator): void {
DB::statement('SET LOCAL app.current_tenant_id = '.$this->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(),
]);
}
}
}
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Jobs\ClientTg;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\RobotJob;
use App\Services\ClientTg\TelegramModerationVerdictApplier;
use App\Services\ClientTg\TelegramRobotQueue;
use App\Services\ClientTg\TelegramRobotRunner;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Опросчик вердикта модерации МТС (план Этап 3, задача 3.4).
*
* Живая отправка кампании ставит статус `moderating` («на модерации МТС», задача
* 3.2). У МТС нет API, поэтому раз в цикл робот-читалка открывает кабинет по
* `mts_campaign_id` и читает вердикт (селекторы экрана отказа сняты живьём
* bots/mts-telegram-ads/FLOW-FINDINGS.md, «Разведка Сессии 6»). Опросчик применяет
* вердикт КОНСЕРВАТИВНО:
*
* `rejected` («Отклонена») статус `rejected` + причина из кабинета; ВОЗВРАТ
* списанной сметы на общий баланс (в бою refund, зеркало отказа робота);
* уведомление клиенту «реклама отклонена».
* `approved` («Одобрена») `launched` (показы пошли); уведомление «одобрена».
* Деньги НЕ трогаем смету списали при запуске, одобрение ничего не меняет.
* `moderating` / робот не смог прочитать вердикт (null) НЕ трогаем, оставляем
* на модерации до следующего цикла (тихо пропускаем).
*
* 🔴 RLS/роли: на проде очередь бежит под `crm_app_user` (НЕ BYPASSRLS) БЕЗ
* tenant-контекста перечисление кампаний через дефолтное соединение вернуло бы 0
* строк (молчаливый сбой). Поэтому кросс-тенантное перечисление идёт плоскими
* строками через `pgsql_supplier` (BYPASSRLS, как SweepStuckTelegramCampaignsJob), а
* правка статуса, возврат брони и загрузка моделей для уведомления под
* `SET LOCAL app.current_tenant_id` на ДЕФОЛТНОМ соединении; денежные операции
* всегда получают tenant_id явным аргументом.
*/
class PollTelegramModerationJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(TelegramRobotRunner $runner): void
{
$sandbox = (bool) config('client_tg.sandbox', true);
// Кросс-тенантное перечисление кампаний на модерации через BYPASSRLS-роль,
// БЕЗ открытой транзакции. Только те, у кого есть id кабинета — иначе роботу
// читать нечего (кампания без черновика на модерацию уйти не могла).
$rows = DB::connection('pgsql_supplier')->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();
});
}
}
@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace App\Jobs\ClientTg;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\RobotJob;
use App\Services\ClientTg\TelegramCampaignChargeService;
use App\Services\ClientTg\TelegramResubmitResultApplier;
use App\Services\ClientTg\TelegramRobotQueue;
use App\Services\ClientTg\TelegramRobotRunner;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* ПЕРЕСДАЧА отклонённой Telegram-кампании через Node-робота (связка Laravel
* robot mode:'resubmit'). В отличие от RunTelegramCampaignJob (создаёт НОВУЮ
* кампанию), пересдача ЧИНИТ ту же: робот входит в существующую кампанию кабинета
* через «Исправить» (по `mts_campaign_id`), вносит правки текста/ссылки/ОРД + (опц.)
* документ модератору и переотправляет её на модерацию. Аудиторию/файл номеров НЕ
* трогаем они у кампании уже есть.
*
* 🔴 RLS: очередь на проде бежит под `crm_app_user` (НЕ BYPASSRLS) без tenant-
* контекста tenant_id приходит аргументом, джоб сам ставит `SET LOCAL
* app.current_tenant_id` перед каждой операцией (tenantTx). Иначе RLS вернёт 0 строк.
*
* 🔴 Песочница (TG_SANDBOX): робот идёт submitMode:'draft' доходит до подтверждения,
* НЕ отправляет; деньги не трогаются. Бой: submitMode:'live' робот жмёт «Отправить
* на модерацию без оплаты» (0 ). Идемпотентность: работаем только со статусом
* `queued` (его ставит контроллер); иной статус no-op.
*
* Осторожно с деньгами (ревью-фикс F5, симметрия с RunTelegramCampaignJob): при отказе
* робота, когда есть `mts_campaign_id` (кампания в кабинете реальна и могла уйти на
* пере-модерацию), уводим в needs_review БЕЗ возврата брони ждём ручной сверки.
*/
class ResubmitTelegramCampaignJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
/** Робот входит через «Исправить», правит поля и доводит до /payment — с запасом до 420с. */
public int $timeout = 420;
public function __construct(
private readonly int $campaignId,
private readonly int $tenantId,
) {}
public function handle(TelegramRobotRunner $runner): void
{
$sandbox = (bool) config('client_tg.sandbox', true);
// Фаза A — под tenant-контекстом: берём queued-кампанию, переводим в running.
// Не-queued статус → no-op (идемпотентность). Аудиторию НЕ собираем.
$campaign = $this->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();
});
}
}
@@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
namespace App\Jobs\ClientTg;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\RobotJob;
use App\Services\ClientTg\TelegramAudienceService;
use App\Services\ClientTg\TelegramCampaignChargeService;
use App\Services\ClientTg\TelegramCampaignResultApplier;
use App\Services\ClientTg\TelegramRobotQueue;
use App\Services\ClientTg\TelegramRobotRunner;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Запуск клиентской Telegram-кампании через Node-робота кабинета МТС
* (план §Сессия 2, задача 2.3).
*
* 🔴 На проде очередь бежит под ролью `crm_app_user` (НЕ BYPASSRLS) на дефолтном
* соединении, БЕЗ tenant-контекста. Поэтому tenant_id приходит явным аргументом от
* контроллера при dispatch, а джоб сам ставит `SET LOCAL app.current_tenant_id`
* перед каждой читающей/пишущей операцией (helper tenantTx). Без контекста RLS
* вернула бы 0 строк (молчаливый сбой).
*
* Порядок (как ChargeCampaignSpendJob/SendClientSmsCampaignJob): подготовка под
* tenant-транзакцией сетевой запуск робота ВНЕ транзакции запись итога под
* tenant-транзакцией. Файл номеров ПДн (152-ФЗ): пишем во временный файл, отдаём
* роботу, удаляем в finally независимо от исхода.
*
* 🔴 Песочница (TG_SANDBOX): робот всегда mode:'draft', деньги НЕ списываются.
* Живой запуск и списание Сессия 6. Идемпотентность: джоб работает только со
* статусом `queued`; иной статус no-op (повторный заход ничего не ломает).
*/
class RunTelegramCampaignJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
/**
* Робот в браузере (Playwright) идёт ~300с (аудитория черновик объявление)
* плюс навигации кабинета МТС; ставим запас до 420с, чтобы штатный прогон НЕ
* убивался по таймауту раньше времени. Зависшую дольше окна кампанию добивает
* уборщик SweepStuckTelegramCampaignsJob (задача 3.3).
*/
public int $timeout = 420;
public function __construct(
private readonly int $campaignId,
private readonly int $tenantId,
) {}
public function handle(TelegramAudienceService $audience, TelegramRobotRunner $runner): void
{
$sandbox = (bool) config('client_tg.sandbox', true);
// Фаза A — под tenant-контекстом: берём queued-кампанию, переводим в running,
// собираем кандидатов. Не-queued статус → no-op (идемпотентность).
/** @var array{0: Campaign, 1: list<string>}|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();
});
}
}
@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
namespace App\Jobs\ClientTg;
use App\Models\ClientTg\Campaign;
use App\Services\ClientTg\TelegramCampaignChargeService;
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;
/**
* Уборщик зависших Telegram-кампаний (план Этап 3, задача 3.3).
*
* Кампания в статусе `running` дольше окна STUCK_AFTER_MINUTES считается зависшей:
* воркер RunTelegramCampaignJob убили/он упал молча (штатный прогон робота 420с,
* см. RunTelegramCampaignJob::$timeout), поэтому running дольше 15 минут заведомо
* не идущий прогон. Уборщик добивает статус, но 🔴 КОНСЕРВАТИВНО с деньгами:
*
* НЕТ `mts_campaign_id` (черновик в кабинете МТС не создавался) кампания
* заведомо НЕ ушла на модерацию `failed` + возврат списанной сметы на баланс;
* ЕСТЬ `mts_campaign_id` (черновик реально создан) кампания МОГЛА уйти на
* модерацию МТС деньги вслепую НЕ возвращаем (иначе оплатим показанную
* рекламу из своего кармана), помечаем `needs_review` до ручной сверки /
* опросчика вердикта (задача 3.4).
*
* Плюс зависшие `queued` (ревью-фикс: деньги списаны на launch, но джоб так и не
* стартовал воркер умер / Redis сброшен / задача потеряна). Такая кампания без
* этой уборки залипла бы в `queued` НАВСЕГДА со списанными с общего баланса деньгами
* (finalize/failed срабатывают только если джоб реально бежал). queued НЕ имеет
* `mts_campaign_id` (его ставит робот уже в running) робот кабинет не трогал
* безопасно `failed` + ВОЗВРАТ сметы. Порог для queued с большим запасом
* (QUEUED_STUCK_AFTER_MINUTES), чтобы не гоняться с живым, но занятым воркером.
*
* 🔴 RLS/роли: на проде очередь бежит под `crm_app_user` (НЕ BYPASSRLS) БЕЗ
* tenant-контекста перечисление кампаний через дефолтное соединение вернуло бы
* 0 строк (молчаливый сбой). Поэтому кросс-тенантное перечисление идёт плоскими
* строками через `pgsql_supplier` (BYPASSRLS, как PollTelegramModerationJob), а правка
* статуса и возврат сметы под `SET LOCAL app.current_tenant_id` на ДЕФОЛТНОМ
* соединении; денежные операции всегда получают tenant_id явным аргументом.
*/
class SweepStuckTelegramCampaignsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/** Окно «зависания»: running дольше стольких минут = мёртвый прогон (робот ≤ 420с). */
public const STUCK_AFTER_MINUTES = 15;
/**
* Окно для зависшей `queued`: джоб в норме подхватывается за секунды. Большой запас
* (1 час), чтобы НЕ добить кампанию, которую живой, но занятый бэклогом воркер вот-вот
* запустит. Деньги при этом не теряются они уже списаны и вернутся возвратом.
*/
public const QUEUED_STUCK_AFTER_MINUTES = 60;
public function handle(): void
{
$sandbox = (bool) config('client_tg.sandbox', true);
$cutoff = Carbon::now()->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();
});
}
}
+122 -19
View File
@@ -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). Вебхук поставщика приходит на 28 минут ПОЗЖЕ,
* чем строка появляется в журнале отданного. Сверка (каждые 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<int, array<string, mixed>> $missing
* @return array{0: array<int, array<string, mixed>>, 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
{
+220 -43
View File
@@ -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<int, AdCampaignBanner> $banners
* @param array<int, array<string, mixed>> $moderation
* @return array<int, string> номер объявления слова Яндекса, почему не включил
*/
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 [];
}
}
@@ -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);
}
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Письмо ВЛАДЕЛЬЦУ: клиент приложил документ к рекламной кампании.
*
* 🔴 Зачем оно вообще существует. Замысел предполагал, что документ отвезёт робот прямо
* в кабинет Яндекса. Такой дороги нет проверено двумя нарочными отказами 28.07.2026:
* в окне отказа ноль полей для файла, документы Яндекс принимает только снаружи кабинета
* (чат поддержки, форма обратной связи). Значит документ обязан попасть к живому человеку,
* иначе он просто ляжет на диск и о нём никто не узнает а клиенту мы в этот момент
* пишем «разберёмся вручную».
*
* Сам файл письмом НЕ отправляем: это чужие бумаги (лицензии, свидетельства), и рассылать
* их почтой незачем в письме только адрес кампании в портале.
*/
final class AdDocumentAttachedMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly string $campaignName,
public readonly int $campaignId,
public readonly int $tenantId,
public readonly string $fileName,
public readonly string $comment,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Клиент приложил документ по рекламной кампании «'.$this->campaignName.'»',
);
}
public function content(): Content
{
return new Content(
view: 'mail.ad-document-attached',
with: [
'campaignName' => $this->campaignName,
'campaignId' => $this->campaignId,
'tenantId' => $this->tenantId,
'fileName' => $this->fileName,
'comment' => $this->comment,
],
);
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Письмо клиенту: по его рекламной кампании пришёл ответ Яндекса.
* Получателей ставит вызывающий код через Mail::to($email)->queue(new ...).
*/
final class AdModerationMessageMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly string $campaignName,
public readonly int $campaignId,
public readonly string $body,
) {}
public function envelope(): Envelope
{
return new Envelope(subject: 'Ответ Яндекса по рекламной кампании «'.$this->campaignName.'»');
}
public function content(): Content
{
return new Content(
view: 'mail.ad-moderation-message',
with: [
'campaignName' => $this->campaignName,
'campaignId' => $this->campaignId,
'body' => $this->body,
],
);
}
}
+10 -2
View File
@@ -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})",
);
}
+107 -4
View File
@@ -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<AdCampaignAd, $this> */
public function ads(): HasMany
{
return $this->hasMany(AdCampaignAd::class, 'campaign_id');
}
/** Набор баннеров кампании: строка = «размер + файл + креатив + объявление».
*
* @return HasMany<AdCampaignBanner, $this>
*/
public function banners(): HasMany
{
return $this->hasMany(AdCampaignBanner::class, 'campaign_id');
}
/** @return HasMany<AdCampaignPhone, $this> */
public function phones(): HasMany
{
return $this->hasMany(AdCampaignPhone::class, 'campaign_id');
}
/**
* Лента переписки по кампании слова Яндекса, ответы клиента, служебные отметки.
*
* 🔴 Это **единственный допустимый путь** к сообщению, когда по нему принимается
* решение: искать сообщение сырым номером нельзя. Служебный канал робота ходит под
* ролью с кросс-тенантным доступом, где RLS не отфильтрует, и поиск по номеру отдал бы
* чужой документ. Связь привязывает выборку к кампании, а кампания к её владельцу.
*
* @return HasMany<AdCampaignMessage, $this>
*/
public function messages(): HasMany
{
return $this->hasMany(AdCampaignMessage::class, 'campaign_id');
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
+2
View File
@@ -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
{
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Один сгенерированный баннер набора кампании (точный размер блока Яндекса), файл на
* приватном диске 'local'. Tenant-aware с RLS. Источник: db/schema.sql, ad_campaign_banners.
*
* @mixin IdeHelperAdCampaignBanner
*/
class AdCampaignBanner extends Model
{
public const MOD_DRAFT = 'draft';
public const MOD_MODERATION = 'MODERATION';
public const MOD_ACCEPTED = 'ACCEPTED';
public const MOD_REJECTED = 'REJECTED';
protected $fillable = [
'tenant_id', 'campaign_id', 'width', 'height', 'path', 'bytes', 'included',
'yandex_creative_id', 'yandex_ad_id', 'moderation_status', 'moderation_reason',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
'campaign_id' => 'integer',
'width' => 'integer',
'height' => 'integer',
'bytes' => 'integer',
'included' => 'boolean',
'yandex_creative_id' => 'integer',
'yandex_ad_id' => 'integer',
];
}
/** @return BelongsTo<AdCampaign, $this> */
public function campaign(): BelongsTo
{
return $this->belongsTo(AdCampaign::class, 'campaign_id');
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Одно сообщение ленты по рекламной кампании. Tenant-aware с RLS.
*
* Автор `yandex` слова Яндекса, откуда бы мы их ни взяли: из ответа `ads.get` или
* увиденные роботом в кабинете. Мы их не трактуем и не сокращаем.
*
* @mixin IdeHelperAdCampaignMessage
*/
class AdCampaignMessage extends Model
{
public const AUTHOR_YANDEX = 'yandex';
public const AUTHOR_CLIENT = 'client';
public const AUTHOR_SYSTEM = 'system';
protected $fillable = [
'tenant_id', 'campaign_id', 'banner_id', 'author', 'body',
'file_path', 'file_name', 'file_size', 'file_mime',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
'campaign_id' => 'integer',
'banner_id' => 'integer',
'file_size' => 'integer',
];
}
/** @return BelongsTo<AdCampaign, $this> */
public function campaign(): BelongsTo
{
return $this->belongsTo(AdCampaign::class, 'campaign_id');
}
}
+2
View File
@@ -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
{
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Задание роботу-грузчику: отнести набор баннеров кампании в веб-кабинет Яндекса.
*
* Tenant-aware с RLS. Источник: db/schema.sql, table `ad_creative_jobs`.
*
* @mixin IdeHelperAdCreativeJob
*/
class AdCreativeJob extends Model
{
public const STATUS_QUEUED = 'queued';
public const STATUS_TAKEN = 'taken';
public const STATUS_DONE = 'done';
public const STATUS_FAILED = 'failed';
/** Отвезти картинки в кабинет — то, что робот умел с самого начала. */
public const KIND_UPLOAD = 'upload';
/** Сходить посмотреть, что кабинет говорит про объявление. Ничего не меняет. */
public const KIND_INSPECT = 'inspect';
/** Отвезти документ клиента и отправить объявление на повторную модерацию. */
public const KIND_DELIVER = 'deliver';
protected $fillable = [
'tenant_id', 'campaign_id', 'status', 'attempts',
'snapshot_before', 'failure_reason', 'taken_at', 'finished_at',
'kind', 'message_id', 'yandex_ad_id',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
'campaign_id' => 'integer',
'message_id' => 'integer',
'yandex_ad_id' => 'integer',
'attempts' => 'integer',
'snapshot_before' => 'array',
'taken_at' => 'datetime',
'finished_at' => 'datetime',
];
}
/** @return BelongsTo<AdCampaign, $this> */
public function campaign(): BelongsTo
{
return $this->belongsTo(AdCampaign::class, 'campaign_id');
}
/**
* Сообщение ленты, документ из которого везёт задание вида `deliver`.
*
* @return BelongsTo<AdCampaignMessage, $this>
*/
public function message(): BelongsTo
{
return $this->belongsTo(AdCampaignMessage::class, 'message_id');
}
}
+2
View File
@@ -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
{
+2
View File
@@ -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
{
+2
View File
@@ -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
{
+4
View File
@@ -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 = [
+2
View File
@@ -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
{
+1
View File
@@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* (каст `array`). Без этой пометки статанализ читает тип из миграции и видит строку.
*
* @property array<int, string>|null $audience_statuses
* @mixin IdeHelperClientSmsCampaign
*/
class ClientSmsCampaign extends Model
{
@@ -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
{
+2
View File
@@ -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
{
+2
View File
@@ -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
{
+2
View File
@@ -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
{
+2
View File
@@ -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
{
+1
View File
@@ -23,6 +23,7 @@ use Illuminate\Database\Eloquent\Model;
* @property int $stuck_after_minutes
* @property array<array-key, string>|null $allowed_operators
* @property int $max_upload_phones
* @mixin IdeHelperClientSmsSettings
*/
class ClientSmsSettings extends Model
{
+2
View File
@@ -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
{
+2
View File
@@ -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
{
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Models\ClientTg;
use Illuminate\Database\Eloquent\Model;
/**
* Правило авто-режима Telegram-рекламы (одно на тенанта UNIQUE tenant_id).
*
* Хранит объявление и лимит на пачку. По нему TelegramAutoAccumulator формирует
* авто-кампанию, когда наберётся пачка (≥367 кандидатов). Tenant-aware с RLS.
*
* Источник: миграция client_tg_auto_rule. Зеркало App\Models\ClientSmsAutoRule.
*
* @mixin IdeHelperAutoRule
*/
class AutoRule extends Model
{
protected $table = 'client_tg_auto_rule';
protected $fillable = [
'tenant_id',
'enabled',
'ad_text',
'ad_link',
'ord_category',
'budget_cap_rub',
'daily_limit_rub',
'batch_threshold',
'spent_today_rub',
'spent_date',
'updated_by',
];
protected function casts(): array
{
return [
'tenant_id' => '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',
];
}
}
+168
View File
@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\Models\ClientTg;
use DomainException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Кампания клиентской Telegram-рекламы «по своей базе» (сделки / база / список).
*
* Tenant-aware с RLS. В отличие от СМС-кампании, «мотор» робот в кабинете МТС
* (у МТС нет API), поэтому здесь объявление (`ad_text`/`ad_link`/`media_path`),
* категория ОРД, лимит трат на объявление (`budget_cap_rub`) и способ выбора
* аудитории (`audience_kind` + `audience_params`). `matched_count` заполняет робот
* после загрузки базы в кабинет МТС.
*
* Источник: миграция client_tg_campaigns.
*
* @property array<array-key, mixed>|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<string, list<string>>
*/
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<CampaignPhone, $this> */
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();
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Models\ClientTg;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Один номер-кандидат кампании клиентской Telegram-рекламы.
*
* Кандидат = отобран нами до загрузки в МТС; реальное «не МТС / есть в Телеграме»
* определяет робот в кабинете. Tenant-aware с RLS.
*
* Источник: миграция client_tg_campaign_phones.
*
* @mixin IdeHelperCampaignPhone
*/
class CampaignPhone extends Model
{
protected $table = 'client_tg_campaign_phones';
protected $fillable = [
'tenant_id',
'campaign_id',
'phone',
'expires_at',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
'campaign_id' => 'integer',
'expires_at' => 'datetime',
];
}
/** @return BelongsTo<Campaign, $this> */
public function campaign(): BelongsTo
{
return $this->belongsTo(Campaign::class, 'campaign_id');
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Models\ClientTg;
use Illuminate\Database\Eloquent\Model;
/**
* Контакт клиентской базы для Telegram-рекламы (телефон + имя + оператор).
*
* Tenant-aware с RLS.
*
* Источник: миграция client_tg_contacts.
*
* @mixin IdeHelperContact
*/
class Contact extends Model
{
protected $table = 'client_tg_contacts';
protected $fillable = [
'tenant_id',
'phone',
'name',
'operator',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
];
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Models\ClientTg;
use Illuminate\Database\Eloquent\Model;
/**
* Список «не показывать этим номерам» ручной exclude-лист тенанта (по номеру
* телефона). В показах рекламы «отписки» нет (реклама показывается пачке в кабинете
* МТС, а не шлётся адресно); это просто номера, которые клиент не хочет включать
* в аудиторию вырезаются при сборке (TelegramAudienceService).
*
* Tenant-aware с RLS. Источник: миграция client_tg_optouts.
*
* @mixin IdeHelperOptout
*/
class Optout extends Model
{
protected $table = 'client_tg_optouts';
protected $fillable = [
'tenant_id',
'phone',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
];
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Models\ClientTg;
use Illuminate\Database\Eloquent\Model;
/**
* Задание телеграм-роботу кабинета МТС: «доведи вот эту кампанию».
*
* Номеров телефонов здесь нет и быть не должно (ПДн) робот забирает их отдельным
* запросом, пока задание в работе.
*
* @mixin IdeHelperRobotJob
*/
class RobotJob extends Model
{
public const STATUS_QUEUED = 'queued';
public const STATUS_TAKEN = 'taken';
public const STATUS_DONE = 'done';
public const STATUS_FAILED = 'failed';
public const MODE_DRAFT = 'draft';
public const MODE_LIVE = 'live';
/** Прочитать вердикт модерации по номеру кампании в кабинете. Номера НЕ нужны. */
public const MODE_READ_STATUS = 'read-status';
/** Пересдать отклонённую кампанию. Номера НЕ нужны — аудитория у кампании уже есть. */
public const MODE_RESUBMIT = 'resubmit';
protected $table = 'client_tg_robot_jobs';
protected $fillable = [
'tenant_id', 'campaign_id', 'mode', 'payload', 'status',
'attempts', 'result', 'failure_reason', 'taken_at', 'finished_at',
];
protected function casts(): array
{
return [
'payload' => 'array',
'result' => 'array',
'attempts' => 'integer',
'taken_at' => 'datetime',
'finished_at' => 'datetime',
];
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Models\ClientTg;
use Illuminate\Database\Eloquent\Model;
/**
* Ступень тарифа клиентской Telegram-рекламы ( за показ по объёму).
*
* У нас тариф = потолок/оценка для интерфейса и лимита: итоговую стоимость показа
* считает кабинет МТС. Общая справочная таблица (не tenant-aware, без RLS).
*
* Источник: миграция client_tg_tariffs.
*
* @mixin IdeHelperTariff
*/
class Tariff extends Model
{
protected $table = 'client_tg_tariffs';
protected $fillable = [
'min_qty',
'price_rub',
];
protected function casts(): array
{
return [
'min_qty' => 'integer',
'price_rub' => 'decimal:2',
];
}
}
-1
View File
@@ -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
-1
View File
@@ -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
-1
View File
@@ -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
+22 -11
View File
@@ -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<int, SupplierProject>
*/
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(),
-1
View File
@@ -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
-1
View File
@@ -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
-1
View File
@@ -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
-1
View File
@@ -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
+1
View File
@@ -42,6 +42,7 @@ use Illuminate\Support\Carbon;
* @property Carbon $updated_at
* @property-read Collection<int, SalesAdAudiencePhone> $phones
* @property-read Collection<int, SalesAdAudienceFirmChannel> $firmChannels
* @mixin IdeHelperSalesAdAudienceFirm
*/
class SalesAdAudienceFirm extends Model
{
@@ -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
{
+2
View File
@@ -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
{
@@ -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
{
+1
View File
@@ -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
{
@@ -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
{
@@ -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
-1
View File
@@ -30,7 +30,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property array<string,mixed> $tariff_params
* @property Carbon $assigned_at
* @property Carbon $created_at
*
* @mixin IdeHelperSalesClientAssignment
*/
class SalesClientAssignment extends Model
-1
View File
@@ -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
+56 -1
View File
@@ -34,13 +34,17 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property string|null $rating_label
* @property array<string,mixed> $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(),
]);
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Движение карточки по стадиям воронки (append-only).
*
* Пишется событием модели SalesProspect не контроллером и не джобой по
* отдельности. Так в журнал попадает ЛЮБАЯ смена стадии: и результат разговора
* менеджера, и автоматический переезд по деньгам (SalesProspectsAdvanceJob),
* который раньше не оставлял следа вообще.
*
* sales_user_id = null двигал не человек (джоба по деньгам аккаунта).
* from_stage = null самая первая запись, карточку только что завели.
*
* Спека: docs/superpowers/specs/2026-08-01-korzina-filtry-schetchik-design.md §4.2.
*
* @property int $id
* @property int $prospect_id
* @property int|null $sales_user_id
* @property string|null $from_stage
* @property string $to_stage
* @property Carbon $created_at
* @mixin IdeHelperSalesProspectMove
*/
class SalesProspectMove extends Model
{
/** Движение пишется один раз — updated_at не нужен. */
public const UPDATED_AT = null;
protected $fillable = ['prospect_id', 'sales_user_id', 'from_stage', 'to_stage'];
protected function casts(): array
{
return ['created_at' => 'datetime'];
}
/** @return BelongsTo<SalesProspect, $this> */
public function prospect(): BelongsTo
{
return $this->belongsTo(SalesProspect::class, 'prospect_id');
}
}
+1
View File
@@ -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
{
+1
View File
@@ -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
{
+2
View File
@@ -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
{
+1
View File
@@ -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
{
+1
View File
@@ -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
{
-1
View File
@@ -26,7 +26,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property string $kind
* @property array<string,mixed> $params
* @property bool $is_active
*
* @mixin IdeHelperSalesTariff
*/
class SalesTariff extends Model
-1
View File
@@ -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
+2
View File
@@ -18,6 +18,8 @@ use Illuminate\Database\Eloquent\Model;
* причина словами и дата этого хватает и для разбора жалобы, и для договора с МТС.
*
* Источник: db/schema.sql, table `sms_global_optouts`.
*
* @mixin IdeHelperSmsGlobalOptout
*/
final class SmsGlobalOptout extends Model
{
-1
View File
@@ -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
-1
View File
@@ -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
@@ -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
-1
View File
@@ -17,7 +17,6 @@ use Illuminate\Support\Carbon;
* @property string $contact
* @property string $message
* @property Carbon $created_at
*
* @mixin IdeHelperSupportRequest
*/
class SupportRequest extends Model
-1
View File
@@ -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
-2
View File
@@ -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
-1
View File
@@ -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
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Jobs\ClientTg\AccumulateTelegramLeadJob;
use App\Models\Deal;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Авто-режим Telegram: кормит накопитель каждым НОВЫМ лидом (план §Сессия 4, задача 4.2).
*
* 🔴🔴🔴 Observer живёт ВНУТРИ боевого потока приёма лидов (RouteSupplierLeadJob,
* DealController, вебхуки). Он НИКОГДА не должен бросить исключение или сломать
* создание сделки всё тело обёрнуто в try/catch(Throwable) + Log::warning и
* выходит штатно. Провал авто-Telegram не должен ронять лид. Зеркало DealSmsObserver.
*
* Дёшево по построению: НЕ ходит в auto_rule (решение «копить/нет» за накопителем).
* Freshness-guard (без запроса) отсекает массовый/исторический импорт: джоб ставится
* только на лиды с received_at за последние сутки.
*
* Ставит ДЖОБ (а не зовёт накопитель напрямую): приём лида на проде идёт под ролью
* crm_supplier_worker, у которой НЕТ GRANT на client_tg_auto_rule прямой вызов дал
* бы «тихий ноль». Джоб исполняется на очереди под crm_app_user с tenant-контекстом.
*/
class DealTelegramObserver
{
public function created(Deal $deal): void
{
try {
// Freshness-guard: без запроса. Старые/импортные лиды не триггерят авто-режим.
// received_at NOT NULL по схеме; Carbon::parse безопасен и на пустом (=> 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(),
]);
}
}
}
+9 -1
View File
@@ -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 до аутентификации. Ключ лимитера —
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
/**
* Расчёт денег для медийной рекламы «за показы». Чистый, без БД и без Яндекса.
*
* MONEY: только bcmath (scale 2). Клиентская цена ПЛОСКАЯ ( за 1000 показов),
* наценка клиенту НЕ показывается. Округление клиентской суммы ВВЕРХ до копейки.
*/
final class AdImpressionPricing
{
/** Оценка показов: размер аудитории × частота на человека. */
public function impressionsForAudience(int $audienceCount, int $frequency): int
{
return max(0, $audienceCount) * max(0, $frequency);
}
/** Клиентская сумма (₽) за показы по плоской цене cpmRub за 1000, округление ВВЕРХ до копейки. */
public function clientCostRub(int $impressions, string $cpmRub): string
{
if ($impressions <= 0) {
return '0.00';
}
$raw = bcdiv(bcmul((string) $impressions, $cpmRub, 6), '1000', 6);
return $this->roundUpToKopeck($raw);
}
/** Маржа (₽) = списано клиенту − расход Яндекса. */
public function marginRub(string $chargedClientRub, string $yandexCostRub): string
{
return bcsub($chargedClientRub, $yandexCostRub, 2);
}
/** Округление денежной строки ВВЕРХ до 2 знаков (копейки). */
private function roundUpToKopeck(string $value): string
{
$kopecks = bcmul($value, '100', 6);
$floor = bcdiv($kopecks, '1', 0);
if (bccomp($kopecks, $floor, 6) > 0) {
$floor = bcadd($floor, '1', 0);
}
return bcdiv($floor, '100', 2);
}
}
-29
View File
@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
final class AdMarkup
{
/** @param string $percent напр. '30.00' */
public function __construct(private readonly string $percent) {}
/** Множитель наценки: 1 + percent/100. */
private function factor(): string
{
return bcadd('1', bcdiv($this->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);
}
}
@@ -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,

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