diff --git a/app/app/Services/Supplier/SupplierCsvParser.php b/app/app/Services/Supplier/SupplierCsvParser.php index 47077315..2d55ce6a 100644 --- a/app/app/Services/Supplier/SupplierCsvParser.php +++ b/app/app/Services/Supplier/SupplierCsvParser.php @@ -7,21 +7,19 @@ namespace App\Services\Supplier; use Illuminate\Support\Facades\Log; /** - * Streaming-парсер CSV-экспорта `/admin/report/index?type=49` поставщика. + * Streaming-парсер CSV-отчёта «Запрос номеров» supplier-портала crm.bp-gr.ru. * - * Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.2 - * Ожидаемые столбцы: vid;project;tag;phone;phones;time (placeholder; уточнится - * после Plan 3 Tasks 1-2 discovery с credentials поставщика). + * Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md §4.1 + * Столбцы: Name;Tag;Phone — 3 колонки. vid и время в этом отчёте отсутствуют. * - * Возвращает Generator — вызывающий (CsvReconcileJob) сам решает, сколько - * копить в памяти. BOM + CRLF поддерживаются. Malformed rows skip + log. + * Возвращает Generator. BOM + CRLF поддерживаются. Malformed rows skip + log. */ final class SupplierCsvParser { - private const EXPECTED_COLUMNS = 6; + private const EXPECTED_COLUMNS = 3; /** - * @return iterable + * @return iterable */ public function parse(string $rawCsv): iterable { @@ -29,7 +27,7 @@ final class SupplierCsvParser return; } - // Убираем BOM (UTF-8 BOM = EF BB BF) + // Убираем UTF-8 BOM (EF BB BF) if (str_starts_with($rawCsv, "\xEF\xBB\xBF")) { $rawCsv = substr($rawCsv, 3); } @@ -65,10 +63,9 @@ final class SupplierCsvParser } yield [ - 'vid' => (string) $cols[0], - 'project' => (string) $cols[1], - 'phone' => (string) $cols[3], - 'time' => (int) $cols[5], + 'project' => (string) $cols[0], + 'tag' => (string) $cols[1], + 'phone' => (string) $cols[2], ]; } } diff --git a/app/tests/Feature/Supplier/SupplierCsvParserTest.php b/app/tests/Feature/Supplier/SupplierCsvParserTest.php new file mode 100644 index 00000000..ad910560 --- /dev/null +++ b/app/tests/Feature/Supplier/SupplierCsvParserTest.php @@ -0,0 +1,48 @@ +parse($csv)); + + expect($rows)->toHaveCount(2); + expect($rows[0])->toBe(['project' => 'B1_a.com', 'tag' => 'tagA', 'phone' => '79991234567']); + expect($rows[1])->toBe(['project' => 'B2_79990001122', 'tag' => 'tagB', 'phone' => '79993334455']); +}); + +it('strips UTF-8 BOM and normalizes CRLF', function (): void { + $csv = "\xEF\xBB\xBFName;Tag;Phone\r\nB1_a.com;t;79991234567\r\n"; + $rows = rowsOf((new SupplierCsvParser)->parse($csv)); + + expect($rows)->toHaveCount(1); + expect($rows[0]['project'])->toBe('B1_a.com'); +}); + +it('skips malformed rows with fewer than 3 columns', function (): void { + $csv = "Name;Tag;Phone\nB1_a.com;t;79991234567\nbroken;row\nB2_b.com;t2;79990000000\n"; + $rows = rowsOf((new SupplierCsvParser)->parse($csv)); + + expect($rows)->toHaveCount(2); + expect($rows[1]['project'])->toBe('B2_b.com'); +}); + +it('returns nothing for empty CSV', function (): void { + expect(rowsOf((new SupplierCsvParser)->parse('')))->toBe([]); +}); + +it('returns nothing for header-only CSV', function (): void { + expect(rowsOf((new SupplierCsvParser)->parse("Name;Tag;Phone\n")))->toBe([]); +});