ac16861b87
Статус launched был терминальным: конца у рекламы не существовало вовсе. Отсюда две беды разом — робот ходил за цифрами вечно, а неоткрученные деньги возвращать было некуда. Живая улика, кампания №15: списано 255,36 ₽ за 380 показов, показан 21, площадка израсходовала 10,08 ₽. Переплата 241,25 ₽ не вернулась бы никогда. Портал честно читал stats_spend_rub и не использовал его нигде. Ш1 робот перестаёт долбиться в потерянные кампании: 5 неудач подряд уводят в разбор, считаются только заходы после последней правки строки кампании Ш2 статус кабинета доезжает до портала; незнакомое слово идёт в журнал Ш3 новый статус completed «Показы закончились», переход launched → completed Ш4 суточная задача в 04:40 закрывает отработавшую рекламу и возвращает переплату; возврат идемпотентен ключом tg:campaign:ID:vozvrat, неизвестный расход не закрываем Ш5 тревога в журнал, если заморозка денег висит дольше суток Попутно вскрыто и починено: разбор кампании писал клиенту «деньги вернулись на рекламный кошелёк», не вернув ни рубля. Новый вход в старый узел сделал его сообщение ложью. Новая колонка client_tg_campaigns.spend_changed_at — двигается только при реальном изменении суммы; stats_read_at для этого не годится, он растёт на каждом заходе робота. Журнал схемы v9.74. Кнопка «Остановить» не сделана, закрытие по слову кабинета тоже: разведка 08.08 показала, что кабинет говорит «Останавливается, до 30 минут», но итогового слова мы так и не узнали, а вернулись ли деньги — не замерено. Подробности и что делать дальше — в плане. На боевой не выкачено: нужна миграция и разрешение владельца. Сторожа: ClientTg 438 зелёных, экраны телеграма 47 зелёных. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
103 lines
4.8 KiB
PHP
103 lines
4.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Jobs\ClientTg\ZavershitOtrabotavshieTelegramJob;
|
|
use App\Models\AdWalletHold;
|
|
use App\Models\ClientTg\Campaign;
|
|
use App\Models\Tenant;
|
|
use App\Services\Advertising\AdWalletService;
|
|
use App\Services\ClientTg\TelegramCampaignChargeService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Tests\Concerns\SharesSupplierPdo;
|
|
|
|
/**
|
|
* Суточная проверка денег Telegram-рекламы: закрыть отработавшее и крикнуть про
|
|
* зависшую заморозку.
|
|
*
|
|
* Перечисление кросс-тенантное через BYPASSRLS-роль — шарим PDO, иначе данные транзакции
|
|
* RefreshDatabase не видны supplier-соединению.
|
|
*/
|
|
uses(RefreshDatabase::class, SharesSupplierPdo::class);
|
|
|
|
function jobKampaniya(int $tenantId, array $peremenit = []): Campaign
|
|
{
|
|
return Campaign::query()->create(array_merge([
|
|
'tenant_id' => $tenantId,
|
|
'status' => Campaign::STATUS_LAUNCHED,
|
|
'ad_text' => 'Приходите к нам за услугой',
|
|
'ad_link' => 'https://example.test/promo',
|
|
'ord_category' => 'Размещение рекламы',
|
|
'budget_cap_rub' => '400.00',
|
|
'audience_kind' => Campaign::AUDIENCE_LIST,
|
|
'planned_count' => 400,
|
|
'estimated_cost_rub' => '268.80',
|
|
'mts_cost_rub' => '182.40',
|
|
'actual_cost_rub' => '255.36',
|
|
'stats_impressions' => 21,
|
|
'stats_spend_rub' => '10.08',
|
|
'stats_read_at' => now(),
|
|
'created_by' => 1,
|
|
], $peremenit));
|
|
}
|
|
|
|
beforeEach(function () {
|
|
config()->set('client_tg.sandbox', false);
|
|
config()->set('client_tg.markup', '1.40');
|
|
config()->set('client_tg.spend_stale_days', 3);
|
|
|
|
$this->tenant = Tenant::factory()->create(['balance_rub' => '0.00']);
|
|
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id);
|
|
app(AdWalletService::class)->topup($this->tenant->id, '1000.00', 'telegram', 'Пополнение для теста');
|
|
});
|
|
|
|
it('закрывает вставшую рекламу и не трогает идущую', function () {
|
|
$vstala = jobKampaniya($this->tenant->id, ['spend_changed_at' => now()->subDays(4)]);
|
|
$idyot = jobKampaniya($this->tenant->id, ['spend_changed_at' => now()->subHours(2)]);
|
|
|
|
ZavershitOtrabotavshieTelegramJob::dispatchSync();
|
|
|
|
expect($vstala->fresh()->status)->toBe(Campaign::STATUS_COMPLETED)
|
|
->and($idyot->fresh()->status)->toBe(Campaign::STATUS_LAUNCHED);
|
|
});
|
|
|
|
it('кричит в журнал про заморозку, висящую дольше суток', function () {
|
|
$campaign = jobKampaniya($this->tenant->id, ['spend_changed_at' => now()]);
|
|
app(TelegramCampaignChargeService::class)->freeze($campaign, '268.80');
|
|
|
|
// Отматываем заморозку на двое суток — ровно случай боевой №14 (268,80 ₽ заперты
|
|
// с 06.08, никто не знал).
|
|
AdWalletHold::where('source_id', $campaign->id)->update(['created_at' => now()->subDays(2)]);
|
|
|
|
$zhurnal = Log::spy();
|
|
ZavershitOtrabotavshieTelegramJob::dispatchSync();
|
|
|
|
$zhurnal->shouldHaveReceived('warning', [
|
|
Mockery::on(fn (string $soobshenie): bool => $soobshenie === 'client_tg.zamorozka_visit'),
|
|
Mockery::on(fn (array $ctx): bool => ($ctx['campaign_id'] ?? null) === (int) $campaign->id
|
|
&& ($ctx['summa_rub'] ?? null) === '268.80'),
|
|
]);
|
|
});
|
|
|
|
it('свежая заморозка тревоги не поднимает', function () {
|
|
$campaign = jobKampaniya($this->tenant->id, ['spend_changed_at' => now()]);
|
|
app(TelegramCampaignChargeService::class)->freeze($campaign, '268.80');
|
|
|
|
$zhurnal = Log::spy();
|
|
ZavershitOtrabotavshieTelegramJob::dispatchSync();
|
|
|
|
// 🪤 Два матчера, а не одна строка: у Log::warning ДВА довода (сообщение и подробности),
|
|
// и проверка по одному доводу не совпала бы никогда — сторож был бы вечно зелёным и не
|
|
// охранял бы ничего.
|
|
//
|
|
// 🔑 Проверено вырезанием: с порогом тревоги −1 час этот тест КРАСНЕЕТ. Порог 0 его не
|
|
// валит, и это не слабость сторожа — заморозка создаётся за доли секунды до проверки,
|
|
// и строгое «раньше, чем» на таком зазоре честно не срабатывает.
|
|
$zhurnal->shouldNotHaveReceived('warning', [
|
|
Mockery::on(fn (string $soobshenie): bool => $soobshenie === 'client_tg.zamorozka_visit'),
|
|
Mockery::any(),
|
|
]);
|
|
});
|