Реалізація імпорту товарів із файлів постачальника (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 МБ) використовувати 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 потоком без завантаження всього файлу.
Маппінг колонок джерела
Кожний постачальник використовує свої назви колонок. Конфігурація зберігається у БД:
{
"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;
}
}
Конвеєр імпорту
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 парсери, маппінг колонок, базовий конвеєр — 2 дні
- XML (потоковий) + JSON + автодетект формату — +1 день
- Конфігурація маппінгу в UI + обробка кодувань + обробка помилок — +1 день







