Для импорта товаров нам понадобится обновить модель и добавить к ней новые связи: с переводами, другими товарами и категориями.
Никаких сложностей не предвидится, всё по образу и подобию предыдущих моделей.
Затем опять обновим админку для вывода товаров. Сегодня будет не особо интересно, но увы, сделать это надо.
Всё по образу и подобию категорий:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $product_id
* @property string $lang
* @property string $title
* @property string $subtitle
* @property string $description
* @property string $content
*
* @property-read Product $product
* @property-read Language $language
*/
class ProductTranslation extends Model
{
use Traits\CompositeKey;
public $timestamps = false;
protected $primaryKey = ['product_id', 'lang'];
protected $fillable = ['product_id', 'lang', 'title', 'subtitle', 'description', 'content'];
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function language(): BelongsTo
{
return $this->belongsTo(Language::class, 'lang');
}
}
Заодно прописываем связь с этой моделью и в Language
:
/**
...
* @property-read ProductTranslation[] $productsTranslations
* @property-read Product[] $products
*/
class Language extends Model
{
// ...
public function productsTranslations(): HasMany
{
return $this->hasMany(ProductTranslation::class, 'lang');
}
public function products(): HasManyThrough
{
return $this->hasManyThrough(
Product::class,
ProductTranslation::class,
'lang',
'id',
'lang',
'product_id'
);
}
}
Простейшая модель для связи товаров с категориями, использующая трейт составного ключа:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $product_id
* @property int $category_id
* @property int $rank
*
* @property-read Product $product
* @property-read Category $category
*/
class ProductCategory extends Model
{
use Traits\CompositeKey;
public $timestamps = false;
public $incrementing = false;
protected $primaryKey = ['product_id', 'category_id'];
protected $guarded = [];
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
}
Всё то же самое, с той лишь разницей, что здесь модель ссылается на одну только таблицу с товарами:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $product_id
* @property int $link_id
* @property int $rank
*
* @property-read Product $product
* @property-read Product $link
*/
class ProductLink extends Model
{
use Traits\CompositeKey;
public $timestamps = false;
public $incrementing = false;
protected $primaryKey = ['product_id', 'link_id'];
protected $guarded = [];
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function link(): BelongsTo
{
return $this->belongsTo(Product::class);
}
}
Эта модель позволит нам связывать товары между собой, например для вывода рекомендуемых комплектов.
Добаляем длиный перечень новых свойств (не буду копировать) и прописываем новые связи:
public function translations(): HasMany
{
return $this->hasMany(ProductTranslation::class);
}
public function productCategories(): HasMany
{
return $this->hasMany(ProductCategory::class);
}
// У этого товара есть дочерние товары
public function productLinks(): HasMany
{
return $this->hasMany(ProductLink::class, 'product_id');
}
// Этот товар принадлежит другому
public function productLink(): HasOne
{
return $this->hasOne(ProductLink::class, 'link_id');
}
Также в начале можели прописываем обновление uri
при сохранении. Он составляется из полного uri
родительской категории и псевдонима товарв:
protected static function booted(): void
{
static::saving(static function (self $model) {
$model->uri = implode('/', [$model->category->uri, $model->alias]);
});
}
Здесь я просто включил трейт CompositeKey
и перенёс в него метод getKeyName()
для нормальной работы связи hasOne()->ofMany()
с первой картинкой товара.
А функция setKeysForSaveQuery
теперь использует не getKeyName()
, а getKey()
, потому что getKeyName
возвращает строку, а нам нужен массив.
Рутина продолжается, пишем сидер товаров.
В исходном дампе miniShop2 у товаров использовались разные колонки для хранения вариантов на разных языках, что, конечно, не очень правильно. По-хорошему, колонка с вариантами должна быть одна, а на разных языках её можно отображать юзеру на фронтенде.
Поэтому в скрипте импорта я привожу французские названия к немецким функцией translateVariants()
. Там куча текста, там что выводить здесь я её не буду. Если интересно - посмотрите в репозитории, там просто замена одного текста на другой.
Также стоит обратить внимание на выборку категорий товара из БД через функцию GROUP_CONCAT
объединённой строкой с id
, разделёнными запятой, которая потом разбирается для создания записей ProductCategory
. Очень удобно и позволяет не делать лишних запросов.
<?php
use App\Models\Category;
use App\Models\Product;
use Phinx\Seed\AbstractSeed;
use Vesp\Services\Eloquent;
class Products extends AbstractSeed
{
public function getDependencies(): array
{
return ['Languages', 'Categories'];
}
public function run(): void
{
$categories = [];
/** @var Category $category */
// Получаем список категорий для указания товарам
foreach (Category::query()->select('id', 'remote_id')->cursor() as $category) {
$categories[$category->remote_id] = $category->id;
}
$pdo = (new Eloquent())->getDatabaseManager()->getPdo();
$stmt = $pdo->query(
"SELECT `r`.*, `d`.*, GROUP_CONCAT(`c`.`category_id` SEPARATOR ',') AS `categories`,
`ln`.`pagetitle` as `pagetitle_fr`, `ln`.`longtitle` as `longtitle_fr`, `ln`.`description` as `description_fr`, `ln`.`content` as `content_fr`
FROM `modx_site_content` `r`
LEFT JOIN `modx_ms2_products` `d` ON `r`.`id` = `d`.`id`
LEFT JOIN `modx_ms2_product_categories` `c` ON `r`.`id` = `c`.`product_id`
LEFT JOIN `modx_lingua_site_content` `ln` ON `ln`.`resource_id` = `r`.`id`
WHERE `class_key` = 'msProduct'
GROUP BY `r`.`id`
ORDER BY `r`.`parent`,`r`.`id` ASC"
);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
// Проверка наличия товара по remote_id
if (!$product = Product::query()->where('remote_id', $row['id'])->first()) {
$product = new Product();
$product->remote_id = $row['id'];
}
if (empty($categories[$row['parent']])) {
throw new RuntimeException('Could not find product primary category ' . print_r($row, true));
}
// Заполнение основных свойств
$product->category_id = $categories[$row['parent']];
$product->alias = trim($row['alias']);
$product->uri = preg_replace('#^shop/#', '', trim($row['uri'], '/'));
$product->price = $row['price'] ?? 0;
$product->old_price = $row['old_price'] ?? 0;
$product->article = $row['article'] ?? null;
$product->weight = $row['weight'] ?? 0;
$product->new = !empty($row['new']);
$product->popular = !empty($row['popular']);
$product->favorite = !empty($row['favorite']);
$product->made_in = !empty($row['made_in']) ? trim($row['made_in']) : null;
$product->colors = $row['color'] && $row['color'] !== '[""]' ? json_decode($row['color'], true) : null;
// Заполнение вариантов с конветацией французских в немецкие
if ($de = $row['variants'] && $row['variants'] !== '[""]' ? json_decode($row['variants'], true) : null) {
$de = array_filter($de);
}
if (!$de && $fr = $row['variants_fr'] && $row['variants_fr'] !== '[""]' ? json_decode($row['variants_fr'], true) : null) {
if ($fr = array_filter($fr)) {
$de = [];
foreach ($fr as $tmp) {
$de[] = self::translateVariants($tmp, 'fr');
}
}
}
if ($de) {
$product->variants = $de;
}
$product->active = empty($row['deleted']) && !empty($row['published']);
$product->rank = $row['menuindex'];
$product->created_at = $row['createdon'];
$product->updated_at = $row['editedon'] ?: null;
$product->save();
// Удаляем старые категории товара, если есть
$product->productCategories()->delete();
// И добавляем новые, если есть
if (!empty($row['categories'])) {
$tmp = explode(',', $row['categories']);
foreach ($tmp as $id) {
// Дополнительные категории не должны повторять основную
if (isset($categories[$id]) && (int)$categories[$id] !== $product->category_id) {
$product->productCategories()->insert(
['product_id' => $product->id, 'category_id' => $categories[$id]]
);
}
}
}
// Сохраняем тексты в соответствующие языки
$product->translations()->updateOrCreate(['lang' => 'ru'], [
'title' => trim($row['pagetitle']) ?: null,
'subtitle' => trim($row['longtitle']) ?: null,
'description' => trim($row['description']) ?: null,
'content' => trim($row['content']) ?: null,
]);
$product->translations()->updateOrCreate(['lang' => 'en'], [
'title' => trim($row['pagetitle_fr']) ?: null,
'subtitle' => trim($row['longtitle_fr']) ?: null,
'description' => trim($row['description_fr']) ?: null,
'content' => trim($row['content_fr']) ?: null,
]);
}
}
}
Запускаем composer db:seed-one Products
и проверяем таблицу с товарами:
Мы загрузили старые товары, теперь нужно добавить их ссылки друг на друга и файлы.
Тут совсем небольшой сидер:
<?php
use App\Models\Product;
use App\Models\ProductLink;
use Phinx\Seed\AbstractSeed;
use Vesp\Services\Eloquent;
class ProductLinks extends AbstractSeed
{
public function getDependencies(): array
{
return ['Products'];
}
public function run(): void
{
$pdo = (new Eloquent())->getDatabaseManager()->getPdo();
$stmt = $pdo->query("SELECT * FROM `modx_ms2_product_links`");
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
/** @var Product $product */
$product = Product::query()->where('remote_id', $row['master'])->first();
/** @var Product $link */
$link = Product::query()->where('remote_id', $row['slave'])->first();
if ($product && $link) {
ProductLink::query()->updateOrInsert(['product_id' => $product->id, 'link_id' => $link->id]);
}
}
}
}
Получаем id
из старой таблицы, проверяем по remote_id
есть ли такие товары в новой, и если есть - создаём связь.
Зачем вообще нужна подобная проверка? Ну затем, что это MODX и там за целостностью базы никто особо не следит, внешних ключей-то нет.
Запускаем сид командой composer db:seed-one ProductLinks
Здесь мы проходим по уже загруженным товарам и проверяем, нет ли для них файлов в старой БД? Если есть - создаём плейсхолдер, потому что оригинальных файлов у нас по-прежнему нет.
В отличие от категории, здесь файлов может быть много, связываются они с товарами через отдельную таблицу, поэтому мы сохраняем remote_id
для этой связи, чтобы не грузить новые файлы при повторном запуске сидера.
<?php
use App\Models\File;
use App\Models\Product;
use Phinx\Seed\AbstractSeed;
use Slim\Psr7\UploadedFile;
use Vesp\Services\Eloquent;
class ProductFiles extends AbstractSeed
{
public function getDependencies(): array
{
return ['Categories', 'Products'];
}
public function run(): void
{
$pdo = (new Eloquent())->getDatabaseManager()->getPdo();
$stmt = $pdo->prepare(
'SELECT * FROM `modx_ms2_product_files` WHERE `product_id` = :remote_id AND `parent` = 0'
);
/** @var Product $product */
foreach (Product::query()->cursor() as $product) {
$stmt->execute([':remote_id' => $product->remote_id]);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
// Проверка на наличие по remote_id
if ($product->productFiles()->where('remote_id', $row['id'])->count()) {
continue;
}
// Создание нового файла из плейсхолдера
$filename = tempnam(getenv('UPLOAD_DIR'), 'img_');
file_put_contents($filename, file_get_contents('https://i.pravatar.cc/500'));
$data = new UploadedFile($filename, $row['name'] ?? basename($filename), mime_content_type($filename));
$file = new File();
$file->uploadFile($data);
$product->productFiles()->insert([
'product_id' => $product->id,
'file_id' => $file->id,
'rank' => $row['rank'],
'remote_id' => $row['id'],
]);
unlink($filename);
}
}
}
}
Не забываем сделать composer db:seed-one ProductFiles
.
Скучная обязательная часть на бэкенде закончена, можно обновлять админку для вывода товаров.
Как и в случае с товарами, нам нужно обновить контроллер для вывода записей. Код приводить уже не буду, напишу словами:
web
).В самой таблице дорабатываем вывод названий товаров и категорий, используя нашу функцию $translate()
<template>
<div>
<vesp-table
...
>
<template #cell(title)="{item}">
{{ $translate(item.translations) }}
<div class="small">
<b-link :href="getProductUrl(item)" target="_blank">
{{ item.uri }}
</b-link>
</div>
</template>
<template #cell(category)="{item}">
<b-link :to="getCategoryUrl(item)">
{{ $translate(item.category.translations) }}
</b-link>
</template>
...
</template>
Функции getProductUrl
и getCategoryUrl
предусмотрены для перехода на просмотр товара на сайте и редактирование категории соответственно.
<script>
// ...
methods: {
// ...
getProductUrl(item) {
return this.$config.SITE_URL + item.uri
},
getCategoryUrl(item) {
return {name: 'categories-edit-id', params: {id: item.id}}
},
},
}
</script>
Для того, чтобы работала переменная SITE_URL
, мы должны её загрузить в
nuxt.config.js
:
const env = loadEnv(findEnv('../'))
// ...
Config.publicRuntimeConfig = {
SITE_URL: env.SITE_URL,
}
Сюда можно засовывать любые значения, которые вы хотите использовать в приложении. Только учтите, что они доступны всем, кто догадается посмотреть исходники страницы - секретные данные здесь держать нельзя! Подробнее про эту функцию можно почитать в документации Nuxt 2.
Ссылка на редактировании категории уже работает, а вот обработка ссылки на товар сработает только для товаров из первого уровня вложения категории, но это мы поправим позже, когда будем работать над публичным сайтом.
Здесь тоже ничего нового - добавляем поля и записи в лексиконах, плюс работа с переводами. Также добавляется поле выбора категории с автоподсказкой. Мне кажется, описывать здесь просто нечего - посмотрите код в репозитории. Картинка с итоговым результатом в начале заметки.
Правда, во время проверки кода обнаружил, что создание новых товаров не работает из-за того, что у формы вообще нет свойства translations
, даже пустого. Соответственно, не работает и создание новой категории - браузер выдаёт ошибку translations undefined
.
Добавил это свойство обеим страницам:
frontend/src/admin/pages/categories/create.vue
frontend/src/admin/pages/products/create.vue
<script>
// ...
export default {
// ...
data() {
return {
url,
record: {
// ...
translations: [],
},
}
},
}
</script>
Загрузку файлов менять не нужно, там всё работает как раньше.
Сегодня была скучная, но нужная заметка.
Товары импортированы вместе с файлами и связями, на следующем занятии займёмся редактированием этих связей и улучшением работы с товарами.
Там нужно будет написать рекурсивное дерево категорий, чтобы как в MODX, или даже лучше!
Итоговый коммит со всеми изменениями.
А у трейта CompositeKey в функции find в цикле нужно поменять $me->getKeyName() на $me->primaryKey? А то там тоже нужен массив, а не строка.
Абсоюлютно верно, у меня даже IDE подсвечивает ошибку - не знаю, как пропустил.
Поменял на
$me->getKey()
.