f2ac3f4c7f
Приёмка владельца на боевом, два замечания из трёх (третье — про рекламный кошелёк и заморозку — отложено, кусок большой). 1. Подсказка «?» у поля медиа обещала «600 ₽ за тысячу с картинкой, 680 ₽ с видео». Клиент платит 1008 и 1142,40 ₽. Числа были вбиты в текст руками и протухли в ту минуту, когда миграция client_tg_cena_po_media поменяла тариф. Лечение в корень, а не подстановкой верных чисел: цену называет тот, кто её считает. Ручка оценки отдаёт ceny_za_tysyachu по каждому виду медиа (себестоимость × наценка), подсказка собирается из них функцией podskazkaProMedia. Пока сервер не ответил — текст без единой цифры: подставить «примерные» числа значило бы вернуть ровно эту беду. Прайс держим отдельно от охвата: охват на ошибке гасим (устаревший хуже никакого), а цены от настроек аудитории не зависят — иначе подсказка мигала бы на каждой опечатке в поле. 2. Замечание дословно: «нету скачать файл с примером как надо заполнить для нас файл». Кнопка «Скачать образец» рядом с полем загрузки — подпись клиент читает уже ПОСЛЕ того, как файл отклонили. Пять строк, написания разные (с плюсом, с восьмёркой, со скобками), номера синтетические 7999. Заголовка-строки в образце намеренно нет: разборщик нормализует первый столбец КАЖДОЙ строки, и слово «Телефон» попало бы в «не похоже на номер» — наш собственный образец показал бы клиенту ошибку. Проверено вырезанием, а не только зелёным: - вернул в подсказку вбитые 600/680 — покраснели 4 датчика, включая тот, что прямо запрещает эти два числа; - вставил в образец строку-заголовок — покраснели 3. Полный прогон поймал две мои же поломки, обе настоящие: - значок mdi-file-download-outline на новой кнопке ОТСУТСТВОВАЛ в карте Lucide — на экране стал бы вопросом в кружке. Поймал сторож значков, у которого вчера опустошили список поблажек. Добавлен (Download — точного «файла со стрелкой» в Lucide нет); - датчик подсказок ждал PODSKAZKI.media строкой, а её больше нет. Замеры: экраны 245 файлов / 1870 тестов / 0 падений; телеграм-модуль 327 / 0; статанализ 0; формат чисто; типы 6 — все в чужих файлах, столько же было до; сборка 3,80 с. Счётчик в phpstan-baseline сдвинут 13→15 (новые тесты на Pest), diff проверен глазами: изменилась ровно эта строка. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
220 lines
10 KiB
PHP
220 lines
10 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\ClientTg\Tariff;
|
||
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);
|
||
});
|
||
|
||
/**
|
||
* Цена за тысячу показов — ОТ СЕРВЕРА, а не вбитая в текст экрана.
|
||
*
|
||
* 🔴 Откуда взялось (приёмка владельца 03.08.2026). Подсказка «?» у поля медиа обещала
|
||
* «600 ₽ за тысячу с картинкой, 680 ₽ с видео». Настоящие клиентские цены на тот момент —
|
||
* 1008 и 1142,40 ₽: числа были вбиты в текст руками и протухли в ту же минуту, когда
|
||
* миграция client_tg_cena_po_media поменяла тариф. Обещать клиенту цену вдвое ниже
|
||
* настоящей — это не опечатка, это неверная цена в кабинете.
|
||
*
|
||
* Лечение: цену называет тот же, кто её считает. Экран не имеет права знать числа.
|
||
*/
|
||
it('оценка отдаёт клиентскую цену за тысячу по каждому виду медиа', function () {
|
||
$otvet = $this->postJson('/api/telegram/campaigns/estimate', [
|
||
'audience_kind' => Campaign::AUDIENCE_BASE,
|
||
])->assertOk();
|
||
|
||
// Себестоимость 0.480 / 0.720 / 0.816 ₽ за показ × наценка 1.40 × 1000 показов.
|
||
expect($otvet->json('ceny_za_tysyachu.none'))->toBe('672.00');
|
||
expect($otvet->json('ceny_za_tysyachu.image'))->toBe('1008.00');
|
||
expect($otvet->json('ceny_za_tysyachu.video'))->toBe('1142.40');
|
||
});
|
||
|
||
it('цена за тысячу едет из таблицы тарифа — поменяли тариф, поменялся ответ', function () {
|
||
Tariff::where('media_kind', Campaign::MEDIA_IMAGE)->update(['price_rub' => '1.000']);
|
||
|
||
$otvet = $this->postJson('/api/telegram/campaigns/estimate', [
|
||
'audience_kind' => Campaign::AUDIENCE_BASE,
|
||
])->assertOk();
|
||
|
||
// 1.000 × 1.40 × 1000 = 1400. Если бы число было вбито в код, ответ не сдвинулся бы.
|
||
expect($otvet->json('ceny_za_tysyachu.image'))->toBe('1400.00');
|
||
// Соседние виды медиа при этом не поехали.
|
||
expect($otvet->json('ceny_za_tysyachu.none'))->toBe('672.00');
|
||
});
|