Files
portal/app/app/Models/ClientTg/Campaign.php
T
Дмитрий 32df332901 fix(телеграм-реклама): заголовок объявления для рекламы сайта — кампания больше не встаёт
Приёмка глазами вскрыла: кабинет МТС требует «Заголовок объявления» (до 40 знаков),
когда в объявлении ссылка на САЙТ, а не на телеграм-канал. Робот про это поле не знал,
«Продолжить» молча не срабатывало, кампания вставала на шаге «Объявление» — в бою уже
ПОСЛЕ списания денег. Проверено живьём: 2234454 (сайт — встала) против 2234462 (канал —
дошла до подтверждения) и 2234490 (сайт с заголовком — дошла).

Портал спрашивает заголовок заранее, на создании черновика: обязателен только для
не-телеграмной ссылки (App\Support\TelegramLink), колонка ad_headline varchar(40),
поле на экране появляется по той же развилке. Робот заполняет его в кабинете.

Три ловушки, добытые живыми прогонами (описаны в коде):
- поле дорисовывается в ОТВЕТ на ссылку, с задержкой — надо ждать, а не спрашивать;
- под описание подходит несколько элементов — нужен .first();
- серая надпись внутри поля НЕ placeholder, а нарисованная подпись: поиск по атрибуту
  давал ноль совпадений при видимом на снимке поле. Опознаём по видимой надписи.

Тесты: робот 130/130, ClientTg 250/250, экран 23/23. Полный прогон бэкенда — те же
13 падающих классов до и после правки (ни одного в телеграм-части). В baseline
статанализа добавлен известный ложный класс Pest для нового файла тестов.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:42:46 +03:00

168 lines
6.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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
*/
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();
}
}