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:
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user