feat: G2-A дайджест новых сделок на почту - письмо-сводка раз в 30 минут вместо письма на каждую сделку

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-18 14:40:12 +03:00
parent 14d076b805
commit 78e4c09d2d
8 changed files with 746 additions and 6 deletions
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Deal;
use App\Models\Tenant;
use App\Services\NotificationService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\DB;
/**
* G2-A: раз в 30 минут (routes/console.php) рассылает дайджест новых сделок.
* Окно последние 30 минут по received_at. Идемпотентен (непересекающееся окно).
* Паттерн по-тенантного джоба BalancePreflightSweepJob (SET LOCAL tenant).
*/
final class SendNewLeadsDigestJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
public function handle(NotificationService $notifier): void
{
Tenant::query()->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);
});
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
/**
* Письмо-сводка о новых сделках за окно (G2-A дайджест).
* Заменяет пер-лид NewLeadNotification как email-канал события new_lead.
*
* @property Collection<int, \App\Models\Deal> $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(),
],
);
}
}
+14 -6
View File
@@ -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, если задан и активен;
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Новые сделки {{ $count }}</title>
</head>
<body style="font-family: Inter, -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; color: #081319;">
<h1 style="color: #0F6E56; font-size: 20px;">Лидерра. Новые сделки</h1>
<p>Здравствуйте, {{ $user->first_name ?? $user->email }}.</p>
<p>Поступило <strong>{{ $count }}</strong> новых сделок.</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr>
<th style="padding: 8px 12px; background: #F6F3EC; text-align: left;">Телефон</th>
<th style="padding: 8px 12px; background: #F6F3EC; text-align: left;">Имя</th>
<th style="padding: 8px 12px; background: #F6F3EC; text-align: left;">Город</th>
<th style="padding: 8px 12px; background: #F6F3EC; text-align: left;">Получено</th>
</tr>
@foreach ($deals as $deal)
<tr>
<td style="padding: 8px 12px; border-top: 1px solid #E1EEEA;">{{ $deal->phone }}</td>
<td style="padding: 8px 12px; border-top: 1px solid #E1EEEA;">{{ $deal->contact_name ?: '—' }}</td>
<td style="padding: 8px 12px; border-top: 1px solid #E1EEEA;">{{ $deal->city ?: '—' }}</td>
<td style="padding: 8px 12px; border-top: 1px solid #E1EEEA;">{{ $deal->received_at?->setTimezone($user->timezone ?? 'Europe/Moscow')->format('d.m.Y H:i') }}</td>
</tr>
@endforeach
</table>
<p style="margin-top: 24px;">Откройте CRM, чтобы взять сделки в работу.</p>
<p style="color: #66635C; font-size: 12px; margin-top: 32px;">
Это автоматическая сводка по событию «Новый лид». Чтобы отключить снимите галочку в Настройки Уведомления.
</p>
</body>
</html>
+5
View File
@@ -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')
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
use App\Jobs\SendNewLeadsDigestJob;
use App\Mail\NewLeadNotification;
use App\Mail\NewLeadsDigestMail;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
use App\Services\NotificationService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
uses(DatabaseTransactions::class);
beforeEach(function () {
Mail::fake();
});
function digestUser(Tenant $tenant, string $email, bool $emailOn): User
{
return User::factory()->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);
});
@@ -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
<?php
declare(strict_types=1);
use App\Jobs\SendNewLeadsDigestJob;
use App\Mail\NewLeadNotification;
use App\Mail\NewLeadsDigestMail;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
use App\Services\NotificationService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
uses(DatabaseTransactions::class);
beforeEach(function () {
Mail::fake();
});
function digestUser(Tenant $tenant, string $email, bool $emailOn): User
{
return User::factory()->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
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
/**
* Письмо-сводка о новых сделках за окно (G2-A дайджест).
* Заменяет пер-лид NewLeadNotification как email-канал события new_lead.
*
* @property Collection<int, \App\Models\Deal> $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
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Новые сделки — {{ $count }}</title>
</head>
<body style="font-family: Inter, -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; color: #081319;">
<h1 style="color: #0F6E56; font-size: 20px;">Лидерра. Новые сделки</h1>
<p>Здравствуйте, {{ $user->first_name ?? $user->email }}.</p>
<p>Поступило <strong>{{ $count }}</strong> новых сделок.</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr>
<th style="padding: 8px 12px; background: #F6F3EC; text-align: left;">Телефон</th>
<th style="padding: 8px 12px; background: #F6F3EC; text-align: left;">Имя</th>
<th style="padding: 8px 12px; background: #F6F3EC; text-align: left;">Город</th>
<th style="padding: 8px 12px; background: #F6F3EC; text-align: left;">Получено</th>
</tr>
@foreach ($deals as $deal)
<tr>
<td style="padding: 8px 12px; border-top: 1px solid #E1EEEA;">{{ $deal->phone }}</td>
<td style="padding: 8px 12px; border-top: 1px solid #E1EEEA;">{{ $deal->contact_name ?: '—' }}</td>
<td style="padding: 8px 12px; border-top: 1px solid #E1EEEA;">{{ $deal->city ?: '—' }}</td>
<td style="padding: 8px 12px; border-top: 1px solid #E1EEEA;">{{ $deal->received_at?->setTimezone($user->timezone ?? 'Europe/Moscow')->format('d.m.Y H:i') }}</td>
</tr>
@endforeach
</table>
<p style="margin-top: 24px;">Откройте CRM, чтобы взять сделки в работу.</p>
<p style="color: #66635C; font-size: 12px; margin-top: 32px;">
Это автоматическая сводка по событию «Новый лид». Чтобы отключить — снимите галочку в Настройки → Уведомления.
</p>
</body>
</html>
```
### Task 4 — Шаг 4: джоб (ref D2)
**Create:** `app/app/Jobs/SendNewLeadsDigestJob.php`
```php
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Deal;
use App\Models\Tenant;
use App\Services\NotificationService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\DB;
/**
* G2-A: раз в 30 минут (routes/console.php) рассылает дайджест новых сделок.
* Окно — последние 30 минут по received_at. Идемпотентен (непересекающееся окно).
* Паттерн по-тенантного джоба — BalancePreflightSweepJob (SET LOCAL tenant).
*/
final class SendNewLeadsDigestJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
public function handle(NotificationService $notifier): void
{
Tenant::query()->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).
@@ -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 = "}
]
```