Импортируем товары

Для импорта товаров нам понадобится обновить модель и добавить к ней новые связи: с переводами, другими товарами и категориями.
Никаких сложностей не предвидится, всё по образу и подобию предыдущих моделей.
Затем опять обновим админку для вывода товаров. Сегодня будет не особо интересно, но увы, сделать это надо.

Добавляем модель ProductTranslation

Всё по образу и подобию категорий:
<?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'
        );
    }
}

Добавляем модель ProductCategory

Простейшая модель для связи товаров с категориями, использующая трейт составного ключа:
<?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);
    }
}

Добавляем модель ProductLink

Всё то же самое, с той лишь разницей, что здесь модель ссылается на одну только таблицу с товарами:
<?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);
    }
}
Эта модель позволит нам связывать товары между собой, например для вывода рекомендуемых комплектов.

Обновляем модель Product

Добаляем длиный перечень новых свойств (не буду копировать) и прописываем новые связи:
    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]);
        });
    }

Обновляем модель ProductFile

Здесь я просто включил трейт 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, или даже лучше!
Итоговый коммит со всеми изменениями.

2 комментария

Дмитрий П.
А у трейта CompositeKey в функции find в цикле нужно поменять $me->getKeyName() на $me->primaryKey? А то там тоже нужен массив, а не строка.
Василий Наумкин
Абсоюлютно верно, у меня даже IDE подсвечивает ошибку - не знаю, как пропустил.
Поменял на $me->getKey().
bezumkin.ru
Personal website of Vasily Naumkin
Прямой эфир
Александр Наумов
23.07.2024, 00:20:37
Василий, спасибо большое!!
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так. А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен. Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. ...
Василий Наумкин
20.03.2024, 21:21:52
Volledig!
Андрей
14.03.2024, 13:47:10
Василий! Как всегда очень круто! Моё почтение!
russel gal
09.03.2024, 20:17:18
> А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал...
Александр Наумов
27.01.2024, 03:06:18
Василий, спасибо! Извини, тупанул.
Василий Наумкин
22.01.2024, 07:43:20
Давай-давай!
Василий Наумкин
24.12.2023, 14:26:13
Спасибо!