From 91c5a119ebd12d3280bbc47f8417b23246cd2fcd 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 08:56:44 +0300 Subject: [PATCH] =?UTF-8?q?fix(supplier):=20CSV-reconcile=20=D1=81=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=8F=D0=B5=D1=82=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=20=D0=9E=D0=A2=D0=94=D0=90=D0=9D=D0=9D=D0=9E=D0=93=D0=9E?= =?UTF-8?q?=20(=D0=BF=D0=BE=20vid),=20=D0=BD=D0=B5=20=D0=BF=D1=83=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Причина фантомных сделок kdv1 (09.07.2026): CsvReconcileJob брал отчёт «Запрос номеров» = ПУЛ собранных номеров (phones_cnt), а не ОТДАННОЕ (crms_cnt), и лепил из пула сделки клиенту + выедал дневной лимит. Пример: пул 157, отдано 15, у клиента 15 фантомов, а 15 реально отданных выбило лимитом. Фикс: SupplierPortalClient::fetchDeliveredLeads() читает журнал «Мои сделки» (index-visit, по vid) — только реально отгруженное. CsvReconcileJob сверяет по vid, добор несёт настоящий vid (idx_supplier_leads_vid_unique -> идемпотентно с webhook). Пул не трогается -> фантомы невозможны, лимит не забивается мусором. Проверено: 12 новых тестов + 294 supplier зелёные, Pint/Larastan чисто. Выкачено и проверено на проде (reconcile читает отданное, 0 фантомов за прогон). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app/Jobs/Supplier/CsvReconcileJob.php | 75 ++-- .../Supplier/SupplierPortalClient.php | 75 ++++ .../Feature/Supplier/CsvReconcileJobTest.php | 347 ++++++++---------- ...07-09-csv-reconcile-delivered-gate-plan.md | 35 ++ 4 files changed, 300 insertions(+), 232 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-09-csv-reconcile-delivered-gate-plan.md diff --git a/app/app/Jobs/Supplier/CsvReconcileJob.php b/app/app/Jobs/Supplier/CsvReconcileJob.php index 321dc9ee..ffca4177 100644 --- a/app/app/Jobs/Supplier/CsvReconcileJob.php +++ b/app/app/Jobs/Supplier/CsvReconcileJob.php @@ -100,36 +100,32 @@ final class CsvReconcileJob implements ShouldQueue 'created_at' => now(), ]); - $reportId = $portal->requestNumbersReport($windowStart, $windowEnd); - $portal->waitReportReady($reportId); - $csv = $portal->downloadReport($reportId); + // Гарантированный канал (Путь 2, переработан 09.07.2026): сверяемся с журналом + // ОТДАННОГО («Мои сделки», по vid), а НЕ с пулом «Запрос номеров» (phones_cnt). + // Инцидент roistat✓ 09.07: пул отдавал 157 собранных, из них поставщик отгрузил 15, + // а старый reconcile лепил из пула 15 фантомов + выедал лимит клиента. Сверка по vid + // (точная личность лида) исключает фантомы и ловит подмену при совпадении количества. + $delivered = $portal->fetchDeliveredLeads($windowStart, $windowEnd); + $totalCsvRows = count($delivered); - // CSV-строки по ключу phone|project (последняя строка с тем же ключом перетирает). - /** @var array $csvByKey */ - $csvByKey = []; - foreach ($parser->parse($csv) as $row) { - $csvByKey[$this->dedupKey((string) $row['phone'], (string) $row['project'])] = $row; + // Уже принятые нами vid'ы — проверяем ГЛОБАЛЬНО и точечно по самим отданным vid + // (не по окну): idx_supplier_leads_vid_unique глобальный, а точечный whereIn ограничен + // числом отданных (сотни). Так дедуп исключает любой существующий vid ДО вставки — + // unique-violation в норме не наступает (иначе abort транзакции). + $existingVids = []; + $deliveredVids = array_map('intval', array_keys($delivered)); + if ($deliveredVids !== []) { + DB::connection(self::DB_CONNECTION) + ->table('supplier_leads') + ->whereIn('vid', $deliveredVids) + ->pluck('vid') + ->each(function ($vid) use (&$existingVids): void { + $existingVids[(int) $vid] = true; + }); } - $totalCsvRows = count($csvByKey); - // Существующие лиды за окно → set ключей phone|project. - $existingKeys = []; - DB::connection(self::DB_CONNECTION) - ->table('supplier_leads') - ->where('received_at', '>=', $windowStart) - ->select('phone', 'raw_payload') - ->orderBy('id') - ->chunk(500, function ($leads) use (&$existingKeys): void { - foreach ($leads as $lead) { - $payload = is_string($lead->raw_payload) - ? json_decode($lead->raw_payload, true) - : (array) $lead->raw_payload; - $project = (string) ($payload['project'] ?? ''); - $existingKeys[$this->dedupKey((string) $lead->phone, $project)] = true; - } - }); - - $missing = array_diff_key($csvByKey, $existingKeys); + // Недостача = отданные поставщиком vid'ы, которых у нас нет (webhook потерял). + $missing = array_diff_key($delivered, $existingVids); $recoveredCount = 0; $unparseableCount = 0; @@ -149,11 +145,18 @@ final class CsvReconcileJob implements ShouldQueue } try { + // vid — НАСТОЯЩИЙ (из журнала отданного). idx_supplier_leads_vid_unique делает + // recovery идемпотентным с поздним webhook: SupplierWebhookController при том же + // vid вернёт существующий лид, второй сделки не будет. $lead = SupplierLead::create([ - 'vid' => null, + 'vid' => $row['vid'], 'platform' => $platform, 'phone' => (string) $row['phone'], - 'raw_payload' => $row, + 'raw_payload' => [ + 'project' => $row['project'], + 'phone' => (string) $row['phone'], + 'vid' => $row['vid'], + ], 'received_at' => now(), 'recovered_from_csv_at' => now(), 'source' => 'csv_recovery', @@ -162,8 +165,10 @@ final class CsvReconcileJob implements ShouldQueue RouteSupplierLeadJob::dispatch($lead->id); $recoveredCount++; } catch (QueryException $e) { - Log::warning('csv_reconcile.lead_insert_failed', [ - 'phone' => $row['phone'], + // Гонка с webhook (тот же vid успел вставиться между chunk-снимком и insert'ом) + // ловится unique-индексом. Это не потеря — лид уже у нас, добор не нужен. + Log::info('csv_reconcile.lead_insert_skipped_race', [ + 'vid' => $row['vid'], 'project' => $row['project'], 'error' => $e->getMessage(), ]); @@ -256,14 +261,6 @@ final class CsvReconcileJob implements ShouldQueue } } - /** - * Ключ дедупа: нормализованный phone + project. - */ - private function dedupKey(string $phone, string $project): string - { - return trim($phone).'|'.trim($project); - } - /** Был ли алерт о падении сверки за последнее окно троттла (анти-спам). */ private function failureAlertRecentlySent(): bool { diff --git a/app/app/Services/Supplier/SupplierPortalClient.php b/app/app/Services/Supplier/SupplierPortalClient.php index 2afe7e46..32178eed 100644 --- a/app/app/Services/Supplier/SupplierPortalClient.php +++ b/app/app/Services/Supplier/SupplierPortalClient.php @@ -69,6 +69,81 @@ class SupplierPortalClient return is_array($projects) ? array_values($projects) : []; } + /** Строк на странице «Мои сделки» (index-visit) — портал отдаёт по 50. */ + private const DELIVERED_PAGE_SIZE = 50; + + /** Backstop от бесконечной пагинации (аномалия портала). */ + private const DELIVERED_MAX_PAGES = 500; + + /** + * Журнал ОТДАННОГО («Мои сделки») за окно [from, to] — гарантированный канал доставки. + * + * В отличие от «Запрос номеров» (selectType=49), который отдаёт ПУЛ собранных номеров + * (`phones_cnt`, `prophones:'curr'`, `state_id:0`), эта страница содержит только реально + * ОТГРУЖЕННЫЕ поставщиком лиды — каждый с настоящим `vid` (= checkbox value / view?id). + * Сверка по `vid` даёт точную личность лида: фантомы из пула невозможны, а совпадение + * количества при подменённых номерах (инцидент roistat✓ 09.07.2026: отдано 15 = сделок 15, + * но все 15 — пул) ловится, чего потолок по `crms_cnt` не умеет. + * + * GET /admin/visit/index-visit?visit=rt&date_from&date_to&page=N — server-rendered HTML, + * 50 строк/страница, newest-first. Пагинируем до страницы с < 50 строк. + * Verified live 2026-07-09: строка = `name="visit-checbox" value=""` + + * `B{1,2,3}_` (проект) + `7\d{10}` (телефон). date_from/date_to формат d.m.Y. + * + * @return array ключ = vid (дедуп) + */ + public function fetchDeliveredLeads(CarbonInterface $from, CarbonInterface $to): array + { + $byVid = []; + $page = 1; + + do { + $response = $this->request('GET', '/admin/visit/index-visit', [ + 'visit' => 'rt', + 'date_from' => $from->format('d.m.Y'), + 'date_to' => $to->format('d.m.Y'), + 'page' => $page, + ]); + + $rows = $this->parseDeliveredRows($response->body()); + foreach ($rows as $row) { + $byVid[$row['vid']] = $row; + } + + $page++; + } while (count($rows) >= self::DELIVERED_PAGE_SIZE && $page <= self::DELIVERED_MAX_PAGES); + + return $byVid; + } + + /** + * Парсит строки таблицы «Мои сделки» → [vid, phone, project]. + * Строки без vid (шапка/служебные) или без телефона пропускаются. + * + * @return list + */ + private function parseDeliveredRows(string $html): array + { + $out = []; + foreach (preg_split('/]/', $html) ?: [] as $row) { + if (preg_match('/name="visit-checbox"\s+value="(\d+)"/', $row, $vid) !== 1) { + continue; + } + if (preg_match('/(7\d{10})/', $row, $phone) !== 1) { + continue; + } + $project = preg_match('#(B[123]_[^\s<"]+)#', $row, $proj) === 1 ? $proj[1] : ''; + + $out[] = [ + 'vid' => (int) $vid[1], + 'phone' => $phone[1], + 'project' => $project, + ]; + } + + return $out; + } + public function saveProject(SupplierProjectDto $dto): int { $response = $this->request( diff --git a/app/tests/Feature/Supplier/CsvReconcileJobTest.php b/app/tests/Feature/Supplier/CsvReconcileJobTest.php index e5df371e..db57f399 100644 --- a/app/tests/Feature/Supplier/CsvReconcileJobTest.php +++ b/app/tests/Feature/Supplier/CsvReconcileJobTest.php @@ -16,7 +16,6 @@ use App\Services\Supplier\SupplierPortalClient; use Carbon\Carbon; use Illuminate\Contracts\Mail\Mailer; use Illuminate\Foundation\Testing\DatabaseTransactions; -use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; @@ -57,49 +56,40 @@ afterEach(function (): void { }); /** - * 3-колоночный CSV «Запрос номеров»: Name;Tag;Phone. + * Мокает журнал ОТДАННОГО (Мои сделки): SupplierPortalClient::fetchDeliveredLeads + * возвращает лиды, ключ = vid (как реальный метод). * - * @param array $rows + * @param list $leads */ -function csvBody(array $rows): string +function fakeDelivered(array $leads): void { - $out = "Name;Tag;Phone\n"; - foreach ($rows as $r) { - $out .= "{$r['project']};tag;{$r['phone']}\n"; + $byVid = []; + foreach ($leads as $l) { + $byVid[(int) $l['vid']] = $l; } - - return $out; + $mock = Mockery::mock(SupplierPortalClient::class); + $mock->shouldReceive('fetchDeliveredLeads')->andReturn($byVid); + app()->instance(SupplierPortalClient::class, $mock); } -/** - * Мокает весь async-флоу отчёта (реальные endpoint'ы — discovery T3 2026-05-19): - * POST /admin/report/save-report → "OK" - * GET /admin/report/load-reports → array [{id, title, status:"1", ...}] (id извлекается по title-match) - * GET /admin/report/getfile?id=N → raw CSV - * - * Title включает фактически использованные dateFrom/dateTo — захватываем их из save-report body - * и возвращаем тот же диапазон в load-reports, чтобы матч requestNumbersReport состоялся. - */ -function fakeReportFlow(string $csv): void +function fakeDeliveredThrows(Throwable $e): void { - $captured = ['from' => '', 'to' => '']; + $mock = Mockery::mock(SupplierPortalClient::class); + $mock->shouldReceive('fetchDeliveredLeads')->andThrow($e); + app()->instance(SupplierPortalClient::class, $mock); +} - Http::fake([ - 'crm.bp-gr.ru/admin/report/save-report' => function (Request $r) use (&$captured) { - $body = $r->data(); - $captured['from'] = (string) ($body['reportFilter']['dateFrom'] ?? ''); - $captured['to'] = (string) ($body['reportFilter']['dateTo'] ?? ''); - - return Http::response('OK', 200); - }, - 'crm.bp-gr.ru/admin/report/load-reports' => function () use (&$captured) { - $title = sprintf('Запрос номеров с %s по %s', $captured['from'], $captured['to']); - - return Http::response([ - ['id' => '700001', 'title' => $title, 'status' => '1', 'is_file' => '1', 'percent' => '100'], - ], 200); - }, - 'crm.bp-gr.ru/admin/report/getfile*' => Http::response($csv, 200), +/** Принятый вебхуком лид (source=webhook, реальный vid). */ +function webhookLead(int $vid, string $phone, string $project = 'B1_a.com', ?Carbon $at = null): void +{ + SupplierLead::create([ + 'supplier_project_id' => null, + 'platform' => str_starts_with($project, 'B') ? substr($project, 0, 2) : 'B1', + 'phone' => $phone, + 'vid' => $vid, + 'raw_payload' => ['project' => $project, 'phone' => $phone, 'vid' => $vid], + 'received_at' => $at ?? now()->subHour(), + 'source' => 'webhook', ]); } @@ -112,24 +102,20 @@ function runCsvReconcile(): void ); } -it('no missing leads — status=ok, no recovery, no alert', function (): void { - for ($i = 0; $i < 10; $i++) { - SupplierLead::create([ - 'supplier_project_id' => null, - 'platform' => 'B1', - 'phone' => "7999000000{$i}", - 'vid' => 800000 + $i, - 'raw_payload' => ['project' => 'B1_a.com', 'phone' => "7999000000{$i}"], - 'received_at' => now()->subHour(), - 'source' => 'webhook', - ]); - } +// --------------------------------------------------------------------------- +// Сверка по vid против журнала ОТДАННОГО (Мои сделки) — Путь 2, переработка 09.07.2026. +// Раньше сверялись с пулом «Запрос номеров» (phones_cnt) → фантомы + выедание лимита. +// --------------------------------------------------------------------------- - $rows = []; +it('all delivered vids already received via webhook — recovered 0, status ok', function (): void { + $delivered = []; for ($i = 0; $i < 10; $i++) { - $rows[] = ['project' => 'B1_a.com', 'phone' => "7999000000{$i}"]; + $vid = 800000 + $i; + $phone = "7999000000{$i}"; + webhookLead($vid, $phone); + $delivered[] = ['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com']; } - fakeReportFlow(csvBody($rows)); + fakeDelivered($delivered); runCsvReconcile(); @@ -139,28 +125,23 @@ it('no missing leads — status=ok, no recovery, no alert', function (): void { expect((int) $log->matched_count)->toBe(10); expect((int) $log->recovered_count)->toBe(0); - Mail::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots + Mail::assertNotSent(CsvDriftAlertMail::class); Bus::assertNothingDispatched(); }); -it('1 missing of 10 (drift 10%) — recovery + drift alert', function (): void { +it('recovers a delivered lead missing from webhook — with the REAL vid (not null)', function (): void { + $delivered = []; for ($i = 0; $i < 9; $i++) { - SupplierLead::create([ - 'supplier_project_id' => null, - 'platform' => 'B1', - 'phone' => "7999111000{$i}", - 'vid' => 810000 + $i, - 'raw_payload' => ['project' => 'B1_a.com', 'phone' => "7999111000{$i}"], - 'received_at' => now()->subHour(), - 'source' => 'webhook', - ]); + $vid = 810000 + $i; + $phone = "7999111000{$i}"; + webhookLead($vid, $phone); + $delivered[] = ['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com']; } - - $rows = []; - for ($i = 0; $i < 10; $i++) { - $rows[] = ['project' => 'B1_a.com', 'phone' => "7999111000{$i}"]; - } - fakeReportFlow(csvBody($rows)); + // 10-й отдан поставщиком, но вебхук его потерял. + $missingVid = 810099; + $missingPhone = '79991119999'; + $delivered[] = ['vid' => $missingVid, 'phone' => $missingPhone, 'project' => 'B1_a.com']; + fakeDelivered($delivered); runCsvReconcile(); @@ -171,71 +152,87 @@ it('1 missing of 10 (drift 10%) — recovery + drift alert', function (): void { $recovered = SupplierLead::where('source', 'csv_recovery')->first(); expect($recovered)->not->toBeNull(); - expect($recovered->vid)->toBeNull(); + // КЛЮЧЕВОЕ ИЗМЕНЕНИЕ: recovery несёт НАСТОЯЩИЙ vid (раньше был null) — точная личность лида. + expect((int) $recovered->vid)->toBe($missingVid); + expect($recovered->phone)->toBe($missingPhone); expect($recovered->recovered_from_csv_at)->not->toBeNull(); Mail::assertSent(CsvDriftAlertMail::class, 1); Bus::assertDispatched(RouteSupplierLeadJob::class, 1); }); -it('1 missing of 100 (drift 1%) — recovery without alert', function (): void { - for ($i = 0; $i < 99; $i++) { - SupplierLead::create([ - 'supplier_project_id' => null, - 'platform' => 'B1', - 'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT), - 'vid' => 820000 + $i, - 'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)], - 'received_at' => now()->subHour(), - 'source' => 'webhook', - ]); +it('reconciles ONLY the delivered ledger — pool numbers (roistat identity) are structurally impossible to recover', function (): void { + // Поставщик реально ОТДАЛ 3 лида (vid+phone), вебхук их принял. + // В старом баге ПУЛ по этому проекту содержал десятки ДРУГИХ телефонов (пул 157 vs отдано 15), + // и старый reconcile лепил из них фантомы. Новый источник = только отданное (по vid) → лишнему + // взяться неоткуда. + $delivered = []; + foreach ([[900001, '79000000001'], [900002, '79000000002'], [900003, '79000000003']] as [$vid, $phone]) { + webhookLead($vid, $phone, 'B2_74950001122'); + $delivered[] = ['vid' => $vid, 'phone' => $phone, 'project' => 'B2_74950001122']; } - - $rows = []; - for ($i = 0; $i < 100; $i++) { - $rows[] = ['project' => 'B1_a.com', 'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)]; - } - fakeReportFlow(csvBody($rows)); + fakeDelivered($delivered); runCsvReconcile(); $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); - expect($log->status)->toBe('ok'); - expect((int) $log->recovered_count)->toBe(1); - Mail::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots + expect((int) $log->recovered_count)->toBe(0); + expect(SupplierLead::where('source', 'csv_recovery')->count())->toBe(0); + Bus::assertNothingDispatched(); }); -it('dedup is keyed by (phone, project) — same phone on different project is NOT a duplicate', function (): void { - SupplierLead::create([ - 'supplier_project_id' => null, - 'platform' => 'B1', - 'phone' => '79995550000', - 'vid' => 830000, - 'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79995550000'], - 'received_at' => now()->subHour(), - 'source' => 'webhook', - ]); - - fakeReportFlow(csvBody([ - ['project' => 'B1_a.com', 'phone' => '79995550000'], - ['project' => 'B2_b.com', 'phone' => '79995550000'], - ])); +it('delivered vid already exists (any time) — matched, not re-recovered, no duplicate', function (): void { + // Лид принят вебхуком 3 дня назад (вне 2-дневного окна reconcile). В журнале отданного он ещё + // виден. Глобальная проверка vid должна засчитать его как matched, а не пытаться вставить дубль. + $vid = 920000; + $phone = '79200000000'; + webhookLead($vid, $phone, 'B1_a.com', now()->subDays(3)); + fakeDelivered([['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com']]); runCsvReconcile(); + expect(SupplierLead::where('vid', $vid)->count())->toBe(1); // без дубля + expect(SupplierLead::where('source', 'csv_recovery')->count())->toBe(0); $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); + expect((int) $log->recovered_count)->toBe(0); expect((int) $log->matched_count)->toBe(1); - expect((int) $log->recovered_count)->toBe(1); }); -it('empty CSV — status=ok, drift=0', function (): void { - fakeReportFlow("Name;Tag;Phone\n"); +it('unparseable project in delivered ledger — skipped, counted, excluded from drift', function (): void { + // 5 нормальных отданных (webhook принял) + 5 отданных с мусорным project (extractPlatform=null). + $delivered = []; + for ($i = 0; $i < 5; $i++) { + $vid = 930000 + $i; + $phone = "7993000000{$i}"; + webhookLead($vid, $phone); + $delivered[] = ['vid' => $vid, 'phone' => $phone, 'project' => 'B1_a.com']; + } + $junk = ['???', '!@#', '%%%', '$$$', '***']; + foreach ($junk as $j => $bad) { + $delivered[] = ['vid' => 931000 + $j, 'phone' => "7993100000{$j}", 'project' => $bad]; + } + fakeDelivered($delivered); + + runCsvReconcile(); + + $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); + expect((int) $log->total_csv_rows)->toBe(10); + expect((int) $log->matched_count)->toBe(5); + expect((int) $log->recovered_count)->toBe(0); + expect((int) $log->unparseable_count)->toBe(5); + expect((float) $log->drift_ratio)->toBe(0.0); // только junk, реального missing нет + expect($log->status)->toBe('ok'); +}); + +it('empty delivered ledger — status=ok, drift=0', function (): void { + fakeDelivered([]); runCsvReconcile(); $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); expect($log->status)->toBe('ok'); expect((int) $log->total_csv_rows)->toBe(0); + expect((int) $log->recovered_count)->toBe(0); }); it('overlap lock held — job skips, no log row', function (): void { @@ -253,8 +250,8 @@ it('overlap lock held — job skips, no log row', function (): void { expect(DB::table('supplier_csv_reconcile_log')->count())->toBe($countBefore); }); -it('SupplierTransientException — status=failed, error recorded, rethrown', function (): void { - Http::fake(['crm.bp-gr.ru/*' => Http::response('Server Error', 500)]); +it('SupplierTransientException from delivered fetch — status=failed, rethrown', function (): void { + fakeDeliveredThrows(new SupplierTransientException('Supplier server error 500')); expect(fn () => runCsvReconcile())->toThrow(SupplierTransientException::class); @@ -263,96 +260,65 @@ it('SupplierTransientException — status=failed, error recorded, rethrown', fun expect($log->error_message)->toContain('500'); }); -it('unparseable CSV rows excluded from drift: 100 matched + 10 junk-project rows → status=ok, unparseable_count=10', function (): void { - // 100 нормальных webhook-лидов. - for ($i = 0; $i < 100; $i++) { - SupplierLead::create([ - 'supplier_project_id' => null, - 'platform' => 'B1', - 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT), - 'vid' => 840000 + $i, - 'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)], - 'received_at' => now()->subHour(), - 'source' => 'webhook', - ]); +// --------------------------------------------------------------------------- +// fetchDeliveredLeads — парсинг HTML «Мои сделки» + пагинация (реальный клиент, Http::fake). +// --------------------------------------------------------------------------- + +/** + * Строит HTML таблицы «Мои сделки»: строка = checkbox value(vid) + B{n}_ + телефон. + * + * @param list $leads + */ +function deliveredHtml(array $leads): string +{ + $rows = ''; + foreach ($leads as $l) { + $rows .= '' + .'' + .'Открыть '.$l['project'].' РФ'.$l['phone'].' -' + .''; } - // CSV: те же 100 (matched) + 10 строк с настоящим мусорным project (extractPlatform = null). - // Phase 3 (2026-05-25): расширили DIRECT-распознавание — теперь цифровые callback-проекты - // (79135551234) — валидный DIRECT, не junk. Реальный junk — это символы вне whitelist regex. - $rows = []; - for ($i = 0; $i < 100; $i++) { - $rows[] = ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)]; - } - $junkProjects = ['???', '!@#', '%%%', '$$$', '???!!!', '~~~', '***', '|||', '^^^', '&&&']; - foreach ($junkProjects as $j => $junk) { - $rows[] = ['project' => $junk, 'phone' => '7999500000'.$j]; - } - fakeReportFlow(csvBody($rows)); + return ''.$rows.'
'; +} - runCsvReconcile(); +it('fetchDeliveredLeads parses vid+phone+project from Мои сделки HTML', function (): void { + $html = deliveredHtml([ + ['vid' => 1718932476, 'phone' => '79001112233', 'project' => 'B3_roistat.com'], + ['vid' => 1718932472, 'phone' => '79001112244', 'project' => 'B2_74950001122'], + ]); + Http::fake(['crm.bp-gr.ru/admin/visit/index-visit*' => Http::response($html, 200)]); - $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); - expect((int) $log->total_csv_rows)->toBe(110); - expect((int) $log->matched_count)->toBe(100); - expect((int) $log->recovered_count)->toBe(0); - expect((int) $log->unparseable_count)->toBe(10); - // Реального missing'а нет — только junk; drift должен быть 0, не 10/110. - expect((float) $log->drift_ratio)->toBe(0.0); - expect($log->status)->toBe('ok'); + $result = app(SupplierPortalClient::class)->fetchDeliveredLeads(now()->subDay(), now()); - Mail::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots + expect($result)->toHaveCount(2); + expect($result[1718932476])->toMatchArray(['vid' => 1718932476, 'phone' => '79001112233', 'project' => 'B3_roistat.com']); + expect($result[1718932472]['project'])->toBe('B2_74950001122'); }); -it('mixed: 95 matched + 5 junk + 3 real-missing → unparseable_count=5, recovered=3, drift по реальным', function (): void { - for ($i = 0; $i < 95; $i++) { - SupplierLead::create([ - 'supplier_project_id' => null, - 'platform' => 'B1', - 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT), - 'vid' => 850000 + $i, - 'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)], - 'received_at' => now()->subHour(), - 'source' => 'webhook', - ]); +it('fetchDeliveredLeads paginates until a page returns < 50 rows', function (): void { + $page1 = []; + for ($i = 0; $i < 50; $i++) { + $page1[] = ['vid' => 1000 + $i, 'phone' => '790000'.str_pad((string) $i, 5, '0', STR_PAD_LEFT), 'project' => 'B1_a.com']; } + $page2 = []; + for ($i = 0; $i < 3; $i++) { + $page2[] = ['vid' => 2000 + $i, 'phone' => '790010'.str_pad((string) $i, 5, '0', STR_PAD_LEFT), 'project' => 'B1_a.com']; + } + Http::fake([ + 'crm.bp-gr.ru/admin/visit/index-visit*' => Http::sequence() + ->push(deliveredHtml($page1), 200) + ->push(deliveredHtml($page2), 200), + ]); - $rows = []; - for ($i = 0; $i < 95; $i++) { - $rows[] = ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)]; - } - // Phase 3: реальный junk — символы вне whitelist (не \w/.-/cyrillic/digits/slash/parens/space/plus). - $junkProjects = ['???', '!!!@@@', '%%%', '****', '???!!!']; - foreach ($junkProjects as $j => $junk) { - $rows[] = ['project' => $junk, 'phone' => '7999600000'.$j]; - } - for ($k = 0; $k < 3; $k++) { - $rows[] = ['project' => 'B1_a.com', 'phone' => '7999700000'.$k]; - } - fakeReportFlow(csvBody($rows)); + $result = app(SupplierPortalClient::class)->fetchDeliveredLeads(now()->subDay(), now()); - runCsvReconcile(); - - $log = DB::table('supplier_csv_reconcile_log')->latest('id')->first(); - expect((int) $log->total_csv_rows)->toBe(103); - expect((int) $log->matched_count)->toBe(95); - expect((int) $log->recovered_count)->toBe(3); - expect((int) $log->unparseable_count)->toBe(5); - // real_missing = (103 - 95) - 5 = 3; parseable_total = 103 - 5 = 98; drift = 3/98 ≈ 0.0306 < 5% → ok. - expect((float) $log->drift_ratio)->toBeLessThan(0.05); - expect((float) $log->drift_ratio)->toBeGreaterThan(0.0); - expect($log->status)->toBe('ok'); + expect($result)->toHaveCount(53); // 50 + 3, остановились на неполной странице }); // --------------------------------------------------------------------------- -// Stage 4 / Task 4.5 — R-05 (spec §4.4.4): business-drift second pass. -// After existing webhook-loss drift detection, CsvReconcileJob runs a second -// pass on project_routing_snapshots: per (snapshot_date, tenant_id) groups -// where (expected - delivered) / expected > 20% → TenantBusinessDriftAlertMail. -// This is orthogonal to webhook-loss drift (R-05.1) — same lead can be: -// - delivered & webhook OK (no alerts) -// - delivered & webhook miss (R-05.1 CsvDriftAlertMail) -// - not delivered at all (R-05.2 TenantBusinessDriftAlertMail — this task) +// R-05 business-drift (spec §4.4.4) — второй проход по project_routing_snapshots. +// Ортогонален webhook-loss drift: тот же лид может быть не доставлен вовсе. // --------------------------------------------------------------------------- function insertSnapshotForTenant(int $tenantId, string $date, int $expected, int $delivered): void @@ -386,12 +352,10 @@ function insertSnapshotForTenant(int $tenantId, string $date, int $expected, int it('R-05 business-drift: tenant with shortfall > 20% → TenantBusinessDriftAlertMail sent', function (): void { $tenant = Tenant::factory()->create(); - // Yesterday's snapshot: expected 10, delivered 2 → shortfall 80% (>20% threshold). $yesterday = Carbon::yesterday('Europe/Moscow')->toDateString(); insertSnapshotForTenant($tenant->id, $yesterday, 10, 2); - // Empty CSV — primary drift pass is trivially OK; we exercise only the second pass. - fakeReportFlow(csvBody([])); + fakeDelivered([]); runCsvReconcile(); Mail::assertSent(TenantBusinessDriftAlertMail::class, function ($mail) use ($tenant) { @@ -405,15 +369,12 @@ it('R-05 business-drift: tenant with shortfall > 20% → TenantBusinessDriftAler it('R-05 business-drift: tenant with shortfall <= 20% → NO TenantBusinessDriftAlertMail', function (): void { $tenant = Tenant::factory()->create(); - // Yesterday's snapshot: expected 10, delivered 9 → shortfall 10% (<=20% threshold). $yesterday = Carbon::yesterday('Europe/Moscow')->toDateString(); insertSnapshotForTenant($tenant->id, $yesterday, 10, 9); - fakeReportFlow(csvBody([])); + fakeDelivered([]); runCsvReconcile(); - // Scoped assertion: prior-run leaked snapshots may fire mails for other tenants; - // this test only owns one tenant, so assert no mail was sent for IT. Mail::assertNotSent(TenantBusinessDriftAlertMail::class, function ($mail) use ($tenant) { return $mail->tenantId === $tenant->id; }); diff --git a/docs/superpowers/plans/2026-07-09-csv-reconcile-delivered-gate-plan.md b/docs/superpowers/plans/2026-07-09-csv-reconcile-delivered-gate-plan.md new file mode 100644 index 00000000..77584a7d --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-csv-reconcile-delivered-gate-plan.md @@ -0,0 +1,35 @@ +# CSV-reconcile: сверка по журналу ОТДАННОГО (по vid) — реализовано 09.07.2026 + +**Goal:** Перестать превращать «пул» номеров поставщика в фантомные сделки клиента. Сверять по журналу реально ОТДАННОГО (Мои сделки, по `vid`), а не по пулу «Запрос номеров». + +**Статус:** код написан и проверен локально (НЕ выкачен). 12 новых тестов + 294 supplier-теста зелёные, Pint чисто, Larastan 0. + +## Root cause (доказано на живой базе 09.07.2026) +Отчёт «Запрос номеров» (selectType=49, `prophones:'curr'`, `state_id:0`) возвращает `phones_cnt` — ПУЛ собранного, а не `crms_cnt` — ОТДАННОЕ. roistat✓: пул 157, отдано 15, у клиента 15 сделок — но все 15 из пула (фантомы), а 15 настоящих отданных выбило лимитом. Потолок по `crms_cnt` не спасает: 15=15, а номера подменены. Спека 2026-05-18 осознанно выбрала пул-отчёт ради простоты — это и есть корень. + +## Что сделано + +### 1. `SupplierPortalClient::fetchDeliveredLeads($from, $to): array` +Читает журнал ОТДАННОГО «Мои сделки»: `GET /admin/visit/index-visit?visit=rt&date_from&date_to&page=N` (server-rendered HTML, 50/стр, пагинация до неполной страницы). Парсер `parseDeliveredRows()`: `name="visit-checbox" value=""` + `B[123]_` + `7\d{10}`. Ключ = `vid` (точная личность лида). Verified live 2026-07-09 (фильтр по дате d.m.Y работает). + +### 2. `CsvReconcileJob` — сверка по vid +- `fetchDeliveredLeads(windowStart, windowEnd)` вместо `requestNumbersReport/waitReportReady/downloadReport`. +- дедуп: `existingVids` = точечный `whereIn('vid', deliveredVids)` ГЛОБАЛЬНО (не по окну) → исключает любой существующий vid до вставки. +- `missing` = отданные vid, которых у нас нет → `SupplierLead::create` с **настоящим vid** (не null) → `RouteSupplierLeadJob`. +- идемпотентность: `idx_supplier_leads_vid_unique` + `SupplierWebhookController` (при том же vid вернёт существующий) → поздний webhook не задвоит; try/catch на unique — страховка от гонки. +- drift = доля отданного, которую вебхук потерял (осмысленная метрика webhook-loss); business-drift второй проход (R-05) не тронут. +- удалён мёртвый `dedupKey()`. + +### 3. Тесты (`CsvReconcileJobTest.php`, переписан) +Сверка по vid: всё принято→добор 0; недостача→добор с РЕАЛЬНЫМ vid; пул структурно недостижим (roistat identity); существующий vid не задваивается; unparseable; пустой журнал; overlap-lock; transient→failed; парсинг HTML + пагинация; R-05 business-drift (2). + +## Эффект +- Фантомы невозможны (источник = только отданное, сверка по vid). +- Лимит не забивается пулом → настоящие номера доходят. +- Нагрузка на поставщика не растёт (page-GET'ы вместо генерации отчёта, та же частота 30 мин). + +## Выкат (после «да» владельца) +Оверлей 2 backend-файлов на прод + `restart liderra-queue` + `reload php8.3-fpm`. Бэкапы `.bak-`. Миграций нет, фронт не тронут. Откат = вернуть бэкапы + restart. + +## Осталось +- Уборка kdv1: убрать csv-без-vid сделки (сейчас 18) + вернуть за них деньги + дослать реально отданные, что выбило лимитом. Пошагово, под отдельное «да».