diff --git a/app/app/Services/Supplier/Channel/FailoverProjectChannel.php b/app/app/Services/Supplier/Channel/FailoverProjectChannel.php index 7bcea610..90192e57 100644 --- a/app/app/Services/Supplier/Channel/FailoverProjectChannel.php +++ b/app/app/Services/Supplier/Channel/FailoverProjectChannel.php @@ -53,6 +53,17 @@ final class FailoverProjectChannel implements SupplierProjectChannel */ public function createProjectForLiderra(Project $project, SupplierProjectDto $dto): int { + // Spec §4.4 шаг 2: портальная сверка через listProjects() до любого create. + // Защита от дубля при полу-успехе яруса 1 в прошлом запуске. + try { + $existing = $this->findOnPortal($dto); + if ($existing !== null) { + return $existing; + } + } catch (Throwable) { + // listProjects недоступен — продолжаем, ярус-эскалация и так покроет. + } + try { return $this->tier1->createProject($dto); } catch (WindowDeferredException $e) { @@ -146,6 +157,26 @@ final class FailoverProjectChannel implements SupplierProjectChannel )); } + /** + * Портальная сверка: ищет уже существующий проект на портале по тройке + * (platform, signal_type, unique_key). Возвращает external_id найденного + * или null. Spec §4.4 шаг 2, §7. + */ + private function findOnPortal(SupplierProjectDto $dto): ?int + { + foreach ($this->tier1->listProjects() as $row) { + if ( + ($row['platform'] ?? null) === $dto->platform + && ($row['signal_type'] ?? null) === $dto->signalType + && ($row['unique_key'] ?? null) === $dto->uniqueKey + ) { + return (int) ($row['id'] ?? 0) ?: null; + } + } + + return null; + } + private function classifyTier2Failure(Throwable $e): string { $msg = mb_strtolower($e->getMessage()); diff --git a/app/tests/Feature/Supplier/Channel/FailoverProjectChannelTest.php b/app/tests/Feature/Supplier/Channel/FailoverProjectChannelTest.php index 02d4ca46..63fae09b 100644 --- a/app/tests/Feature/Supplier/Channel/FailoverProjectChannelTest.php +++ b/app/tests/Feature/Supplier/Channel/FailoverProjectChannelTest.php @@ -271,3 +271,35 @@ it('createProject — WindowDeferred: no queue, no escalation, op rescheduled (r expect(SupplierManualSyncQueue::count())->toBe(0); Mail::assertNothingQueued(); }); + +it('createProject — portal already has project (listProjects match): adopts external_id, skips create', function (): void { + $tenant = Tenant::factory()->create(); + $project = Project::factory()->for($tenant)->create(); + + $tier1CreateCalled = false; + $tier1 = new class($tier1CreateCalled) implements SupplierProjectChannel + { + public function __construct(public bool &$createCalled) {} + + public function createProject(SupplierProjectDto $dto): int + { + $this->createCalled = true; + + return 0; + } + + public function updateProject(int $externalId, SupplierProjectDto $dto): void {} + + public function listProjects(): array + { + return [ + ['id' => 555555, 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'foo.com'], + ]; + } + }; + + $id = makeFailover($tier1)->createProjectForLiderra($project, makeDto()); + + expect($id)->toBe(555555); + expect($tier1CreateCalled)->toBeFalse(); +});