a2496dfe61
Замечание владельца З-6: «почему статус крутится, когда она отклонена?» Живой замер кампании #6 на бою: статус running, зелёное «Крутится», показов доставлено НОЛЬ, потрачено 0 ₽, заморожено 3 333,36 ₽ — третьи сутки. Главное, что вскрылось: экран физически не мог показать правду. Список кампаний отдавал estimated_impressions («сколько обещали») и не отдавал delivered_impressions («сколько было»), хотя колонка есть. Чисел принятых/отклонённых объявлений тоже не было. Поэтому чинили с сервера, а не с подписей. - сервер отдаёт факты: delivered_impressions + счётчики объявлений (всего/принято/ отклонено), ОДНИМ запросом на весь список — на N+1 поставлен отдельный датчик; те же счётчики доезжают и в отчёт по кампании; - считаются только включённые в показ: снятое галочкой в Яндекс не уезжает и в знаменателе «2 из 15» ему не место; - ярлык по фактам: running при нуле показов → «Принято, показов пока нет» нейтральным цветом; пошли показы → зелёное «Крутится»; часть отклонена → приписка «часть объявлений отклонена — 2 из 15»; все → красное «Отклонено»; - правило перехода статусов НЕ тронуто: на rejected висит возврат заморозки (AdWalletService::release, «ВЫХОД 2»). Чинили то, что видит человек; - подписи собраны в один файл (composables/campaignStatusMeta.ts). Их было ДВЕ копии — в списке и в отчёте — и они уже разъехались; разъехавшиеся копии и есть та разница между экранами, на которую жалуется владелец; - телеграм-экран: подписи вынесены отдельно и приведены к тем же словам («На модерации в МТС» ↔ «На модерации в Яндексе», «Отклонено» на обоих); голое «Запущена» → «Запущена в кабинете МТС». Граница честности: у телеграм-модуля нет счётчика показов ВООБЩЕ — actual_cost_rub в таблице есть, но её не пишет ни одна строка кода. Поэтому там нельзя сказать ни «крутится», ни «показов пока нет»: это была бы выдумка того же сорта. Записано открытым вопросом владельцу в файле замечаний. Проверено: 435 тестов рекламы Яндекса, 281 телеграма, 1771 тест экранов (235 файлов, 3 пропущено), статанализ 0, формат чист. Новых тестов 18. Глазами НЕ принято (приёмка — пачка 5), на боевой НЕ выкачено. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
179 lines
7.9 KiB
PHP
179 lines
7.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\AdCampaign;
|
|
use App\Models\AdCampaignBanner;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* Честные статусы кампаний — факты для экрана (замечание владельца З-6).
|
|
*
|
|
* 🔴 Беда, которую закрываем. Живой замер кампании #6 на бою 02.08.2026: статус
|
|
* `running`, клиент видит зелёное «Крутится», а показов доставлено НОЛЬ, потрачено
|
|
* 0,00 ₽ и при этом заморожено 3 333,36 ₽ — третьи сутки. Формально ярлык не врёт
|
|
* (правило спеки §6: кампания работает, если принято хотя бы одно объявление), по
|
|
* смыслу врёт.
|
|
*
|
|
* Правило перехода статусов трогать НЕЛЬЗЯ: на `rejected` завязан возврат заморозки
|
|
* (AdWalletService::release, «ВЫХОД 2»). Чиним то, что видит человек — но экран
|
|
* физически не может сказать правду, потому что список кампаний не отдаёт ни
|
|
* `delivered_impressions`, ни числа принятых/отклонённых объявлений. Этот файл
|
|
* стережёт, что факты доезжают.
|
|
*
|
|
* ПДн нет — телефонов тут не создаём вовсе.
|
|
*/
|
|
uses(DatabaseTransactions::class);
|
|
|
|
beforeEach(function () {
|
|
$this->tenant = Tenant::factory()->create();
|
|
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
|
$this->actingAs($this->user);
|
|
});
|
|
|
|
/** Объявление кампании с заданным вердиктом модерации. Размер — лишь бы уникальный. */
|
|
function bannerSVerdiktom(AdCampaign $campaign, string $status, int $width, bool $included = true): AdCampaignBanner
|
|
{
|
|
return AdCampaignBanner::create([
|
|
'tenant_id' => $campaign->tenant_id,
|
|
'campaign_id' => $campaign->id,
|
|
'width' => $width,
|
|
'height' => 250,
|
|
'path' => "ad-banners/{$campaign->tenant_id}/{$campaign->id}/{$width}x250.jpg",
|
|
'bytes' => 1000,
|
|
'included' => $included,
|
|
'moderation_status' => $status,
|
|
]);
|
|
}
|
|
|
|
it('список кампаний отдаёт СКОЛЬКО ПОКАЗОВ БЫЛО, а не только сколько обещали', function () {
|
|
AdCampaign::create([
|
|
'tenant_id' => $this->tenant->id,
|
|
'status' => AdCampaign::STATUS_RUNNING,
|
|
'name' => 'Приёмка робота',
|
|
'audience_days' => 30,
|
|
'estimated_impressions' => 12000,
|
|
'delivered_impressions' => 0,
|
|
]);
|
|
|
|
$row = $this->getJson('/api/advertising/campaigns')->assertOk()->json('data.0');
|
|
|
|
// Обещали 12000, показали 0 — экран обязан видеть обе цифры, иначе «Крутится»
|
|
// при нуле показов не отличить от настоящей работы.
|
|
expect($row['estimated_impressions'])->toBe(12000)
|
|
->and($row['delivered_impressions'])->toBe(0);
|
|
});
|
|
|
|
it('список отдаёт, сколько объявлений принято и сколько отклонено', function () {
|
|
$campaign = AdCampaign::create([
|
|
'tenant_id' => $this->tenant->id,
|
|
'status' => AdCampaign::STATUS_RUNNING,
|
|
'name' => 'Кампания с частичным отказом',
|
|
'audience_days' => 30,
|
|
]);
|
|
|
|
// Слепок боевой кампании #6: приняли 13, отклонили 2 (узкие полоски).
|
|
foreach (range(1, 13) as $i) {
|
|
bannerSVerdiktom($campaign, AdCampaignBanner::MOD_ACCEPTED, 300 + $i);
|
|
}
|
|
bannerSVerdiktom($campaign, AdCampaignBanner::MOD_REJECTED, 320);
|
|
bannerSVerdiktom($campaign, AdCampaignBanner::MOD_REJECTED, 321);
|
|
|
|
$row = $this->getJson('/api/advertising/campaigns')->assertOk()->json('data.0');
|
|
|
|
expect($row['banners_total'])->toBe(15)
|
|
->and($row['banners_accepted'])->toBe(13)
|
|
->and($row['banners_rejected'])->toBe(2);
|
|
});
|
|
|
|
it('кампания без объявлений отдаёт нули, а не пропущенные поля', function () {
|
|
AdCampaign::create([
|
|
'tenant_id' => $this->tenant->id,
|
|
'status' => AdCampaign::STATUS_DRAFT,
|
|
'name' => 'Пустой черновик',
|
|
'audience_days' => 30,
|
|
]);
|
|
|
|
$row = $this->getJson('/api/advertising/campaigns')->assertOk()->json('data.0');
|
|
|
|
// Не null и не отсутствие ключа: экран считает по этим числам, а `undefined`
|
|
// в арифметике даёт NaN и молча ломает ярлык.
|
|
expect($row['banners_total'])->toBe(0)
|
|
->and($row['banners_accepted'])->toBe(0)
|
|
->and($row['banners_rejected'])->toBe(0);
|
|
});
|
|
|
|
it('объявления, снятые с показа, в счёт не идут', function () {
|
|
$campaign = AdCampaign::create([
|
|
'tenant_id' => $this->tenant->id,
|
|
'status' => AdCampaign::STATUS_RUNNING,
|
|
'name' => 'Часть снята с показа',
|
|
'audience_days' => 30,
|
|
]);
|
|
|
|
bannerSVerdiktom($campaign, AdCampaignBanner::MOD_ACCEPTED, 300);
|
|
// Снято галочкой «в показ» — в Яндекс не уезжает, в приписке «2 из 15» ему не место.
|
|
bannerSVerdiktom($campaign, AdCampaignBanner::MOD_REJECTED, 301, included: false);
|
|
|
|
$row = $this->getJson('/api/advertising/campaigns')->assertOk()->json('data.0');
|
|
|
|
expect($row['banners_total'])->toBe(1)
|
|
->and($row['banners_accepted'])->toBe(1)
|
|
->and($row['banners_rejected'])->toBe(0);
|
|
});
|
|
|
|
it('чужие объявления в счётчики не попадают', function () {
|
|
$moya = AdCampaign::create([
|
|
'tenant_id' => $this->tenant->id,
|
|
'status' => AdCampaign::STATUS_RUNNING,
|
|
'name' => 'Моя',
|
|
'audience_days' => 30,
|
|
]);
|
|
bannerSVerdiktom($moya, AdCampaignBanner::MOD_ACCEPTED, 300);
|
|
|
|
$chuzhoyTenant = Tenant::factory()->create();
|
|
$chuzhaya = AdCampaign::create([
|
|
'tenant_id' => $chuzhoyTenant->id,
|
|
'status' => AdCampaign::STATUS_RUNNING,
|
|
'name' => 'Чужая',
|
|
'audience_days' => 30,
|
|
]);
|
|
foreach (range(1, 5) as $i) {
|
|
bannerSVerdiktom($chuzhaya, AdCampaignBanner::MOD_REJECTED, 300 + $i);
|
|
}
|
|
|
|
$data = $this->getJson('/api/advertising/campaigns')->assertOk()->json('data');
|
|
|
|
expect($data)->toHaveCount(1)
|
|
->and($data[0]['banners_total'])->toBe(1)
|
|
->and($data[0]['banners_rejected'])->toBe(0);
|
|
});
|
|
|
|
it('счётчики берутся ОДНИМ запросом на весь список, а не по кампании в цикле', function () {
|
|
foreach (range(1, 12) as $i) {
|
|
$campaign = AdCampaign::create([
|
|
'tenant_id' => $this->tenant->id,
|
|
'status' => AdCampaign::STATUS_RUNNING,
|
|
'name' => "Кампания {$i}",
|
|
'audience_days' => 30,
|
|
]);
|
|
bannerSVerdiktom($campaign, AdCampaignBanner::MOD_ACCEPTED, 300 + $i);
|
|
}
|
|
|
|
// Датчик на N+1: считаем запросы к таблице объявлений. По одному на кампанию —
|
|
// это 12 лишних походов в базу на каждое открытие экрана, и растёт с числом кампаний.
|
|
$zaprosy = 0;
|
|
DB::listen(function ($query) use (&$zaprosy) {
|
|
if (str_contains($query->sql, 'ad_campaign_banners')) {
|
|
$zaprosy++;
|
|
}
|
|
});
|
|
|
|
$this->getJson('/api/advertising/campaigns')->assertOk();
|
|
|
|
expect($zaprosy)->toBe(1);
|
|
});
|