Files
portal/app/tests/Feature/ClientTg/StatistikaPokazovTest.php
T
Дмитрий dc4d835b73 телеграм-реклама: клиент видит отчёт, управление и показы
Клиент после создания рекламы был слеп: ни отчёта, ни причины отказа проверки,
ни способа что-то изменить. Закрыто.

Что теперь видит клиент
- слова «МТС» на клиентских экранах больше нет — он покупает рекламу в Телеграме
- на карточке: увидят рекламу, зарезервировано, списано, показов, кликов, подписались
- кнопки «Отчёт», «Отменить», «Повторить эту рекламу»
- отчёт даёт ПОЛНЫЙ текст причин отказа проверки, дословно
- пересдача принимает новый заголовок и новую картинку
- вердикт проверки уходит клиенту письмом, а не только в колокольчик

Показы читает робот
- новый режим read-stats: список кабинета, срок «За всё время», строка кампании
- разбор строки С КОНЦА: начало ряда плавает, хвост из 12 колонок постоянен
- прочерк площадки значит «не знаем», а не ноль, и различие доезжает до экрана
- пять колонок stats_* в client_tg_campaigns, запись v9.73 в журнале схемы
- опросчик PollTelegramStatsJob раз в час

Закрыто по дороге, сверх заказа
- утечка: закупочная цена mts_cost_rub уезжала клиенту в браузер, из пары с
  actual_cost_rub наценка считалась вычитанием
- расход площадки stats_spend_rub скрыт по той же причине
- кабинет пишет «Запущена», робот такого слова не знал: кампания висела на
  проверке навсегда, хотя реклама уже шла и деньги площадки тратились
- в runner.js висел неразрешённый конфликт слияния — робот не запускался вовсе
- сторож сессии ходил БЕЗ окна, а работа робота ходит С окном: сторож ловил отказ
  там, где работа проходила, и слал письмо каждый час о беде, которой не было
- текст отказа больше не советует менять адрес выхода — совет был ошибочный и
  увёл целую смену на поиск блокировки, которой не существовало

Замеры
- Pest tests/Feature/ClientTg 396 зелёных, смежные области 97 зелёных
- тесты робота 182 зелёных, Larastan 0 ошибок
- Vitest tests/Frontend 2231 зелёный; 5 падений кошелька — чужие, были до этого
- сторожа проверены КРАСНЫМ: подсунутые ошибки красят их, возврат кода зеленит
- живая проверка чтения цифр на настоящей кампании кабинета

П-8 «Остановить» не входит: владелец запретил нажимать «Завершить» в кабинете,
а без этого неизвестно, что она делает с деньгами.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:24:58 +03:00

160 lines
6.8 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\ClientTg\PollTelegramStatsJob;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\RobotJob;
use App\Models\Tenant;
use App\Models\User;
use App\Services\ClientTg\RobotResult;
use App\Services\ClientTg\TelegramStatsApplier;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
/**
* П‑7 спеки 2026-08-07-telegram-otchyot-i-upravlenie: что реклама принесла.
*
* 🔴 До этого портал не знал о работающей кампании НИ ОДНОЙ цифры. Живая разведка
* кабинета 07.08.2026 (кампания 2237821) нашла там 13 показов и расход площадки
* 6,24 ₽ — портал не знал ни того, ни другого.
*
* Стережём три вещи:
* — цифры доезжают до базы и до клиента;
* — 🔴 «нет данных» НЕ превращается в ноль (граница честности §3.1);
* — 🔴 расход ПЛОЩАДКИ (наши закупочные рубли) клиенту не уезжает никогда.
*/
uses(RefreshDatabase::class, SharesSupplierPdo::class);
beforeEach(function () {
config()->set('client_tg.sandbox', true);
$this->tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id);
});
function statKampaniya(int $tenantId, array $overrides = []): Campaign
{
return Campaign::query()->create(array_merge([
'tenant_id' => $tenantId,
'status' => Campaign::STATUS_LAUNCHED,
'ad_text' => 'Приходите к нам за услугой',
'ad_link' => 'https://example.test/promo',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '500.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 400,
'estimated_cost_rub' => '182.40',
'mts_campaign_id' => '2237821',
'created_by' => 1,
], $overrides));
}
// --- сохранение цифр -------------------------------------------------------
it('цифры от робота доезжают до кампании и время съёма проставляется', function () {
$c = statKampaniya($this->tenant->id);
app(TelegramStatsApplier::class)->apply($this->tenant->id, $c->id, new RobotResult(
ok: true,
impressions: 13,
clicks: 2,
subscribers: 1,
spendRub: '6.24',
));
$fresh = Campaign::find($c->id);
expect($fresh->stats_impressions)->toBe(13)
->and($fresh->stats_clicks)->toBe(2)
->and($fresh->stats_subscribers)->toBe(1)
->and($fresh->stats_spend_rub)->toBe('6.24')
->and($fresh->stats_read_at)->not->toBeNull();
});
it('🔴 пустой ответ площадки НЕ затирает уже известные цифры нулями', function () {
// Кабинет рисует прочерк, пока данных нет. Записать вместо прочерка ноль — значит
// показать клиенту «показов 0» там, где показы были: экран соврёт, а выглядеть
// будет правдой.
$c = statKampaniya($this->tenant->id, [
'stats_impressions' => 13,
'stats_clicks' => 2,
'stats_subscribers' => 1,
]);
app(TelegramStatsApplier::class)->apply($this->tenant->id, $c->id, new RobotResult(
ok: true,
impressions: null,
clicks: null,
subscribers: null,
spendRub: null,
));
$fresh = Campaign::find($c->id);
expect($fresh->stats_impressions)->toBe(13)
->and($fresh->stats_clicks)->toBe(2)
->and($fresh->stats_subscribers)->toBe(1);
});
it('провальный отчёт робота цифры не трогает', function () {
$c = statKampaniya($this->tenant->id, ['stats_impressions' => 13]);
app(TelegramStatsApplier::class)->apply($this->tenant->id, $c->id, new RobotResult(
ok: false,
reason: 'Кабинет не отрисовался',
));
expect(Campaign::find($c->id)->stats_impressions)->toBe(13);
});
// --- что видит клиент ------------------------------------------------------
it('🔴 расход площадки клиенту не уезжает, а показы и клики — уезжают', function () {
// Из пары «расход площадки» + «списано с клиента» наценка Лидерры считается
// вычитанием. Показы и клики нейтральны — их клиент видеть обязан.
$this->actingAs($this->user);
$c = statKampaniya($this->tenant->id, [
'stats_impressions' => 13,
'stats_clicks' => 2,
'stats_spend_rub' => '6.24',
'actual_cost_rub' => '7.49',
]);
$odna = $this->getJson("/api/telegram/campaigns/{$c->id}")->assertOk();
$spisok = $this->getJson('/api/telegram/campaigns')->assertOk();
expect($odna->json('campaign'))->not->toHaveKey('stats_spend_rub')
->and($spisok->json('campaigns.0'))->not->toHaveKey('stats_spend_rub')
->and($odna->json('campaign.stats_impressions'))->toBe(13)
->and($odna->json('campaign.stats_clicks'))->toBe(2)
->and($odna->json('campaign.actual_cost_rub'))->toBe('7.49');
});
// --- опросчик --------------------------------------------------------------
it('опросчик ставит роботу задание на чтение цифр только запущенным кампаниям', function () {
config()->set('client_tg.robot.transport', 'poll');
$zapushchena = statKampaniya($this->tenant->id);
$chernovik = statKampaniya($this->tenant->id, [
'status' => Campaign::STATUS_DRAFT,
'mts_campaign_id' => '2237812',
]);
PollTelegramStatsJob::dispatchSync();
$job = RobotJob::where('campaign_id', $zapushchena->id)->first();
expect($job)->not->toBeNull()
->and($job->mode)->toBe(RobotJob::MODE_READ_STATS)
->and($job->payload['campaignId'])->toBe('2237821')
->and(RobotJob::where('campaign_id', $chernovik->id)->exists())->toBeFalse();
});
it('кампании без номера в кабинете опросчик пропускает — роботу читать нечего', function () {
config()->set('client_tg.robot.transport', 'poll');
$bezNomera = statKampaniya($this->tenant->id, ['mts_campaign_id' => null]);
PollTelegramStatsJob::dispatchSync();
expect(RobotJob::where('campaign_id', $bezNomera->id)->exists())->toBeFalse();
});