feat(supplier): SupplierCsvParser под отчёт «Запрос номеров» (Name;Tag;Phone)
This commit is contained in:
@@ -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<int, array{vid: string, project: string, phone: string, time: int}>
|
||||
* @return iterable<int, array{project: string, tag: string, phone: string}>
|
||||
*/
|
||||
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],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Supplier\SupplierCsvParser;
|
||||
|
||||
function rowsOf(iterable $gen): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($gen as $row) {
|
||||
$out[] = $row;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
it('parses 3-column Name;Tag;Phone CSV', function (): void {
|
||||
$csv = "Name;Tag;Phone\nB1_a.com;tagA;79991234567\nB2_79990001122;tagB;79993334455\n";
|
||||
$rows = rowsOf((new SupplierCsvParser)->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([]);
|
||||
});
|
||||
Reference in New Issue
Block a user