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>
160 lines
6.8 KiB
PHP
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();
|
|
});
|