feat(supplier): FailoverProjectChannel portal-side dedup before create

listProjects() матч по (platform, signal_type, unique_key) до create.
Защита от дубля при полу-успехе яруса 1 (create прошёл на портале, но
локальная запись не сохранилась → следующий запуск дублировал бы).
listProjects-сбой проглатывается — ярус-эскалация всё равно покроет.

Spec §4.4 шаг 2, §7. Task 5 of 12. Тесты 7/7 (19 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-19 12:09:50 +03:00
parent 0dba30014d
commit ce0fe4fd16
2 changed files with 63 additions and 0 deletions
@@ -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());
@@ -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();
});