From d82b1bf17c2e4b8ff7f2e65d65700cf323c4b57a 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: Mon, 25 May 2026 17:31:32 +0300 Subject: [PATCH] feat(supplier): LedgerService + CsvReconcileJob recognise DIRECT platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LedgerService::resolveSupplierId returns suppliers.code='direct' row for DIRECT-platform supplier_projects (and for parsed-from-payload non-B projects). CsvReconcileJob::extractPlatform now classifies most non-empty, non-junk project strings as DIRECT (instead of dumping them into unparseable_count) — this allows CSV recovery to also create DIRECT supplier_leads, mirroring the webhook path. CsvReconcileJobTest junk-rows fixtures updated: previously used callback phone-number-as-project (79135551234) and URL-like strings as 'junk', but those are now valid DIRECT identifiers. Replaced with truly junk strings matching only outside-whitelist symbols (e.g. '???', '!@#'). Co-Authored-By: Claude Opus 4.7 --- app/app/Jobs/Supplier/CsvReconcileJob.php | 13 ++++++++++-- app/app/Services/Billing/LedgerService.php | 21 +++++++++++++++---- .../Feature/Supplier/CsvReconcileJobTest.php | 16 ++++++++------ 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/app/app/Jobs/Supplier/CsvReconcileJob.php b/app/app/Jobs/Supplier/CsvReconcileJob.php index 8256e541..3640e855 100644 --- a/app/app/Jobs/Supplier/CsvReconcileJob.php +++ b/app/app/Jobs/Supplier/CsvReconcileJob.php @@ -231,14 +231,23 @@ final class CsvReconcileJob implements ShouldQueue } /** - * Извлекает platform (B1/B2/B3) из имени проекта формата `B[123]_`. - * Возвращает null если не парсится — caller пропустит строку с warning. + * Извлекает platform из имени проекта: + * - `B[123]_` → 'B1' / 'B2' / 'B3'; + * - Phase 3: иначе, если строка непустая и состоит из identifier-символов + * (домены / телефоны / SMS-отправители) → 'DIRECT'; + * - откровенный мусор (только спец-символы, пусто) → null (unparseable). */ private function extractPlatform(string $project): ?string { if (preg_match('/^(B[123])_/', $project, $m) === 1) { return $m[1]; } + // Phase 3: всё что выглядит как разумный identifier (домен / телефон / SMS-sender) → DIRECT. + // unparseable_count теперь только для откровенного мусора (пустые / только спец-символы). + $trimmed = trim($project); + if ($trimmed !== '' && preg_match('/^[\w\-.а-яА-Я0-9\/() +]+$/u', $trimmed) === 1) { + return 'DIRECT'; + } return null; } diff --git a/app/app/Services/Billing/LedgerService.php b/app/app/Services/Billing/LedgerService.php index 2c6988c0..ea9002ec 100644 --- a/app/app/Services/Billing/LedgerService.php +++ b/app/app/Services/Billing/LedgerService.php @@ -128,10 +128,17 @@ final class LedgerService { if ($lead->supplier_project_id !== null) { $sp = DB::table('supplier_projects')->where('id', $lead->supplier_project_id)->first(); - if ($sp !== null && in_array($sp->platform, ['B1', 'B2', 'B3'], true)) { - $supplier = Supplier::where('code', strtolower($sp->platform))->first(); - if ($supplier !== null) { - return (int) $supplier->id; + if ($sp !== null) { + if (in_array($sp->platform, ['B1', 'B2', 'B3'], true)) { + $supplier = Supplier::where('code', strtolower($sp->platform))->first(); + if ($supplier !== null) { + return (int) $supplier->id; + } + } + if ($sp->platform === 'DIRECT') { + $supplier = Supplier::where('code', 'direct')->first(); + + return $supplier?->id; } } } @@ -143,6 +150,12 @@ final class LedgerService return $supplier?->id; } + // Phase 3: project без B-префикса (и не пустой) → DIRECT. + if ($project !== '') { + $supplier = Supplier::where('code', 'direct')->first(); + + return $supplier?->id; + } return null; } diff --git a/app/tests/Feature/Supplier/CsvReconcileJobTest.php b/app/tests/Feature/Supplier/CsvReconcileJobTest.php index c0cb63c2..79de07c5 100644 --- a/app/tests/Feature/Supplier/CsvReconcileJobTest.php +++ b/app/tests/Feature/Supplier/CsvReconcileJobTest.php @@ -272,14 +272,16 @@ it('unparseable CSV rows excluded from drift: 100 matched + 10 junk-project rows ]); } - // CSV: те же 100 (matched) + 10 строк с мусорным project (extractPlatform = null). - // Это реальный паттерн поставщика — телефон в поле «Name» вместо проекта (см. 22.05 в ПИЛОТ). + // 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)]; } - for ($j = 0; $j < 10; $j++) { - $rows[] = ['project' => '79135551234', 'phone' => '7999500000'.$j]; + $junkProjects = ['???', '!@#', '%%%', '$$$', '???!!!', '~~~', '***', '|||', '^^^', '&&&']; + foreach ($junkProjects as $j => $junk) { + $rows[] = ['project' => $junk, 'phone' => '7999500000'.$j]; } fakeReportFlow(csvBody($rows)); @@ -314,8 +316,10 @@ it('mixed: 95 matched + 5 junk + 3 real-missing → unparseable_count=5, recover for ($i = 0; $i < 95; $i++) { $rows[] = ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)]; } - for ($j = 0; $j < 5; $j++) { - $rows[] = ['project' => 'https://junk.example/'.$j, 'phone' => '7999600000'.$j]; + // 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];