f70527c3df
🔴 Найдено чтением кода, а не по памяти: робот НИКОГДА не оплачивал кампанию. finalize доводил боевой запуск до кассы кабинета и уходил (launched:false, stoppedAt:'payment'), денежные кнопки ему были запрещены наглухо, а «реальная оплата» отложена на «Сессию 6», которой не случилось. Песочницу при этом выключили 02.08 в 07:40. Итог на бою: клиент жал «Запустить» → у него списывалась ВСЯ смета по размеру списка → в кабинете оставался неоплаченный черновик → на модерацию он не уходил → реклама не показывалась ни разу → деньги не возвращались никогда (статус draft_ready терминальный, возврата не имеет). То есть беда была не «клиент переплачивает разницу», как записали накануне, а «клиент платит сто процентов ни за что». Слепки установленного у владельца робота совпали с веткой до буквы — на боевой машине тот же код. Владелец решил: боевой не трогать (стоит как стоит), роботу денежную кнопку разрешить, но с потолком. ── Робот теперь платит ──────────────────────────────────────────────────────── submitWithPayment на шаге /payment: сперва ЧИТАЕТ сумму к оплате, потом гонит её через гейт против меньшего из двух потолков (лимит кампании и общий потолок робота), и только потом ищет кнопку и жмёт. Сумма не прочиталась — не платим: не знаем, что списываем. Не ушли со /payment после клика — падаем громко, портал по отказу отпустит заморозку. Общий чёрный список денежных кнопок НЕ ослаблен: та же кнопка остаётся запретной для всех прочих путей, включая пересдачу. Разрешение точечное. Прочитанная сумма — это НАШИ расходы у МТС; она едет в портал полем actualCostRub, которое до сих пор было пустой заготовкой. ── Кошелёк вместо общего баланса ────────────────────────────────────────────── Порядок зеркалит сам МТС (билинг снят живьём 27–28.07, FLOW-FINDINGS «Задача 2.0»: кабинет резервирует сумму, окончательно списывает по факту показов, остаток возвращает): запуск → морозим смету на ad_wallets (канал telegram) робот заплатил → фактическая сумма легла в кампанию (mts_cost_rub, v9.66) модерация «да» → списываем по факту × наценка, остаток отпускаем модерация «нет» → отпускаем всё, ни рубля не списано сбой до кабинета → отпускаем всё Заморозка была убрана 29.07 намеренно — тогда рассуждали «сумма известна в момент запуска, морозить нечего». Рассуждение верно ровно до вопроса владельца: сумма известна, а сколько человек из списка вообще есть в телеграме — нет. Факта нет, а модерация одобрила — НЕ списываем ничего и кричим в журнал. Списать «по оценке» значило бы вернуть ровно ту беду, ради которой всё и делалось. Идемпотентность больше не самодельная: бронь уникальна по кампании, списание — по ключу события. Прежнее «сальдо проводок» стало не нужно, CampaignChargeServiceTest удалён — его предмет (charge/refund по общему балансу) больше не существует, замена KoshelekTelegramaTest. ── Экран ────────────────────────────────────────────────────────────────────── Карточка денег показывала общий баланс портала. После переезда это стало прямым враньём: клиент видел бы «денег хватает» там, где запуск отвечает 409. Теперь ручка отдаёт СВОБОДНЫЕ деньги кошелька (баланс минус заморозка) и заморозку отдельной графой, а кнопка пополнения ведёт в рекламный кошелёк — прежняя клала бы деньги в общий баланс, и запустить рекламу всё равно было бы нельзя. ── Проверено вырезанием, а не только зелёным ───────────────────────────────── - убрал запрет «сумма не прочитана» — покраснели 2 датчика оплаты; - вернул списание по смете вместо факта — датчик поймал 315 ₽ там, где должно быть 210 ₽. Замеры: телеграм-модуль 330/330, вместе с рекламой и СМС 1078/1078, экраны 251 файл / 2033 теста / 0 падений, робот 160/160, статанализ 0, типы 0, формат чисто. Сторож денег под боевой ролью переписан: класс «тихий ноль» закрылся сам — AdWalletService ставит контекст клиента сам, а не надеется на вызывающего. 🔴 На боевой НЕ выкачено. Осталось открытым: показать клиенту строкой «заморожено / списано по факту / возвращено» на карточке кампании; пересдача по-прежнему шлёт «без оплаты» (кампания вернётся на модерацию неоплаченной); числа 0,720 и 0,816 за показ с медиа так и не замерены живьём. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
184 lines
7.5 KiB
PHP
184 lines
7.5 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
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\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' => '0.00']);
|
||
tgKoshelekTopup($this->tenant->id, '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();
|
||
|
||
expect(tgKoshelekFrozen($this->tenant->id))->toBe('456.96')
|
||
->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();
|
||
|
||
expect(tgKoshelekFrozen($this->tenant->id))->toBe('268.80');
|
||
});
|