From e655af6298029b69ecaf24f7ad46b3b3258abf1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 25 Jun 2026 19:03:24 +0300 Subject: [PATCH] =?UTF-8?q?feat/notifications:=20in-app=20=D1=83=D0=B2?= =?UTF-8?q?=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BA?= =?UTF-8?q?=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D1=83=20=D0=BE=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D0=BB=D0=B0=D1=85=20=D1=81=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=B8=20=D0=B4=D0=B5=D0=B9=D1=81=D1=82?= =?UTF-8?q?=D0=B2=D0=B8=D1=8F=D1=85=20=D1=81=20=D0=BF=D1=80=D0=BE=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D0=BC=20(=D0=AD=D0=BF=D0=B8=D0=BA=206.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProjectService::update по завершении шлёт in-app объяснение правила (текст из ProjectRuleMessages): смена источника по защищённому проекту → про хвост старого источника; иначе slepok-правка → когда вступит в силу. NotificationService::notifyProjectRule шлёт всем активным юзерам тенанта через колокольчик без pref-гейта (правила должны доходить всегда). Уведомление о хвосте — только если проект реально под защитой (есть поставщик). 3/3. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app/Services/NotificationService.php | 30 +++++++ app/app/Services/Project/ProjectService.php | 28 +++++++ .../ProjectRuleNotificationTest.php | 84 +++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 app/tests/Feature/Notifications/ProjectRuleNotificationTest.php diff --git a/app/app/Services/NotificationService.php b/app/app/Services/NotificationService.php index 9a580a10..dedb57e6 100644 --- a/app/app/Services/NotificationService.php +++ b/app/app/Services/NotificationService.php @@ -54,6 +54,9 @@ class NotificationService public const EVENT_MARKETING = 'marketing'; + /** Эпик 6: объяснение правил сбора (хвост источника, вступление правок и т.п.). */ + public const EVENT_PROJECT_RULE = 'project_rule'; + public const ALL_EVENTS = [ self::EVENT_NEW_LEAD, self::EVENT_LOW_BALANCE, @@ -182,6 +185,33 @@ class NotificationService } } + /** + * Эпик 6: in-app уведомление-объяснение правила сбора всем активным + * пользователям тенанта (через колокольчик). БЕЗ pref-гейта — правила должны + * доходить до клиента всегда (это объяснение «как сейчас», не маркетинг). + * Текст — из ProjectRuleMessages (единый источник). Сбой канала не валит вызов. + * + * @param array $payload + */ + public function notifyProjectRule(Tenant $tenant, string $title, string $body, array $payload = []): void + { + $users = User::query() + ->where('tenant_id', $tenant->id) + ->where('is_active', true) + ->whereNull('deleted_at') + ->get(); + + foreach ($users as $user) { + $this->notifyInApp( + $user, + self::EVENT_PROJECT_RULE, + $title, + $body, + array_merge(['tenant_id' => $tenant->id], $payload), + ); + } + } + /** * INSERT в `in_app_notifications` для bell-icon UI. RLS требует * `app.current_tenant_id` = user.tenant_id, поэтому SET LOCAL внутри diff --git a/app/app/Services/Project/ProjectService.php b/app/app/Services/Project/ProjectService.php index 5e9428d3..90381a2c 100644 --- a/app/app/Services/Project/ProjectService.php +++ b/app/app/Services/Project/ProjectService.php @@ -12,6 +12,7 @@ use App\Models\Tenant; use App\Repositories\PricingTierRepository; use App\Services\Audit\OperationsLogger; use App\Services\Billing\BalancePreflightService; +use App\Services\NotificationService; use App\Services\Supplier\SupplierProjectGrouping; use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Support\Collection; @@ -39,6 +40,9 @@ class ProjectService $sourceFieldsTouched = array_key_exists('signal_identifier', $data) || array_key_exists('sms_senders', $data) || array_key_exists('sms_keyword', $data); + // Эпик 6.2: захватываем «была ли защита» ДО правки — для in-app уведомления + // о хвосте старого источника (только если по проекту реально летят лиды). + $wasProtected = $sourceFieldsTouched && $this->snapshotGuard->isProtected($project); if ($sourceFieldsTouched) { $this->snapshotGuard->assertCanMutateSource($project, 'change_source'); } @@ -126,6 +130,30 @@ class ProjectService } } + // Эпик 6.2: in-app уведомление-объяснение правила (единый текст из ProjectRuleMessages). + // Смена источника по защищённому проекту → объясняем хвост; иначе slepok-правка → + // когда вступит. Через notifyInApp (колокольчик), не синхронный email (§9c). + $tenant = Tenant::find((int) $project->tenant_id); + if ($tenant !== null) { + $rules = app(ProjectRuleMessages::class); + $notifications = app(NotificationService::class); + if ($sourceFieldsTouched && $wasProtected) { + $notifications->notifyProjectRule( + $tenant, + 'Источник проекта изменён', + $rules->sourceChanged($this->snapshotGuard->computeGraceUntil(now())), + ['project_id' => $project->id], + ); + } elseif ($appliesFrom !== null) { + $notifications->notifyProjectRule( + $tenant, + 'Изменения сохранены', + $rules->changesApplyFrom($appliesFrom), + ['project_id' => $project->id], + ); + } + } + $fresh = $project->fresh(); // Dynamic attribute — не в БД, сериализуется ProjectResource (Task 2.11). $fresh->applies_from = $appliesFrom; diff --git a/app/tests/Feature/Notifications/ProjectRuleNotificationTest.php b/app/tests/Feature/Notifications/ProjectRuleNotificationTest.php new file mode 100644 index 00000000..1a3febd6 --- /dev/null +++ b/app/tests/Feature/Notifications/ProjectRuleNotificationTest.php @@ -0,0 +1,84 @@ +create(); + User::factory()->for($tenant)->create(['is_active' => true]); + + $project = Project::factory()->for($tenant)->create([ + 'is_active' => true, + 'signal_type' => 'sms', + 'signal_identifier' => null, + 'sms_senders' => ['Caranga'], + 'sms_keyword' => null, + ]); + $sp = SupplierProject::factory()->create(['platform' => 'B3', 'signal_type' => 'sms', 'unique_key' => 'Caranga']); + linkProjectToSupplier($project, $sp); + + return $project; +} + +beforeEach(function (): void { + // Новый матч ВКЛ — смена источника по защищённому проекту проходит (иначе 422-гард). + DB::table('system_settings')->updateOrInsert( + ['key' => 'routing_match_by_snapshot'], + ['value' => 'true', 'type' => 'bool', 'updated_at' => now()], + ); +}); + +it('шлёт клиенту in-app уведомление о правиле при смене источника', function (): void { + $project = makeSupplierLinkedActiveProject(); + + app(ProjectService::class)->update($project, ['sms_senders' => ['NewSender']]); + + expect( + InAppNotification::where('tenant_id', $project->tenant_id) + ->where('body', 'like', '%по старому источнику%') + ->exists() + )->toBeTrue(); +}); + +it('шлёт уведомление «вступит в силу» при правке лимита (slepok-поле) без смены источника', function (): void { + $project = makeSupplierLinkedActiveProject(); + + app(ProjectService::class)->update($project, ['daily_limit_target' => 20]); + + expect( + InAppNotification::where('tenant_id', $project->tenant_id) + ->where('body', 'like', '%вступят в силу%') + ->exists() + )->toBeTrue(); +}); + +it('НЕ шлёт уведомление о хвосте если проект без поставщика (нет защиты)', function (): void { + $tenant = Tenant::factory()->create(); + User::factory()->for($tenant)->create(['is_active' => true]); + $project = Project::factory()->for($tenant)->create([ + 'is_active' => true, 'signal_type' => 'sms', 'sms_senders' => ['Caranga'], + ]); + + app(ProjectService::class)->update($project, ['sms_senders' => ['NewSender']]); + + expect( + InAppNotification::where('tenant_id', $project->tenant_id) + ->where('body', 'like', '%по старому источнику%') + ->exists() + )->toBeFalse(); +});