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

Для импорта товаров нам понадобится обновить модель и добавить к ней новые связи: с переводами, другими товарами и категориями.

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

Затем опять обновим админку для вывода товаров. Сегодня будет не особо интересно, но увы, сделать это надо.

Добавляем модель 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);
    }
}

Всё то же самое, с той лишь разницей, что здесь модель ссылается на одну только таблицу с товарами:

<?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)
bezumkinВасилий Наумкин
28.08.2023 01:02

Абсоюлютно верно, у меня даже IDE подсвечивает ошибку - не знаю, как пропустил.

Поменял на $me->getKey().

bezumkin
Василий Наумкин
09.04.2024 01:45
Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. Во...
futuris
Futuris
04.04.2024 05:56
Я просто немного запутался. Когда в абзаце &quot;Vesp/Core&quot; ты пишешь про &quot;новый trait Fil...
bezumkin
Василий Наумкин
20.03.2024 18:21
Volledig!
Андрей
14.03.2024 10:47
Василий! Как всегда очень круто! Моё почтение!
russelgal
russel gal
09.03.2024 17:17
А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал ...
inetlover
Александр Наумов
27.01.2024 00:06
Василий, спасибо! Извини, тупанул.
bezumkin
Василий Наумкин
22.01.2024 04:43
Давай-давай!
bezumkin
Василий Наумкин
24.12.2023 11:26
Спасибо!
bezumkin
Василий Наумкин
27.11.2023 02:43
Ура!
bezumkin
Василий Наумкин
25.11.2023 08:30
Vesp тянет 2 зависимости: vesp-frontent для фронта и vesp-core для бэкенда. Их можно обновлять, но э...