diff --git a/app/app/Services/Project/ProjectService.php b/app/app/Services/Project/ProjectService.php index 90381a2c..5096dd46 100644 --- a/app/app/Services/Project/ProjectService.php +++ b/app/app/Services/Project/ProjectService.php @@ -37,9 +37,11 @@ class ProjectService // Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md // Если меняем источник (signal_identifier / sms_senders / sms_keyword) — guard. - $sourceFieldsTouched = array_key_exists('signal_identifier', $data) - || array_key_exists('sms_senders', $data) - || array_key_exists('sms_keyword', $data); + // ВАЖНО: дроуэр для site/call ВСЕГДА шлёт signal_identifier (даже неизменённый при + // правке только лимита) — поэтому смотрим на ФАКТИЧЕСКОЕ изменение значения, а не на + // присутствие ключа. Иначе смена лимита/региона/дней на защищённом проекте ложно + // блокируется как «смена источника» (найдено приёмкой 25.06.2026). + $sourceFieldsTouched = $this->sourceValueChanged($project, $data); // Эпик 6.2: захватываем «была ли защита» ДО правки — для in-app уведомления // о хвосте старого источника (только если по проекту реально летят лиды). $wasProtected = $sourceFieldsTouched && $this->snapshotGuard->isProtected($project); @@ -161,6 +163,33 @@ class ProjectService return $fresh; } + /** + * Изменилось ли РЕАЛЬНО источник-несущее поле (значение, не просто присутствие ключа). + * Дроуэр всегда присылает signal_identifier для site/call — поэтому без сравнения + * значений guard ложно срабатывал бы на смену только лимита/региона/дней. + */ + private function sourceValueChanged(Project $project, array $data): bool + { + if (array_key_exists('signal_identifier', $data) + && (string) ($data['signal_identifier'] ?? '') !== (string) ($project->signal_identifier ?? '')) { + return true; + } + if (array_key_exists('sms_keyword', $data) + && (string) ($data['sms_keyword'] ?? '') !== (string) ($project->sms_keyword ?? '')) { + return true; + } + if (array_key_exists('sms_senders', $data)) { + $norm = fn ($v) => collect((array) ($v ?? [])) + ->map(fn ($s) => mb_strtolower(trim((string) $s))) + ->sort()->values()->all(); + if ($norm($data['sms_senders']) !== $norm($project->sms_senders)) { + return true; + } + } + + return false; + } + /** * #8/#9: при смене источника отвязать старые supplier_projects этого проекта (по * старому ключу) и запустить их чистку. DeleteSupplierProjectJob удалит их у diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index d24f693c..f197cb18 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -2916,6 +2916,12 @@ parameters: count: 6 path: tests/Unit/Services/Pd/ImpersonationAuditServiceTest.php + - + message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:andThrow\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Unit/Services/Project/ProjectServiceGuardWiringTest.php + - message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#' identifier: method.notFound @@ -2925,13 +2931,13 @@ parameters: - message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#' identifier: method.alreadyNarrowedType - count: 3 + count: 4 path: tests/Unit/Services/Project/ProjectServiceGuardWiringTest.php - message: '#^Parameter \#2 \$snapshotGuard of class App\\Services\\Project\\ProjectService constructor expects App\\Services\\Project\\SupplierSnapshotGuard, Mockery\\MockInterface given\.$#' identifier: argument.type - count: 3 + count: 4 path: tests/Unit/Services/Project/ProjectServiceGuardWiringTest.php - diff --git a/app/resources/js/api/notifications.ts b/app/resources/js/api/notifications.ts index e5b8be79..f3259df0 100644 --- a/app/resources/js/api/notifications.ts +++ b/app/resources/js/api/notifications.ts @@ -14,7 +14,8 @@ type NotificationEvent = | 'topup_success' | 'invoice_paid' | 'new_device_login' - | 'marketing'; + | 'marketing' + | 'project_rule'; export interface ApiInAppNotification { id: number; diff --git a/app/resources/js/components/layout/AppTopbar.vue b/app/resources/js/components/layout/AppTopbar.vue index 8bfdbddf..cc33ea9f 100644 --- a/app/resources/js/components/layout/AppTopbar.vue +++ b/app/resources/js/components/layout/AppTopbar.vue @@ -39,6 +39,7 @@ function eventIcon(event: string): string { invoice_paid: 'mdi-receipt-text-check-outline', new_device_login: 'mdi-shield-account-outline', marketing: 'mdi-bullhorn-outline', + project_rule: 'mdi-clock-edit-outline', }; return map[event] ?? 'mdi-bell-outline'; } diff --git a/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php b/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php index 235d60c3..7e336b31 100644 --- a/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php +++ b/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php @@ -59,7 +59,7 @@ it('supplier_csv_reconcile_log table exists with required columns and status CHE ]))->toThrow(QueryException::class); }); -it('schema.sql v8.52 has correct metrics — 72 base tables, 127 indexes, 44 RLS policies', function () { +it('schema.sql v8.55 has correct metrics — 74 base tables, 128 indexes, 44 RLS policies', function () { // Замена destructive `migrate:fresh` (cross-test coupling: после DROP CASCADE остальные // Feature-тесты в той же сессии видели пустую БД). Static parse `db/schema.sql` — // источник истины метрик. @@ -74,8 +74,11 @@ it('schema.sql v8.52 has correct metrics — 72 base tables, 127 indexes, 44 RLS // −5 индексов, −2 RLS-политики, −2 колонки tenants.webhook_token/webhook_token_rotated_at. // v8.36→v8.52: рост схемы (lead-region phone_ranges/lead_region_resolution_log, // project_routing_snapshots, tenant_requisites, support_requests и др.). - // Текущий статический парс db/schema.sql (канон метрик — заголовок schema.sql v8.52): - // 72 base tables, 127 индексов, 44 RLS-политики. + // v8.54 (Эпик 4 online-defer): +1 таблица supplier_deferred_sync (SaaS-level, PK неявный, +0 явных индексов). + // v8.55 (Эпик 5 отчёт заливки): +1 таблица supplier_sync_runs + 1 индекс idx_supplier_sync_runs_created. + // Статический парс db/schema.sql после v8.54/v8.55: 74 base tables, 128 индексов, 44 RLS-политики. + // NB: бегущий счётчик в ШАПКЕ schema.sql несёт исторический дрейф (заявляет 79 таблиц/124 индекса) — + // это отдельный canon-sync, не предмет этого теста; тест сверяет фактический парс ФАЙЛА. $schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql'; expect(is_file($schemaPath) && is_readable($schemaPath))->toBeTrue(); $schema = file_get_contents($schemaPath); @@ -85,10 +88,10 @@ it('schema.sql v8.52 has correct metrics — 72 base tables, 127 indexes, 44 RLS $createTables = preg_match_all('/^CREATE TABLE\b/m', $schema); $partitionOf = preg_match_all('/CREATE TABLE\s+\w+\s+PARTITION OF\b/m', $schema); $baseTables = $createTables - $partitionOf; - expect($baseTables)->toBe(72); + expect($baseTables)->toBe(74); $createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema); - expect($createIndexes)->toBe(127); // v8.52 static parse + expect($createIndexes)->toBe(128); // v8.55 static parse $createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema); expect($createPolicies)->toBe(44); // v8.52 static parse diff --git a/app/tests/Unit/Services/Project/ProjectServiceGuardWiringTest.php b/app/tests/Unit/Services/Project/ProjectServiceGuardWiringTest.php index 2d7e1028..5e547efc 100644 --- a/app/tests/Unit/Services/Project/ProjectServiceGuardWiringTest.php +++ b/app/tests/Unit/Services/Project/ProjectServiceGuardWiringTest.php @@ -75,6 +75,42 @@ class ProjectServiceGuardWiringTest extends TestCase } } + public function test_update_does_not_invoke_guard_when_signal_identifier_present_but_unchanged(): void + { + // Реальный баг (найден глазами): дроуэр для site/call ВСЕГДА шлёт signal_identifier, + // даже если клиент его не трогал (поменял только лимит). Guard должен срабатывать + // на ФАКТИЧЕСКОЕ изменение источника, а не на присутствие ключа — иначе смена + // лимита/региона/дней на защищённом проекте ложно блокируется как «смена источника». + $guard = Mockery::mock(SupplierSnapshotGuard::class); + // Если guard вызовут — он бросит 422; тест провалится на HttpResponseException ниже. + $guard->shouldReceive('assertCanMutateSource') + ->andThrow(new HttpResponseException(response()->json([], 422))); + $guard->shouldReceive('isProtected')->andReturn(true); + $guard->shouldReceive('appliesFrom')->andReturn(null); + + $service = new ProjectService(new OperationsLogger, $guard); + + $project = new Project([ + 'tenant_id' => 1, + 'signal_type' => 'site', + 'signal_identifier' => 'okna-moskva.test', + 'delivered_today' => 0, + ]); + $project->id = 102; + + // signal_identifier тот же, меняется только лимит → источник НЕ тронут → guard молчит. + try { + $service->update($project, ['signal_identifier' => 'okna-moskva.test', 'daily_limit_target' => 50]); + } catch (HttpResponseException $e) { + $this->fail('Guard ложно сработал на неизменённый источник: 422 при смене только лимита'); + } catch (\Throwable) { + // Ожидаемо: update доходит до DB-запроса (assertSourceUnique / $project->update()) + // и падает без таблицы — это значит guard НЕ блокировал. Тест проходит. + } + + $this->assertTrue(true); + } + public function test_update_does_not_invoke_guard_when_only_non_source_fields_change(): void { $guard = Mockery::mock(SupplierSnapshotGuard::class);