feat: G2-A дайджест новых сделок на почту - письмо-сводка раз в 30 минут вместо письма на каждую сделку
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 = "}
|
||||
]
|
||||
```
|
||||
Reference in New Issue
Block a user