76cb44b91d
Замечание владельца: «мастер шагов вместо простыни» и «живой счётчик охвата вместо кнопки Рассчитать». Форма была одна длинная, охват узнавался только по нажатию кнопки. Экран: - мастер из четырёх шагов: Кому показываем → Объявление → Деньги → Проверка; - живой счётчик охвата на первом шаге, пересчитывается сам при смене источника людей, периода или списка номеров (с задержкой, чтобы не дёргать сервер на каждую букву); - кнопки «Рассчитать» больше нет: «Запустить» создаёт кампанию, прикладывает картинку и отправляет в кабинет одним нажатием; - на шаге «Деньги» рядом смета и остаток баланса — нехватка денег видна ДО запуска, а не отказом после; - сводка на последнем шаге повторяет всё выбранное. Сервер — новая ручка POST /api/telegram/campaigns/estimate: 🔴 Живой счётчик обязан считать на каждое изменение поля, а охват до сих пор возвращала только POST /campaigns — и она СОЗДАЁТ черновик в базе. Через неё счётчик наплодил бы десятки кампаний-призраков в списке клиента. Новая ручка только читает: ни записи, ни денег, ни робота. На это стоит отдельный тест. Ручка считает ТЕМ ЖЕ кодом, что и создание кампании (общее тело выборки из сделок вынесено на оба входа TelegramAudienceService). Отдельный тест сверяет два ответа: разойдись они — клиент видел бы на экране одно число, а платил по другому. 🔴 Запуск запрещён, когда людей меньше 367 — правило площадки, не наше. Тот же предохранитель стоит на сервере (AudienceGateTest); на экране он объясняет заранее, вместо отказа после оплаты. Новых прав в базе ручка не требует: читает строго то же, что уже читает создание кампании, и не пишет никуда. Тесты: +EstimateApiTest (11 на PHP), +telegram-master-shagov (27 на экранах). 14 прежних проверок формы переехали в набор мастера вместе с кодом — сперва переписаны на новом месте и там проверены, потом убраны со старого. Устарели по смыслу только две («Рассчитать создаёт черновик», «правка сбрасывает смету»): считать вручную больше нечего, устареть расчёту негде. Замер: экраны 240 файлов, 1829 тестов, 0 падений (было 239/1813); телеграм- модуль на PHP 292 теста, 0 падений (было 281); статанализ 0 (базовая линия пересобрана — добавлен только новый Pest-файл, удалений нет); типы 6 чужих ошибок вместо 7; pint чист. На боевой НЕ выкачено. Глазами не принято — приёмка в пачке 5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
184 lines
7.8 KiB
PHP
184 lines
7.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\ClientTg\Campaign;
|
|
use App\Models\ClientTg\Contact;
|
|
use App\Models\ClientTg\Optout;
|
|
use App\Models\Deal;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
|
|
/**
|
|
* Ручка оценки охвата (пачка 4б): «сколько людей увидит рекламу и во сколько это
|
|
* обойдётся» — БЕЗ создания кампании.
|
|
*
|
|
* 🔴 Зачем она понадобилась. Живой счётчик охвата на экране должен пересчитываться
|
|
* на каждое изменение поля. До сих пор охват возвращала ТОЛЬКО `POST /campaigns` —
|
|
* а она создаёт черновик в базе. Дёргать её на каждую букву значило бы засорить
|
|
* `client_tg_campaigns` мусорными черновиками (и у клиента в списке кампаний
|
|
* появлялись бы призраки). Отсюда отдельная ручка, которая считает и НИЧЕГО не пишет.
|
|
*
|
|
* 🔴 Ручка обязана считать ТЕМИ ЖЕ правилами, что и создание черновика. Иначе клиент
|
|
* увидит на экране одно число, а заплатит по другому — это ровно тот класс вранья,
|
|
* из-за которого владелец писал замечание З-6.
|
|
*
|
|
* Синтетические номера 7999… — реальные НИКОГДА (это ПДн).
|
|
*/
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
$this->tenant = Tenant::factory()->create();
|
|
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
|
$this->actingAs($this->user);
|
|
});
|
|
|
|
/** Столько контактов в своей базе тенанта. */
|
|
function zavestiKontakty(int $tenantId, int $skolko, int $ot = 0): void
|
|
{
|
|
for ($i = $ot; $i < $ot + $skolko; $i++) {
|
|
Contact::create([
|
|
'tenant_id' => $tenantId,
|
|
'phone' => sprintf('7999%07d', $i),
|
|
'name' => null,
|
|
'operator' => null,
|
|
]);
|
|
}
|
|
}
|
|
|
|
it('🔴 считает охват и смету, НЕ создавая кампанию в базе', function () {
|
|
zavestiKontakty($this->tenant->id, 5);
|
|
|
|
$otvet = $this->postJson('/api/telegram/campaigns/estimate', [
|
|
'audience_kind' => Campaign::AUDIENCE_BASE,
|
|
]);
|
|
|
|
$otvet->assertOk()->assertJsonStructure(['planned_count', 'estimated_cost_rub', 'min_count', 'enough']);
|
|
expect($otvet->json('planned_count'))->toBe(5);
|
|
|
|
// Главное: в базе не осталось ни одного черновика. Иначе живой счётчик за минуту
|
|
// работы клиента наплодил бы десятки кампаний-призраков.
|
|
expect(Campaign::count())->toBe(0);
|
|
});
|
|
|
|
it('смета совпадает с той, что даёт создание черновика — иначе счётчик врёт перед оплатой', function () {
|
|
zavestiKontakty($this->tenant->id, 7);
|
|
|
|
$ocenka = $this->postJson('/api/telegram/campaigns/estimate', [
|
|
'audience_kind' => Campaign::AUDIENCE_BASE,
|
|
])->assertOk();
|
|
|
|
$chernovik = $this->postJson('/api/telegram/campaigns', [
|
|
'ad_text' => 'Заходите',
|
|
'ad_link' => 'https://t.me/example',
|
|
'audience_kind' => Campaign::AUDIENCE_BASE,
|
|
'budget_cap_rub' => '1000.00',
|
|
])->assertCreated();
|
|
|
|
expect($ocenka->json('planned_count'))->toBe($chernovik->json('planned_count'));
|
|
expect($ocenka->json('estimated_cost_rub'))->toBe($chernovik->json('estimated_cost_rub'));
|
|
});
|
|
|
|
it('сделки за период: то, что старше окна, в охват не идёт', function () {
|
|
Deal::factory()->create([
|
|
'tenant_id' => $this->tenant->id, 'phone' => '79990000001',
|
|
'phone_operator' => 'МТС', 'received_at' => now()->subDays(2),
|
|
]);
|
|
Deal::factory()->create([
|
|
'tenant_id' => $this->tenant->id, 'phone' => '79990000002',
|
|
'phone_operator' => 'МТС', 'received_at' => now()->subDays(30),
|
|
]);
|
|
|
|
$otvet = $this->postJson('/api/telegram/campaigns/estimate', [
|
|
'audience_kind' => Campaign::AUDIENCE_DEALS,
|
|
'audience_days' => 5,
|
|
])->assertOk();
|
|
|
|
expect($otvet->json('planned_count'))->toBe(1);
|
|
});
|
|
|
|
it('свой список: считает по присланным номерам и схлопывает дубли', function () {
|
|
$otvet = $this->postJson('/api/telegram/campaigns/estimate', [
|
|
'audience_kind' => Campaign::AUDIENCE_LIST,
|
|
'phones' => ['79990000001', '8 999 000 00 01', '79990000002', 'ерунда'],
|
|
])->assertOk();
|
|
|
|
// Первые два — один и тот же человек в разных записях, третий отдельный, четвёртый не номер.
|
|
expect($otvet->json('planned_count'))->toBe(2);
|
|
|
|
// И тут тоже ничего не записалось — ни кампании, ни номеров.
|
|
expect(Campaign::count())->toBe(0);
|
|
});
|
|
|
|
it('отписавшихся в охват не считает — как и создание черновика', function () {
|
|
zavestiKontakty($this->tenant->id, 3);
|
|
Optout::create(['tenant_id' => $this->tenant->id, 'phone' => '79990000000']);
|
|
|
|
$otvet = $this->postJson('/api/telegram/campaigns/estimate', [
|
|
'audience_kind' => Campaign::AUDIENCE_BASE,
|
|
])->assertOk();
|
|
|
|
expect($otvet->json('planned_count'))->toBe(2);
|
|
});
|
|
|
|
it('чужих контактов не видит', function () {
|
|
$chuzhoy = Tenant::factory()->create();
|
|
zavestiKontakty($chuzhoy->id, 40, 500);
|
|
zavestiKontakty($this->tenant->id, 3);
|
|
|
|
$otvet = $this->postJson('/api/telegram/campaigns/estimate', [
|
|
'audience_kind' => Campaign::AUDIENCE_BASE,
|
|
])->assertOk();
|
|
|
|
expect($otvet->json('planned_count'))->toBe(3);
|
|
});
|
|
|
|
it('говорит, хватает ли людей для площадки — 367 это её правило, не наше', function () {
|
|
zavestiKontakty($this->tenant->id, 5);
|
|
|
|
$malo = $this->postJson('/api/telegram/campaigns/estimate', [
|
|
'audience_kind' => Campaign::AUDIENCE_BASE,
|
|
])->assertOk();
|
|
|
|
expect($malo->json('min_count'))->toBe(367);
|
|
expect($malo->json('enough'))->toBeFalse();
|
|
|
|
zavestiKontakty($this->tenant->id, 400, 1000);
|
|
|
|
$hvatit = $this->postJson('/api/telegram/campaigns/estimate', [
|
|
'audience_kind' => Campaign::AUDIENCE_BASE,
|
|
])->assertOk();
|
|
|
|
expect($hvatit->json('enough'))->toBeTrue();
|
|
});
|
|
|
|
it('пустая своя база — честный ноль, а не отказ', function () {
|
|
$otvet = $this->postJson('/api/telegram/campaigns/estimate', [
|
|
'audience_kind' => Campaign::AUDIENCE_BASE,
|
|
])->assertOk();
|
|
|
|
expect($otvet->json('planned_count'))->toBe(0);
|
|
expect($otvet->json('enough'))->toBeFalse();
|
|
});
|
|
|
|
it('незнакомый вид аудитории отвергается, а не считается нулём молча', function () {
|
|
$this->postJson('/api/telegram/campaigns/estimate', [
|
|
'audience_kind' => 'chto-to-svoyo',
|
|
])->assertStatus(422);
|
|
});
|
|
|
|
it('сделки без числа дней отвергаются — иначе молча посчитаем за всё время', function () {
|
|
$this->postJson('/api/telegram/campaigns/estimate', [
|
|
'audience_kind' => Campaign::AUDIENCE_DEALS,
|
|
])->assertStatus(422);
|
|
});
|
|
|
|
it('без входа в кабинет ручка недоступна', function () {
|
|
app('auth')->forgetGuards();
|
|
|
|
$this->postJson('/api/telegram/campaigns/estimate', [
|
|
'audience_kind' => Campaign::AUDIENCE_BASE,
|
|
])->assertStatus(401);
|
|
});
|