# Спецификация G2-A: дайджест новых сделок на почту (движок)

## Цель

Заменить письмо-на-каждый-лид одним периодическим письмом-сводкой: раз в 30 минут клиент получает одно письмо «пришло N новых сделок» со списком, если за окно были новые сделки. Это убирает почтовый спам при пачках лидов (30+/день в 3–4 волны) и сохраняет своевременность (задержка ≤30 мин).

Объём G2-A — движок дайджеста без изменения схемы БД. Включение по умолчанию (флип дефолта `new_lead.email` + дотяжка) — отдельная работа G2-B.

## Контекст и текущее поведение {#D1}

Сейчас при создании сделки `RouteSupplierLeadJob` вызывает `NotificationService::notifyNewLead`, который шлёт два канала: in-app (колокольчик) и email-на-каждую-сделку (`NewLeadNotification`) тем, у кого `notification_preferences.new_lead.email = true`. Email-на-каждую-сделку при пачках = спам.

G2-A: email-канал события `new_lead` переводится с пер-лид на дайджест. In-app (колокольчик) на каждую сделку — без изменений. Галочка «Новый лид / Email» в настройках теперь управляет дайджестом (тот же ключ `new_lead.email`); снятие галочки = писем нет.

## Джоб дайджеста {#D2}

Новый `App\Jobs\SendNewLeadsDigestJob` (implements `ShouldQueue`, трейты `Dispatchable`/`InteractsWithQueue`/`Queueable`) по образцу `BalancePreflightSweepJob`:

- `handle(NotificationService $notifier)`: `Tenant::query()->whereNull('deleted_at')->chunkById(200, ...)`.
- На каждого тенанта — `DB::transaction(function () { DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tenant->id); ... })` (RLS-контекст для CLI/джоба, как в `BalancePreflightSweepJob`).
- Внутри: выборка новых сделок за окно — `Deal::query()->where('received_at', '>', now()->subMinutes(30))->where('is_test', false)->whereNull('deleted_at')->orderBy('received_at')->get()`.
- Если коллекция не пуста — `$notifier->notifyNewLeadsDigest($tenant, $deals)`.

Окно 30 минут совпадает с интервалом запуска ({#D5}) — без таблицы трекинга (G2-A schema-free). Edge-case: пропуск запуска планировщика → окно теряется (приемлемо для уведомления; сделки видны в колокольчике и ленте). Дубли исключены непересекающимся окном.

## Сервис и письмо {#D3}

В `App\Services\NotificationService`:

- новый метод `notifyNewLeadsDigest(Tenant $tenant, \Illuminate\Support\Collection $deals): void` — для каждого активного пользователя тенанта с `prefEnabled($user, EVENT_NEW_LEAD, CHANNEL_EMAIL)` шлёт `new NewLeadsDigestMail($user, $tenant, $deals)` через приватный `sendEmail` (Throwable проглатывается, как в существующих методах).

Новое письмо `App\Mail\NewLeadsDigestMail` (по образцу `NewLeadNotification`):

- конструктор `(User $user, Tenant $tenant, \Illuminate\Support\Collection $deals)`;
- `envelope()`: subject `"Лидерра. Новые сделки — {$deals->count()}"`;
- `content()`: view `emails.new_leads_digest`, передаёт `user`, `tenant`, `deals`, `count`.

Шаблон `resources/views/emails/new_leads_digest.blade.php` — заголовок «Лидерра. Новые сделки», строка «Поступило N новых сделок», таблица по сделкам (телефон, имя, город, проект, время `received_at`), ссылка «Откройте CRM», футер про настройки уведомлений (по образцу `new_lead.blade.php`).

## Отключение пер-лид email {#D4}

В `NotificationService::notifyNewLead` блок отправки email (цикл `recipientsForEvent(..., CHANNEL_EMAIL)` → `sendEmail(... NewLeadNotification ...)`) удаляется. Остаётся только in-app цикл. Так пер-лид письмо больше не уходит, и дайджест ({#D2}/{#D3}) становится единственным email-каналом события `new_lead` — без двойных писем. Класс `NewLeadNotification` и его шаблон остаются в репозитории (не используются методом, удаление — вне объёма).

## Планировщик {#D5}

В `routes/console.php` добавить запись по образцу уже существующей записи `CsvReconcileJob`.

ВАЖНО (контекст области видимости): переменная `$hb` НЕ создаётся заново в новой записи. Она уже объявлена в начале `routes/console.php` один раз — строка `$hb = app(\App\Services\SchedulerHeartbeatTracker::class);` (около строки 21, с аннотацией `/** @var SchedulerHeartbeatTracker $hb */`) — и находится в области видимости файла для всех последующих `Schedule::`-записей. Существующие записи (`CsvReconcileJob`, `RefreshSupplierSessionJob`, `SyncSupplierProjectsJob` и др.) ссылаются на тот же `$hb` без повторного `app(...)`. Новая запись делает так же:

```
Schedule::job(new SendNewLeadsDigestJob)->everyThirtyMinutes()
    ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\SendNewLeadsDigestJob', true, null, null))
    ->onFailure(fn () => $hb->recordRunResult('App\Jobs\SendNewLeadsDigestJob', false, 'Job failed', null));
```

Без `withoutOverlapping`/`onOneServer` (нет таблицы локов; джоб идемпотентен — фиксированное окно lookback).

## Критерий приёмки (Pest) {#D6}

Тест `tests/Feature/Notifications/NewLeadsDigestJobTest.php` (`uses(DatabaseTransactions::class)`, `beforeEach(Mail::fake)`, RLS `set_config('app.current_tenant_id','0',true)`; ассерты — per-tenant/email, т.к. liderra_testing persistent):

1. Тенант с пользователем `new_lead.email=true` и 3 свежими сделками → ровно одно `NewLeadsDigestMail` этому email; письмо «знает» про 3 сделки.
2. Пользователь с `new_lead.email=false` → `Mail::assertNotQueued(NewLeadsDigestMail::class, ...)` для его email.
3. Тенант без новых сделок за окно (сделки старше 30 мин) → письма нет.
4. `notifyNewLead` больше не шлёт `NewLeadNotification` (только in-app): после `notifyNewLead` для пользователя с `new_lead.email=true` — `Mail::assertNotSent(NewLeadNotification::class)`, in-app запись создана.

Все 4 — GREEN на `composer --working-dir=app test`.

```verified-context-json
[
  {"id":"vc1","kind":"EXTRACTED","ref":"app/app/Services/NotificationService.php","anchor":"public function notifyNewLead(Tenant $tenant, Deal $deal): void"},
  {"id":"vc2","kind":"EXTRACTED","ref":"app/app/Jobs/Billing/BalancePreflightSweepJob.php","anchor":"SET LOCAL app.current_tenant_id = "}
]
```
