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(); +});