32df332901
Приёмка глазами вскрыла: кабинет МТС требует «Заголовок объявления» (до 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>
168 lines
6.9 KiB
PHP
168 lines
6.9 KiB
PHP
<?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();
|
||
}
|
||
}
|