29a4d01ff4
Code-review Task 5 (non-blocking 🟡): CsvLeadsParser объявлен final (симметрия с DTO ParsedLeadRow/CsvParseResult, утилитарный класс без наследования); строка ошибки про число колонок использует self::EXPECTED_COLUMNS вместо литерала 9. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
131 lines
4.6 KiB
PHP
131 lines
4.6 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Services\Import;
|
||
|
||
use Carbon\CarbonImmutable;
|
||
use Throwable;
|
||
|
||
/**
|
||
* Парсер CSV-выгрузки лидов из crm.bp-gr.ru (ТЗ §6.2/§6.3).
|
||
*
|
||
* Формат: UTF-8 с BOM, разделитель — запятая, дата `Y/m/d H:i:s`,
|
||
* телефон `7XXXXXXXXXX`. Заголовок:
|
||
* id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя
|
||
*
|
||
* Невалидные строки не роняют парсинг — собираются в errors[].
|
||
* Файл целиком загружается в память (MVP: ожидаемый объём — единицы тысяч строк).
|
||
*/
|
||
final class CsvLeadsParser
|
||
{
|
||
private const EXPECTED_COLUMNS = 9;
|
||
|
||
private const DATE_FORMAT = 'Y/m/d H:i:s';
|
||
|
||
public function parse(string $content): CsvParseResult
|
||
{
|
||
// Срезаем UTF-8 BOM.
|
||
if (str_starts_with($content, "\xEF\xBB\xBF")) {
|
||
$content = substr($content, 3);
|
||
}
|
||
|
||
$lines = preg_split('/\r\n|\r|\n/', trim($content)) ?: [];
|
||
$rows = [];
|
||
$errors = [];
|
||
|
||
// Строка 1 — заголовок, пропускаем. dataLine — абсолютный номер строки файла (заголовок = 1).
|
||
foreach (array_slice($lines, 1) as $index => $rawLine) {
|
||
$dataLine = $index + 2; // +2: пропущен заголовок (index 0 → строка 2)
|
||
|
||
if (trim($rawLine) === '') {
|
||
continue;
|
||
}
|
||
|
||
$cells = str_getcsv($rawLine);
|
||
|
||
if (count($cells) < self::EXPECTED_COLUMNS) {
|
||
$errors[] = ['line' => $dataLine, 'message' => 'Ожидалось '.self::EXPECTED_COLUMNS.' колонок, получено '.count($cells)];
|
||
|
||
continue;
|
||
}
|
||
|
||
$parsed = $this->parseRow($cells, $dataLine, $errors);
|
||
if ($parsed !== null) {
|
||
$rows[] = $parsed;
|
||
}
|
||
}
|
||
|
||
return new CsvParseResult($rows, $errors);
|
||
}
|
||
|
||
/**
|
||
* @param array<int, string> $cells
|
||
* @param array<int, array{line: int, message: string}> $errors
|
||
*/
|
||
private function parseRow(array $cells, int $dataLine, array &$errors): ?ParsedLeadRow
|
||
{
|
||
[$id, $project, $tag, $phone, $createdAt, $reminder, $comment, $status, $name] = $cells;
|
||
|
||
$phone = trim($phone);
|
||
if (preg_match('/^7\d{10}$/', $phone) !== 1) {
|
||
$errors[] = ['line' => $dataLine, 'message' => "Невалидный телефон: '{$phone}'"];
|
||
|
||
return null;
|
||
}
|
||
|
||
$receivedAt = $this->parseDate($createdAt);
|
||
if ($receivedAt === null) {
|
||
$errors[] = ['line' => $dataLine, 'message' => "Невалидная дата 'Создано': '{$createdAt}'"];
|
||
|
||
return null;
|
||
}
|
||
|
||
$reminderAt = trim($reminder) === '' ? null : $this->parseDate($reminder);
|
||
if (trim($reminder) !== '' && $reminderAt === null) {
|
||
$errors[] = ['line' => $dataLine, 'message' => "Невалидная дата 'Напоминание': '{$reminder}'"];
|
||
|
||
return null;
|
||
}
|
||
|
||
$status = trim($status);
|
||
if ($status === '') {
|
||
$errors[] = ['line' => $dataLine, 'message' => 'Пустое поле «Состояние»'];
|
||
|
||
return null;
|
||
}
|
||
|
||
// Префикс B[123]_ из названия проекта срезается (паритет с ProcessWebhookJob).
|
||
$projectName = (string) preg_replace('/^B[123]_/', '', trim($project));
|
||
if ($projectName === '') {
|
||
$errors[] = ['line' => $dataLine, 'message' => 'Пустое название проекта'];
|
||
|
||
return null;
|
||
}
|
||
|
||
return new ParsedLeadRow(
|
||
sourceCrmId: (int) trim($id),
|
||
projectName: $projectName,
|
||
projectTag: trim($tag) === '' ? null : trim($tag),
|
||
phone: $phone,
|
||
receivedAt: $receivedAt,
|
||
reminderAt: $reminderAt,
|
||
comment: trim($comment) === '' ? null : trim($comment),
|
||
statusRu: $status,
|
||
contactName: trim($name) === '' ? null : trim($name),
|
||
);
|
||
}
|
||
|
||
private function parseDate(string $value): ?CarbonImmutable
|
||
{
|
||
try {
|
||
$date = CarbonImmutable::createFromFormat(self::DATE_FORMAT, trim($value));
|
||
} catch (Throwable) {
|
||
return null;
|
||
}
|
||
|
||
// createFromFormat возвращает false при несовпадении формата.
|
||
return $date instanceof CarbonImmutable ? $date : null;
|
||
}
|
||
}
|