Files
portal/app/tests/Feature/ClientTg/MediaCenaTest.php
T
Дмитрий 3864f6f11a feat,телеграм-реклама: цена показа по виду медиа вместо ступеней по объёму
Мы продавали дешевле, чем покупали. Тариф давал скидку за объём — 0.45 → 0.36 ₽
за показ, — а у МТС такой скидки нет: прайс кабинета, стр. 13, берёт 0,48 ₽ за
показ плоско. Скидку давали мы, а нам её не давал никто: на объёме от 50 000
наценка 1.40 превращалась в 5%.

Второе. Кабинет показывает CPM БЕЗ НДС — колонка списка так и названа. Счёт
кампании 2231134 сошёлся: 420 × 400 ₽/1000 × 1,2 = 201,60 ₽. Мы ИП на УСН, НДС
не возмещается, это расход. Себестоимость с НДС: 0.480 без медиа, 0.720 с
картинкой, 0.816 с видео.

Третье. Вид медиа до расчёта не доходил вовсе — объявление с видео продавалось
по цене объявления без картинки. На видео уходили в минус до 176 ₽ с тысячи, и
портал нигде свою цену с ценой МТС не сравнивал. Песочница выключена с 02.08 —
деньги живые.

Что сделано:
- миграция client_tg_cena_po_media: min_qty → media_kind, точность цены 6,2 →
  6,3, три строки вместо пяти ступеней, media_kind на кампании и авто-правиле;
- вид медиа определяется по СОДЕРЖИМОМУ файла, а не по расширению имени;
- экран админки переделан: три фиксированные строки по видам медиа, рядом цена
  за тысячу для сверки с кабинетом, добавлять и удалять нечего;
- запись v9.65 в журнале схемы.

Замеры этой смены: телеграм-модуль 313 тестов 0 падений, экраны 242 файла
1841 тест 0 падений, статанализ 0, форматтер чисто. Проверка типов — 6 ошибок,
все в чужих файлах, были до нас.

Тест экрана тарифов проверен вырезанием, а не только зелёным: сломал передачу
media_kind — красный; сломал расчёт цены за тысячу — красный.

Осталось незакрытым: числа 0.720 и 0.816 — вывод по правилу «кабинет пишет без
НДС», а не замер. Счёт кампании с картинкой живьём не снимали, шаг «Стоимость»
недостижим без загрузки живых номеров. Замерите — правится одной строкой.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:15:46 +03:00

193 lines
8.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\BalanceTransaction;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\Contact;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Tests\Support\MtsMediaFixture;
/**
* Цена зависит от медиа — на всём пути от экрана до списания (решение владельца
* 02.08.2026 после замера кабинета МТС).
*
* 🔴 Что охраняем. Кабинет берёт за тысячу показов 480 ₽ без медиа, 720 ₽ с картинкой
* и 816 ₽ с видео (с НДС). До этой правки расчёт принимал ТОЛЬКО число показов, и
* объявление с видео продавалось по цене объявления без картинки — убыток молчал,
* потому что портал нигде не сравнивает свою цену с ценой МТС. Песочница выключена
* 02.08.2026, деньги живые.
*
* Клиентская цена = себестоимость × 1.40 ⇒ за 400 показов:
* без медиа 400 × 0.480 × 1.40 = 268.80 ₽
* картинка 400 × 0.720 × 1.40 = 403.20 ₽
* видео 400 × 0.816 × 1.40 = 456.96 ₽
*
* Синтетические файлы (Tests\Support\MtsMediaFixture) и номера 7999… — реальных ПДн нет.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
Storage::fake('local');
$this->withHeaders(['Accept' => 'application/json']);
$this->tenant = Tenant::factory()->create(['balance_rub' => '5000.00']);
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
config(['client_tg.markup' => '1.40']);
});
/** Черновик кампании «по своей базе». */
function chernovikCena(mixed $test, int $planned = 0): Campaign
{
return Campaign::create([
'tenant_id' => $test->tenant->id,
'status' => Campaign::STATUS_DRAFT,
'ad_text' => 'Приходите к нам',
'ad_link' => 'https://t.me/example',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '5000.00',
'audience_kind' => Campaign::AUDIENCE_BASE,
'planned_count' => $planned,
'estimated_cost_rub' => '0.00',
'created_by' => $test->user->id,
]);
}
/** Набивает базу контактов, чтобы кампания прошла порог площадки. */
function kontaktyCena(mixed $test, int $skolko): void
{
for ($i = 0; $i < $skolko; $i++) {
Contact::create([
'tenant_id' => $test->tenant->id,
'phone' => sprintf('7999%07d', $i),
'name' => null,
'operator' => null,
]);
}
}
// ── Вид медиа запоминается при загрузке ─────────────────────────────────────
it('картинка помечается как image', function () {
$campaign = chernovikCena($this);
$this->post("/api/telegram/campaigns/{$campaign->id}/media", [
'media' => MtsMediaFixture::kartinka(),
])->assertOk();
expect($campaign->fresh()->media_kind)->toBe(Campaign::MEDIA_IMAGE);
});
it('видео помечается как video', function () {
$campaign = chernovikCena($this);
$this->post("/api/telegram/campaigns/{$campaign->id}/media", [
'media' => MtsMediaFixture::video(),
])->assertOk();
expect($campaign->fresh()->media_kind)->toBe(Campaign::MEDIA_VIDEO);
});
it('черновик без медиа считается как «без медиа»', function () {
expect(chernovikCena($this)->media_kind)->toBeNull();
});
// ── Смета на экране пересчитывается сразу после загрузки ────────────────────
it('после загрузки видео смета кампании растёт с 268.80 до 456.96', function () {
$campaign = chernovikCena($this, 400);
$campaign->update(['estimated_cost_rub' => '268.80']);
$this->post("/api/telegram/campaigns/{$campaign->id}/media", [
'media' => MtsMediaFixture::video(),
])->assertOk();
expect((string) $campaign->fresh()->estimated_cost_rub)->toBe('456.96');
});
it('замена видео на картинку удешевляет смету обратно', function () {
$campaign = chernovikCena($this, 400);
$this->post("/api/telegram/campaigns/{$campaign->id}/media", ['media' => MtsMediaFixture::video()])->assertOk();
expect((string) $campaign->fresh()->estimated_cost_rub)->toBe('456.96');
$this->post("/api/telegram/campaigns/{$campaign->id}/media", [
'media' => MtsMediaFixture::kartinka(),
])->assertOk();
expect($campaign->fresh()->media_kind)->toBe(Campaign::MEDIA_IMAGE)
->and((string) $campaign->fresh()->estimated_cost_rub)->toBe('403.20');
});
// ── Живой счётчик на экране знает про медиа ─────────────────────────────────
it('оценка охвата считает цену по заявленному медиа', function () {
kontaktyCena($this, 400);
$bez = $this->postJson('/api/telegram/campaigns/estimate', [
'audience_kind' => Campaign::AUDIENCE_BASE,
]);
$kartinka = $this->postJson('/api/telegram/campaigns/estimate', [
'audience_kind' => Campaign::AUDIENCE_BASE,
'media_kind' => Campaign::MEDIA_IMAGE,
]);
$video = $this->postJson('/api/telegram/campaigns/estimate', [
'audience_kind' => Campaign::AUDIENCE_BASE,
'media_kind' => Campaign::MEDIA_VIDEO,
]);
expect($bez->json('estimated_cost_rub'))->toBe('268.80')
->and($kartinka->json('estimated_cost_rub'))->toBe('403.20')
->and($video->json('estimated_cost_rub'))->toBe('456.96');
});
it('выдуманный вид медиа в оценке не проходит', function () {
kontaktyCena($this, 400);
$this->postJson('/api/telegram/campaigns/estimate', [
'audience_kind' => Campaign::AUDIENCE_BASE,
'media_kind' => 'gif',
])->assertStatus(422);
});
// ── Списание при запуске идёт по медиа, а не по «просто показам» ────────────
it('запуск кампании с видео списывает 456.96, а не 268.80', function () {
config(['client_tg.sandbox' => false]);
kontaktyCena($this, 400);
Queue::fake();
$campaign = chernovikCena($this, 400);
$this->post("/api/telegram/campaigns/{$campaign->id}/media", ['media' => MtsMediaFixture::video()])->assertOk();
$this->postJson("/api/telegram/campaigns/{$campaign->id}/launch")->assertOk();
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id);
$spisano = (string) BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)
->value('amount_rub');
expect(bccomp(ltrim($spisano, '-'), '456.96', 2))->toBe(0)
->and((string) $campaign->fresh()->estimated_cost_rub)->toBe('456.96');
});
it('запуск кампании без медиа списывает 268.80', function () {
config(['client_tg.sandbox' => false]);
kontaktyCena($this, 400);
Queue::fake();
$campaign = chernovikCena($this, 400);
$this->postJson("/api/telegram/campaigns/{$campaign->id}/launch")->assertOk();
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id);
$spisano = (string) BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)
->value('amount_rub');
expect(bccomp(ltrim($spisano, '-'), '268.80', 2))->toBe(0);
});