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];