From 7fa811b431114616141b761fb1174b414c2b88fe 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: Tue, 14 Jul 2026 10:44:44 +0300 Subject: [PATCH] =?UTF-8?q?fix(supplier):=20=D0=B2=20=D0=BE=D1=84=D0=BB?= =?UTF-8?q?=D0=B0=D0=B9=D0=BD-=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=D0=B5=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=B4=D1=91=D1=80=D0=B3=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D1=89=D0=B8=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прод-инцидент 14.07.2026. В batch-режиме портал при создании проекта слал поставщику «каркас» с limit=0 и без регионов. Кабинет такой запрос ОТБИВАЕТ ВСЕГДА — снято живьём с боевого 14.07: POST /admin/visit/rt-project-save {"status":"Error","message":"Введите limit!"} Дальше портал считал отказ поломкой: дёргал запасной путь через браузер, тот тоже падал, и проект уезжал в ручную очередь. Итог на бою: 114 неразобранных записей и 2 ложных high-инцидента «похоже, кабинет поставщика упал» (08.07 и 14.07). Кабинет при этом жив — проверено запросом с боевого: отдаёт 140 проектов, сессия рабочая. Лиды и деньги при этом НЕ терялись: настоящие строки создаёт вечерний SyncSupplierProjectsJob (18:00 МСК) — уже с посчитанными лимитами и регионами. Так доехали 19/19 (07.07), 25/26 (08.07), 1/1 (10.07); «недоехавший» проект №20 у поставщика на деле есть (3 строки, включены, лимит 1+1+1 = заказ клиента) — пусты лишь поля-ссылки в карточке. Что сделано: handleBatch больше не ходит к поставщику при создании — слать нечего, дневной лимит считается на cut-off, а не в момент создания. Идемпотентная привязка уже существующих строк сохранена. Слать limit>0, чтобы кабинет «принял», НЕЛЬЗЯ: у каркаса нет регионов, и включённая строка потянет лиды со всей страны за деньги клиента. Тесты: batch-путь переписан под новое правило (поставщик не зовётся, ручная очередь пуста); разбор проекта на площадки (site/call → B1+B2+B3, sms+keyword → B2+B3, sms → B3) вынесен в прямые проверки SupplierProjectGrouping — раньше он проверялся через вызовы createProject. Прогон: 2453/2458 (единственное падение — ExampleTest/Vite manifest, окружение свежего worktree, к правке отношения не имеет), phpstan 0, pint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app/Jobs/SyncSupplierProjectJob.php | 53 ++---- app/phpstan-baseline.neon | 4 +- .../Plan5/Jobs/SyncSupplierProjectJobTest.php | 153 +++++++----------- .../Supplier/SyncSupplierProjectJobTest.php | 68 ++++++-- 4 files changed, 136 insertions(+), 142 deletions(-) diff --git a/app/app/Jobs/SyncSupplierProjectJob.php b/app/app/Jobs/SyncSupplierProjectJob.php index 96dd4d5f..daabc7aa 100644 --- a/app/app/Jobs/SyncSupplierProjectJob.php +++ b/app/app/Jobs/SyncSupplierProjectJob.php @@ -431,43 +431,24 @@ class SyncSupplierProjectJob implements ShouldQueue continue; } - $dto = new SupplierProjectDto( - platform: $platform, - signalType: (string) $project->signal_type, - uniqueKey: $uniqueKey, - limit: 0, - workdays: $workdays, - regions: [], - regionsReverse: false, - status: 'active', + // 🔴 К поставщику при создании НЕ идём (прод-инцидент 14.07.2026). + // + // Раньше здесь слался «каркас» с limit=0 и без регионов. Кабинет такой + // запрос ОТБИВАЕТ ВСЕГДА: {"status":"Error","message":"Введите limit!"} + // (снято с боевого 14.07). Дальше портал считал отказ поломкой, дёргал + // запасной путь через браузер, тот тоже падал, и проект уезжал в ручную + // очередь — 114 записей мусора и 2 ложных инцидента «кабинет упал». + // + // Настоящие строки создаёт ночной SyncSupplierProjectsJob (18:00) — уже с + // посчитанными лимитами и регионами; так доехали 19/19 (07.07), 25/26 + // (08.07), 1/1 (10.07). Слать раньше просто нечего: дневной лимит на завтра + // считается на cut-off, а не в момент создания проекта. + // + // Слать сюда limit>0, чтобы кабинет «принял», НЕЛЬЗЯ: у каркаса нет регионов, + // и включённая строка потянет лиды со всей страны за деньги клиента. + Log::info( + "SyncSupplierProjectJob: project {$project->id} {$platform} — batch, создание отложено до вечернего прогона (18:00 МСК)" ); - - try { - $externalId = $channel instanceof FailoverProjectChannel - ? $channel->createProjectForLiderra($project, $dto) - : $channel->createProject($dto); - } catch (TierEscalatedException $e) { - Log::info("SyncSupplierProjectJob: project {$project->id} {$platform} escalated to manual queue #{$e->queueRowId}"); - - continue; - } catch (WindowDeferredException) { - Log::info("SyncSupplierProjectJob: project {$project->id} {$platform} deferred by portal window"); - - continue; - } - - $sp = SupplierProject::on(self::DB_CONNECTION)->create([ - 'platform' => $platform, - 'signal_type' => $project->signal_type, - 'unique_key' => $uniqueKey, - 'supplier_external_id' => (string) $externalId, - 'current_limit' => 0, - 'current_workdays' => $workdays, - 'current_regions' => null, - 'sync_status' => 'ok', - ]); - - $project->{$column} = $sp->id; } $project->save(); diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 3677f937..94c69c0c 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -2409,7 +2409,7 @@ parameters: - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#' identifier: method.notFound - count: 6 + count: 2 path: tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php - @@ -3003,7 +3003,7 @@ parameters: - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#' identifier: method.notFound - count: 6 + count: 7 path: tests/Feature/Supplier/SyncSupplierProjectJobTest.php - diff --git a/app/tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php b/app/tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php index 68cf199d..fdd1d2f1 100644 --- a/app/tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php +++ b/app/tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php @@ -7,27 +7,73 @@ use App\Models\Project; use App\Models\SupplierProject; use App\Models\Tenant; use App\Services\Supplier\Channel\SupplierProjectChannel; +use App\Services\Supplier\SupplierProjectGrouping; use Illuminate\Foundation\Testing\DatabaseTransactions; +use Illuminate\Support\Facades\DB; use Tests\Concerns\SharesSupplierPdo; // TestCase auto-bound via tests/Pest.php (->in('Feature')). // DatabaseTransactions — per-test isolation. -// SharesSupplierPdo — SyncSupplierProjectJob теперь пишет через pgsql_supplier (BYPASSRLS); +// SharesSupplierPdo — SyncSupplierProjectJob пишет через pgsql_supplier (BYPASSRLS); // без шаринга PDO записи джоба не видны default-connection ассертам под DatabaseTransactions. uses(DatabaseTransactions::class, SharesSupplierPdo::class); /** * Хелпер: разрешает SupplierProjectChannel из контейнера и вызывает Job.handle(). - * Mock SupplierProjectChannel НЕ instanceof FailoverProjectChannel → job идёт - * по ветке createProject() (без эскалации) — это и тестируем здесь. - * Failover-эскалация покрыта FailoverProjectChannelTest. */ function dispatchJobSync(SyncSupplierProjectJob $job): void { $job->handle(app(SupplierProjectChannel::class)); } -it('site project: creates B1+B2+B3 supplier_projects and sets all three IDs', function () { +/** + * Прод-инцидент 14.07.2026 (см. SyncSupplierProjectJobTest в tests/Feature/Supplier): + * batch-режим больше НЕ ходит к поставщику при создании — кабинет всё равно отбивает + * каркас с limit=0 («Введите limit!»), а строки создаёт вечерний прогон 18:00 МСК. + * + * Раньше этот файл проверял разбор проекта на площадки ЧЕРЕЗ вызовы createProject. + * Сам разбор (site/call → B1+B2+B3, sms+keyword → B2+B3, sms без keyword → B3) никуда + * не делся — проверяем его напрямую у SupplierProjectGrouping, без похода к поставщику. + */ +it('site project resolves to B1+B2+B3', function () { + $project = Project::factory()->create([ + 'signal_type' => 'site', + 'signal_identifier' => 'okna.ru', + ]); + + expect(SupplierProjectGrouping::resolvePlatforms($project))->toBe(['B1', 'B2', 'B3']); +}); + +it('call project resolves to B1+B2+B3', function () { + $project = Project::factory()->create([ + 'signal_type' => 'call', + 'signal_identifier' => '79161234567', + ]); + + expect(SupplierProjectGrouping::resolvePlatforms($project))->toBe(['B1', 'B2', 'B3']); +}); + +it('sms project with keyword resolves to B2+B3 only (no B1)', function () { + $project = Project::factory()->create([ + 'signal_type' => 'sms', + 'sms_senders' => ['TINKOFF'], + 'sms_keyword' => 'ипотека', + ]); + + expect(SupplierProjectGrouping::resolvePlatforms($project))->toBe(['B2', 'B3']); +}); + +it('sms project without keyword resolves to B3 only', function () { + $project = Project::factory()->create([ + 'signal_type' => 'sms', + 'sms_senders' => ['TINKOFF'], + 'sms_keyword' => null, + ]); + + expect(SupplierProjectGrouping::resolvePlatforms($project))->toBe(['B3']); +}); + +it('batch create: supplier is never called and nothing lands in the manual queue', function () { $tenant = Tenant::factory()->create(); $project = Project::factory()->create([ 'tenant_id' => $tenant->id, @@ -36,100 +82,24 @@ it('site project: creates B1+B2+B3 supplier_projects and sets all three IDs', fu ]); $this->mock(SupplierProjectChannel::class, function ($mock) { - $mock->shouldReceive('createProject')->times(3) - ->andReturn(700001, 700002, 700003); - }); - - dispatchJobSync(new SyncSupplierProjectJob($project->id)); - - $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(); - // FK ведёт на local supplier_projects.id, не на portal external_id. - expect(SupplierProject::find($project->supplier_b1_project_id)->supplier_external_id)->toBe('700001'); -}); - -it('call project: creates B1+B2+B3 with phone signal_identifier', function () { - $project = Project::factory()->create([ - 'signal_type' => 'call', - 'signal_identifier' => '79161234567', - ]); - - $this->mock(SupplierProjectChannel::class, function ($mock) { - $mock->shouldReceive('createProject')->times(3) - ->andReturn(800001, 800002, 800003); - }); - - dispatchJobSync(new SyncSupplierProjectJob($project->id)); - - expect($project->fresh()->supplier_b1_project_id)->not->toBeNull(); - expect($project->fresh()->supplier_b2_project_id)->not->toBeNull(); - expect($project->fresh()->supplier_b3_project_id)->not->toBeNull(); -}); - -it('sms project with keyword: creates B2+B3 only (no B1)', function () { - $project = Project::factory()->create([ - 'signal_type' => 'sms', - 'sms_senders' => ['TINKOFF'], - 'sms_keyword' => 'ипотека', - ]); - - $this->mock(SupplierProjectChannel::class, function ($mock) { - $mock->shouldReceive('createProject')->times(2) - ->andReturn(900001, 900002); + $mock->shouldNotReceive('createProject'); }); dispatchJobSync(new SyncSupplierProjectJob($project->id)); $project->refresh(); expect($project->supplier_b1_project_id)->toBeNull(); - expect($project->supplier_b2_project_id)->not->toBeNull(); - expect($project->supplier_b3_project_id)->not->toBeNull(); + expect(SupplierProject::where('unique_key', 'okna.ru')->count())->toBe(0); + expect(DB::table('supplier_manual_sync_queue')->where('project_id', $project->id)->count())->toBe(0); }); -it('sms project without keyword: creates B3 only', function () { - $project = Project::factory()->create([ - 'signal_type' => 'sms', - 'sms_senders' => ['TINKOFF'], - 'sms_keyword' => null, - ]); - - $this->mock(SupplierProjectChannel::class, function ($mock) { - $mock->shouldReceive('createProject')->once() - ->andReturn(910001); - }); - - dispatchJobSync(new SyncSupplierProjectJob($project->id)); - - $project->refresh(); - expect($project->supplier_b1_project_id)->toBeNull(); - expect($project->supplier_b2_project_id)->toBeNull(); - expect($project->supplier_b3_project_id)->not->toBeNull(); -}); - -it('channel exception: re-throws for queue retry', function () { +it('idempotency: pre-existing supplier_project row is linked, channel still not called', function () { $project = Project::factory()->create([ 'signal_type' => 'site', 'signal_identifier' => 'x.ru', ]); - $this->mock(SupplierProjectChannel::class, function ($mock) { - $mock->shouldReceive('createProject') - ->andThrow(new RuntimeException('timeout')); - }); - - expect(fn () => dispatchJobSync(new SyncSupplierProjectJob($project->id))) - ->toThrow(RuntimeException::class); -}); - -it('idempotency: pre-existing supplier_project row is reused, channel not called for it', function () { - $project = Project::factory()->create([ - 'signal_type' => 'site', - 'signal_identifier' => 'x.ru', - ]); - - // B2 уже существует локально (например, от прошлого частичного запуска). + // B2 уже существует локально (например, создан вечерним прогоном). $spB2 = SupplierProject::factory()->create([ 'platform' => 'B2', 'signal_type' => 'site', @@ -137,17 +107,16 @@ it('idempotency: pre-existing supplier_project row is reused, channel not called 'sync_status' => 'failed', ]); - // Channel дёргается только для B1 и B3 — B2 берётся из существующей строки. $this->mock(SupplierProjectChannel::class, function ($mock) { - $mock->shouldReceive('createProject')->times(2) - ->andReturn(700001, 700003); + $mock->shouldNotReceive('createProject'); }); dispatchJobSync(new SyncSupplierProjectJob($project->id)); $project->refresh(); + // Существующая строка привязана... expect($project->supplier_b2_project_id)->toBe($spB2->id); - expect(SupplierProject::find($project->supplier_b2_project_id)->sync_status)->toBe('failed'); - expect($project->supplier_b1_project_id)->not->toBeNull(); - expect($project->supplier_b3_project_id)->not->toBeNull(); + // ...а недостающие B1/B3 остаются на вечерний прогон. + expect($project->supplier_b1_project_id)->toBeNull(); + expect($project->supplier_b3_project_id)->toBeNull(); }); diff --git a/app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php b/app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php index 2aa081f2..d1ac8228 100644 --- a/app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php +++ b/app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php @@ -394,9 +394,23 @@ it('online mode also populates legacy supplier_b{1,2,3}_project_id so UI sync-st // Batch mode: keeps каркас (limit 0, no per-subject save, no pivot) // --------------------------------------------------------------------------- -it('batch mode keeps каркас (limit=0, sets supplier_b{1,2,3}_project_id, no project_supplier_links pivot)', function (): void { - // batch is already set in beforeEach — no change needed - +/** + * Прод-инцидент 14.07.2026: «каркас» batch-режима кабинет ОТБИВАЕТ ВСЕГДА. + * + * Живой ответ поставщика на create с limit=0 (снято с боевого 14.07): + * {"status":"Error","message":"Введите limit!"} + * + * Дальше портал считал это поломкой: пробовал запасной путь через браузер, тот тоже + * падал, проект уезжал в ручную очередь (114 записей), а сторож поднимал ложную тревогу + * «кабинет поставщика упал» (2 high-инцидента). При этом сами проекты доезжали до + * поставщика вечером: ночной SyncSupplierProjectsJob (18:00) создаёт их с НАСТОЯЩИМИ + * лимитами и регионами — 19/19 (07.07), 25/26 (08.07), 1/1 (10.07) доехали именно так. + * + * Правило: в batch-режиме к поставщику при создании НЕ ходим — слать нечего (лимит ещё + * не посчитан), кабинет всё равно откажет. Создание — забота вечернего прогона. + * Идемпотентная привязка уже существующих строк остаётся (см. тест ниже). + */ +it('batch mode does NOT call the supplier on create — the nightly job creates rows with real limits', function (): void { $tenant = Tenant::factory()->create(); $project = Project::factory()->create([ 'tenant_id' => $tenant->id, @@ -409,23 +423,53 @@ it('batch mode keeps каркас (limit=0, sets supplier_b{1,2,3}_project_id, n ]); $this->mock(SupplierProjectChannel::class, function ($mock): void { - $mock->shouldReceive('createProject')->times(3)->andReturn(200001, 200002, 200003); + $mock->shouldNotReceive('createProject'); + }); + + (new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class)); + + // Ни строк у поставщика, ни мусора в ручной очереди — просто ждём вечернего прогона. + expect(SupplierProject::where('unique_key', 'batch-test.ru')->count())->toBe(0); + expect(DB::table('supplier_manual_sync_queue')->where('project_id', $project->id)->count())->toBe(0); + expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(0); +}); + +it('batch mode still links supplier_projects rows that already exist (idempotency, no supplier call)', function (): void { + $tenant = Tenant::factory()->create(); + $project = Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'signal_type' => 'site', + 'signal_identifier' => 'already-there.ru', + 'is_active' => true, + 'daily_limit_target' => 10, + 'regions' => [82], + 'delivery_days_mask' => 127, + ]); + + foreach (['B1' => '900001', 'B2' => '900002', 'B3' => '900003'] as $platform => $externalId) { + SupplierProject::create([ + 'platform' => $platform, + 'signal_type' => 'site', + 'unique_key' => 'already-there.ru', + 'supplier_external_id' => $externalId, + 'current_limit' => 4, + 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], + 'current_regions' => null, + 'sync_status' => 'ok', + ]); + } + + $this->mock(SupplierProjectChannel::class, function ($mock): void { + $mock->shouldNotReceive('createProject'); }); (new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class)); $project->refresh(); - // Batch: the old FK columns are set expect($project->supplier_b1_project_id)->not->toBeNull(); expect($project->supplier_b2_project_id)->not->toBeNull(); expect($project->supplier_b3_project_id)->not->toBeNull(); - - // Batch: каркас → limit=0 - $sp = SupplierProject::find($project->supplier_b1_project_id); - expect($sp->current_limit)->toBe(0); - - // Batch: no pivot rows (nightly job fills them) - expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(0); + expect(SupplierProject::find($project->supplier_b1_project_id)->supplier_external_id)->toBe('900001'); }); // ---------------------------------------------------------------------------