# План 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).
