feat телеграм: дата старта показов, признак живости системы и человеческий язык ошибок
Дата старта (решение владельца 06.08.2026). Портал не передаёт кабинету МТС ни одной даты — их ставит кабинет своими умолчаниями. Живой прогон показал: старт оказался ЗАВТРАШНИМ, тогда как экран обещал клиенту показы «7 дней», подразумевая сегодня. Робот читает день начала из ТОЙ ЖЕ строки списка, куда и так ходит за вердиктом — ни одного лишнего захода в кабинет; портал хранит его в client_tg_campaigns.starts_on и показывает клиенту «Показы начнутся 7 августа». Проверено на ЖИВОМ кабинете: три задания подряд вернули startDate 2026-08-07 по кампании МТС 2237821. Мастер перестал молчать о том, что день начала ставит кабинет, а не мы. Ф-2, карточка приёмки Т-Ф4. У кампаний в движении видно «Проверяли 5 минут назад». Считаются только ЗАКОНЧЕННЫЕ проверки, включая неудачные: задание в очереди работой не является, а неудачная проверка — всё равно признак жизни. Именно в такой тишине владелец 36 часов не знал, что робот вообще не может войти в кабинет. Ф-3. Имена полей в ошибках формы по-русски: «Лимит на объявление не может быть меньше 1 ₽» вместо «Поле budget cap rub должно быть не меньше 1». Серая кнопка «Запустить» называет причину и шаг, куда вернуться, а не гаснет молча. Карточки Т-Р3 и Т-Р4 закрыты тестами (живьём не воспроизвести). Попутно найдено: обе защиты, стерегущие ЕДИНСТВЕННОЕ место траты живых денег роботом, были без единого теста. Сторожа доказаны вырезанием. Тесты: ClientTg 378 зелёных, экраны 2158, робот 197. Статанализ 0, стиль 0, типы чисто. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<int, Campaign> $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<string, string>
|
||||
*/
|
||||
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<string, string>
|
||||
*/
|
||||
private const IMENA_POLEY = [
|
||||
'ad_text' => 'Текст объявления',
|
||||
'ad_link' => 'Ссылка',
|
||||
'ad_headline' => 'Заголовок объявления',
|
||||
'audience_kind' => 'Кому показывать',
|
||||
'audience_days' => 'За сколько дней брать сделки',
|
||||
'budget_cap_rub' => 'Лимит на объявление',
|
||||
'ord_category' => 'Категория рекламы',
|
||||
'phones' => 'Список номеров',
|
||||
'phones.*' => 'Номер из списка',
|
||||
];
|
||||
|
||||
/**
|
||||
* Нормализует и складывает номера списка в строки кампании. Нераспознанные —
|
||||
* молча пропускаем (клиент видит итоговое число кандидатов), но СЧИТАЕМ их —
|
||||
|
||||
@@ -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<array-key, mixed>|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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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-маркера робота
|
||||
|
||||
@@ -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] для пост-транзакционных действий, либо
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* День, с которого кабинет МТС начнёт показы кампании.
|
||||
*
|
||||
* 🔴 Зачем. Портал не передаёт кабинету НИ ОДНОЙ даты — их ставит сам кабинет своими
|
||||
* умолчаниями, и портал до сих пор не знал о них ничего. Живьём 06.08.2026 (кампания
|
||||
* МТС 2237821) старт оказался ЗАВТРАШНИМ, тогда как экран обещал клиенту показы
|
||||
* «7 дней», подразумевая сегодня. Клиент ждал рекламу в тот же день, её не было, и
|
||||
* объяснить это было нечем: колонки с датой не существовало.
|
||||
*
|
||||
* Кто заполняет: робот в режиме `read-status` — дата стоит в начале строки кампании
|
||||
* в списке кабинета и приезжает вместе с вердиктом модерации, без единого лишнего
|
||||
* захода в кабинет. Пусто, пока робот не сходил (или пока строка без даты) — это
|
||||
* штатно, экран тогда просто молчит о дате.
|
||||
*
|
||||
* Почему `date`, а не `timestamp`: кабинет показывает в списке ДЕНЬ («07.08»), часа
|
||||
* там нет. Хранить час, которого мы не знаем, значит выдумывать точность.
|
||||
*
|
||||
* RLS не меняется: колонка на существующей таблице client_tg_campaigns, политика
|
||||
* tenant_isolation задана миграцией создания. Табличный GRANT в PostgreSQL
|
||||
* распространяется и на новые колонки — ре-GRANT не нужен.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('client_tg_campaigns', function (Blueprint $table) {
|
||||
$table->date('starts_on')->nullable()->after('mts_cost_rub');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('client_tg_campaigns', function (Blueprint $table) {
|
||||
$table->dropColumn('starts_on');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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 часов.
|
||||
</v-alert>
|
||||
|
||||
<!--
|
||||
День начала показов. Его ставит кабинет МТС — портал дат не
|
||||
задаёт; робот читает день вместе с вердиктом. Пока он не сходил,
|
||||
строки нет вовсе: выдуманная дата хуже отсутствующей.
|
||||
-->
|
||||
<div
|
||||
v-if="denNachala(c.starts_on)"
|
||||
class="text-body-2 mt-1"
|
||||
:data-testid="`tg-start-date-${c.id}`"
|
||||
>
|
||||
Показы начнутся {{ denNachala(c.starts_on) }}
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Признак жизни: когда система последний раз ходила за новостями.
|
||||
Только у кампаний в движении — у законченной это шум (дефект Ф-2).
|
||||
-->
|
||||
<div
|
||||
v-if="LIVE_STATUSES.includes(c.status) && kogdaProveryali(c.last_robot_check_at)"
|
||||
class="text-caption text-medium-emphasis mt-1"
|
||||
:data-testid="`tg-posledniaya-proverka-${c.id}`"
|
||||
>
|
||||
Проверяли {{ kogdaProveryali(c.last_robot_check_at) }}
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="c.status_reason && (c.status === 'rejected' || c.status === 'failed')"
|
||||
type="warning"
|
||||
|
||||
@@ -298,6 +298,26 @@ const canLaunch = computed(
|
||||
() => !submitting.value && audienciyaGotova.value && obyavlenieGotovo.value && budgetCap.value.trim() !== '',
|
||||
);
|
||||
|
||||
/**
|
||||
* Почему «Запустить» серая. Пустая строка — можно запускать.
|
||||
*
|
||||
* 🔴 Дефект Ф-3 приёмки 06.08.2026: кнопка просто гасла. Причина («реклама ведёт на
|
||||
* сайт — нужен заголовок») показывалась шагом раньше, а на шаге проверки не
|
||||
* повторялась: клиент видел мёртвую кнопку и не знал, что чинить.
|
||||
*
|
||||
* Называем и беду, и ШАГ, куда вернуться: искать по мастеру наугад клиент не обязан.
|
||||
*/
|
||||
const pochemuNelzya = computed<string>(() => {
|
||||
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 });
|
||||
строку править вместе с разведкой, а не по памяти.
|
||||
-->
|
||||
<v-list-item
|
||||
title="7 дней, каждый человек увидит объявление один раз"
|
||||
subtitle="Срок показа"
|
||||
data-testid="tg-svodka-srok"
|
||||
/>
|
||||
>
|
||||
<v-list-item-title class="text-wrap">
|
||||
7 дней, каждый человек увидит объявление один раз
|
||||
</v-list-item-title>
|
||||
<!--
|
||||
🔴 Живой прогон 06.08.2026: показы начались НЕ в день
|
||||
отправки, а на следующий. День начала ставит кабинет МТС,
|
||||
мы дат не задаём — молчать об этом значит обещать клиенту
|
||||
сегодняшний старт, которого не будет.
|
||||
-->
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
День начала ставит кабинет МТС — обычно ближайший
|
||||
свободный. Точную дату покажем в списке кампаний,
|
||||
как только реклама уйдёт на проверку.
|
||||
</div>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<v-alert
|
||||
@@ -711,6 +745,21 @@ defineExpose({ step, obnovitOhvat });
|
||||
>
|
||||
Людей меньше {{ ocenka.min_count }} — вернитесь на первый шаг и расширьте аудиторию.
|
||||
</v-alert>
|
||||
|
||||
<!--
|
||||
Серая кнопка обязана объяснять себя. Молчащая кнопка — это загадка
|
||||
клиенту, а не защита (дефект Ф-3 приёмки 06.08.2026).
|
||||
-->
|
||||
<v-alert
|
||||
v-if="pochemuNelzya"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mt-2"
|
||||
data-testid="tg-pochemu-nelzya"
|
||||
>
|
||||
{{ pochemuNelzya }}
|
||||
</v-alert>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Дефект Ф-3 приёмки 06.08.2026 — английские имена полей в русских ошибках.
|
||||
*
|
||||
* 🔴 Платящему клиенту показывали дословно: «Поле budget cap rub должно быть не меньше 1».
|
||||
* Владелец не программист, и таких полей на экране нет вовсе — есть «Лимит на объявление».
|
||||
* Клиент не понимает, что чинить, и звонит вместо того, чтобы поправить одно число.
|
||||
*
|
||||
* Правило: ни одна ошибка формы не смеет называть поле так, как оно зовётся в базе.
|
||||
*/
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
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', 'позавчера'],
|
||||
]);
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ClientTg\Campaign;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\ClientTg\RobotResult;
|
||||
use App\Services\ClientTg\TelegramModerationVerdictApplier;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Дата старта рекламы: клиент должен видеть, С КАКОГО ДНЯ пойдут показы.
|
||||
*
|
||||
* 🔴 Откуда взялось. Портал не передаёт кабинету МТС ни одной даты — их ставит сам
|
||||
* кабинет своими умолчаниями. Живьём 06.08.2026 (кампания 2237821) старт оказался
|
||||
* ЗАВТРАШНИМ, а экран обещал клиенту показы «7 дней», подразумевая сегодня. Клиент
|
||||
* ждёт рекламу сегодня, её нет, и объяснить это нечем.
|
||||
*
|
||||
* Решение владельца — самое дешёвое: дат не выбирать, а ПОКАЗАТЬ ту, что поставил
|
||||
* кабинет. Робот читает строку кампании в списке раз в час (`read-status`) — дата
|
||||
* стоит в её начале и приезжает вместе с вердиктом, без единого лишнего захода.
|
||||
*
|
||||
* 🔑 Главный случай — именно «ещё на модерации»: дата нужна клиенту РАНЬШЕ вердикта.
|
||||
* Применение вердикта на «moderating» выходит рано и кампанию не трогает, поэтому
|
||||
* сохранение даты обязано стоять ДО этого выхода — иначе поле не заполнится никогда.
|
||||
*/
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function kampaniyaSDatoy(int $tenantId): Campaign
|
||||
{
|
||||
return Campaign::query()->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');
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ClientTg\Campaign;
|
||||
use App\Models\ClientTg\RobotJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Дефект Ф-2 приёмки 06.08.2026, карточка ранбука Т-Ф4 — не видно, жива ли система.
|
||||
*
|
||||
* 🔴 Кампания может неделями висеть «На модерации», и клиент никак не отличит работу от
|
||||
* поломки: портал не отдавал ему ни времени последнего опроса робота, ни времени
|
||||
* последней проверки вердикта. Именно в такой тишине владелец 36 часов не знал, что
|
||||
* робот вообще не может войти в кабинет.
|
||||
*
|
||||
* Отдаём время ПОСЛЕДНЕЙ ЗАКОНЧЕННОЙ проверки. Не «поставлено в очередь» — задание
|
||||
* могло быть поставлено и не выполниться ни разу; такое «мы работаем» было бы враньём.
|
||||
*/
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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);
|
||||
});
|
||||
@@ -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<string, unknown>) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof mount>;
|
||||
|
||||
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<string, unknown>): Promise<Wrapper> {
|
||||
const w = smontirovat();
|
||||
Object.assign(w.vm as unknown as Record<string, unknown>, { 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('лимит');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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`
|
||||
|
||||
**Только текст. Структура базы не тронута:** ни столбцов, ни указателей, ни таблиц,
|
||||
|
||||
@@ -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`** — разбирать ли чужую падающую задачу?
|
||||
Reference in New Issue
Block a user