В предыдущей статье я описал процесс импорта продуктов в Magento 2 обычным способом — через модели и репозитории. Обычный способ отличается весьма низкой скоростью обработки данных. На моём ноутбуке выходило примерно один продукт в секунду. В данном продолжении я рассматриваю альтернативный способ импорта продукта — прямой записью в базу, в обход стандартных механизмов Magento 2 (модели, фабрики, репозитории). Последовательность шагов, обеспечивающих импорт продуктов, может быть адаптирована под любой язык программирования, способный работать с MySQL.
Disclaimer: В Magento есть готовый функционал по импорту данных и, скорее всего, вам его хватит. Однако если вам нужен более полный контроль за процессом импорта, не ограничивающийся подготовкой CSV-файла для того, что есть — добро пожаловать под кат.
Код, получившийся в результате написания обеих статей, можно посмотреть в Magento-модуле "flancer32/mage2_ext_demo_import". Вот некоторые ограничения, которых я придерживался, чтобы упростить код демо-модуля:
- Продукты только создаются, не обновляются.
- Один склад
- Импортируются только названия категорий, без их структуры
- Структуры данных соответствуют версии 2.3
JSON для импорта отдельного продукта:
{
"sku": "MVA20D-UBV-3",
"name": "Заглушка для пломбировки ВА47-29 IEK",
"desc": "Обеспечение доступа к устройствам ...",
"desc_short": "Заглушка для пломбировки ВА47-29 IEK предназначена для ...",
"price": 5.00,
"qty": 25,
"categories": ["Категория 1", "Категория 2"],
"image_path": "mva20d_ubv_3.png"
}
Обзор основных этапов импорта
- регистрация самого продукта
- связь продукта и web-сайта
- базовые атрибуты продукта (EAV)
- ивентарные данные (количество продукта на складе)
- медиа (картинки)
- связь с категориями каталога
Регистрация продукта
Базовая информация о продукте находится в catalog_product_entity
:
CREATE TABLE `catalog_product_entity` (
`entity_id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'Entity Id',
`attribute_set_id` smallint(5) unsigned NOT NULL DEFAULT '0' COMMENT 'Attribute Set ID',
`type_id` varchar(32) NOT NULL DEFAULT 'simple' COMMENT 'Type ID',
`sku` varchar(64) DEFAULT NULL COMMENT 'SKU',
`has_options` smallint(6) NOT NULL DEFAULT '0' COMMENT 'Has Options',
`required_options` smallint(5) unsigned NOT NULL DEFAULT '0' COMMENT 'Required Options',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Creation Time',
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Update Time',
PRIMARY KEY (`entity_id`),
KEY `CATALOG_PRODUCT_ENTITY_ATTRIBUTE_SET_ID` (`attribute_set_id`),
KEY `CATALOG_PRODUCT_ENTITY_SKU` (`sku`)
)
Минимально необходимая информация для создания записи в реестре продуктов:
attribute_set_id
sku
дополнительная:
type_id
— если не укажем, то будет использовано 'simple'
Для прямой записи в базу использую DB-адаптер самой Magento:
function create($sku, $typeId, $attrSetId)
{
/** @var MagentoFrameworkAppResourceConnection $this->resource */
/** @var MagentoFrameworkDBAdapterPdoMysql $conn */
$conn = $this->resource->getConnection();
$table = $this->resource->getTableName('catalog_product_entity');
$bind = [
'sku' => $sku,
'type_id' => $typeId,
'attribute_set_id' => $attrSetId
];
$conn->insert($table, $bind);
$result = $conn->lastInsertId($table);
return $result;
}
После регистрации продукта в catalog_product_entity
он становится видимым в админке, в гриде продуктов (Catalog / Products).
Связь продукта и web-сайта
Связь продукта с сайтом определяет, в каких магазинах и на каких витринах продукт будет доступен на фронте.
function linkToWebsite($prodId, $websiteId)
{
/** @var MagentoFrameworkAppResourceConnection $this->resource */
/** @var MagentoFrameworkDBAdapterPdoMysql $conn */
$conn = $this->resource->getConnection();
$table = $this->resource->getTableName('catalog_product_website');
$bind = [
'product_id' => $prodId,
'website_id' => $websiteId
];
$conn->insert($table, $bind);
}
Базовые атрибуты продукта
У свежезарегистрированного продукта пока нет ни имени, ни описания. Всё это делается через EAV-аттрибуты. Вот список базовых атрибутов продукта, которые нужны для того, чтобы продукт достаточно корректно показывался на фронте:
name
price
description
short_description
status
tax_class_id
url_key
visibility
Отдельный атрибут к продукту добавляется вот так (опущены детали получения идентификатора и типа атрибута по его коду):
public function create($prodId, $attrCode, $attrValue)
{
$attrId = /* get attribute ID by attribute code */
$attrType = /* get attribute type [datetime|decimal|int|text|varchar]) by attribute code */
if ($attrId) {
/** @var MagentoFrameworkAppResourceConnection $this->resource */
/** @var MagentoFrameworkDBAdapterPdoMysql $conn */
$conn = $this->resource->getConnection();
$tblName = 'catalog_product_entity_' . $attrType;
$table = $this->resource->getTableName($tblName);
$bind = [
'attribute_id' => $attrId,
'entity_id' => $prodId,
/* put all attributes to default store view with id=0 (admin) */
'store_id' => 0,
'value' => $attrValue
];
$conn->insert($table, $bind);
}
}
По коду атрибута определяем его id и тип данных (datetime
, decimal
, int
, text
, varchar
), затем в соответствующую таблицу пишем данные для административной витрины (store_id = 0
).
После добавления вышеперечисленных атрибутов к продукту получается вот такая картинка в админке:
Инвентарные данные
Начиная с версии 2.3 в Magento параллельно существует два набора таблиц, обеспечивающих хранение инвентарной информации (количество продукта):
cataloginventory_*
: старая структура;inventory_*
: новая структура (MSI — Multi Source Inventory);
Добавлять инвентарные данные нужно в обе структуры, т.к. новая структура пока ещё не полностью независима от старой (очень похоже, что для default
склада в новой структуре задействована таблица cataloginventory_stock_status
в качестве inventory_stock_1
).
cataloginventory_
При развёртывании Magneto 2.3 мы изначально имеем 2 записи в store_website
, что соответствует двум сайтам — административному и основному клиентскому:
website_id|code |name |sort_order|default_group_id|is_default|
----------|-----|------------|----------|----------------|----------|
0|admin|Admin | 0| 0| 0|
1|base |Main Website| 0| 1| 1|
В таблице cataloginventory_stock
у нас есть только одна запись:
stock_id|website_id|stock_name|
--------|----------|----------|
1| 0|Default |
Т.е., у нас в старой структуре есть только один "склад" (stock
) и он привязан к административному website'у. Добавление через админку новых sources
/stocks
в MSI (новую структуру) не приводит к появлению новых записей в cataloginventory_stock
.
Инвентарные данные о продуктах в старой структуре изначально прописываются в таблицах:
cataloginventory_stock_item
cataloginventory_stock_status
cataloginventory_stock_item
function createOldItem($prodId, $qty)
{
$isQtyDecimal = (((int)$qty) != $qty);
$isInStock = ($qty > 0);
/** @var MagentoFrameworkAppResourceConnection $this->resource */
/** @var MagentoFrameworkDBAdapterPdoMysql $conn */
$conn = $this->resource->getConnection();
$table = $this->resource->getTableName('cataloginventory_stock_item');
$bind = [
'product_id' => $prodId,
/* we use one only stock in 'cataloginventory' structure by default */
'stock_id' => 1,
'qty' => $qty,
'is_qty_decimal' => $isQtyDecimal,
'is_in_stock' => $isInStock,
/* default stock is bound to admin website (see `cataloginventory_stock`) */
'website_id' => 0
];
$conn->insert($table, $bind);
}
cataloginventory_stock_status
function createOldStatus($prodId, $qty)
{
$isInStock = ($qty > 0);
/** @var MagentoFrameworkAppResourceConnection $this->resource */
/** @var MagentoFrameworkDBAdapterPdoMysql $conn */
$conn = $this->resource->getConnection();
$table = $this->resource->getTableName('cataloginventory_stock_status');
$bind = [
'product_id' => $prodId,
/* we use one only stock in 'cataloginventory' structure by default */
'stock_id' => 1,
'qty' => $qty,
'stock_status' => MagentoCatalogInventoryApiDataStockStatusInterface::STATUS_IN_STOCK,
/* default stock is bound to admin website (see `cataloginventory_stock`) */
'website_id' => 0
];
$conn->insert($table, $bind);
}
inventory_
Изначально новая структура для хранения инвентарных данных содержит 1 "источник" (inventory_source
):
source_code|name |enabled|description |latitude|longitude|country_id|...|
-----------|--------------|-------|--------------|--------|---------|----------|...|
default |Default Source| 1|Default Source|0.000000| 0.000000|US |...|
и один "склад" (inventory_stock
):
stock_id|name |
--------|-------------|
1|Default Stock|
"Источник" представляет собой физическое хранилище для продуктов (запись содержит физические координаты и почтовый адрес). "Склад" представляет собой логическое объединение нескольких "источников" (inventory_source_stock_link
)
link_id|stock_id|source_code|priority|
-------|--------|-----------|--------|
1| 1|default | 1|
на уровне которого происходит привязка к каналу продажи (inventory_stock_sales_channel
)
type |code|stock_id|
-------|----|--------|
website|base| 1|
Судя по структуре данных предполагаются различные типы каналов продаж, но по-умолчанию используется только связь "stock"-"website" (ссылка на web-сайт идёт по коду web-сайта — base
).
Один "склад" может быть привязан к нескольким "источникам", а один "источник" — к нескольким "складам" (отношение "многие-ко-многим"). Исключения составляют default'овые "источник" и "склад". Они не перепривязываются к другим сущностям (ограничение на уровне кода — вылетает ошибка "Can not save link related to Default Source or Default Stock"). Более подробно о структуре MSI в Magento 2 можно прочитать в статье "Система управления складом с использованием CQRS и Event Sourcing. Проектирование".
Я буду использовать default'овую конфигурацию и добавлять всю инвентарную информацию в источник default
, который задействован в канале продажи, связанном с web-сайтом с кодом base
(соответствует клиентской части магазина — см. store_website
):
function createNewItem($sku, $qty)
{
/** @var MagentoFrameworkAppResourceConnection $this->resource */
/** @var MagentoFrameworkDBAdapterPdoMysql $conn */
$conn = $this->resource->getConnection();
$table = $this->resource->getTableName('inventory_source_item');
$bind = [
'source_code' => 'default',
'sku' => $sku,
'quantity' => $qty,
'status' => MagentoInventoryApiApiDataSourceItemInterface::STATUS_IN_STOCK
];
$conn->insert($table, $bind);
}
После добавления инвентарных данных к продукту в админке получается вот такая картинка:
Медиа
При "ручном" добавлении к продукту изображения через админку соответствующая информация прописывается в следующих таблицах:
catalog_product_entity_media_gallery
: медиа-реестр (изображения и видео-файлы);catalog_product_entity_media_gallery_value
: привязка медиа к продуктам и витринам (локализация);catalog_product_entity_media_gallery_value_to_entity
: привязка медиа только к продуктам (предположительно, default медиа-контент для продукта);catalog_product_entity_varchar
: здесь сохраняются роли, в которых используется изображение;
а сами изображения сохраняются в каталог ./pub/media/catalog/product/x/y/
, где x
и y
— первая и вторая буквы имени файла с изображением. Например, файл image.png
должен быть сохранён как ./pub/media/catalog/product/i/m/image.png
, чтобы платформа могла использовать его в качестве изображения при описании продуктов из каталога.
catalog_product_entity_media_gallery
Регистрируем размещённый в ./pub/media/catalog/product/
медиа-файл (сам процесс размещения файла в данной статье не рассматривается):
function createMediaGallery($imgPathPrefixed)
{
$attrId = /* get attribute ID by attribute code 'media_gallery' */
/** @var MagentoFrameworkAppResourceConnection $this->resource */
/** @var MagentoFrameworkDBAdapterPdoMysql $conn */
$conn = $this->resource->getConnection();
$table = $this->resource->getTableName('catalog_product_entity_media_gallery');
$bind = [
'attribute_id' => $attrId,
'value' => $imgPathPrefixed,
/* 'image' or 'video' */
'media_type' => 'image',
'disabled' => false
];
$conn->insert($table, $bind);
$result = $conn->lastInsertId($table);
return $result;
}
При регистрации новому медиа-файлу присваивается идентификтор.
catalog_product_entity_media_gallery_value
Связываем зарегистрированный медиа-файл с соответствующим продуктом для default-витрины:
function createGalleryValue($mediaId, $prodId)
{
/** @var MagentoFrameworkAppResourceConnection $this->resource */
/** @var MagentoFrameworkDBAdapterPdoMysql $conn */
$conn = $this->resource->getConnection();
$table = $this->resource->getTableName('catalog_product_entity_media_gallery_value');
$bind = [
'value_id' => $mediaId,
/* use admin store view by default */
'store_id' => 0,
'entity_id' => $prodId,
'label' => null,
/* we have one only image */
'position' => 1,
'disabled' => false
];
$conn->insert($table, $bind);
}
catalog_product_entity_media_gallery_value_to_entity
Связываем зарегистрированный медиа-файл с соответствующим продуктом без привязки к какой-либо витрине. Не понятно, где именно используются эти данные и почему нельзя обращаться к данным предыдущей таблицы, но вот эта таблица существует и данные в неё записываются при добавлении картинки к продукту. Поэтому вот так.
function createGalleryValueToEntity($mediaId, $prodId)
{
/** @var MagentoFrameworkAppResourceConnection $this->resource */
/** @var MagentoFrameworkDBAdapterPdoMysql $conn */
$conn = $this->resource->getConnection();
$table = $this->resource->getTableName('catalog_product_entity_media_gallery_value_to_entity');
$bind = [
'value_id' => $mediaId,
'entity_id' => $prodId
];
$conn->insert($table, $bind);
}
catalog_product_entity_varchar
Медиа-файл может использоваться с разными ролями (в скобках указан код соответствующего атрибута):
- Base (
image
) - Small Image (
small_image
) - Thumbnail (
thumbnail
) - Swatch Image (
swatch_image
)
Привязка ролей к медиа-файлу как раз и происходит в catalog_product_entity_varchar
. Код привязки аналогичен коду в разделе "Базовые атрибуты продукта".
После добавления изображения к продукту в админке получается вот так:
Категории
Основные таблицы, в которых содержатся данные по категориям:
catalog_category_entity
: реестр категорий;catalog_category_product
: связь продуктов и категорий;catalog_category_entity_*
: значения EAV-атрибутов;
Изначально, в пустом Magento-приложении в реестре категорий содержится 2 категории (я сократил названия колонок: crt
— created_at
, upd
— updated_at
):
entity_id|attribute_set_id|parent_id|crt|upd|path|position|level|children_count|
---------|----------------|---------|---|---|----|--------|-----|--------------|
1| 3| 0|...|...|1 | 0| 0| 1|
2| 3| 1|...|...|1/2 | 1| 1| 0|
Категория с id=1 является корнем всего Magento-каталога и недоступна ни в админке, ни на фронте. Категория с id=2 (Default Category) является корневой категорией для основного магазина основного сайта (Main Website Store), создаваемого при развёртывании приложения (см. Admin / Stores / All Stores). Причём сама корневая категория магазина на фронте также недоступна, только её подкатегории.
Поскольку темой данной статьи всё-таки является импорт данных по продуктам, то я не буду использовать прямую запись в базу при создании категорий, а воспользуюсь классами предоставляемыми самой Magento (модели и репозитории). Прямая запись в базу используется только для связи импортируемого продукта с категорией (сопоставление категории происходит по её имени, при сопоставлении извлекается id категории):
function create($prodId, $catId)
{
/** @var MagentoFrameworkAppResourceConnection $this->resource */
/** @var MagentoFrameworkDBAdapterPdoMysql $conn */
$conn = $this->resource->getConnection();
$table = $this->resource->getTableName('catalog_category_product');
$bind = [
'category_id' => $catId,
'product_id' => $prodId,
];
$conn->insert($table, $bind);
}
После добавления связи продукта с категориями "Категория 1" и "Категория 2" детали продукта в админке выглядят примерно так:
Дополнительные действия
После завершения импорта данных нужно выполнить следующие дополнительные действия:
- индексация данных: вызов в консоли
./bin/magento indexer:reindex
; - регенерация URL'ов для продуктов/категорий: можно использовать расширение "elgentos/regenerate-catalog-urls"
Продукты в админке после выполнения дополнительных действий:
и на фронте:
Резюме
Тот же самый набор продуктов (10 штук), что и в прошлой статье, импортируется, как минимум, на порядок быстрее (1 секунда против 10). Для более точной оценки скорости нужно большее количество продуктов — несколько сотен, а лучше тысяч. Тем не менее, даже при таком небольшом размере входных данных можно сделать вывод, что использование инструментария, предоставляемого Magento (модели и репозитории), значительно (акцентирую — значительно!) ускоряют разработку требуемого функционала, но при этом значительно (акцентирую — значительно!) снижают скорость попадания данных в базу.
В итоге вода оказалась мокрой и это никакое не откровение. Тем не менее, теперь у меня есть код, чтобы играться дальше и, возможно, сделать более интересные выводы.
Автор: flancer