Files
portal/app/tests/Feature/ClientTg/AutoAccumulatorTest.php
T
Дмитрий 5b04d751c0 feat(телеграм-модуль): авто-режим, своё имя + помесячная оплата, админ-тарифы
Сессия 4 плана docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md.
Всё в песочнице (TG_SANDBOX): деньги/живой запуск выключены; робот в тестах замокан.

4.1 Авто-режим (пачка):
- Таблица client_tg_auto_rule (правило: объявление + budget_cap на пачку, одно на тенанта, RLS).
- TelegramAutoAccumulator: копит номера новых лидов в один открытый авто-черновик
  (audience_kind=list, created_by IS NULL); при наборе пачки (порог client_tg.auto_batch_threshold,
  по умолчанию 367 кандидатов) доводит смету, ставит кампанию в очередь В РАМКАХ budget_cap_rub
  и ставит RunTelegramCampaignJob. Ниже порога — только копим. Дедуп номеров на входе.
  Отличие от СМС-близнеца: СМС шлёт по одной на лид, Telegram копит ПАЧКУ (МТС показывает базе).

4.2 Защитный observer:
- DealTelegramObserver на created: freshness-guard (сутки) → ставит AccumulateTelegramLeadJob.
  🔴 try/catch(Throwable) НИКОГДА не роняет приём лида (регресс DealCreateTest 10/10 зелёный).
- Приёма-джоб уводит работу с роли приёмщика (crm_supplier_worker, без GRANT на auto_rule) на
  очередь (crm_app_user) с tenant-контекстом — иначе на проде был бы «тихий ноль». ПДн: телефон
  в payload не кладём, перечитываем по deal_id. Зеркало DealSmsObserver + SendAutoSmsForDealJob.

4.3 Своё имя/бренд + помесячная оплата:
- Таблица client_tg_senders (одно имя на тенанта, RLS; app_user S/I/U, supplier S, admin S/U).
- TelegramSenderService — жизненный цикл: клиент requestSender (заявка+бронь месячной платы) /
  disableSender; админ approve (списание+снятие брони+paid_until +1мес) / reject / disableByAdmin.
- ChargeTgNameFeeJob (ежедневно 05:10 МСК, routes/console.php): помесячное списание, проверка
  free≥cost ДО списания, идемпотентно по периоду (external_key), долг > grace (29д) → suspended,
  кросс-тенантно через pgsql_supplier, деньги под SET LOCAL. В песочнице деньги не двигаются.
  Лёгкое зеркало client_sms_senders + ChargeSmsNameFeeJob (без документов/операторов — согласование
  в Телеграме уточняется живой разведкой §8 спеки; здесь фиксируем денежный цикл).

4.4 Админ-тарифы/настройки:
- Api\Admin\TgTariffController (GET/PUT /api/admin/telegram/tariffs|settings) под saas-admin,admin-db.
- Фронт: api/admin.ts (fetchTgTariffs/updateTgTariffs/updateTgSettings), AdminTgView.vue (правка
  ступеней + плата за имя/грейс), маршрут /admin/telegram. Зеркало AdminSmsView (без секции имён).

Проверки: 89/89 Pest ClientTg зелёные (+37 новых), регресс DealCreateTest 10/10, Vitest зелёный
(+4 admin-tg-view), phpstan 0 по своему коду (level 5), pint чисто, deptrac 0 нарушений.
rls-reviewer: PASS по обеим новым таблицам (client_tg_auto_rule, client_tg_senders). CHANGELOG_schema
v8.87 + напоминание ПЕРЕзапустить db/03_service_bypass_policies.sql на кластере (Сессия 6). Робот в
тестах замокан (кабинет МТС не тронут). Синтетические номера 7999… — реальных нет.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 18:41:33 +03:00

136 lines
5.6 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\ClientTg\TelegramAutoAccumulator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
/**
* Авто-режим (план §Сессия 4, задача 4.1): новые лиды копятся в открытый черновик
* авто-кампании; при достижении порога (≥367 кандидатов; в тестах порог занижаем
* через config) черновик формируется в кампанию и ставится в очередь В РАМКАХ
* `budget_cap_rub` из авто-правила. Ниже порога — только копим.
*
* Отличие от СМС-близнеца: СМС шлёт по одной на каждый лид, Telegram копит ПАЧКУ
* (МТС показывает базе, а не по одному). Синтетические номера 7999… — реальных нет.
*/
uses(RefreshDatabase::class);
function makeTgAutoRule(int $tenantId, bool $enabled = true): AutoRule
{
return AutoRule::create([
'tenant_id' => $tenantId,
'enabled' => $enabled,
'ad_text' => 'Заходите в наш канал',
'ad_link' => 'https://t.me/example_channel',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '2500.00',
]);
}
function tgAccumulator(): TelegramAutoAccumulator
{
return app(TelegramAutoAccumulator::class);
}
/** @return list<string> */
function tgFakePhones(int $n, int $offset = 0): array
{
$out = [];
for ($i = 1; $i <= $n; $i++) {
// 7999 + 7 цифр — синтетический номер (ПДн реальных клиентов не касаемся).
$out[] = '7999'.str_pad((string) ($offset + $i), 7, '0', STR_PAD_LEFT);
}
return $out;
}
beforeEach(function () {
Queue::fake();
$this->tenant = Tenant::factory()->create();
config(['client_tg.auto_batch_threshold' => 3]); // в тестах порог занижен
});
it('правило выключено — ничего не копит и не запускает', function () {
makeTgAutoRule($this->tenant->id, enabled: false);
tgAccumulator()->accumulate($this->tenant->id, '79990000001');
expect(Campaign::where('tenant_id', $this->tenant->id)->count())->toBe(0);
Queue::assertNothingPushed();
});
it('нет правила вовсе — тихо выходим', function () {
tgAccumulator()->accumulate($this->tenant->id, '79990000001');
expect(Campaign::where('tenant_id', $this->tenant->id)->count())->toBe(0);
Queue::assertNothingPushed();
});
it('ниже порога — копим в один черновик, в очередь не ставим', function () {
makeTgAutoRule($this->tenant->id);
tgAccumulator()->accumulate($this->tenant->id, '79990000001');
tgAccumulator()->accumulate($this->tenant->id, '79990000002');
$campaigns = Campaign::where('tenant_id', $this->tenant->id)->get();
expect($campaigns)->toHaveCount(1);
$draft = $campaigns->first();
expect($draft->status)->toBe(Campaign::STATUS_DRAFT);
expect($draft->audience_kind)->toBe(Campaign::AUDIENCE_LIST);
expect($draft->created_by)->toBeNull(); // авто-кампания — без автора
expect($draft->phones()->count())->toBe(2);
Queue::assertNothingPushed();
});
it('повторный номер не задваивает пачку', function () {
makeTgAutoRule($this->tenant->id);
tgAccumulator()->accumulate($this->tenant->id, '79990000001');
tgAccumulator()->accumulate($this->tenant->id, '79990000001');
$draft = Campaign::where('tenant_id', $this->tenant->id)->first();
expect($draft->planned_count)->toBe(1); // кандидат один
Queue::assertNothingPushed();
});
it('достигли порога — кампания в очереди, budget_cap из правила соблюдён', function () {
$rule = makeTgAutoRule($this->tenant->id);
foreach (tgFakePhones(3) as $phone) {
tgAccumulator()->accumulate($this->tenant->id, $phone);
}
$campaign = Campaign::where('tenant_id', $this->tenant->id)->first();
expect($campaign->status)->toBe(Campaign::STATUS_QUEUED);
expect($campaign->planned_count)->toBe(3);
expect((string) $campaign->budget_cap_rub)->toBe((string) $rule->budget_cap_rub);
Queue::assertPushed(RunTelegramCampaignJob::class, 1);
});
it('после запуска пачки следующий лид открывает новую пачку', function () {
makeTgAutoRule($this->tenant->id);
foreach (tgFakePhones(3) as $phone) { // первая пачка → в очередь
tgAccumulator()->accumulate($this->tenant->id, $phone);
}
tgAccumulator()->accumulate($this->tenant->id, '79991110001'); // новый лид
$drafts = Campaign::where('tenant_id', $this->tenant->id)
->where('status', Campaign::STATUS_DRAFT)->get();
expect($drafts)->toHaveCount(1);
expect($drafts->first()->phones()->count())->toBe(1);
});
it('порог пачки по умолчанию — 367', function () {
expect(config('client_tg.auto_batch_threshold'))->not->toBeNull();
// Значение по умолчанию читаем из свежесобранного конфига (без тестового override).
$default = require config_path('client_tg.php');
expect($default['auto_batch_threshold'])->toBe(367);
});