From 9eff9aaafdfbc7fb235edc27f6abd3372dbe6852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 21 May 2026 10:59:37 +0300 Subject: [PATCH] fix(supplier): recreate deleted donor + fill legacy FK in online sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleOnline/syncGroup: сверка external_id со списком живых проектов портала (listProjects); пересоздание удалённых на портале доноров in-place без удаления записей (на supplier_projects могут висеть лиды/списания). online-режим заполняет supplier_b1/b2/b3_project_id, чтобы UI sync-бейдж не залипал в pending. +3 Pest. Co-Authored-By: Claude Opus 4.7 --- .../Jobs/Supplier/SyncSupplierProjectsJob.php | 46 +++++++++ app/app/Jobs/SyncSupplierProjectJob.php | 60 ++++++++++++ .../Supplier/SyncSupplierProjectJobTest.php | 97 +++++++++++++++++++ .../Supplier/SyncSupplierProjectsJobTest.php | 54 +++++++++++ 4 files changed, 257 insertions(+) diff --git a/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php index e7d5cdfa..6cb3df07 100644 --- a/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php +++ b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php @@ -281,6 +281,52 @@ class SyncSupplierProjectsJob implements ShouldQueue $existingSps->push($sp); } } else { + // External-deletion recovery: донор мог быть удалён на портале → external_id + // в нашей БД мёртв, updateProject его молча no-op'ит. Сверяемся со списком живых + // проектов портала и пересоздаём недостающих in-place (НЕ удаляя записи — на них + // могут висеть лиды/списания). Throws пропагируют в outer handle() catch + // (SupplierAuth/Transient/Client) — failover-counter semantics сохраняется. + $livePortalIds = collect($this->client->listProjects()) + ->map(fn ($p) => (string) ($p['id'] ?? '')) + ->filter() + ->all(); + + $deadSps = $existingSps->filter( + fn (SupplierProject $sp) => $sp->supplier_external_id !== null + && ! in_array((string) $sp->supplier_external_id, $livePortalIds, true) + ); + + if ($deadSps->isNotEmpty()) { + $deadPlatforms = array_values($deadSps->pluck('platform')->all()); + $recreateDto = new SupplierProjectDto( + platform: $deadPlatforms[0], + signalType: $signalType, + uniqueKey: $identifier, + limit: $order, + workdays: $workdays, + regions: $allRegions, + regionsReverse: false, + status: 'active', + tag: $tag, + platforms: $deadPlatforms, + ); + + $recreatedIdMap = $this->client->saveProjectMultiFlag($recreateDto); + + foreach ($deadSps as $sp) { + $newId = $recreatedIdMap[$sp->platform] ?? null; + if ($newId !== null) { + $sp->forceFill(['supplier_external_id' => (string) $newId])->save(); + SupplierSyncLog::on(self::DB_CONNECTION)->create([ + 'supplier_project_id' => $sp->id, + 'action' => 'create', + 'http_status' => 200, + 'created_at' => now(), + ]); + } + } + } + // Fix #3 (review-followup): partial-set recovery — если предыдущий run создал // не все platforms (e.g. B1+B2 OK, B3 escalated), re-attempt missing via multi-flag // save с platforms=$missingPlatforms. Throws пропагируют в outer handle() catch diff --git a/app/app/Jobs/SyncSupplierProjectJob.php b/app/app/Jobs/SyncSupplierProjectJob.php index 7e69dbcd..075efae9 100644 --- a/app/app/Jobs/SyncSupplierProjectJob.php +++ b/app/app/Jobs/SyncSupplierProjectJob.php @@ -164,6 +164,57 @@ class SyncSupplierProjectJob implements ShouldQueue $existingSps->push($sp); } } else { + // External-deletion recovery: донор мог быть удалён на портале (вручную или + // прошлым hard-delete). Тогда external_id в нашей БД мёртв, а updateProject + // такого id портал молча принимает (no-op) — донор не пересоздаётся. Поэтому + // сверяемся со списком живых проектов портала и пересоздаём недостающих + // in-place (НЕ удаляя записи — на supplier_project могут висеть лиды/списания). + $livePortalIds = collect($client->listProjects()) + ->map(fn ($p) => (string) ($p['id'] ?? '')) + ->filter() + ->all(); + + $deadSps = $existingSps->filter( + fn (SupplierProject $sp) => $sp->supplier_external_id !== null + && ! in_array((string) $sp->supplier_external_id, $livePortalIds, true) + ); + + if ($deadSps->isNotEmpty()) { + $deadPlatforms = array_values($deadSps->pluck('platform')->all()); + $recreateDto = new SupplierProjectDto( + platform: $deadPlatforms[0], + signalType: (string) $project->signal_type, + uniqueKey: $identifier, + limit: (int) $project->daily_limit_target, + workdays: $workdays, + regions: $allRegions, + regionsReverse: false, + status: 'active', + tag: $tag, + platforms: $deadPlatforms, + ); + + try { + $recreatedIdMap = $client->saveProjectMultiFlag($recreateDto); + } catch (TierEscalatedException $e) { + Log::info("SyncSupplierProjectJob: project {$project->id} dead-donor re-create escalated #{$e->queueRowId}"); + $recreatedIdMap = []; + } catch (WindowDeferredException) { + Log::info("SyncSupplierProjectJob: project {$project->id} dead-donor re-create deferred by portal window"); + $recreatedIdMap = []; + } catch (\Throwable $e) { + Log::warning("SyncSupplierProjectJob: dead-donor re-create failed for project {$project->id}: ".$e->getMessage()); + $recreatedIdMap = []; + } + + foreach ($deadSps as $sp) { + $newId = $recreatedIdMap[$sp->platform] ?? null; + if ($newId !== null) { + $sp->forceFill(['supplier_external_id' => (string) $newId])->save(); + } + } + } + // Partial-set recovery: если предыдущий run создал не все platforms. $existingPlatforms = $existingSps->pluck('platform')->all(); $missingPlatforms = array_values(array_diff($platforms, $existingPlatforms)); @@ -253,6 +304,15 @@ class SyncSupplierProjectJob implements ShouldQueue 'subject_code' => null, ]); } + + // Mirror the link into the legacy FK columns (supplier_b{1,2,3}_project_id) so the + // UI sync-status (ProjectResource → aggregateSyncStatus, which reads supplierB1/B2/B3) + // reflects the synced stack in online mode too — online primarily uses the pivot. + foreach ($existingSps as $sp) { + $column = 'supplier_'.strtolower((string) $sp->platform).'_project_id'; + $project->{$column} = $sp->id; + } + $project->save(); } // ------------------------------------------------------------------------- diff --git a/app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php b/app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php index 6f2347bc..91912332 100644 --- a/app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php +++ b/app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php @@ -213,6 +213,103 @@ it('online mode all-RF (no regions): 1 group subject_code=null, 3 supplier_proje expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(3); }); +it('online mode re-creates donor on portal when its external_id no longer exists there', function (): void { + // Regression: если донора удалили на портале, в нашей БД остаются supplier_projects + // с мёртвыми external_id. Раньше джоб шёл по update-ветке → updateProject мёртвого id + // портал молча принимает (no-op) → донор не пересоздаётся. Фикс: проверять, жив ли + // external_id на портале (listProjects), и пересоздавать недостающих in-place + // (НЕ удаляя записи — на них могут висеть лиды/списания). + DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']); + + $tenant = Tenant::factory()->create(['balance_leads' => 100]); + $project = Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'signal_type' => 'call', + 'signal_identifier' => '79990001122', + 'is_active' => true, + 'daily_limit_target' => 10, + 'regions' => [], + 'delivery_days_mask' => 31, + ]); + + // Pre-seed supplier_projects, чьи external_id указывают на удалённых с портала доноров. + foreach (['B1', 'B2', 'B3'] as $platform) { + SupplierProject::create([ + 'platform' => $platform, + 'signal_type' => 'call', + 'unique_key' => '79990001122', + 'subject_code' => null, + 'supplier_external_id' => 'DEAD'.$platform, + 'current_limit' => 10, + 'current_workdays' => [1, 2, 3, 4, 5], + 'current_regions' => [], + 'sync_status' => 'ok', + 'last_synced_at' => now()->subDay(), + ]); + } + + $loadCalls = 0; + Http::fake([ + 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '7003'], 200), + 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => function () use (&$loadCalls) { + $loadCalls++; + // Первый load = проверка существования → донор удалён (пусто). + if ($loadCalls === 1) { + return Http::response(['projects' => []], 200); + } + + // Последующие load (внутри saveProjectMultiFlag) = свежесозданные доноры. + return Http::response(['projects' => [ + ['id' => '7001', 'src' => 'rt', 'name' => '79990001122', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79990001122'], + ['id' => '7002', 'src' => 'bl', 'name' => '79990001122', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79990001122'], + ['id' => '7003', 'src' => 'mt', 'name' => '79990001122', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79990001122'], + ]], 200); + }, + ]); + + (new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class)); + + // external_id переписаны на свежесозданных доноров (не DEAD*), записи не удалены. + $sps = SupplierProject::where('unique_key', '79990001122')->orderBy('platform')->get(); + expect($sps)->toHaveCount(3); + expect($sps->pluck('supplier_external_id')->all())->toBe(['7001', '7002', '7003']); +}); + +it('online mode also populates legacy supplier_b{1,2,3}_project_id so UI sync-status is not stuck pending', function (): void { + // Regression: online mode writes the link to the pivot, but ProjectResource/aggregateSyncStatus + // read the legacy FK columns (supplierB1/B2/B3). They stayed NULL in online → "Sync pending" + // forever even though the stack is synced. Online must populate them too. + DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']); + + $tenant = Tenant::factory()->create(['balance_leads' => 100]); + $project = Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'signal_type' => 'site', + 'signal_identifier' => 'uisync.example.com', + 'is_active' => true, + 'daily_limit_target' => 5, + 'regions' => [], + 'delivery_days_mask' => 127, + ]); + + Http::fake([ + 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '9003'], 200), + 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [ + ['id' => '9001', 'src' => 'rt', 'name' => 'uisync.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'uisync.example.com'], + ['id' => '9002', 'src' => 'bl', 'name' => 'uisync.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'uisync.example.com'], + ['id' => '9003', 'src' => 'mt', 'name' => 'uisync.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'uisync.example.com'], + ]], 200), + ]); + + (new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class)); + + $project->refresh(); + expect($project->supplier_b1_project_id)->not->toBeNull(); + expect($project->supplier_b2_project_id)->not->toBeNull(); + expect($project->supplier_b3_project_id)->not->toBeNull(); + expect($project->aggregateSyncStatus())->toBe('ok'); +}); + // --------------------------------------------------------------------------- // Batch mode: keeps каркас (limit 0, no per-subject save, no pivot) // --------------------------------------------------------------------------- diff --git a/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php b/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php index 21fd42ce..0e2f6b78 100644 --- a/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php +++ b/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php @@ -480,3 +480,57 @@ test('writes supplier_sync_log row for each successful action', function (): voi ->and($log->http_status)->toBe(200) ->and($log->error_message)->toBeNull(); }); + +test('nightly: re-creates donor on portal when its external_id no longer exists there', function (): void { + // Regression mirror of SyncSupplierProjectJobTest: donor deleted on portal → stale + // external_id in our DB → updateProject is a silent no-op → donor never re-created. + // Nightly reconciler must detect missing donors (listProjects) and re-create in-place. + $tenant = Tenant::factory()->create(); + Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'signal_type' => 'call', + 'signal_identifier' => '79993334455', + 'daily_limit_target' => 10, + 'delivery_days_mask' => 127, + 'regions' => [], + ]); + + foreach (['B1', 'B2', 'B3'] as $platform) { + SupplierProject::on('pgsql_supplier')->forceCreate([ + 'platform' => $platform, + 'signal_type' => 'call', + 'unique_key' => '79993334455', + 'subject_code' => null, + 'supplier_external_id' => 'GONE'.$platform, + 'current_limit' => 10, + 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], + 'current_regions' => [], + 'sync_status' => 'ok', + 'last_synced_at' => now()->subDay(), + ]); + } + + $loadCalls = 0; + Http::fake([ + 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '8003'], 200), + 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => function () use (&$loadCalls) { + $loadCalls++; + if ($loadCalls === 1) { + return Http::response(['projects' => []], 200); + } + + return Http::response(['projects' => [ + ['id' => '8001', 'src' => 'rt', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'], + ['id' => '8002', 'src' => 'bl', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'], + ['id' => '8003', 'src' => 'mt', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'], + ]], 200); + }, + ]); + + (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); + + $sps = SupplierProject::on('pgsql_supplier')->where('unique_key', '79993334455')->orderBy('platform')->get(); + expect($sps)->toHaveCount(3); + expect($sps->pluck('supplier_external_id')->all())->toBe(['8001', '8002', '8003']); +});