diff --git a/app/app/Jobs/SendNewLeadsDigestJob.php b/app/app/Jobs/SendNewLeadsDigestJob.php new file mode 100644 index 00000000..25518aba --- /dev/null +++ b/app/app/Jobs/SendNewLeadsDigestJob.php @@ -0,0 +1,58 @@ +whereNull('deleted_at')->chunkById(200, function (EloquentCollection $tenants) use ($notifier): void { + foreach ($tenants as $tenant) { + /** @var Tenant $tenant */ + $this->digestForTenant($tenant, $notifier); + } + }); + } + + private function digestForTenant(Tenant $tenant, NotificationService $notifier): void + { + DB::transaction(function () use ($tenant, $notifier): void { + DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tenant->id); + + $deals = Deal::query() + ->where('tenant_id', $tenant->id) + ->where('received_at', '>', now()->subMinutes(30)) + ->where('is_test', false) + ->whereNull('deleted_at') + ->orderBy('received_at') + ->get(); + + if ($deals->isEmpty()) { + return; + } + + $notifier->notifyNewLeadsDigest($tenant, $deals); + }); + } +} diff --git a/app/app/Mail/NewLeadsDigestMail.php b/app/app/Mail/NewLeadsDigestMail.php new file mode 100644 index 00000000..17dd37f6 --- /dev/null +++ b/app/app/Mail/NewLeadsDigestMail.php @@ -0,0 +1,52 @@ + $deals + */ +class NewLeadsDigestMail extends Mailable +{ + use Queueable; + use SerializesModels; + + public function __construct( + public User $user, + public Tenant $tenant, + public Collection $deals, + ) {} + + public function envelope(): Envelope + { + return new Envelope( + subject: 'Лидерра. Новые сделки — '.$this->deals->count(), + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.new_leads_digest', + with: [ + 'user' => $this->user, + 'tenant' => $this->tenant, + 'deals' => $this->deals, + 'count' => $this->deals->count(), + ], + ); + } +} diff --git a/app/app/Services/NotificationService.php b/app/app/Services/NotificationService.php index 867c0632..fe0737cf 100644 --- a/app/app/Services/NotificationService.php +++ b/app/app/Services/NotificationService.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace App\Services; use App\Mail\InvoicePaidNotification; -use App\Mail\NewLeadNotification; use App\Mail\ReminderDueNotification; use App\Mail\TopupSuccessNotification; use App\Mail\ZeroBalancePausedMail; @@ -90,11 +89,8 @@ class NotificationService { $projectName = $deal->project?->name ?? 'Без проекта'; - // Канал email. - foreach ($this->recipientsForEvent($tenant, self::EVENT_NEW_LEAD, self::CHANNEL_EMAIL) as $user) { - $this->sendEmail($user, self::EVENT_NEW_LEAD, new NewLeadNotification($user, $deal, $tenant)); - } - + // Канал email события new_lead переведён на дайджест (SendNewLeadsDigestJob). + // Здесь — только in-app (колокольчик) на каждую сделку. // Канал inapp. $title = "Новый лид — {$projectName}"; $body = $deal->contact_name ?: $deal->phone; @@ -106,6 +102,18 @@ class NotificationService } } + /** + * G2-A дайджест: одно письмо-сводка о новых сделках за окно — каждому + * активному пользователю тенанта с включённым new_lead.email. Заменяет + * пер-лид email-канал события new_lead. + */ + public function notifyNewLeadsDigest(Tenant $tenant, \Illuminate\Support\Collection $deals): void + { + foreach ($this->recipientsForEvent($tenant, self::EVENT_NEW_LEAD, self::CHANNEL_EMAIL) as $user) { + $this->sendEmail($user, self::EVENT_NEW_LEAD, new \App\Mail\NewLeadsDigestMail($user, $tenant, $deals)); + } + } + /** * Уведомление о наступлении срока напоминания. Получатели: * — assignee_id, если задан и активен; diff --git a/app/resources/views/emails/new_leads_digest.blade.php b/app/resources/views/emails/new_leads_digest.blade.php new file mode 100644 index 00000000..4f507f52 --- /dev/null +++ b/app/resources/views/emails/new_leads_digest.blade.php @@ -0,0 +1,37 @@ + + +
+ +Здравствуйте, {{ $user->first_name ?? $user->email }}.
+ +Поступило {{ $count }} новых сделок.
+ +| Телефон | +Имя | +Город | +Получено | +
|---|---|---|---|
| {{ $deal->phone }} | +{{ $deal->contact_name ?: '—' }} | +{{ $deal->city ?: '—' }} | +{{ $deal->received_at?->setTimezone($user->timezone ?? 'Europe/Moscow')->format('d.m.Y H:i') }} | +
Откройте CRM, чтобы взять сделки в работу.
+ ++ Это автоматическая сводка по событию «Новый лид». Чтобы отключить — снимите галочку в Настройки → Уведомления. +
+ + diff --git a/app/routes/console.php b/app/routes/console.php index ee8b8916..5968fa0b 100644 --- a/app/routes/console.php +++ b/app/routes/console.php @@ -148,6 +148,11 @@ Schedule::job(new CsvReconcileJob)->everyThirtyMinutes() ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\CsvReconcileJob', true, null, null)) ->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\CsvReconcileJob', false, 'Job failed', null)); +// G2-A: дайджест новых сделок клиентам — каждые 30 минут. +Schedule::job(new \App\Jobs\SendNewLeadsDigestJob)->everyThirtyMinutes() + ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\SendNewLeadsDigestJob', true, null, null)) + ->onFailure(fn () => $hb->recordRunResult('App\Jobs\SendNewLeadsDigestJob', false, 'Job failed', null)); + // Audit #2 Phase 14 P2: авто-детекция штормов упавших webhook-джобов. // Сканирует за последние 10 мин, порог 200, дедуп 60 мин. Schedule::command('incidents:watch-failures') diff --git a/app/tests/Feature/Notifications/NewLeadsDigestJobTest.php b/app/tests/Feature/Notifications/NewLeadsDigestJobTest.php new file mode 100644 index 00000000..342efe81 --- /dev/null +++ b/app/tests/Feature/Notifications/NewLeadsDigestJobTest.php @@ -0,0 +1,86 @@ +create([ + 'tenant_id' => $tenant->id, + 'email' => $email, + 'notification_preferences' => [ + 'new_lead' => ['email' => $emailOn, 'inapp' => true], + ], + ]); +} + +it('шлёт одно письмо-сводку с N сделками подписанному пользователю', function () { + $tenant = Tenant::factory()->create(); + digestUser($tenant, 'digest-on@example.test', true); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(5), 'is_test' => false]); + Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(10), 'is_test' => false]); + Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(15), 'is_test' => false]); + + (new SendNewLeadsDigestJob)->handle(app(NotificationService::class)); + + Mail::assertSent( + NewLeadsDigestMail::class, + fn (NewLeadsDigestMail $m) => $m->hasTo('digest-on@example.test') && $m->deals->count() === 3, + ); +}); + +it('не шлёт сводку пользователю с выключенным email', function () { + $tenant = Tenant::factory()->create(); + digestUser($tenant, 'digest-off@example.test', false); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(5), 'is_test' => false]); + + (new SendNewLeadsDigestJob)->handle(app(NotificationService::class)); + + Mail::assertNotSent( + NewLeadsDigestMail::class, + fn (NewLeadsDigestMail $m) => $m->hasTo('digest-off@example.test'), + ); +}); + +it('не шлёт сводку, если за окно нет новых сделок', function () { + $tenant = Tenant::factory()->create(); + digestUser($tenant, 'digest-old@example.test', true); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(45), 'is_test' => false]); + + (new SendNewLeadsDigestJob)->handle(app(NotificationService::class)); + + Mail::assertNotSent( + NewLeadsDigestMail::class, + fn (NewLeadsDigestMail $m) => $m->hasTo('digest-old@example.test'), + ); +}); + +it('notifyNewLead больше не шлёт пер-лид письмо', function () { + $tenant = Tenant::factory()->create(); + digestUser($tenant, 'perlead@example.test', true); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + $deal = Deal::factory()->for($tenant)->create(['received_at' => now(), 'is_test' => false]); + + app(NotificationService::class)->notifyNewLead($tenant, $deal); + + Mail::assertNotSent(NewLeadNotification::class); +}); diff --git a/docs/superpowers/plans/2026-06-18-g2a-new-leads-digest-plan.md b/docs/superpowers/plans/2026-06-18-g2a-new-leads-digest-plan.md new file mode 100644 index 00000000..8f1d682d --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-g2a-new-leads-digest-plan.md @@ -0,0 +1,420 @@ +# План G2-A: дайджест новых сделок на почту (движок) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Раз в 30 минут слать клиенту одно письмо-сводку «N новых сделок» вместо письма-на-каждую-сделку. + +**Architecture:** Новый scheduled-джоб (по образцу `BalancePreflightSweepJob`) собирает новые сделки тенанта за 30-мин окно и через `NotificationService` шлёт одно письмо-сводку получателям с включённым `new_lead.email`. Пер-лид email из `notifyNewLead` убирается. Без изменений схемы БД. + +**Tech Stack:** PHP 8.3 + Laravel 13, Pest 4. + +--- + +## Цель + +Движок дайджеста: джоб + письмо + шаблон + запись планировщика + снятие пер-лид email, проверенный Pest'ом. Включение по умолчанию (флип дефолта) — отдельный план G2-B. + +```skills-json +["test-driven-development"] +``` + +```steps-json +[ + {"op":"Write","object":"app/tests/Feature/Notifications/NewLeadsDigestJobTest.php","ref":"D6"}, + {"op":"Write","object":"app/app/Mail/NewLeadsDigestMail.php","ref":"D3"}, + {"op":"Write","object":"app/resources/views/emails/new_leads_digest.blade.php","ref":"D3"}, + {"op":"Write","object":"app/app/Jobs/SendNewLeadsDigestJob.php","ref":"D2"}, + {"op":"Edit","object":"app/app/Services/NotificationService.php","ref":"D3"}, + {"op":"Edit","object":"app/app/Services/NotificationService.php","ref":"D4"}, + {"op":"Edit","object":"app/app/Services/NotificationService.php","ref":"D4"}, + {"op":"Edit","object":"app/routes/console.php","ref":"D5"}, + {"op":"Bash","object":"composer --working-dir=app test -- tests/Feature/Notifications/NewLeadsDigestJobTest.php","ref":"D6"} +] +``` + +```verified-context-json +[ + {"id":"vc1","kind":"EXTRACTED","ref":"app/app/Services/NotificationService.php","anchor":"public function notifyNewLead(Tenant $tenant, Deal $deal): void"} +] +``` + +--- + +### Task 1 — Шаг 1: тест дайджеста (ref D6) + +**Create:** `app/tests/Feature/Notifications/NewLeadsDigestJobTest.php` + +```php +create([ + 'tenant_id' => $tenant->id, + 'email' => $email, + 'notification_preferences' => [ + 'new_lead' => ['email' => $emailOn, 'inapp' => true], + ], + ]); +} + +it('шлёт одно письмо-сводку с N сделками подписанному пользователю', function () { + $tenant = Tenant::factory()->create(); + digestUser($tenant, 'digest-on@example.test', true); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(5), 'is_test' => false]); + Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(10), 'is_test' => false]); + Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(15), 'is_test' => false]); + + (new SendNewLeadsDigestJob)->handle(app(NotificationService::class)); + + Mail::assertSent( + NewLeadsDigestMail::class, + fn (NewLeadsDigestMail $m) => $m->hasTo('digest-on@example.test') && $m->deals->count() === 3, + ); +}); + +it('не шлёт сводку пользователю с выключенным email', function () { + $tenant = Tenant::factory()->create(); + digestUser($tenant, 'digest-off@example.test', false); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(5), 'is_test' => false]); + + (new SendNewLeadsDigestJob)->handle(app(NotificationService::class)); + + Mail::assertNotSent( + NewLeadsDigestMail::class, + fn (NewLeadsDigestMail $m) => $m->hasTo('digest-off@example.test'), + ); +}); + +it('не шлёт сводку, если за окно нет новых сделок', function () { + $tenant = Tenant::factory()->create(); + digestUser($tenant, 'digest-old@example.test', true); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + Deal::factory()->for($tenant)->create(['received_at' => now()->subMinutes(45), 'is_test' => false]); + + (new SendNewLeadsDigestJob)->handle(app(NotificationService::class)); + + Mail::assertNotSent( + NewLeadsDigestMail::class, + fn (NewLeadsDigestMail $m) => $m->hasTo('digest-old@example.test'), + ); +}); + +it('notifyNewLead больше не шлёт пер-лид письмо', function () { + $tenant = Tenant::factory()->create(); + digestUser($tenant, 'perlead@example.test', true); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + $deal = Deal::factory()->for($tenant)->create(['received_at' => now(), 'is_test' => false]); + + app(NotificationService::class)->notifyNewLead($tenant, $deal); + + Mail::assertNotSent(NewLeadNotification::class); +}); +``` + +### Task 2 — Шаг 2: письмо-сводка (ref D3) + +**Create:** `app/app/Mail/NewLeadsDigestMail.php` + +```php + $deals + */ +class NewLeadsDigestMail extends Mailable +{ + use Queueable; + use SerializesModels; + + public function __construct( + public User $user, + public Tenant $tenant, + public Collection $deals, + ) {} + + public function envelope(): Envelope + { + return new Envelope( + subject: 'Лидерра. Новые сделки — '.$this->deals->count(), + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.new_leads_digest', + with: [ + 'user' => $this->user, + 'tenant' => $this->tenant, + 'deals' => $this->deals, + 'count' => $this->deals->count(), + ], + ); + } +} +``` + +### Task 3 — Шаг 3: шаблон письма (ref D3) + +**Create:** `app/resources/views/emails/new_leads_digest.blade.php` + +```blade + + + + +Здравствуйте, {{ $user->first_name ?? $user->email }}.
+ +Поступило {{ $count }} новых сделок.
+ +| Телефон | +Имя | +Город | +Получено | +
|---|---|---|---|
| {{ $deal->phone }} | +{{ $deal->contact_name ?: '—' }} | +{{ $deal->city ?: '—' }} | +{{ $deal->received_at?->setTimezone($user->timezone ?? 'Europe/Moscow')->format('d.m.Y H:i') }} | +
Откройте CRM, чтобы взять сделки в работу.
+ ++ Это автоматическая сводка по событию «Новый лид». Чтобы отключить — снимите галочку в Настройки → Уведомления. +
+ + +``` + +### Task 4 — Шаг 4: джоб (ref D2) + +**Create:** `app/app/Jobs/SendNewLeadsDigestJob.php` + +```php +whereNull('deleted_at')->chunkById(200, function (EloquentCollection $tenants) use ($notifier): void { + foreach ($tenants as $tenant) { + /** @var Tenant $tenant */ + $this->digestForTenant($tenant, $notifier); + } + }); + } + + private function digestForTenant(Tenant $tenant, NotificationService $notifier): void + { + DB::transaction(function () use ($tenant, $notifier): void { + DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tenant->id); + + $deals = Deal::query() + ->where('tenant_id', $tenant->id) + ->where('received_at', '>', now()->subMinutes(30)) + ->where('is_test', false) + ->whereNull('deleted_at') + ->orderBy('received_at') + ->get(); + + if ($deals->isEmpty()) { + return; + } + + $notifier->notifyNewLeadsDigest($tenant, $deals); + }); + } +} +``` + +### Task 5 — Шаг 5: метод дайджеста в NotificationService (ref D3) + +**Modify:** `app/app/Services/NotificationService.php` — добавить метод сразу ПОСЛЕ закрывающей `}` метода `notifyNewLead` (перед `notifyReminder`). + +Заменить: + +```php + /** + * Уведомление о наступлении срока напоминания. Получатели: +``` + +на: + +```php + /** + * G2-A дайджест: одно письмо-сводка о новых сделках за окно — каждому + * активному пользователю тенанта с включённым new_lead.email. Заменяет + * пер-лид email-канал события new_lead. + */ + public function notifyNewLeadsDigest(Tenant $tenant, \Illuminate\Support\Collection $deals): void + { + foreach ($this->recipientsForEvent($tenant, self::EVENT_NEW_LEAD, self::CHANNEL_EMAIL) as $user) { + $this->sendEmail($user, self::EVENT_NEW_LEAD, new \App\Mail\NewLeadsDigestMail($user, $tenant, $deals)); + } + } + + /** + * Уведомление о наступлении срока напоминания. Получатели: +``` + +### Task 6 — Шаг 6: убрать пер-лид email из notifyNewLead (ref D4) + +**Modify:** `app/app/Services/NotificationService.php` + +Заменить: + +```php + $projectName = $deal->project?->name ?? 'Без проекта'; + + // Канал email. + foreach ($this->recipientsForEvent($tenant, self::EVENT_NEW_LEAD, self::CHANNEL_EMAIL) as $user) { + $this->sendEmail($user, self::EVENT_NEW_LEAD, new NewLeadNotification($user, $deal, $tenant)); + } + + // Канал inapp. +``` + +на: + +```php + $projectName = $deal->project?->name ?? 'Без проекта'; + + // Канал email события new_lead переведён на дайджест (SendNewLeadsDigestJob). + // Здесь — только in-app (колокольчик) на каждую сделку. + // Канал inapp. +``` + +### Task 7 — Шаг 7: убрать неиспользуемый импорт (ref D4) + +**Modify:** `app/app/Services/NotificationService.php` + +Заменить: + +```php +use App\Mail\InvoicePaidNotification; +use App\Mail\NewLeadNotification; +use App\Mail\ReminderDueNotification; +``` + +на: + +```php +use App\Mail\InvoicePaidNotification; +use App\Mail\ReminderDueNotification; +``` + +### Task 8 — Шаг 8: запись планировщика (ref D5) + +**Modify:** `app/routes/console.php` + +Заменить: + +```php +Schedule::job(new CsvReconcileJob)->everyThirtyMinutes() + ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\CsvReconcileJob', true, null, null)) + ->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\CsvReconcileJob', false, 'Job failed', null)); +``` + +на: + +```php +Schedule::job(new CsvReconcileJob)->everyThirtyMinutes() + ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\CsvReconcileJob', true, null, null)) + ->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\CsvReconcileJob', false, 'Job failed', null)); + +// G2-A: дайджест новых сделок клиентам — каждые 30 минут. +Schedule::job(new \App\Jobs\SendNewLeadsDigestJob)->everyThirtyMinutes() + ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\SendNewLeadsDigestJob', true, null, null)) + ->onFailure(fn () => $hb->recordRunResult('App\Jobs\SendNewLeadsDigestJob', false, 'Job failed', null)); +``` + +### Task 9 — Шаг 9: прогон Pest (ref D6) + +Run: `composer --working-dir=app test -- tests/Feature/Notifications/NewLeadsDigestJobTest.php` +Expected: PASS — 4 теста зелёные. + +### Task 10 — коммит + +После зелёного прогона — через owner-escape (не шаг плана). Сообщение без скобок, трейлер `Co-Authored-By: Claude Opus 4.8`. + +--- + +## Переговоры + +### Круг 1 + +Возражений по существу нет. Деал-запрос в джобе содержит явный `where('tenant_id', $tenant->id)` (defense-in-depth, как принято в кодовой базе) поверх RLS-контекста `SET LOCAL` — корректно и на prod (RLS), и в тестах (superuser BYPASSRLS). Письмо шлётся через `NotificationService::sendEmail` (`Mail::send`, поэтому в тестах `Mail::assertSent`). Окно lookback 30 мин совпадает с интервалом планировщика — дублей нет, трекинг-колонка не нужна (это G2-A). diff --git a/docs/superpowers/specs/2026-06-18-g2a-new-leads-digest-spec-v2.md b/docs/superpowers/specs/2026-06-18-g2a-new-leads-digest-spec-v2.md new file mode 100644 index 00000000..d530a281 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-g2a-new-leads-digest-spec-v2.md @@ -0,0 +1,74 @@ +# Спецификация 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 = "} +] +```