aa4b482d45
Закрывает Этап 5 плана docs/superpowers/plans/2026-07-27-telegram-module-hardening.md.
Клиент управляет именем отправителя и авторассылкой сам из кабинета; авто не тратит
без денег и сверх дневного лимита. TDD, весь бэкенд+фронт зелёный.
## 5.1 — предохранители авторассылки
Накопитель в БОЮ перед постановкой пачки проверяет: смета умещается И в свободный
остаток кошелька (balance−frozen), И в дневной лимит правила за вычетом трат за
сегодня. Не прошла — держим черновиком + Log::info('client_tg.auto_skipped', reason).
Дефолт daily_limit_rub=0 → авто выключено (само денег не потратит). В песочнице гейта
нет (деньги не трогаются, как ручной launch). Новые колонки client_tg_auto_rule:
daily_limit_rub, spent_today_rub, spent_date (счётчик за день, сброс при смене даты).
## 5.2 — клиентское API имени (SenderController)
GET /sender (статус + остаток грейса), POST /sender (завести), /sender/disable,
/sender/enable (suspended→active, идемпотентно по периоду — без двойной оплаты в месяц;
проверка средств ДО списания). Логика в TelegramSenderService (enableSender+snapshot),
контроллер тонкий. Всё скоуп тенантом.
## 5.3 — экран имени (TelegramSenderPanel.vue)
Самодостаточная панель: статус имени человеческими словами, дата оплаты, остаток грейса
при долге; suspended → «Отключено за долг» + «Включить»; нет имени → форма «Завести имя».
telegram.ts: fetchSender/createSender/disableSender/enableSender.
## 5.4 — экран авторассылки + API (AutoRuleController)
GET/PUT /api/telegram/auto-rule (вкл/выкл, объявление, порог, бюджет, дневной лимит).
Порог клиентский — новая колонка client_tg_auto_rule.batch_threshold (NULL → дефолт
конфига 367; ниже 367 API не даёт — минимум МТС). TelegramAutoRulePanel.vue: тумблер +
поля порога/бюджета/лимита. В списке кампаний авто-кампании (created_by=null) помечены
чипом «авто».
Обе панели встроены в AdvertisingTelegramView.
## Схема
Две аддитивные миграции на существующую таблицу client_tg_auto_rule (000014 daily_limit
+ счётчик за день; 000015 batch_threshold). RLS/GRANT не тронуты (табличный GRANT
покрывает новые колонки). Записи db/CHANGELOG_schema.md v8.91/v8.92; обе прогнаны через
rls-reviewer — CLEAN.
## Приёмка
TDD. Бэкенд ClientTg 177/177; phpstan/pint/deptrac чисто. Фронт полный набор
212 файлов/1538 тестов зелёные; vue-tsc+ESLint по нашим файлам чисто. Проверено вживую
в браузере (клиент demo, песочница): обе панели читают и пишут — правило сохраняется
точь-в-точь (порог/бюджет/лимит), имя заводится (pending, деньги не тронуты).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
150 lines
6.4 KiB
PHP
150 lines
6.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Jobs\ClientTg\RunTelegramCampaignJob;
|
|
use App\Models\ClientTg\AutoRule;
|
|
use App\Models\ClientTg\Campaign;
|
|
use App\Models\Tenant;
|
|
use App\Services\Advertising\AdWalletService;
|
|
use App\Services\ClientTg\TelegramAutoAccumulator;
|
|
use App\Services\ClientTg\TelegramTariffService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Queue;
|
|
|
|
/**
|
|
* Предохранители авто-режима Telegram-рекламы (план §Этап 5, задача 5.1).
|
|
*
|
|
* Авто-путь копит номера и при пороге ставит кампанию в очередь. В БОЮ перед
|
|
* постановкой должен пройти двойной гейт: (1) на кошельке хватает свободных денег
|
|
* под смету пачки; (2) сумма за сегодня + смета не превышает дневной лимит правила
|
|
* (по умолчанию 0 → авто выключено). Не прошёл — не ставим в очередь, пишем причину.
|
|
*
|
|
* В песочнице деньги не трогаются (как ручной launch) — гейт не вмешивается.
|
|
* Синтетические номера 7999… — реальных ПДн не касаемся.
|
|
*/
|
|
uses(RefreshDatabase::class);
|
|
|
|
function safeguardRule(int $tenantId, string $dailyLimit = '1000.00'): AutoRule
|
|
{
|
|
return AutoRule::create([
|
|
'tenant_id' => $tenantId,
|
|
'enabled' => true,
|
|
'ad_text' => 'Заходите в наш канал',
|
|
'ad_link' => 'https://t.me/example_channel',
|
|
'ord_category' => 'Размещение рекламы',
|
|
'budget_cap_rub' => '2500.00',
|
|
'daily_limit_rub' => $dailyLimit,
|
|
]);
|
|
}
|
|
|
|
/** @return list<string> */
|
|
function safeguardPhones(int $n): array
|
|
{
|
|
$out = [];
|
|
for ($i = 1; $i <= $n; $i++) {
|
|
$out[] = '7999'.str_pad((string) $i, 7, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
function feed(int $tenantId, int $n): void
|
|
{
|
|
foreach (safeguardPhones($n) as $phone) {
|
|
app(TelegramAutoAccumulator::class)->accumulate($tenantId, $phone);
|
|
}
|
|
}
|
|
|
|
beforeEach(function () {
|
|
Carbon::setTestNow('2026-07-28 10:00:00');
|
|
Queue::fake();
|
|
config(['client_tg.auto_batch_threshold' => 3]); // порог занижен для теста
|
|
config(['client_tg.sandbox' => false]); // деньги включены — проверяем боевой гейт
|
|
$this->tenant = Tenant::factory()->create();
|
|
// Смета пачки из 3 кандидатов = 3 × 0.45 (ступень min_qty=1) = 1.35 ₽.
|
|
$this->cost = app(TelegramTariffService::class)->estimateRub(3);
|
|
});
|
|
|
|
afterEach(function () {
|
|
Carbon::setTestNow();
|
|
});
|
|
|
|
it('в бою при нехватке денег не ставит в очередь и пишет причину', function () {
|
|
app(AdWalletService::class)->topup($this->tenant->id, '1.00', 'telegram', 'test'); // < 1.35
|
|
safeguardRule($this->tenant->id, dailyLimit: '1000.00');
|
|
Log::spy();
|
|
|
|
feed($this->tenant->id, 3);
|
|
|
|
Queue::assertNothingPushed();
|
|
$campaign = Campaign::where('tenant_id', $this->tenant->id)->first();
|
|
expect($campaign->status)->toBe(Campaign::STATUS_DRAFT); // остаётся черновиком
|
|
Log::shouldHaveReceived('info')->withArgs(fn ($msg) => $msg === 'client_tg.auto_skipped')->atLeast()->once();
|
|
});
|
|
|
|
it('в бою при исчерпанном дневном лимите не ставит в очередь', function () {
|
|
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test'); // денег вдоволь
|
|
safeguardRule($this->tenant->id, dailyLimit: '1.00'); // лимит < сметы 1.35
|
|
|
|
feed($this->tenant->id, 3);
|
|
|
|
Queue::assertNothingPushed();
|
|
expect(Campaign::where('tenant_id', $this->tenant->id)->first()->status)->toBe(Campaign::STATUS_DRAFT);
|
|
});
|
|
|
|
it('дневной лимит по умолчанию 0 → авто выключено даже при деньгах', function () {
|
|
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
|
|
// daily_limit_rub не задан явно → дефолт 0.00
|
|
AutoRule::create([
|
|
'tenant_id' => $this->tenant->id,
|
|
'enabled' => true,
|
|
'ad_text' => 'Текст',
|
|
'ad_link' => 'https://t.me/x',
|
|
'ord_category' => 'Размещение рекламы',
|
|
'budget_cap_rub' => '2500.00',
|
|
]);
|
|
|
|
feed($this->tenant->id, 3);
|
|
|
|
Queue::assertNothingPushed();
|
|
});
|
|
|
|
it('в бою при деньгах и лимите в норме — ставит в очередь и учитывает трату за день', function () {
|
|
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
|
|
$rule = safeguardRule($this->tenant->id, dailyLimit: '1000.00');
|
|
|
|
feed($this->tenant->id, 3);
|
|
|
|
Queue::assertPushed(RunTelegramCampaignJob::class, 1);
|
|
expect(Campaign::where('tenant_id', $this->tenant->id)->first()->status)->toBe(Campaign::STATUS_QUEUED);
|
|
|
|
$rule->refresh();
|
|
expect((string) $rule->spent_today_rub)->toBe($this->cost); // 1.35 записано
|
|
expect($rule->spent_date->toDateString())->toBe('2026-07-28');
|
|
});
|
|
|
|
it('счётчик за день сбрасывается на новую дату', function () {
|
|
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
|
|
$rule = safeguardRule($this->tenant->id, dailyLimit: '1.35');
|
|
// Вчерашняя трата «под завязку» — но сегодня лимит должен освободиться.
|
|
$rule->update(['spent_today_rub' => '1.35', 'spent_date' => '2026-07-27']);
|
|
|
|
feed($this->tenant->id, 3);
|
|
|
|
Queue::assertPushed(RunTelegramCampaignJob::class, 1);
|
|
$rule->refresh();
|
|
expect((string) $rule->spent_today_rub)->toBe($this->cost); // счётчик пересчитан с нуля за сегодня
|
|
});
|
|
|
|
it('в песочнице предохранитель не вмешивается — ставит в очередь без денег', function () {
|
|
config(['client_tg.sandbox' => true]);
|
|
safeguardRule($this->tenant->id, dailyLimit: '0.00'); // ни денег, ни лимита
|
|
|
|
feed($this->tenant->id, 3);
|
|
|
|
Queue::assertPushed(RunTelegramCampaignJob::class, 1);
|
|
});
|