Реализация импорта товаров из файлов поставщика (CSV/Excel/XML/JSON)
Поставщики присылают прайс-листы в том формате, который удобен им: кто-то в Excel, кто-то в XML, кто-то в CSV с нестандартным разделителем. Задача — построить систему импорта, которая работает с любым форматом через единый интерфейс и не требует отдельного кода под каждого поставщика.
Унифицированный интерфейс парсера
interface FileParserInterface
{
/** @return iterable<array<string, mixed>> */
public function parse(string $filePath): iterable;
public function supports(string $mimeType, string $extension): bool;
}
Фабрика выбирает нужный парсер по расширению или MIME:
class FileParserFactory
{
private array $parsers;
public function make(string $filePath): FileParserInterface
{
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
$mime = mime_content_type($filePath);
foreach ($this->parsers as $parser) {
if ($parser->supports($mime, $ext)) return $parser;
}
throw new \RuntimeException("No parser for: {$ext} / {$mime}");
}
}
CSV-парсер
class CsvParser implements FileParserInterface
{
public function __construct(
private string $delimiter = ',',
private string $enclosure = '"',
private bool $hasHeader = true,
) {}
public function parse(string $filePath): iterable
{
$handle = fopen($filePath, 'r');
$headers = $this->hasHeader ? fgetcsv($handle, 0, $this->delimiter, $this->enclosure) : null;
while ($row = fgetcsv($handle, 0, $this->delimiter, $this->enclosure)) {
if (!array_filter($row)) continue; // пустая строка
yield $headers
? array_combine($headers, $row)
: $row;
}
fclose($handle);
}
public function supports(string $mimeType, string $extension): bool
{
return in_array($extension, ['csv', 'txt'])
|| str_contains($mimeType, 'csv');
}
}
Разделитель и кодировка настраиваются через конфиг источника. Для Windows-1251 — обёртка через mb_convert_encoding построчно.
Excel-парсер через PhpSpreadsheet
class ExcelParser implements FileParserInterface
{
public function parse(string $filePath): iterable
{
$spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($filePath);
$sheet = $spreadsheet->getActiveSheet();
$rows = $sheet->toArray(null, true, true, false);
$headers = array_shift($rows);
foreach ($rows as $row) {
if (!array_filter($row)) continue;
yield array_combine($headers, $row);
}
}
public function supports(string $mimeType, string $extension): bool
{
return in_array($extension, ['xls', 'xlsx', 'ods']);
}
}
Для больших Excel-файлов (>100 МБ) использовать \PhpOffice\PhpSpreadsheet\Reader\Xlsx с setReadDataOnly(true) и setLoadSheetsOnly(['Sheet1']) — снижает потребление памяти в 3–5 раз.
XML-парсер (потоковый)
class XmlParser implements FileParserInterface
{
public function __construct(
private string $itemTag = 'product',
) {}
public function parse(string $filePath): iterable
{
$reader = new \XMLReader();
$reader->open($filePath);
while ($reader->read()) {
if ($reader->nodeType === \XMLReader::ELEMENT
&& $reader->name === $this->itemTag) {
$node = new \SimpleXMLElement($reader->readOuterXml());
yield $this->nodeToArray($node);
}
}
$reader->close();
}
private function nodeToArray(\SimpleXMLElement $node): array
{
$result = [];
foreach ($node->children() as $child) {
$key = $child->getName();
$result[$key] = $child->count() > 0
? $this->nodeToArray($child)
: (string) $child;
}
foreach ($node->attributes() as $k => $v) {
$result['@' . $k] = (string) $v;
}
return $result;
}
public function supports(string $mimeType, string $extension): bool
{
return $extension === 'xml' || str_contains($mimeType, 'xml');
}
}
XMLReader читает файл потоково — не загружает весь документ в память.
JSON-парсер
class JsonParser implements FileParserInterface
{
public function __construct(
private string $itemsPath = 'products', // dot-notation: "data.items"
) {}
public function parse(string $filePath): iterable
{
$content = file_get_contents($filePath);
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
$items = data_get($data, $this->itemsPath) ?? $data;
foreach ($items as $item) {
yield $item;
}
}
public function supports(string $mimeType, string $extension): bool
{
return $extension === 'json' || str_contains($mimeType, 'json');
}
}
Для больших JSON-файлов — использовать halaxa/json-machine, которая читает JSON потоком без загрузки всего файла:
use JsonMachine\Items;
foreach (Items::fromFile($filePath, ['pointer' => '/products']) as $product) {
yield (array) $product;
}
Маппинг колонок источника
Каждый поставщик использует свои названия колонок. Конфигурация хранится в БД:
{
"sku": "Артикул",
"name": "Наименование",
"price": "Цена руб.",
"qty": "Кол-во",
"description": "Описание",
"category": "Раздел"
}
Трансформатор применяет маппинг перед передачей в импортёр:
class ColumnMapper
{
public function transform(array $row, array $mapping): array
{
$result = [];
foreach ($mapping as $internalKey => $sourceKey) {
$result[$internalKey] = $row[$sourceKey] ?? null;
}
return $result;
}
}
Pipeline импорта
FileParserFactory::make($file)
└─> CsvParser / ExcelParser / XmlParser / JsonParser
└─> итерируем строки
└─> ColumnMapper::transform($row, $config->mapping)
└─> ProductValidator::validate($mapped) // пропустить невалидные
└─> ProductUpsertJob::dispatch($mapped) // в очередь
Обработка кодировок и BOM
private function detectAndConvert(string $content): string
{
// UTF-8 BOM
if (str_starts_with($content, "\xEF\xBB\xBF")) {
$content = substr($content, 3);
}
$encoding = mb_detect_encoding($content, ['UTF-8', 'Windows-1251', 'ISO-8859-1'], true);
if ($encoding && $encoding !== 'UTF-8') {
$content = mb_convert_encoding($content, 'UTF-8', $encoding);
}
return $content;
}
Сроки реализации
- CSV + Excel парсеры, маппинг колонок, базовый pipeline — 2 дня
- XML (потоковый) + JSON + автодетект формата — +1 день
- Конфигурация маппинга в UI + обработка кодировок + обработка ошибок — +1 день







