diff --git a/app/app/Services/Project/ProjectService.php b/app/app/Services/Project/ProjectService.php index c2e798f7..24317d58 100644 --- a/app/app/Services/Project/ProjectService.php +++ b/app/app/Services/Project/ProjectService.php @@ -213,6 +213,56 @@ class ProjectService return ['updated' => $updated, 'skipped' => $skipped, 'warnings' => []]; } + private function assertNameUnique(int $tenantId, string $name, ?int $exceptId = null): void + { + $q = Project::where('tenant_id', $tenantId)->where('name', $name); + if ($exceptId !== null) { + $q->where('id', '!=', $exceptId); + } + if ($q->exists()) { + throw new HttpResponseException(response()->json([ + 'errors' => ['name' => ['Проект с таким названием у вас уже есть. Выберите другое название.']], + ], 422)); + } + } + + /** @param array $data */ + private function assertSourceUnique(int $tenantId, array $data, ?int $exceptId = null): void + { + $signalType = $data['signal_type'] ?? null; + $q = Project::where('tenant_id', $tenantId)->where('signal_type', $signalType); + if ($exceptId !== null) { + $q->where('id', '!=', $exceptId); + } + + if (in_array($signalType, ['call', 'site'], true)) { + $identifier = (string) ($data['signal_identifier'] ?? ''); + if ($identifier === '') { + return; + } + $q->where('signal_identifier', $identifier); + } elseif ($signalType === 'sms') { + $senders = (array) ($data['sms_senders'] ?? []); + $norm = collect($senders)->map(fn ($s) => mb_strtolower(trim((string) $s)))->sort()->values()->all(); + if ($norm === []) { + return; + } + $keyword = $data['sms_keyword'] ?? null; + $q->where('sms_keyword', $keyword) + ->whereJsonContains('sms_senders', $norm) + ->whereRaw('jsonb_array_length(sms_senders::jsonb) = ?', [count($norm)]); + } else { + return; + } + + $existing = $q->first(); + if ($existing !== null) { + throw new HttpResponseException(response()->json([ + 'errors' => ['signal_identifier' => ["У вас уже есть проект с этим источником: «{$existing->name}»."]], + ], 422)); + } + } + public function create(Tenant $tenant, array $data): Project { $limit = (int) ($tenant->limits['max_projects'] ?? 10); @@ -230,6 +280,10 @@ class ProjectService // PhonePrefixService / LeadRouter, удаляются в Plan 6.5 после переключения читателей. $data['region_mask'] = 255; $data['region_mode'] = 'include'; + + $this->assertNameUnique($tenant->id, (string) $data['name']); + $this->assertSourceUnique($tenant->id, $data); + $project = Project::create($data); SyncSupplierProjectJob::dispatch($project->id); diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index e3356923..f1c480f9 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -2105,3 +2105,21 @@ parameters: identifier: argument.type count: 1 path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 6 + path: tests/Feature/Project/ProjectCreateDedupTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:fail\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Project/ProjectCreateDedupTest.php + + - + message: '#^Call to an undefined method Symfony\\Component\\HttpFoundation\\Response\:\:getData\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Project/ProjectCreateDedupTest.php diff --git a/app/tests/Feature/Project/ProjectCreateDedupTest.php b/app/tests/Feature/Project/ProjectCreateDedupTest.php new file mode 100644 index 00000000..909dbfe9 --- /dev/null +++ b/app/tests/Feature/Project/ProjectCreateDedupTest.php @@ -0,0 +1,46 @@ +tenant = Tenant::factory()->create(['balance_leads' => 100]); +}); + +function makeCall(array $over = []): array +{ + return array_merge([ + 'name' => 'Проект A', 'signal_type' => 'call', 'signal_identifier' => '79991110000', + 'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31, + ], $over); +} + +it('blocks duplicate source within tenant with human message', function () { + app(ProjectService::class)->create($this->tenant, makeCall()); + expect(fn () => app(ProjectService::class) + ->create($this->tenant, makeCall(['name' => 'Проект B']))) + ->toThrow(HttpResponseException::class); +}); + +it('allows same source for a different tenant (sharing)', function () { + $other = Tenant::factory()->create(['balance_leads' => 100]); + app(ProjectService::class)->create($this->tenant, makeCall()); + $p = app(ProjectService::class)->create($other, makeCall(['name' => 'Проект B'])); + expect($p)->toBeInstanceOf(Project::class); +}); + +it('blocks duplicate name within tenant with human message (not SQL)', function () { + app(ProjectService::class)->create($this->tenant, makeCall()); + try { + app(ProjectService::class) + ->create($this->tenant, makeCall(['name' => 'Проект A', 'signal_identifier' => '79992220000'])); + $this->fail('expected HttpResponseException'); + } catch (HttpResponseException $e) { + $body = $e->getResponse()->getData(true); + expect($body['errors']['name'][0] ?? '')->not->toContain('SQLSTATE'); + } +});