Files
portal/app/app/Services/Import/CsvLeadsParser.php
T
Дмитрий 29a4d01ff4 fix(import): Task 5 code-review — final-класс CsvLeadsParser + self::EXPECTED_COLUMNS
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>
2026-05-16 17:51:27 +03:00

131 lines
4.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}