diff --git a/app/app/Http/Controllers/Api/ClientTg/CampaignController.php b/app/app/Http/Controllers/Api/ClientTg/CampaignController.php index 3a653a07..ab6c4520 100644 --- a/app/app/Http/Controllers/Api/ClientTg/CampaignController.php +++ b/app/app/Http/Controllers/Api/ClientTg/CampaignController.php @@ -11,6 +11,7 @@ use App\Jobs\ClientTg\RunTelegramCampaignJob; use App\Models\AdWallet; use App\Models\ClientTg\Campaign; use App\Models\ClientTg\CampaignPhone; +use App\Models\ClientTg\RobotJob; use App\Rules\ClientTg\MtsMedia; use App\Services\ClientTg\TelegramAudienceService; use App\Services\ClientTg\TelegramCampaignChargeService; @@ -19,6 +20,8 @@ use App\Support\PhoneNormalizer; use App\Support\TelegramLink; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; @@ -57,17 +60,60 @@ class CampaignController extends Controller $balance = $wallet !== null ? (string) $wallet->balance_rub : '0.00'; $frozen = $wallet !== null ? (string) $wallet->frozen_rub : '0.00'; + $campaigns = Campaign::where('tenant_id', $tenantId) + ->orderByDesc('id') + ->limit(50) + ->get(); + + $this->pristegniVremyaProverki($tenantId, $campaigns); + return response()->json([ - 'campaigns' => Campaign::where('tenant_id', $tenantId) - ->orderByDesc('id') - ->limit(50) - ->get(), + 'campaigns' => $campaigns, 'sandbox' => (bool) config('client_tg.sandbox', true), 'balance_rub' => bcsub($balance, $frozen, 2), 'frozen_rub' => $frozen, ]); } + /** + * Дописывает каждой кампании время ПОСЛЕДНЕЙ ЗАКОНЧЕННОЙ проверки роботом. + * + * 🔴 Дефект Ф-2 приёмки 06.08.2026 (карточка Т-Ф4). Кампания может неделями висеть + * «На модерации», и клиент не отличит работу от поломки. Именно в такой тишине + * владелец 36 часов не знал, что робот вообще не может войти в кабинет. + * + * 🪤 Считаем только ЗАКОНЧЕННЫЕ задания (`finished_at IS NOT NULL`) — и удачные, и + * неудачные. Задание в очереди работой не является: показать по нему «проверяли» + * значило бы обещать то, чего не было. Неудачная же проверка — всё равно признак + * жизни: система ходила, просто кабинет не ответил. + * + * 🪤 ОДИН запрос на весь список, а не по запросу на кампанию: вопрос внутри перебора + * уже дважды за неделю превращал быструю страницу в медленную. + * + * @param Collection $campaigns + */ + private function pristegniVremyaProverki(int $tenantId, $campaigns): void + { + $ids = $campaigns->pluck('id')->all(); + + $kogda = $ids === [] ? [] : RobotJob::query() + ->where('tenant_id', $tenantId) + ->whereIn('campaign_id', $ids) + ->whereNotNull('finished_at') + ->groupBy('campaign_id') + ->selectRaw('campaign_id, max(finished_at) as posledniy') + ->pluck('posledniy', 'campaign_id') + ->all(); + + foreach ($campaigns as $campaign) { + $syroe = $kogda[$campaign->id] ?? null; + $campaign->setAttribute( + 'last_robot_check_at', + $syroe === null ? null : Carbon::parse((string) $syroe), + ); + } + } + /** * Создаёт ЧЕРНОВИК кампании: сохраняет объявление/аудиторию, считает кандидатов * и смету-потолок. Ни робот, ни деньги не трогаются — это делает `launch`. @@ -444,9 +490,47 @@ class CampaignController extends Controller 'ord_category' => 'nullable|string|max:200', // = колонка client_tg_campaigns.ord_category varchar(200) 'phones' => 'nullable|array|max:200000', 'phones.*' => 'string|max:32', - ]); + ], self::SOOBSHCHENIYA, self::IMENA_POLEY); } + /** + * Фразы для случаев, где подстановка имени поля в общий шаблон звучит не по-русски + * («Поле Лимит на объявление должно быть не меньше 1»). Клиент читает это глазами — + * фраза обязана читаться как обычное предложение. + * + * @var array + */ + private const SOOBSHCHENIYA = [ + 'budget_cap_rub.min' => 'Лимит на объявление не может быть меньше 1 ₽.', + 'budget_cap_rub.required' => 'Укажите лимит на объявление — сколько максимум потратить.', + 'ad_text.required' => 'Напишите текст объявления.', + 'ad_link.required' => 'Укажите ссылку, куда ведёт реклама.', + 'ad_link.url' => 'Ссылка должна быть полным адресом — начинаться с https://', + 'ad_headline.required' => 'Реклама ведёт на сайт — для неё нужен заголовок объявления (до 40 знаков).', + ]; + + /** + * Как поля зовутся ДЛЯ КЛИЕНТА. Дефект Ф-3 приёмки 06.08.2026: платящему клиенту + * показывали «Поле budget cap rub должно быть не меньше 1» — имя колонки базы, + * которого нет ни на одном экране. Клиент не программист: он не знает, что чинить. + * + * 🪤 Имена обязаны совпадать с подписями на экране мастера, а не быть «переводом + * названия колонки»: клиент ищет глазами то поле, которое ему назвали. + * + * @var array + */ + private const IMENA_POLEY = [ + 'ad_text' => 'Текст объявления', + 'ad_link' => 'Ссылка', + 'ad_headline' => 'Заголовок объявления', + 'audience_kind' => 'Кому показывать', + 'audience_days' => 'За сколько дней брать сделки', + 'budget_cap_rub' => 'Лимит на объявление', + 'ord_category' => 'Категория рекламы', + 'phones' => 'Список номеров', + 'phones.*' => 'Номер из списка', + ]; + /** * Нормализует и складывает номера списка в строки кампании. Нераспознанные — * молча пропускаем (клиент видит итоговое число кандидатов), но СЧИТАЕМ их — diff --git a/app/app/Models/ClientTg/Campaign.php b/app/app/Models/ClientTg/Campaign.php index 56324383..e56b59bd 100644 --- a/app/app/Models/ClientTg/Campaign.php +++ b/app/app/Models/ClientTg/Campaign.php @@ -7,6 +7,7 @@ namespace App\Models\ClientTg; use DomainException; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Carbon; /** * Кампания клиентской Telegram-рекламы «по своей базе» (сделки / база / список). @@ -20,6 +21,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; * Источник: миграция client_tg_campaigns. * * @property array|null $audience_params + * @property Carbon|null $starts_on * * @mixin IdeHelperCampaign */ @@ -144,6 +146,10 @@ class Campaign extends Model // Из неё считается клиентская цена (× наценка). Отдельно от actual_cost_rub, // где лежат КЛИЕНТСКИЕ рубли: смешать значит потерять наценку и сверку. 'mts_cost_rub', + // День, с которого кабинет МТС начнёт показы. Дат мы кабинету не задаём — он + // ставит свои; читает их робот вместе с вердиктом. Клиенту дату надо показать, + // иначе он ждёт рекламу сегодня, а она стартует завтра. + 'starts_on', 'actual_cost_rub', 'created_by', ]; @@ -159,6 +165,11 @@ class Campaign extends Model 'budget_cap_rub' => 'decimal:2', 'estimated_cost_rub' => 'decimal:2', 'mts_cost_rub' => 'decimal:2', + // 🪤 Именно 'date:Y-m-d', а не голое 'date': иначе наружу уезжает + // «2026-08-07T00:00:00.000000Z» — момент времени с полуночью по UTC, + // который экран в московском поясе легко покажет ПРЕДЫДУЩИМ днём. + // Кабинет знает только день; час мы не знаем и выдумывать его не будем. + 'starts_on' => 'date:Y-m-d', 'actual_cost_rub' => 'decimal:2', ]; } diff --git a/app/app/Services/ClientTg/RobotResult.php b/app/app/Services/ClientTg/RobotResult.php index 6e934998..55716795 100644 --- a/app/app/Services/ClientTg/RobotResult.php +++ b/app/app/Services/ClientTg/RobotResult.php @@ -19,6 +19,10 @@ namespace App\Services\ClientTg; * («Отклонена»), 'moderating' («На модерации» — ещё не проверено). null — * робот не читал/не смог прочитать вердикт (обычный запуск кампании). * + * startDate — день, с которого кабинет МТС начнёт показы («ГГГГ-ММ-ДД»), прочитанный + * роботом из строки кампании в списке. null — робот не читал/не смог прочитать/прислал + * мусор. Дат мы кабинету не задаём: он ставит свои, и клиенту их надо показать. + * * resubmitted — итог режима пересдачи (mode:'resubmit'): true, если робот * переотправил отклонённую кампанию на модерацию «без оплаты» (submitMode:'live'); * false для песочницы (draft — дошёл до подтверждения, не отправлял). Для обычного @@ -37,6 +41,7 @@ final class RobotResult public readonly ?string $moderationStatus = null, public readonly bool $resubmitted = false, public readonly bool $retryable = false, + public readonly ?string $startDate = null, ) {} /** @@ -61,9 +66,31 @@ final class RobotResult // Временный отказ (кабинет завис / ложное «вход слетел») — не приговор // кампании: канал вернёт задание в очередь, пока не кончились попытки. retryable: ! $ok && (bool) ($json['retryable'] ?? false), + // 🪤 Дата приходит из чужого кабинета через разбор строки — верить ей на + // слово нельзя. Всё, что не «ГГГГ-ММ-ДД» и не существует в календаре, + // считаем «даты нет»: колонка-дата на мусоре упадёт и утащит с собой + // сохранение вердикта, а вердикт и деньги важнее даты. + startDate: self::datuIliNichego($json['startDate'] ?? null), ); } + /** + * Дата вида «ГГГГ-ММ-ДД», реально существующая в календаре, либо null. + * + * Строгость нарочная: 31 февраля PHP молча переедет на 3 марта, и клиенту + * показали бы выдуманный день старта — а выглядело бы это правдой. + */ + private static function datuIliNichego(mixed $raw): ?string + { + if (! is_string($raw) || preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw) !== 1) { + return null; + } + + [$god, $mes, $den] = array_map('intval', explode('-', $raw)); + + return checkdate($mes, $den, $god) ? $raw : null; + } + /** * Обёрточный отказ (таймаут, непарсабельный вывод, сбой процесса). * $campaignId — id черновика кабинета, восстановленный из stderr-маркера робота diff --git a/app/app/Services/ClientTg/TelegramModerationVerdictApplier.php b/app/app/Services/ClientTg/TelegramModerationVerdictApplier.php index 6a1c5646..dda5ee0e 100644 --- a/app/app/Services/ClientTg/TelegramModerationVerdictApplier.php +++ b/app/app/Services/ClientTg/TelegramModerationVerdictApplier.php @@ -35,6 +35,12 @@ final class TelegramModerationVerdictApplier { public function apply(int $tenantId, int $campaignId, RobotResult $result, bool $sandbox): void { + // 🔴 Дату старта запоминаем ДО развилки по вердикту. Она нужна клиенту РАНЬШЕ + // вердикта — пока идёт модерация, «когда начнётся» и есть единственный + // осмысленный ответ. Стой это ниже, поле не заполнилось бы никогда: на + // 'moderating' метод выходит следующей же строкой. + $this->zapomniDatuStarta($tenantId, $campaignId, $result->startDate); + $verdict = $result->moderationStatus; // Ещё на модерации / робот не смог прочитать → ждём следующего цикла. @@ -118,6 +124,35 @@ final class TelegramModerationVerdictApplier } } + /** + * Запоминает день начала показов, прочитанный роботом из кабинета. + * + * 🪤 Даты нет — НЕ пишем ничего. Записать пустоту значило бы стереть уже известную + * клиенту дату всякий раз, когда кабинет показал строку без неё: экран замолчал бы + * ровно там, где раньше говорил. + * + * Сбой сохранения даты не имеет права помешать вердикту и деньгам — глотаем его + * здесь, но громко, в журнал. + */ + private function zapomniDatuStarta(int $tenantId, int $campaignId, ?string $startDate): void + { + if ($startDate === null) { + return; + } + + try { + $this->tenantTx($tenantId, function () use ($campaignId, $startDate): void { + Campaign::where('id', $campaignId)->update(['starts_on' => $startDate]); + }); + } catch (Throwable $e) { + Log::warning('client_tg.start_date_save_failed', [ + 'campaign_id' => $campaignId, + 'start_date' => $startDate, + 'error' => $e->getMessage(), + ]); + } + } + /** * Применяет вердикт `$result` к кампании под tenant-контекстом и возвращает * [терминальный статус, campaign, tenant] для пост-транзакционных действий, либо diff --git a/app/database/migrations/2026_08_06_000001_add_starts_on_to_client_tg_campaigns.php b/app/database/migrations/2026_08_06_000001_add_starts_on_to_client_tg_campaigns.php new file mode 100644 index 00000000..279bb6be --- /dev/null +++ b/app/database/migrations/2026_08_06_000001_add_starts_on_to_client_tg_campaigns.php @@ -0,0 +1,45 @@ +date('starts_on')->nullable()->after('mts_cost_rub'); + }); + } + + public function down(): void + { + Schema::table('client_tg_campaigns', function (Blueprint $table) { + $table->dropColumn('starts_on'); + }); + } +}; diff --git a/app/resources/js/api/telegram.ts b/app/resources/js/api/telegram.ts index 99d10bb7..e1888c10 100644 --- a/app/resources/js/api/telegram.ts +++ b/app/resources/js/api/telegram.ts @@ -32,6 +32,18 @@ export interface TelegramCampaign { planned_count: number; matched_count: number | null; estimated_cost_rub: string; + /** + * День начала показов «ГГГГ-ММ-ДД». Ставит его кабинет МТС (портал дат не задаёт), + * читает робот вместе с вердиктом модерации. null — робот ещё не сходил либо в + * строке кабинета даты не было. + */ + starts_on?: string | null; + /** + * Когда система последний раз ЗАКОНЧИЛА проверку этой кампании роботом. Признак + * жизни для клиента: без него кампания молча висит «На модерации», и работу от + * поломки не отличить (дефект Ф-2). null — проверок ещё не было. + */ + last_robot_check_at?: string | null; actual_cost_rub: string | null; /** null = авто-кампания (накопитель), иначе id создавшего менеджера. */ created_by?: number | null; diff --git a/app/resources/js/views/advertising/AdvertisingTelegramView.vue b/app/resources/js/views/advertising/AdvertisingTelegramView.vue index 333d243d..a8d2c54b 100644 --- a/app/resources/js/views/advertising/AdvertisingTelegramView.vue +++ b/app/resources/js/views/advertising/AdvertisingTelegramView.vue @@ -33,6 +33,7 @@ import TelegramAutoRulePanel from './TelegramAutoRulePanel.vue'; import TelegramCampaignWizard from './TelegramCampaignWizard.vue'; import { tgStatusMeta } from './telegramStatusLabels'; import { formatPlain } from '../../composables/billingFormatters'; +import { pluralRu } from '../../utils/plural'; import { PODSKAZKI } from '../../composables/podskazkiReklamy'; import HelpHint from '../../components/sales/HelpHint.vue'; import TelegramBalanceHeader from '../../components/advertising/TelegramBalanceHeader.vue'; @@ -71,6 +72,63 @@ function formatDate(iso: string): string { return new Date(iso).toLocaleDateString('ru-RU'); } +/** + * День начала показов «2026-08-07» → «7 августа». Пусто/битое → пустая строка, и + * тогда экран о дате МОЛЧИТ. + * + * 🔴 Зачем вообще. Дат кабинету МТС мы не задаём — он ставит свои. Живьём 06.08.2026 + * старт оказался ЗАВТРАШНИМ, а экран обещал показы «7 дней», подразумевая сегодня: + * клиент ждал рекламу в тот же день и не понимал, почему её нет. + * + * 🪤 Якорь `T00:00:00+03:00` обязателен. Голый `new Date('2026-08-07')` — это полночь + * по UTC, и в московском поясе он показал бы 7 августа как... 7-е только до трёх + * ночи. Кабинет знает ДЕНЬ, и днём он обязан остаться. + */ +function denNachala(iso: string | null | undefined): string { + if (typeof iso !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return ''; + const d = new Date(iso + 'T00:00:00+03:00'); + if (Number.isNaN(d.getTime())) return ''; + return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', timeZone: 'Europe/Moscow' }); +} + +/** + * «Когда последний раз проверяли» — по-человечески: «5 минут назад». + * + * 🔴 Дефект Ф-2 приёмки 06.08.2026. Кампания может неделями висеть «На модерации», и + * клиент не отличит работу от поломки. Именно в такой тишине владелец 36 часов не знал, + * что робот вообще не может войти в кабинет. + * + * 🪤 Дальше суток счёт в минутах и часах перестаёт что-либо значить («1500 минут назад» + * не читается) — показываем день и час. Пусто → пустая строка, и экран молчит: выдуманное + * «только что» было бы враньём ровно там, где клиент проверяет, живы ли мы. + */ +function kogdaProveryali(iso: string | null | undefined): string { + if (typeof iso !== 'string' || iso === '') return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + + const sekund = Math.floor((Date.now() - d.getTime()) / 1000); + if (sekund < 0) return ''; + // 🪤 Порог был 90 секунд с округлением минут — и фраза «1 минуту назад» не могла + // появиться НИКОГДА: 90 секунд округлялись сразу в две минуты. Ровная минута и + // округление вниз дают все значения без дыр. + if (sekund < 60) return 'только что'; + + const minut = Math.floor(sekund / 60); + if (minut < 60) return `${minut} ${pluralRu(minut, 'минуту', 'минуты', 'минут')} назад`; + + const chasov = Math.floor(minut / 60); + if (chasov < 24) return `${chasov} ${pluralRu(chasov, 'час', 'часа', 'часов')} назад`; + + return d.toLocaleString('ru-RU', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + timeZone: 'Europe/Moscow', + }); +} + /** «Новая реклама» с пустого списка кампаний — как кнопка на экране Яндекса. */ function goNewCampaign(): void { tab.value = 'new'; @@ -317,6 +375,31 @@ defineExpose({ tab }); Идёт проверка модерацией МТС — обычно занимает около 4 часов. + +
+ Показы начнутся {{ denNachala(c.starts_on) }} +
+ + +
+ Проверяли {{ kogdaProveryali(c.last_robot_check_at) }} +
+ !submitting.value && audienciyaGotova.value && obyavlenieGotovo.value && budgetCap.value.trim() !== '', ); +/** + * Почему «Запустить» серая. Пустая строка — можно запускать. + * + * 🔴 Дефект Ф-3 приёмки 06.08.2026: кнопка просто гасла. Причина («реклама ведёт на + * сайт — нужен заголовок») показывалась шагом раньше, а на шаге проверки не + * повторялась: клиент видел мёртвую кнопку и не знал, что чинить. + * + * Называем и беду, и ШАГ, куда вернуться: искать по мастеру наугад клиент не обязан. + */ +const pochemuNelzya = computed(() => { + if (!audienciyaGotova.value) return 'Вернитесь на шаг «Кому показывать» — аудитория ещё не выбрана.'; + if (adText.value.trim() === '') return 'Вернитесь на шаг «Объявление» — не написан текст объявления.'; + if (adLink.value.trim() === '') return 'Вернитесь на шаг «Объявление» — не указана ссылка, куда ведёт реклама.'; + if (nuzhenZagolovok.value && adHeadline.value.trim() === '') { + return 'Вернитесь на шаг «Объявление» — реклама ведёт на сайт, а такой нужен заголовок (до 40 знаков).'; + } + if (budgetCap.value.trim() === '') return 'Вернитесь на шаг «Сколько тратить» — не указан лимит на объявление.'; + return ''; +}); + function buildPayload(): TelegramCreatePayload { const payload: TelegramCreatePayload = { ad_text: adText.value.trim(), @@ -695,10 +715,24 @@ defineExpose({ step, obnovitOhvat }); строку править вместе с разведкой, а не по памяти. --> + > + + 7 дней, каждый человек увидит объявление один раз + + +
+ День начала ставит кабинет МТС — обычно ближайший + свободный. Точную дату покажем в списке кампаний, + как только реклама уйдёт на проверку. +
+
Людей меньше {{ ocenka.min_count }} — вернитесь на первый шаг и расширьте аудиторию. + + + + {{ pochemuNelzya }} + diff --git a/app/tests/Feature/ClientTg/ChelovecheskiyYazykOshibokTest.php b/app/tests/Feature/ClientTg/ChelovecheskiyYazykOshibokTest.php new file mode 100644 index 00000000..e182fe0b --- /dev/null +++ b/app/tests/Feature/ClientTg/ChelovecheskiyYazykOshibokTest.php @@ -0,0 +1,65 @@ +tenant = Tenant::factory()->create(); + $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); + $this->actingAs($this->user); +}); + +/** @return array */ +function tgPolyaKampanii(array $overrides = []): array +{ + return array_merge([ + 'ad_text' => 'Приходите к нам в канал', + 'ad_link' => 'https://t.me/example_channel', + 'audience_kind' => 'list', + 'budget_cap_rub' => '1000.00', + 'phones' => ['79990000001', '79990000002'], + ], $overrides); +} + +it('лимит на объявление называется по-человечески, а не budget cap rub', function () { + $response = $this->postJson('/api/telegram/campaigns', tgPolyaKampanii(['budget_cap_rub' => '-5'])); + + $response->assertStatus(422); + // 🪤 Читаем РАЗОБРАННЫЙ ответ, а не сырое тело: русские буквы уезжают в \u-коды, + // и сторож по сырой строке искал бы то, чего в ней физически нет. + $soobshchenie = (string) ($response->json('errors.budget_cap_rub.0') ?? ''); + + expect($soobshchenie)->not->toContain('budget cap rub'); + expect($soobshchenie)->toContain('Лимит на объявление'); + // Фраза должна читаться по-русски, а не «Поле Лимит на объявление должно быть…». + expect($soobshchenie)->toContain('не может быть меньше'); +}); + +it('ни одно имя поля из базы не выходит наружу в ошибках формы', function (string $pole, mixed $durnoe) { + $response = $this->postJson('/api/telegram/campaigns', tgPolyaKampanii([$pole => $durnoe])); + + $response->assertStatus(422); + // Имя поля пишется в базе через подчёркивания, а Laravel показывает его пробелами — + // ловим именно показанный вид, иначе сторож смотрит не туда. + expect((string) $response->getContent())->not->toContain(str_replace('_', ' ', $pole)); +})->with([ + 'лимит на объявление' => ['budget_cap_rub', '-5'], + 'текст объявления' => ['ad_text', ''], + 'ссылка объявления' => ['ad_link', 'не-ссылка'], + 'вид аудитории' => ['audience_kind', 'чепуха'], + 'глубина по сделкам' => ['audience_days', 'позавчера'], +]); diff --git a/app/tests/Feature/ClientTg/DataStartaTest.php b/app/tests/Feature/ClientTg/DataStartaTest.php new file mode 100644 index 00000000..62490672 --- /dev/null +++ b/app/tests/Feature/ClientTg/DataStartaTest.php @@ -0,0 +1,144 @@ +create([ + 'tenant_id' => $tenantId, + 'status' => Campaign::STATUS_MODERATING, + 'ad_text' => 'Приходите к нам за услугой', + 'ad_link' => 'https://example.test/promo', + 'ord_category' => 'Размещение рекламы', + 'budget_cap_rub' => '500.00', + 'audience_kind' => Campaign::AUDIENCE_LIST, + 'planned_count' => 2, + 'estimated_cost_rub' => '315.00', + 'mts_campaign_id' => '2237821', + 'created_by' => 1, + ]); +} + +beforeEach(function () { + config()->set('client_tg.sandbox', true); + $this->tenant = Tenant::factory()->create(['balance_rub' => '1000.00']); + DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id); + $this->campaign = kampaniyaSDatoy($this->tenant->id); +}); + +it('дата старта сохраняется, ПОКА кампания ещё на модерации', function () { + app(TelegramModerationVerdictApplier::class)->apply( + $this->tenant->id, + $this->campaign->id, + RobotResult::fromRobotJson([ + 'ok' => true, + 'moderationStatus' => 'moderating', + 'startDate' => '2026-08-07', + ]), + true, + ); + + $fresh = Campaign::find($this->campaign->id); + expect($fresh->starts_on?->toDateString())->toBe('2026-08-07'); + // Вердикта нет — статус трогать не смеем. + expect($fresh->status)->toBe(Campaign::STATUS_MODERATING); +}); + +it('дата сохраняется и вместе с одобрением', function () { + app(TelegramModerationVerdictApplier::class)->apply( + $this->tenant->id, + $this->campaign->id, + RobotResult::fromRobotJson([ + 'ok' => true, + 'moderationStatus' => 'approved', + 'startDate' => '2026-08-07', + ]), + true, + ); + + $fresh = Campaign::find($this->campaign->id); + expect($fresh->starts_on?->toDateString())->toBe('2026-08-07'); + expect($fresh->status)->toBe(Campaign::STATUS_LAUNCHED); +}); + +// 🪤 Робот дату читает «как получится»: кабинет мог показать строку без даты. Записать +// в этом случае пустоту значило бы стереть уже известную клиенту дату и превратить +// экран в молчащий — хуже, чем было. +it('робот не прислал дату — прежнюю не затираем', function () { + $this->campaign->starts_on = '2026-08-07'; + $this->campaign->save(); + + app(TelegramModerationVerdictApplier::class)->apply( + $this->tenant->id, + $this->campaign->id, + RobotResult::fromRobotJson(['ok' => true, 'moderationStatus' => 'moderating']), + true, + ); + + expect(Campaign::find($this->campaign->id)->starts_on?->toDateString())->toBe('2026-08-07'); +}); + +// 🪤 Мусор вместо даты отсекается САМИМ разбором отчёта, а не тем, что ниже по течению +// его проглотит защита. Первая версия этого теста смотрела на колонку в базе и была +// ЛОЖНЫМ сторожем: вырезание проверки её не покрасило — мусор до базы доезжал, база +// ругалась, а ошибку глотал try/catch применения вердикта. Спрашиваем прямо у разбора. +it('мусор вместо даты отсекается разбором отчёта робота', function (mixed $syryo) { + expect(RobotResult::fromRobotJson([ + 'ok' => true, + 'moderationStatus' => 'moderating', + 'startDate' => $syryo, + ])->startDate)->toBeNull(); +})->with([ + 'слово вместо даты' => 'завтра', + 'наш формат наизнанку' => '07.08.2026', + 'дня такого не бывает' => '2026-02-31', + 'месяца такого не бывает' => '2026-13-01', + 'хвост после даты' => '2026-08-07 09:00', + 'не строка вовсе' => 20260807, +]); + +it('правильная дата разбор проходит', function () { + expect(RobotResult::fromRobotJson([ + 'ok' => true, + 'moderationStatus' => 'moderating', + 'startDate' => '2026-08-07', + ])->startDate)->toBe('2026-08-07'); +}); + +it('клиент видит дату старта в списке своих кампаний', function () { + $this->campaign->starts_on = '2026-08-07'; + $this->campaign->save(); + + $user = User::factory()->create(['tenant_id' => $this->tenant->id]); + + $this->actingAs($user) + ->getJson('/api/telegram/campaigns') + ->assertOk() + ->assertJsonPath('campaigns.0.starts_on', '2026-08-07'); +}); diff --git a/app/tests/Feature/ClientTg/ZhivaLiSistemaTest.php b/app/tests/Feature/ClientTg/ZhivaLiSistemaTest.php new file mode 100644 index 00000000..338c0083 --- /dev/null +++ b/app/tests/Feature/ClientTg/ZhivaLiSistemaTest.php @@ -0,0 +1,154 @@ +tenant = Tenant::factory()->create(); + $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); + DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id); + $this->actingAs($this->user); +}); + +function kampaniyaNaModeracii(int $tenantId): Campaign +{ + return Campaign::query()->create([ + 'tenant_id' => $tenantId, + 'status' => Campaign::STATUS_MODERATING, + 'ad_text' => 'Приходите к нам', + 'ad_link' => 'https://t.me/x', + 'ord_category' => 'Размещение рекламы', + 'budget_cap_rub' => '500.00', + 'audience_kind' => Campaign::AUDIENCE_LIST, + 'planned_count' => 400, + 'estimated_cost_rub' => '268.80', + 'mts_campaign_id' => '2237821', + 'created_by' => 1, + ]); +} + +it('клиент видит время последней ЗАКОНЧЕННОЙ проверки робота', function () { + $campaign = kampaniyaNaModeracii($this->tenant->id); + + RobotJob::query()->create([ + 'tenant_id' => $this->tenant->id, + 'campaign_id' => $campaign->id, + 'mode' => RobotJob::MODE_READ_STATUS, + 'payload' => ['mode' => 'read-status'], + 'status' => RobotJob::STATUS_DONE, + 'finished_at' => '2026-08-06 14:31:00', + ]); + + $this->getJson('/api/telegram/campaigns') + ->assertOk() + ->assertJsonPath('campaigns.0.last_robot_check_at', '2026-08-06T14:31:00.000000Z'); +}); + +// 🪤 Провалившаяся проверка — ТОЖЕ проверка: система живая, просто кабинет не ответил. +// Показывать только успешные значило бы молчать ровно в тот момент, когда что-то не так. +it('неудачная проверка тоже считается — система работала', function () { + $campaign = kampaniyaNaModeracii($this->tenant->id); + + RobotJob::query()->create([ + 'tenant_id' => $this->tenant->id, + 'campaign_id' => $campaign->id, + 'mode' => RobotJob::MODE_READ_STATUS, + 'payload' => ['mode' => 'read-status'], + 'status' => RobotJob::STATUS_FAILED, + 'finished_at' => '2026-08-06 15:00:00', + 'failure_reason' => 'Список кампаний не отрисовался', + ]); + + $this->getJson('/api/telegram/campaigns') + ->assertOk() + ->assertJsonPath('campaigns.0.last_robot_check_at', '2026-08-06T15:00:00.000000Z'); +}); + +// 🪤 Задание в очереди — ещё НЕ проверка. Показать по нему «проверяли» значило бы +// обещать работу, которой не было: именно так выглядели 36 часов молчания. +it('задание только поставлено в очередь — это ещё не проверка', function () { + $campaign = kampaniyaNaModeracii($this->tenant->id); + + RobotJob::query()->create([ + 'tenant_id' => $this->tenant->id, + 'campaign_id' => $campaign->id, + 'mode' => RobotJob::MODE_READ_STATUS, + 'payload' => ['mode' => 'read-status'], + 'status' => RobotJob::STATUS_QUEUED, + 'finished_at' => null, + ]); + + $this->getJson('/api/telegram/campaigns') + ->assertOk() + ->assertJsonPath('campaigns.0.last_robot_check_at', null); +}); + +it('из нескольких проверок показывается САМАЯ СВЕЖАЯ', function () { + $campaign = kampaniyaNaModeracii($this->tenant->id); + + foreach (['2026-08-06 12:00:00', '2026-08-06 16:45:00', '2026-08-06 14:00:00'] as $kogda) { + RobotJob::query()->create([ + 'tenant_id' => $this->tenant->id, + 'campaign_id' => $campaign->id, + 'mode' => RobotJob::MODE_READ_STATUS, + 'payload' => ['mode' => 'read-status'], + 'status' => RobotJob::STATUS_DONE, + 'finished_at' => $kogda, + ]); + } + + $this->getJson('/api/telegram/campaigns') + ->assertOk() + ->assertJsonPath('campaigns.0.last_robot_check_at', '2026-08-06T16:45:00.000000Z'); +}); + +it('робота ещё не звали — поле пустое, а не выдуманное', function () { + kampaniyaNaModeracii($this->tenant->id); + + $this->getJson('/api/telegram/campaigns') + ->assertOk() + ->assertJsonPath('campaigns.0.last_robot_check_at', null); +}); + +// 🔴 Чужие проверки в свою карточку не пускаем: это тот же класс дыры, что и чужие +// кампании. Время чужой работы — не наше дело и не наш признак живости. +it('проверка ЧУЖОЙ кампании в карточку не попадает', function () { + $moya = kampaniyaNaModeracii($this->tenant->id); + + $chuzhoy = Tenant::factory()->create(); + DB::statement('SET LOCAL app.current_tenant_id = '.$chuzhoy->id); + $chuzhaya = kampaniyaNaModeracii($chuzhoy->id); + RobotJob::query()->create([ + 'tenant_id' => $chuzhoy->id, + 'campaign_id' => $chuzhaya->id, + 'mode' => RobotJob::MODE_READ_STATUS, + 'payload' => ['mode' => 'read-status'], + 'status' => RobotJob::STATUS_DONE, + 'finished_at' => '2026-08-06 23:59:00', + ]); + DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id); + + $this->getJson('/api/telegram/campaigns') + ->assertOk() + ->assertJsonPath('campaigns.0.id', $moya->id) + ->assertJsonPath('campaigns.0.last_robot_check_at', null); +}); diff --git a/app/tests/Frontend/telegram-data-starta.spec.ts b/app/tests/Frontend/telegram-data-starta.spec.ts new file mode 100644 index 00000000..9ff06c7f --- /dev/null +++ b/app/tests/Frontend/telegram-data-starta.spec.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; + +/** + * Дата старта показов на экране клиента. + * + * 🔴 Откуда взялось. Портал не передаёт кабинету МТС ни одной даты — их ставит сам + * кабинет своими умолчаниями. Живьём 06.08.2026 (кампания 2237821) старт оказался + * ЗАВТРАШНИМ, а экран обещал показы «7 дней», подразумевая сегодня. Клиент ждёт + * рекламу сегодня, её нет, и объяснить нечем. + * + * Дату теперь привозит робот вместе с вердиктом модерации. Экран обязан её показать — + * и обязан МОЛЧАТЬ, пока даты нет: выдуманная дата хуже отсутствующей. + */ +vi.mock('vue-router', () => ({ + useRouter: vi.fn(() => ({ push: vi.fn() })), +})); + +const { fetchTelegramMock } = vi.hoisted(() => ({ + fetchTelegramMock: vi.fn(), +})); + +vi.mock('../../resources/js/api/telegram', () => ({ + fetchTelegram: fetchTelegramMock, + createTelegram: vi.fn(), + fetchTelegramCampaign: vi.fn(() => Promise.resolve({ campaign: { id: 1 } })), + launchTelegram: vi.fn(), + resubmitTelegram: vi.fn(), + uploadTelegramMedia: vi.fn(), + estimateTelegram: vi.fn(() => + Promise.resolve({ planned_count: 512, estimated_cost_rub: '430.08', min_count: 367, enough: true }), + ), + uploadAutoRuleMedia: vi.fn(), + MEDIA_ACCEPT: 'image/jpeg,image/png,video/mp4', + MEDIA_TREBOVANIYA: 'Картинка: JPEG или PNG, до 25 МБ, от 640×360 до 5120×2880.', + MEDIA_CENA: 'С картинкой показ стоит дороже: 600 ₽ за тысячу, с видео — 680 ₽.', + fetchAutoRule: vi.fn(() => + Promise.resolve({ + enabled: false, + ad_text: '', + ad_link: '', + ad_headline: '', + media_path: null, + ord_category: 'Размещение рекламы', + budget_cap_rub: '0.00', + daily_limit_rub: '0.00', + batch_threshold: null, + }), + ), + saveAutoRule: vi.fn(), +})); + +import AdvertisingTelegramView from '../../resources/js/views/advertising/AdvertisingTelegramView.vue'; + +function otvet(kampaniya: Record) { + return { + campaigns: [kampaniya], + sandbox: false, + balance_rub: '1000.00', + frozen_rub: '0.00', + }; +} + +const BAZA = { + id: 40, + ad_text: 'Приходите к нам', + ad_link: 'https://t.me/x', + planned_count: 400, + estimated_cost_rub: '268.80', +}; + +function smontirovat() { + return mount(AdvertisingTelegramView, { global: { plugins: [createVuetify()] } }); +} + +describe('дата старта показов на экране клиента', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('пока идёт модерация, видно с какого дня пойдут показы', async () => { + fetchTelegramMock.mockResolvedValue( + otvet({ ...BAZA, status: 'moderating', starts_on: '2026-08-07' }), + ); + + const wrapper = smontirovat(); + await flushPromises(); + + const stroka = wrapper.find('[data-testid="tg-start-date-40"]'); + expect(stroka.exists()).toBe(true); + // По-человечески, а не «2026-08-07»: заказчик не программист. + expect(stroka.text()).toContain('7 августа'); + }); + + it('у запущенной кампании дата тоже видна', async () => { + fetchTelegramMock.mockResolvedValue( + otvet({ ...BAZA, status: 'launched', starts_on: '2026-12-31' }), + ); + + const wrapper = smontirovat(); + await flushPromises(); + + expect(wrapper.find('[data-testid="tg-start-date-40"]').text()).toContain('31 декабря'); + }); + + // 🪤 Робот ещё не сходил в кабинет — даты нет. Показать вместо неё «сегодня» или + // пустое место со словом «начнётся» значило бы соврать клиенту. + it('даты нет — строки нет вовсе, ничего не выдумываем', async () => { + fetchTelegramMock.mockResolvedValue( + otvet({ ...BAZA, status: 'moderating', starts_on: null }), + ); + + const wrapper = smontirovat(); + await flushPromises(); + + expect(wrapper.find('[data-testid="tg-start-date-40"]').exists()).toBe(false); + // Подсказка про модерацию при этом никуда не делась. + expect(wrapper.find('[data-testid="tg-moderating-hint-40"]').exists()).toBe(true); + }); + + // 🪤 Старое поле могло приехать битым (кабинет сменил формат, чужая правка). + // Битую дату не показываем — но и экран из-за неё не роняем. + it('битая дата не показывается и экран не роняет', async () => { + fetchTelegramMock.mockResolvedValue( + otvet({ ...BAZA, status: 'moderating', starts_on: 'завтра' }), + ); + + const wrapper = smontirovat(); + await flushPromises(); + + expect(wrapper.find('[data-testid="tg-start-date-40"]').exists()).toBe(false); + }); +}); diff --git a/app/tests/Frontend/telegram-seraya-knopka.spec.ts b/app/tests/Frontend/telegram-seraya-knopka.spec.ts new file mode 100644 index 00000000..6369d550 --- /dev/null +++ b/app/tests/Frontend/telegram-seraya-knopka.spec.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; + +/** + * Дефект Ф-3 (вторая половина) приёмки 06.08.2026 — серая кнопка молчит. + * + * 🔴 На последнем шаге мастера кнопка «Запустить» просто серая. Почему — не сказано + * нигде: причина («реклама ведёт на сайт, нужен заголовок») показана шагом раньше, а на + * шаге проверки не повторяется. Клиент видит мёртвую кнопку и не знает, что чинить. + * + * Правило: кнопка серая — рядом обязана стоять причина, и она обязана называть тот шаг, + * куда возвращаться. + */ +vi.mock('vue-router', () => ({ + useRouter: vi.fn(() => ({ push: vi.fn() })), +})); + +const { estimateMock } = vi.hoisted(() => ({ estimateMock: vi.fn() })); + +vi.mock('../../resources/js/api/telegram', () => ({ + estimateTelegram: estimateMock, + createTelegram: vi.fn(), + launchTelegram: vi.fn(), + uploadTelegramMedia: vi.fn(), + fetchTelegram: vi.fn(), + fetchAutoRule: vi.fn(), + resubmitTelegram: vi.fn(), + uploadAutoRuleMedia: vi.fn(), + saveAutoRule: vi.fn(), + MEDIA_ACCEPT: 'image/jpeg,image/png,video/mp4', + MEDIA_TREBOVANIYA: 'Картинка: JPEG или PNG, до 25 МБ, от 640×360 до 5120×2880.', + MEDIA_CENA: 'С картинкой показ стоит дороже: 600 ₽ за тысячу, с видео — 680 ₽.', +})); + +import TelegramCampaignWizard from '../../resources/js/views/advertising/TelegramCampaignWizard.vue'; + +type Wrapper = ReturnType; + +const HVATIT = { planned_count: 512, estimated_cost_rub: '430.08', min_count: 367, enough: true }; + +function smontirovat(): Wrapper { + return mount(TelegramCampaignWizard, { + props: { balanceRub: '5000.00' }, + global: { plugins: [createVuetify()] }, + }); +} + +/** Заполняет поля мастера и уводит на шаг «Проверка и отправка». */ +async function naProverku(polya: Record): Promise { + const w = smontirovat(); + Object.assign(w.vm as unknown as Record, { step: 4, ...polya }); + await flushPromises(); + return w; +} + +describe('серая кнопка «Запустить» обязана объяснять причину', () => { + beforeEach(() => { + vi.clearAllMocks(); + estimateMock.mockResolvedValue(HVATIT); + }); + + it('всё заполнено — кнопка живая и жалоб нет', async () => { + const w = await naProverku({ + adText: 'Приходите к нам', + adLink: 'https://t.me/example', + budgetCap: '1000', + }); + + expect(w.find('[data-testid="tg-launch"]').attributes('disabled')).toBeUndefined(); + expect(w.find('[data-testid="tg-pochemu-nelzya"]').exists()).toBe(false); + }); + + // 🔴 Тот самый случай из приёмки: реклама ведёт на сайт, заголовка нет. + it('реклама на сайт без заголовка — сказано про заголовок и про шаг', async () => { + const w = await naProverku({ + adText: 'Приходите к нам', + adLink: 'https://liderra.ru/promo', + adHeadline: '', + budgetCap: '1000', + }); + + const zhaloba = w.find('[data-testid="tg-pochemu-nelzya"]'); + expect(zhaloba.exists()).toBe(true); + expect(zhaloba.text()).toContain('заголовок'); + expect(zhaloba.text().toLowerCase()).toContain('объявление'); + }); + + it('нет текста объявления — сказано про текст', async () => { + const w = await naProverku({ + adText: '', + adLink: 'https://t.me/example', + budgetCap: '1000', + }); + + expect(w.find('[data-testid="tg-pochemu-nelzya"]').text().toLowerCase()).toContain('текст'); + }); + + it('не указан лимит на объявление — сказано про лимит', async () => { + const w = await naProverku({ + adText: 'Приходите к нам', + adLink: 'https://t.me/example', + budgetCap: '', + }); + + expect(w.find('[data-testid="tg-pochemu-nelzya"]').text().toLowerCase()).toContain('лимит'); + }); +}); diff --git a/app/tests/Frontend/telegram-srok-pokaza.spec.ts b/app/tests/Frontend/telegram-srok-pokaza.spec.ts index f12c8714..c5543406 100644 --- a/app/tests/Frontend/telegram-srok-pokaza.spec.ts +++ b/app/tests/Frontend/telegram-srok-pokaza.spec.ts @@ -83,6 +83,18 @@ describe('срок показа в сводке телеграм-кампани expect(w.find('[data-testid="tg-svodka-srok"]').text()).toContain('один раз'); }); + // 🔴 Живой прогон 06.08.2026: показы начались НЕ в день отправки, а на следующий — + // день старта ставит кабинет МТС, портал дат ему не задаёт. Прежняя строка молчала + // об этом, и клиент честно ждал рекламу сегодня. Молчание тут и есть неправда. + it('сказано, что день начала ставит кабинет, а не сегодняшний день', async () => { + const w = smontirovat(); + await naShag(w, 4); + + const srok = w.find('[data-testid="tg-svodka-srok"]').text(); + expect(srok.toLowerCase()).toContain('день начала ставит кабинет'); + expect(srok).toContain('МТС'); + }); + it('на шаге выбора аудитории срока ещё нет — он относится к отправке', async () => { const w = smontirovat(); await naShag(w, 1); diff --git a/app/tests/Frontend/telegram-zhiva-li-sistema.spec.ts b/app/tests/Frontend/telegram-zhiva-li-sistema.spec.ts new file mode 100644 index 00000000..3791f662 --- /dev/null +++ b/app/tests/Frontend/telegram-zhiva-li-sistema.spec.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; + +/** + * Дефект Ф-2 приёмки 06.08.2026, карточка Т-Ф4 — не видно, жива ли система. + * + * 🔴 Кампания может неделями висеть «На модерации», и клиент не отличит работу от + * поломки. В такой тишине владелец 36 часов не знал, что робот не может войти в кабинет. + * + * Показываем время последней проверки — и только у кампаний, которые ещё в движении: + * у законченной кампании это была бы бессмысленная строка. + */ +vi.mock('vue-router', () => ({ + useRouter: vi.fn(() => ({ push: vi.fn() })), +})); + +const { fetchTelegramMock } = vi.hoisted(() => ({ fetchTelegramMock: vi.fn() })); + +vi.mock('../../resources/js/api/telegram', () => ({ + fetchTelegram: fetchTelegramMock, + createTelegram: vi.fn(), + fetchTelegramCampaign: vi.fn(() => Promise.resolve({ campaign: { id: 1 } })), + launchTelegram: vi.fn(), + resubmitTelegram: vi.fn(), + uploadTelegramMedia: vi.fn(), + estimateTelegram: vi.fn(() => + Promise.resolve({ planned_count: 512, estimated_cost_rub: '430.08', min_count: 367, enough: true }), + ), + uploadAutoRuleMedia: vi.fn(), + MEDIA_ACCEPT: 'image/jpeg,image/png,video/mp4', + MEDIA_TREBOVANIYA: 'Картинка: JPEG или PNG, до 25 МБ, от 640×360 до 5120×2880.', + MEDIA_CENA: 'С картинкой показ стоит дороже: 600 ₽ за тысячу, с видео — 680 ₽.', + fetchAutoRule: vi.fn(() => + Promise.resolve({ + enabled: false, + ad_text: '', + ad_link: '', + ad_headline: '', + media_path: null, + ord_category: 'Размещение рекламы', + budget_cap_rub: '0.00', + daily_limit_rub: '0.00', + batch_threshold: null, + }), + ), + saveAutoRule: vi.fn(), +})); + +import AdvertisingTelegramView from '../../resources/js/views/advertising/AdvertisingTelegramView.vue'; + +const BAZA = { + id: 50, + ad_text: 'Приходите к нам', + ad_link: 'https://t.me/x', + planned_count: 400, + estimated_cost_rub: '268.80', +}; + +/** «Сейчас» замораживаем: иначе «5 минут назад» через час станет «час назад». */ +const SEYCHAS = new Date('2026-08-06T15:00:00+03:00'); + +function otvet(kampaniya: Record) { + return { campaigns: [kampaniya], sandbox: false, balance_rub: '1000.00', frozen_rub: '0.00' }; +} + +function smontirovat() { + return mount(AdvertisingTelegramView, { global: { plugins: [createVuetify()] } }); +} + +describe('видно, что система жива', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(SEYCHAS); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('на модерации видно, когда проверяли последний раз', async () => { + fetchTelegramMock.mockResolvedValue( + otvet({ ...BAZA, status: 'moderating', last_robot_check_at: '2026-08-06T14:55:00+03:00' }), + ); + + const w = smontirovat(); + await flushPromises(); + + const stroka = w.find('[data-testid="tg-posledniaya-proverka-50"]'); + expect(stroka.exists()).toBe(true); + expect(stroka.text()).toContain('5 минут назад'); + }); + + it('склонение по-русски, а не «1 минут назад»', async () => { + fetchTelegramMock.mockResolvedValue( + otvet({ ...BAZA, status: 'moderating', last_robot_check_at: '2026-08-06T14:59:00+03:00' }), + ); + + const w = smontirovat(); + await flushPromises(); + + expect(w.find('[data-testid="tg-posledniaya-proverka-50"]').text()).toContain('1 минуту назад'); + }); + + it('давняя проверка показывается днём и часом, а не «1500 минут назад»', async () => { + fetchTelegramMock.mockResolvedValue( + otvet({ ...BAZA, status: 'moderating', last_robot_check_at: '2026-08-04T09:12:00+03:00' }), + ); + + const w = smontirovat(); + await flushPromises(); + + const tekst = w.find('[data-testid="tg-posledniaya-proverka-50"]').text(); + expect(tekst).toContain('04.08'); + expect(tekst).toContain('09:12'); + }); + + // 🪤 Робота ещё не звали — молчим. «Проверяли никогда» клиента только напугает, + // а выдуманное «только что» было бы враньём. + it('проверок ещё не было — строки нет', async () => { + fetchTelegramMock.mockResolvedValue( + otvet({ ...BAZA, status: 'moderating', last_robot_check_at: null }), + ); + + const w = smontirovat(); + await flushPromises(); + + expect(w.find('[data-testid="tg-posledniaya-proverka-50"]').exists()).toBe(false); + }); + + // У законченной кампании проверять уже нечего — строка была бы шумом. + it('у законченной кампании строки нет', async () => { + fetchTelegramMock.mockResolvedValue( + otvet({ ...BAZA, status: 'rejected', last_robot_check_at: '2026-08-06T14:55:00+03:00' }), + ); + + const w = smontirovat(); + await flushPromises(); + + expect(w.find('[data-testid="tg-posledniaya-proverka-50"]').exists()).toBe(false); + }); +}); diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index 659369bd..48072f94 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -16,6 +16,35 @@ > При сведении обе подвинуты на **v9.29** и **v9.30**; боевая v9.28 осталась на своём номере. > Содержание не менялось, внутренние ссылки между этими двумя записями поправлены. +## v9.72 (2026-08-06) — Telegram-модуль: день начала показов (client_tg_campaigns.starts_on) + +🔴 **Портал не знал, когда начнётся реклама, которую продал.** Дат кабинету МТС он не передаёт +ни одной — их ставит сам кабинет своими умолчаниями, и до сих пор ни одна колонка о них не знала. +Живой прогон 06.08.2026 (кампания МТС `2237821`) показал: старт оказался **завтрашним**, тогда как +экран обещал клиенту показы «7 дней», подразумевая сегодня. Клиент ждал рекламу в тот же день, +её не было, и объяснить это было нечем. + +- **Миграция** `app/database/migrations/2026_08_06_000001_add_starts_on_to_client_tg_campaigns.php`: + - `starts_on DATE NULL` после `mts_cost_rub` — день, с которого кабинет начнёт показы; + - `down` дропает колонку. +- **Кто заполняет:** робот в режиме `read-status`. Дата стоит в начале строки кампании в списке + кабинета и приезжает вместе с вердиктом модерации — **ни одного лишнего захода** в кабинет + (МТС отбивается по частоте, см. отчёт приёмки 06.08). +- **Почему `date`, а не `timestamp`:** в списке кабинета показан ДЕНЬ («07.08»), часа там нет. + Хранить час, которого мы не знаем, значит выдумывать точность. +- **NULL допустим намеренно:** пока робот не сходил (или строка кабинета без даты) — пусто, и + экран о дате молчит. Пустая дата НЕ затирает уже известную: кабинет может показать строку без + даты, и запись пустоты превратила бы говорящий экран в молчащий. +- **Мусор до колонки не доезжает:** отчёт робота проверяется на «ГГГГ-ММ-ДД» + `checkdate` + (`RobotResult`), иначе 31 февраля молча переехало бы на 3 марта и клиенту показали бы + выдуманный день. Сбой сохранения даты не имеет права помешать вердикту и деньгам — он + проглатывается с записью в журнал (`client_tg.start_date_save_failed`). +- **RLS и GRANT не меняются:** колонка добавлена в существующую таблицу, политика + `tenant_isolation` задана миграцией `_create_client_tg_campaigns`; в PostgreSQL табличный GRANT + распространяется и на новые колонки — ре-GRANT не нужен. +- **`schema.sql` не трогается:** таблиц `client_tg_*` в его теле нет (проверено `grep` — ноль + совпадений), их DDL живёт только в дельта-миграциях. Счётчики в header не менялись. + ## v9.71 (2026-08-06) — Канон перестал обещать охранника, которого нет: правда про `processing_restricted` **Только текст. Структура базы не тронута:** ни столбцов, ни указателей, ни таблиц, diff --git a/docs/superpowers/2026-08-06-PROMT-telegram-smena-4.md b/docs/superpowers/2026-08-06-PROMT-telegram-smena-4.md new file mode 100644 index 00000000..da3f85da --- /dev/null +++ b/docs/superpowers/2026-08-06-PROMT-telegram-smena-4.md @@ -0,0 +1,249 @@ +# Промт смене 4: выкатить сделанное и добить замечания приёмки телеграма + +Поставлен 06.08.2026, вечер. Всё ниже — замеры командами этой смены, а не память. + +Предыдущий промт: [2026-08-06-PROMT-telegram-smena-3.md](2026-08-06-PROMT-telegram-smena-3.md) +Ранбук приёмки (карточки): [runbooks/2026-08-03-acceptance-telegram-reklama.md](runbooks/2026-08-03-acceptance-telegram-reklama.md) +Отчёт прогона: [runbooks/2026-08-06-rezultaty-priyomki-telegram.md](runbooks/2026-08-06-rezultaty-priyomki-telegram.md) + +--- + +## 🔴🔴🔴 Первое, что надо знать + +**Телеграм-реклама на ЖИВЫХ ДЕНЬГАХ.** Песочница на боевом выключена. Рекламный кошелёк +клиента `info@lkomega.ru` (tenant 2, кабинет `https://lk.liderra.ru`) — **общий на три +канала**: Яндекс, СМС, телеграм. В нём три живые заморозки Яндекса — работа, осушающая +кошелёк, остановит рекламу владельца. + +🔴 **Разрешение тратить живые деньги было дано 06.08 на приёмку телеграма** в пределах +потолка 1 000 ₽. Оно не бессрочное: новая трата — новое «да». + +🔴 **В репозитории прямо сейчас работает ПАРАЛЛЕЛЬНАЯ смена.** Подробности и следы — +в Работе 4. Прежде чем что-то чинить «сломанное», проверь, твоё ли оно. + +Прод — только с разрешения владельца. База по умолчанию **чтение**. Номера — ПДн, в коммиты +не класть. Владелец **не программист**: простым русским, короткими абзацами. + +--- + +## Состояние на 06.08.2026, 15:20 (замерено) + +| Что | Состояние | +|---|---| +| Кошелёк | `balance_rub` **9876.50** · `frozen_rub` **3007.10** (числа ползут: Яндекс списывает почасово) | +| Кампания №15 | `moderating`, МТС `2237821`, уплачено кабинету **182,40 ₽**, старт **07.08** | +| Вердикт по №15 | **ещё не объявлен** — все проверки возвращают «Запланирована» | +| Застрявшие | №14 `needs_review` (**268,80 ₽ заперты**), №10 `needs_review` (МТС 180,00 ₽, денег нет), №8 `draft_ready` (денег нет) | +| Робот | работает сам, службой `Liderra-MTS-Sluzhba`; старые задачи Планировщика Disabled навсегда | +| Тесты робота | **197 зелёных** (`npm test` в `C:\liderra\mts-telegram-robot`) | +| Тесты модуля телеграма | **378 зелёных** (`php artisan test tests/Feature/ClientTg`) | +| Все тесты экранов | **2158 зелёных** | +| Статанализ | 0 ошибок в моих файлах; 1 ошибка в ЧУЖОМ файле — см. Работу 4 | + +--- + +## 🔴 Работа 1 — выкатить сделанное. Ничего не закоммичено + +Смена сделала три вещи. **Половина уже живёт**, половина ждёт выката. + +### Что УЖЕ работает без выката + +Правки робота живут в `C:\liderra\mts-telegram-robot` — это и есть живой робот, отдельного +выката у него нет. Он **уже** читает из кабинета день начала показов и присылает его порталу. + +🔑 **Проверено на живом кабинете, а не тестами:** три задания подряд (33, 34, 35) вернули +`"startDate": "2026-08-07"` с настоящей страницы кампании 2237821. Боевой портал лишнее поле +пока просто не замечает — вреда нет. + +### Что НЕ выкачено и не закоммичено + +| Что | Зачем | +|---|---| +| **Дата старта** — колонка `starts_on`, хранение, показ клиенту «Показы начнутся 7 августа» | Кабинет ставит старт сам; экран обещал «7 дней» с сегодня, а старт был завтрашним | +| **Ф-3, обе половины** — русские имена полей в ошибках + серая кнопка объясняет себя | Клиенту показывали «Поле budget cap rub должно быть не меньше 1» и мёртвую кнопку без причины | +| **Ф-2 / карточка Т-Ф4** — «Проверяли 5 минут назад» у кампаний в движении | Кампания висела «На модерации», и работу от поломки было не отличить | + +**Мои файлы — ровно эти, чужого в списке нет:** + +Правленые: `app/app/Http/Controllers/Api/ClientTg/CampaignController.php`, +`app/app/Models/ClientTg/Campaign.php`, `app/app/Services/ClientTg/RobotResult.php`, +`app/app/Services/ClientTg/TelegramModerationVerdictApplier.php`, +`app/resources/js/api/telegram.ts`, +`app/resources/js/views/advertising/AdvertisingTelegramView.vue`, +`app/resources/js/views/advertising/TelegramCampaignWizard.vue`, +`app/tests/Frontend/telegram-srok-pokaza.spec.ts`, `db/CHANGELOG_schema.md`. + +Новые: `app/database/migrations/2026_08_06_000001_add_starts_on_to_client_tg_campaigns.php`, +`app/tests/Feature/ClientTg/DataStartaTest.php`, +`app/tests/Feature/ClientTg/ChelovecheskiyYazykOshibokTest.php`, +`app/tests/Feature/ClientTg/ZhivaLiSistemaTest.php`, +`app/tests/Frontend/telegram-data-starta.spec.ts`, +`app/tests/Frontend/telegram-seraya-knopka.spec.ts`, +`app/tests/Frontend/telegram-zhiva-li-sistema.spec.ts`. + +Файлы робота (вне git, уже живые): `src/cabinet.js`, `src/runner.js`, +`test/data-starta.test.mjs`, `test/data-starta-shov.test.mjs`, `test/oplata-gejt.test.js`. + +### 🔴 В этом выкате ВПЕРВЫЕ есть миграция базы + +Прежние выкаты были только кодом. Здесь добавляется колонка — а значит: + +- 🪤 **`crm_app_user` не может менять таблицы.** Миграцию гнать ролью мигратора; пароль + лежит в Yandex Lockbox по ID `e6qeooj45vqtn2fju58r`. +- 🪤 Датчик перед прогоном: `select current_user` **тем же путём**, каким пойдут миграции. + Проверять глазами, а не верить настройке. +- Колонка `nullable`, ничего не переписывает, старые строки остаются пустыми — откат + безопасен, но делать его без нужды незачем. + +### 🔑 Рецепт выката фронтенда, проверенный прошлой сменой + +**Боевой фронтенд — гибрид.** Он не равен ни одному коммиту ветки: там уже лежит чужая +свежая работа, а чужой работы по обзвону — наоборот, нет. Собирать фронт из своей ветки +значит вывалить на бой чужую недоделку. + +1. На боевом скопировать `resources`, `public`, `package.json`, `vite.config.js`, + `tsconfig.json`, `vite-plugins`, `scripts` в `/tmp/sborka`. +2. Туда же **настоящую копию** `node_modules` (435 МБ). 🪤 Симлинк не годится: сборщик + пишет временный файл внутрь `node_modules` и падает. +3. Положить в копию **только свои** файлы фронта, сверить `git hash-object`. +4. `npm run build` там же. +5. **Датчик:** число записей в `manifest.json` новой сборки и живой должно совпасть, + а ключи — совпасть после вырезания хешей из имён. +6. Выкладывать **добавлением**: `cp -a assets/. → assets/`, ничего не удаляя, потом + заменить `manifest.json`. +7. `routes/web.php` собирать как **боевой + свои строки** (`sed '/якорь/r вставка'`). +8. Кэш обновлять **от www-data** (квирк #107: под root роняет портал). + +--- + +## Работа 2 — что осталось по ранбуку + +Из 30 карточек осталось **четыре**, и все четыре ждут не кода. + +| Карточка | Ждёт | Что делать | +|---|---|---| +| **Т-М2 одобрение живьём** | вердикта МТС | Ничего. Служба ловит сама. Это главная незакрытая дыра блока | +| Т-М4 живая половина пересдачи | вердикта МТС | Портальная часть прошла; проход робота по кабинету — нет | +| Т-Б2 / Т-Б3 «кончились деньги на ходу» | **отдельного «да» владельца** | Требует осушить боевой кошелёк → остановит живую рекламу Яндекса | + +Плюс три кнопки в админке, которые может нажать только владелец (дверь закрыта паролем +nginx, пароль есть только у него): №14 «вернуть деньги» (268,80 ₽), №10 и №8 «просто закрыть». + +### Закрыто этой сменой + +| Карточка | Чем | +|---|---| +| **Т-Р3** потолок траты держит | Тестами `test/oplata-gejt.test.js`. 🔑 Обе защиты, стерегущие ЕДИНСТВЕННОЕ место траты живых денег, до сегодня были **без единого теста** | +| **Т-Р4** сумма на кассе не прочиталась | Там же. Оба сторожа доказаны вырезанием (на копии, живой робот не трогали) | +| **Т-Ф4 / Ф-2** видно, что система жива | «Проверяли 5 минут назад» у кампаний в движении | +| **Ф-3** английские имена полей и молчащая кнопка | Русские имена + причина рядом с серой кнопкой | + +--- + +## Работа 3 — что делать дальше по коду + +Незакрытых дефектов приёмки **не осталось**. Что можно улучшить дальше — на выбор владельца: + +1. **Тестовые кампании №5–№16** в списке клиента. Решено оставить до конца приёмки; после — + спросить и убрать. +2. **Уборка черновиков в кабинете МТС**: 2237812 (№14), 2299001 (№8), 2299003 (№10). + Инструмент готов: `bin/cleanup-drafts.js`. Сначала — кнопки разбора. +3. **Диагностические времянки** в папке робота (`_diag-*.mjs`) — мусор, на работу не влияют. +4. **Чужая беда, не телеграм:** в журнале боевого падает служебная задача + `incidents:watch-failures`. Разбирать ли — отдельная работа, владелец не отвечал. + +--- + +## 🔴 Работа 4 — параллельная смена в этом же репозитории + +Во время этой смены кто-то работал в том же рабочем каталоге. Следы, замеренные командами: + +1. **Корневой `package.json` затёрли** строкой `'{"name":"vidtmp","private":true}'` — обрывок + временной видео-папки, прилетевший в корень. Сломалось всё, что зовётся через `npm`: + `npx vitest` падал с «Invalid package config». Файл под git — вернул из истории + (`git checkout -- package.json`). **Проверь его первым делом**, если `npm` ведёт себя странно. +2. **Статанализ красный из-за чужого файла:** `app/tests/Feature/Deals/ObzvonVKartochkeSdelkiTest.php` + (новый, ещё не в git) — «Using nullsafe property access `?->phone` … Use `->` instead». + Не мой, не трогал. Если он ещё красный — сначала выяснить, чей. +3. **В индекс поставлено удаление** `app/tests/Frontend/KoshelyokZakladkiPoKanalam.spec.ts` — + не моё. + +🔑 Вывод для следующей смены: **коммитить только явными путями**, список файлов брать из +Работы 1, а не из `git add .` — иначе унесёшь чужое. + +--- + +## Рецепты, проверенные этой сменой + +**Посмотреть боевую базу** — только чтение, обязательно с контекстом клиента, иначе защита +RLS вернёт пустоту и это выглядит как «ничего нет»: + +```bash +ssh liderra-prod 'cd /var/www/liderra/app && sudo -u www-data php artisan tinker' <<'PHP' +DB::statement("select set_config('app.current_tenant_id','2',false)"); +foreach (DB::select("select * from ad_wallets") as $r) { echo json_encode($r, JSON_UNESCAPED_UNICODE), PHP_EOL; } +PHP +``` + +**Увидеть, что робот прислал с живой страницы кабинета** — отчёты хранятся целиком: + +```sql +select id, status, finished_at, result->>'startDate' as data_starta +from client_tg_robot_jobs order by id desc limit 5; +``` + +**Смотреть журнал службы робота** — 🪤 только с `-Encoding UTF8`, иначе PowerShell 5.1 читает +в ANSI и выдаёт кракозябры (выглядит как поломка службы, хотя сломано чтение): + +```powershell +Get-Content C:\liderra\mts-telegram-robot\sluzhba.log -Encoding UTF8 -Tail 20 +``` + +**Проверить сторожа вырезанием, не трогая живого робота.** Копировать `src` в отдельную +папку ВНУТРИ каталога робота (иначе не найдётся `node_modules`), вырезать защиту там, +прогнать тест, папку удалить. 🪤 Файлы писать без BOM (`[System.IO.File]::WriteAllText` с +`UTF8Encoding($false)`) и положить рядом `package.json` с `{"type":"module"}` — иначе node +не считает файлы модулями, а BOM ломает разбор молча. + +**Коммит под стеной**: сообщение в файл, `git commit -F _msg.txt --pathspec-from-file=_paths.txt`, +разрешение — кликом владельца по опции AskUser, где метка = точная команда. + +--- + +## Чего НЕ делать + +- **Не включать обратно `Liderra-MTS-Telegram-Robot` и `Liderra-MTS-Keepalive`.** Они Disabled + намеренно: под S4U они не видят вход и будут падать раз в минуту, а письма тревоги теперь + доходят — владелец получит письмо на каждое падение. +- **Не коммитить через `git add .`** — в рабочей копии полно чужой незакоммиченной работы. +- Не чинить чужие красные проверки, не выяснив, чьи они. +- Не долбить кабинет проверками подряд: МТС отбивается по частоте, после полутора десятков + заходов за час начинает отдавать «Доступ запрещён». Служба ходит раз в час — этого хватает. +- Не верить одиночной проверке кабинета — 403 перемежается с 200. +- Не поднимать потолок траты робота выше 1 000 ₽ и не жать платящую кнопку руками. +- Не переписывать файлы робота вслепую: там **нет git**, отката не будет. +- Не гнать Т-Б2/Т-Б3 без отдельного «да» — остановит живую рекламу Яндекса. +- Не собирать фронт из своей ветки для выката — только из боевого кода. + +--- + +## Решения владельца (закрыты, не переспрашивать) + +| Вопрос | Решение | +|---|---| +| Как чинить работу по расписанию | отдельная служба ✅ сделано | +| Запертые 268,80 ₽ по №14 | выкатить кнопку ✅ выкачено, ждёт нажатия | +| Куда слать тревогу | оставить `ops@liderra.ru` ✅ | +| Дата старта клиенту | показывать, самым дешёвым способом ✅ сделано, ждёт выката | +| 210 ₽ за приёмку по №9 | внести поправку ✅ сделано | +| Тестовые кампании №5–№16 | оставить пока ✅ | +| Кампания №10 | просто закрыть ⏳ ждёт кнопки | + +## Открытые вопросы владельцу + +1. **Выкатывать ли сделанное этой сменой** (дата старта, Ф-2, Ф-3)? В выкате впервые есть + изменение базы. +2. **Т-Б2/Т-Б3** — разрешаете ли осушить кошелёк, зная, что встанет живая реклама Яндекса? +3. **Сеанс на сервере.** Служба робота живёт в сеансе владельца. Перезагрузка без входа + остановит робота. Нужен ли путь, переживающий перезагрузку? +4. **`incidents:watch-failures`** — разбирать ли чужую падающую задачу?