From 3a5d72147828366711819693df42087bb3e00a99 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, 9 Jul 2026 20:55:47 +0300 Subject: [PATCH] =?UTF-8?q?fix(supplier):=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D0=BA=D0=BE=D0=B4-=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=8C=D1=8E=20=E2=80=94=20=D0=BC=D0=B0=D1=82=D1=87=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=88=D0=B8=D1=85=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=20?= =?UTF-8?q?=D0=BF=D0=BE=20external=5Fid,=20shouldBeOff=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=BD=D1=8B=D1=85,=20sy?= =?UTF-8?q?nc=5Frun=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - normalizeLive матчит наши строки по supplier_external_id (tag=регион, не _lidpotok) — иначе live всегда пуст - shouldBeOff исключает ключи, активные по формуле (нет ложного should_be_off) - recordRunSummary → insertGetId, проверка получает sync_run_id - обновлён phpstan-baseline.neon: count mock() 2→3 (новый тест на баг 2) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Jobs/Supplier/SyncSupplierProjectsJob.php | 10 +-- .../Jobs/Supplier/VerifySupplierOrderJob.php | 36 +++++++++-- app/phpstan-baseline.neon | 2 +- .../Supplier/VerifySupplierOrderJobTest.php | 64 +++++++++++++++++-- docs/observer/STATUS.md | 6 +- 5 files changed, 96 insertions(+), 22 deletions(-) diff --git a/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php index 46867d17..ef6c35ae 100644 --- a/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php +++ b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php @@ -193,7 +193,7 @@ class SyncSupplierProjectsJob implements ShouldQueue } } } finally { - $this->recordRunSummary( + $runId = $this->recordRunSummary( startedAt: $startedAt, groupsTotal: count($groups), syncedOk: $syncedOk, @@ -206,7 +206,7 @@ class SyncSupplierProjectsJob implements ShouldQueue // Итоговая проверка: после «готово» перечитать живой кабинет и сверить. // Идёт всегда — и при штатном финише, и при обрыве. При auth-сбое (кабинет // недоступен) сам джоб зафиксирует unable_to_verify. - VerifySupplierOrderJob::dispatch(); + VerifySupplierOrderJob::dispatch(1, $runId); } } @@ -222,7 +222,7 @@ class SyncSupplierProjectsJob implements ShouldQueue int $deferred, int $failed, bool $aborted, - ): void { + ): int { if ($aborted) { $status = 'aborted'; } elseif ($failed > 0 && $syncedOk === 0) { @@ -233,7 +233,7 @@ class SyncSupplierProjectsJob implements ShouldQueue $status = 'ok'; } - DB::connection(self::DB_CONNECTION)->table('supplier_sync_runs')->insert([ + $id = DB::connection(self::DB_CONNECTION)->table('supplier_sync_runs')->insertGetId([ 'started_at' => $startedAt, 'finished_at' => now(), 'groups_total' => $groupsTotal, @@ -244,6 +244,8 @@ class SyncSupplierProjectsJob implements ShouldQueue 'status' => $status, 'created_at' => now(), ]); + + return $id; } /** diff --git a/app/app/Jobs/Supplier/VerifySupplierOrderJob.php b/app/app/Jobs/Supplier/VerifySupplierOrderJob.php index 33398f05..071aa38c 100644 --- a/app/app/Jobs/Supplier/VerifySupplierOrderJob.php +++ b/app/app/Jobs/Supplier/VerifySupplierOrderJob.php @@ -49,7 +49,23 @@ final class VerifySupplierOrderJob implements ShouldQueue $plan = SupplierOrderPlan::build($eligible, $targetDate); $intended = $plan['intended']; - // 2. shouldBeOff — наши выключенные supplier_projects. + // Ключи активных по формуле — для (а) исключения из shouldBeOff, (б) ничего более. + $intendedKeys = []; + foreach ($intended as $r) { + $intendedKeys[$r['signal_type'].'|'.$r['identifier'].'|'.$r['platform']] = true; + } + + // Наши external_id в кабинете — признак «наша строка» (tag НЕ годится: робот + // шлёт tag=регион/«РФ», не маркер). Сверка живого идёт только по нашим строкам. + $ourExternalIds = SupplierProject::on(self::DB_CONNECTION) + ->whereNotNull('supplier_external_id') + ->pluck('supplier_external_id') + ->map(fn ($id) => (string) $id) + ->all(); + + // 2. shouldBeOff — наши выключенные supplier_projects, КРОМЕ ключей, + // которые сейчас активны по формуле (иначе ложный should_be_off на + // реактивированной группе с зависшим inactive_since). $shouldBeOff = SupplierProject::on(self::DB_CONNECTION) ->whereNotNull('inactive_since') ->get(['signal_type', 'unique_key', 'platform']) @@ -57,11 +73,14 @@ final class VerifySupplierOrderJob implements ShouldQueue 'signal_type' => (string) $sp->signal_type, 'identifier' => (string) $sp->unique_key, 'platform' => (string) $sp->platform, - ])->all(); + ]) + ->reject(fn (array $r): bool => isset($intendedKeys[$r['signal_type'].'|'.$r['identifier'].'|'.$r['platform']])) + ->values() + ->all(); - // 3. Живой кабинет (только наши строки: tag _lidpotok). + // 3. Живой кабинет (только наши строки — по supplier_external_id). try { - $live = $this->normalizeLive($client->listProjects()); + $live = $this->normalizeLive($client->listProjects(), $ourExternalIds); } catch (Throwable $e) { $this->record('unable_to_verify', count($intended), 0, 0, ['error' => $e->getMessage()]); Mail::to((string) config('services.supplier.alert_email')) @@ -93,16 +112,19 @@ final class VerifySupplierOrderJob implements ShouldQueue } /** - * Портальные строки → формат SupplierOrderVerifier (только наши _lidpotok, с распознанной площадкой). + * Портальные строки → формат SupplierOrderVerifier. «Наши» = те, чей портальный id + * совпадает с нашим supplier_external_id (tag НЕ используется — робот шлёт регион). * * @param array> $rows + * @param list $ourExternalIds * @return list */ - private function normalizeLive(array $rows): array + private function normalizeLive(array $rows, array $ourExternalIds): array { + $ours = array_flip($ourExternalIds); $out = []; foreach ($rows as $row) { - if (($row['tag'] ?? null) !== '_lidpotok') { + if (! isset($ours[(string) ($row['id'] ?? '')])) { continue; } $name = (string) ($row['name'] ?? ''); diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 4ea04757..52c6f5be 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -3063,7 +3063,7 @@ parameters: - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#' identifier: method.notFound - count: 2 + count: 3 path: tests/Feature/Supplier/VerifySupplierOrderJobTest.php - diff --git a/app/tests/Feature/Supplier/VerifySupplierOrderJobTest.php b/app/tests/Feature/Supplier/VerifySupplierOrderJobTest.php index 4df047d7..0c00a09e 100644 --- a/app/tests/Feature/Supplier/VerifySupplierOrderJobTest.php +++ b/app/tests/Feature/Supplier/VerifySupplierOrderJobTest.php @@ -50,14 +50,16 @@ function seedActiveCallProject(string $identifier, int $limit): Project /** * Наша supplier_project, которую мы уже выключили (inactive_since IS NOT NULL) — * SupplierOrderVerifier::diff должен поймать kind=should_be_off, если поставщик - * до сих пор показывает её включённой (status=1). + * до сих пор показывает её включённой (status=1). supplier_external_id — признак + * «наша строка» в normalizeLive() (баг 1: tag НЕ годится — робот шлёт регион/«РФ»). */ -function seedPausedSupplierProject(string $identifier, string $platform, string $signalType): SupplierProject +function seedPausedSupplierProject(string $identifier, string $platform, string $signalType, string $externalId): SupplierProject { return SupplierProject::factory()->create([ 'platform' => $platform, 'signal_type' => $signalType, 'unique_key' => $identifier, + 'supplier_external_id' => $externalId, 'inactive_since' => now(), ]); } @@ -66,11 +68,24 @@ it('нет расхождений → письма нет, статус ok', fun Mail::fake(); seedActiveCallProject('79135397707', 3); + SupplierProject::factory()->create([ + 'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79135397707', + 'supplier_external_id' => '1001', 'current_limit' => 1, 'inactive_since' => null, + ]); + SupplierProject::factory()->create([ + 'platform' => 'B2', 'signal_type' => 'call', 'unique_key' => '79135397707', + 'supplier_external_id' => '1002', 'current_limit' => 1, 'inactive_since' => null, + ]); + SupplierProject::factory()->create([ + 'platform' => 'B3', 'signal_type' => 'call', 'unique_key' => '79135397707', + 'supplier_external_id' => '1003', 'current_limit' => 1, 'inactive_since' => null, + ]); + $this->mock(SupplierPortalClient::class, function ($m): void { $m->shouldReceive('listProjects')->andReturn([ - ['name' => 'B1_79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => '_lidpotok'], - ['name' => 'B2_79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => '_lidpotok'], - ['name' => 'B3_79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => '_lidpotok'], + ['id' => '1001', 'name' => 'B1_79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'], + ['id' => '1002', 'name' => 'B2_79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'], + ['id' => '1003', 'name' => 'B3_79135397707', 'content' => '79135397707', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'], ]); }); @@ -84,11 +99,11 @@ it('нет расхождений → письма нет, статус ok', fun it('пауза не дошла (should_be_off) → письмо + статус mismatch на попытке 2', function (): void { Mail::fake(); - seedPausedSupplierProject('automoney.ru', 'B3', 'site'); + seedPausedSupplierProject('automoney.ru', 'B3', 'site', '2001'); $this->mock(SupplierPortalClient::class, function ($m): void { $m->shouldReceive('listProjects')->andReturn([ - ['name' => 'B3_automoney.ru', 'content' => 'automoney.ru', 'type' => 'hosts', 'lim' => 1, 'status' => '1', 'tag' => '_lidpotok'], + ['id' => '2001', 'name' => 'B3_automoney.ru', 'content' => 'automoney.ru', 'type' => 'hosts', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'], ]); }); @@ -99,3 +114,38 @@ it('пауза не дошла (should_be_off) → письмо + статус m expect($row)->not->toBeNull(); expect($row->status)->toBe('mismatch')->and($row->mismatch_count)->toBe(1); }); + +it('ключ активен по формуле, но inactive_since завис → НЕ ложная тревога (баг 2)', function (): void { + Mail::fake(); + seedActiveCallProject('79999999999', 3); + + // B1 «завис» с inactive_since, хотя группа сейчас активна по формуле — + // должен быть исключён из shouldBeOff, а не дать ложный should_be_off. + SupplierProject::factory()->create([ + 'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79999999999', + 'supplier_external_id' => '3001', 'current_limit' => 1, 'inactive_since' => now(), + ]); + SupplierProject::factory()->create([ + 'platform' => 'B2', 'signal_type' => 'call', 'unique_key' => '79999999999', + 'supplier_external_id' => '3002', 'current_limit' => 1, 'inactive_since' => null, + ]); + SupplierProject::factory()->create([ + 'platform' => 'B3', 'signal_type' => 'call', 'unique_key' => '79999999999', + 'supplier_external_id' => '3003', 'current_limit' => 1, 'inactive_since' => null, + ]); + + $this->mock(SupplierPortalClient::class, function ($m): void { + $m->shouldReceive('listProjects')->andReturn([ + ['id' => '3001', 'name' => 'B1_79999999999', 'content' => '79999999999', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'], + ['id' => '3002', 'name' => 'B2_79999999999', 'content' => '79999999999', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'], + ['id' => '3003', 'name' => 'B3_79999999999', 'content' => '79999999999', 'type' => 'calls', 'lim' => 1, 'status' => '1', 'tag' => 'РФ'], + ]); + }); + + (new VerifySupplierOrderJob(attempt: 2))->handle(); + + Mail::assertNothingQueued(); + $row = DB::connection('pgsql_supplier')->table('supplier_order_checks')->latest('id')->first(); + expect($row)->not->toBeNull(); + expect($row->status)->toBe('ok')->and($row->mismatch_count)->toBe(0); +}); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index da9e82b0..41e71446 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-07-09T17:27:50.153Z +Last updated: 2026-07-09T17:36:28.707Z | Контролёр | Состояние | Детали | |---|---|---| @@ -112,8 +112,8 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 11216 | MsMpEng | 2.46ч | NaNч | -| 10400 | Code | 1.83ч | NaNч | +| 11216 | MsMpEng | 2.48ч | NaNч | +| 10400 | Code | 1.85ч | 0.0ч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.