Импорт и управление заказами

Сегодня продолжаем прогружать старые данные в новую базу - дошла очередь и до заказов.
Помимо собственно заказов, miniShop2 хранит ссылки на купленные товары и адреса доставки покупателей. Изначально в нём планировалось сделать выбор адреса при новом заказе, чтобы старый покупатель не вводил его каждый раз, но в стандартный функционал это так и не вошло.
В VespShop мы это исправим.

Таблицы заказов

Создаём миграцию заказов composer db:create Orders:
<?php

declare(strict_types=1);

use Illuminate\Database\Schema\Blueprint;
use Vesp\Services\Migration;

final class Orders extends Migration
{
    public function up(): void
    {
        $this->schema->create(
            'orders',
            static function (Blueprint $table) {
                $table->id();
                $table->uuid('uuid')->unique();
                $table->string('num')->index();
                $table->foreignId('user_id')
                    ->constrained('users')->cascadeOnDelete();
                $table->decimal('total')->default(0);
                $table->decimal('cart')->default(0);
                $table->decimal('discount')->default(0);
                $table->decimal('weight')->default(0);
                $table->text('comment')->nullable();
                $table->timestamps();
            }
        );

        $this->schema->create(
            'order_products',
            static function (Blueprint $table) {
                $table->id();
                $table->foreignId('order_id')
                    ->constrained('orders')->cascadeOnDelete();
                $table->foreignId('product_id')->nullable()
                    ->constrained('products')->nullOnDelete();
                $table->string('title')->nullable();
                $table->unsignedInteger('amount')->default(0);
                $table->decimal('price')->default(0);
                $table->decimal('weight')->default(0);
                $table->decimal('discount')->default(0);
                $table->json('options')->nullable();
            }
        );
    }

    public function down(): void
    {
        $this->schema->drop('order_products');
        $this->schema->drop('orders');
    }
}
Колонка uuid пригодится нам на будущее, для запросов пользователей с публичной части сайта. А колонку discount заполним из импорта.
Для генерации uuid нужно установить новую зависимость через composer require ramsey/uuid.
Создаём таблицу адресов пользователей:
<?php

declare(strict_types=1);

use Illuminate\Database\Schema\Blueprint;
use Vesp\Services\Migration;

final class UserAddresses extends Migration
{
    public function up(): void
    {
        $this->schema->create(
            'user_addresses',
            static function (Blueprint $table) {
                $table->id();
                $table->foreignId('user_id')
                    ->constrained('users')->cascadeOnDelete();
                $table->string('receiver')->nullable();
                $table->string('phone')->nullable();
                $table->tinyInteger('gender')->nullable();
                $table->string('company')->nullable();
                $table->string('address')->nullable();
                $table->string('country')->nullable();
                $table->string('city')->nullable();
                $table->string('zip')->nullable();
                $table->timestamps();
            }
        );

        $this->schema->table(
            'orders',
            static function (Blueprint $table) {
                $table->foreignId('address_id')->after('user_id')->nullable()
                    ->constrained('user_addresses')->nullOnDelete();
            }
        );
    }

    public function down(): void
    {
        $this->schema->table('orders', static function (Blueprint $table) {
            $table->dropConstrainedForeignId('address_id');
        });
        $this->schema->drop('user_addresses');
    }
}
Новая миграция добавляет "задним числом" колонку address_id в таблицу заказов. Обновлённую схему можно посмотреть по ссылке https://drawsql.app/teams/bezumkins-team/diagrams/vesp-shop
Колонки remote_id нам здесь не нужны - будем использовать id старых записей.
В модель заказа добавляем новый метод инициализации, в котором прописываем значения по умолчанию:
    protected static function booted(): void
    {
        static::saving(static function (self $model) {
            if (!$model->uuid) {
                $model->uuid = Uuid::uuid4();
            }
            if (!$model->created_at) {
                $model->created_at = Carbon::now();
            }
            if (!$model->num) {
                $time = $model->created_at->timestamp;
                $count = $model->newQuery()->where('created_at', 'LIKE', date('Y-m-', $time) . '%')->count();
                $model->num = implode('/', [date('ym', $time), $count + 1]);
            }
        });
    }
Пустые uuid, дата создания и номер заказа будут присвоены автоматически.
Так же нам потребуется меод пересчёта итоговых цифр заказа:
    public function calculate(): void
    {
        $cart = 0;
        $weight = 0;
        /** @var OrderProduct $orderProduct */
        foreach ($this->orderProducts()->cursor() as $orderProduct) {
            $cart += $orderProduct->amount * ($orderProduct->price - $orderProduct->discount);
            $weight += $orderProduct->amount * $orderProduct->weight;
        }
        $total = $cart - $this->discount;

        $this->weight = $weight;
        $this->cart = max($cart, 0);
        $this->total = max($total, 0);
        $this->save();
    }
Как следует из метода, скидка в заказе просто указывается вручную.

Импорт заказов

Здесь у нас будет здоровенный сид, который за один прогон импортирует заказы, купленные товары и адреса юзеров.
В оригинальном проекте была самодельная система скидок, которая работала в зависимости от количества или веса заказанного. У нас системы скидок пока нет, но старые данные постараемся загрузить.
<?php

use App\Models\Order;
use App\Models\OrderProduct;
use App\Models\Product;
use App\Models\ProductTranslation;
use App\Models\User;
use App\Models\UserAddress;
use Phinx\Seed\AbstractSeed;
use Ramsey\Uuid\Uuid;
use Vesp\Services\Eloquent;

// Подключаем файл миграции товаров для вызова метода translateVariants() 
require __DIR__ . '/Products.php';

class Orders extends AbstractSeed
{
    public function getDependencies(): array
    {
        return ['Users', 'Categories', 'Products'];
    }

    public function run(): void
    {
        $db = (new Eloquent())->getDatabaseManager();
        $pdo = $db->getPdo();

        $users = [];
        // Генерируем массив для замены id юзеров
        foreach (User::query()->select('id', 'remote_id')->cursor() as $user) {
            $users[$user->remote_id] = $user->id;
        }

        // 2 разных запроса в БД: получения заказов
        $stmtOrder = $pdo->query('SELECT *  FROM `modx_ms2_orders` ORDER BY `id`');
        // И получение одного адреса по id
        $stmtAddress = $pdo->prepare('SELECT *  FROM `modx_ms2_order_addresses` WHERE `id` = :id');
        while ($row = $stmtOrder->fetch(PDO::FETCH_ASSOC)) {
            // В MODX вполне может быть, что заказавший юзер уже удалён - проверяем
            // И пропускаем, если что
            if (empty($users[$row['user_id']])) {
                continue;
            }

            // Создаём или обновляем заказ
            if (!$order = Order::query()->find($row['id'])) {
                $order = new Order();
                $order->id = $row['id'];
                $order->uuid = Uuid::uuid4();
            }
            // Заполняем нужные колонки
            $order->num = trim($row['num']);
            $order->user_id = $users[$row['user_id']];
            $order->total = (float)$row['cost'];
            $order->cart = (float)$row['cart_cost'];
            $order->weight = (float)$row['weight'];
            $order->comment = !empty($row['comment']) ? trim($row['comment']) : null;
            $order->created_at = $row['createdon'];
            $order->updated_at = $row['updatedon'];

            // А вот и обработка скидки заказа
            if (!empty($row['properties']) && $properties = json_decode($row['properties'], true)) {
                $order->discount = isset($properties['extfld_discount']) ? (float)$properties['extfld_discount'] : 0;
            }

            // Пробуем получить адрес заказа
            if ($row['address'] && !$order->address_id && $stmtAddress->execute([':id' => $row['address']])) {
                if (!$old = $stmtAddress->fetch(PDO::FETCH_ASSOC)) {
                    throw new RuntimeException('Could not load address with id = ' . $row['address']);
                }
                if (!$address = UserAddress::query()->find($old['id'])) {
                    $address = new UserAddress();
                    $address->id = $old['id'];
                }
                // Заполняем нужные колонки
                $address->user_id = $users[$old['user_id']];
                $address->receiver = $old['receiver'] ? trim($old['receiver']) : null;
                $address->phone = $old['phone'] ? trim($old['phone']) : null;
                $address->address = trim(
                    implode(', ', array_filter([$old['metro'], $old['street'], $old['building'], $old['room']]))
                );
                $address->country = $old['country'] ? trim($old['country']) : null;
                $address->city = $old['city'] ? trim($old['city']) : null;
                $address->zip = $old['index'] ? trim($old['index']) : null;
                $address->created_at = $old['createdon'];
                $address->updated_at = $old['updatedon'];
                $address->save();

                // Добавляем id адреса в заказ
                $order->address_id = $address->id;
            }

            // Сохраняем заказ
            $order->save();
        }

        // Получаем id всех нагих заказов
        $orders = Order::query()->pluck('id')->toArray();
        // Выбираем все заказанные товары из старой БД
        $stmt = $pdo->query("SELECT *  FROM `modx_ms2_order_products` ORDER BY `id`");
        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
            // Проверяем, что у нас есть такой заказ
            if (!in_array($row['order_id'], $orders, true)) {
                continue;
            }
            // Создаём или обновляем заказанный товар
            if (!$orderProduct = OrderProduct::query()->find($row['id'])) {
                $orderProduct = new OrderProduct();
                $orderProduct->id = $row['id'];
            }
            $orderProduct->order_id = $row['order_id'];
            $orderProduct->title = !empty($row['name']) ? trim($row['name']) : null;
            $orderProduct->amount = (int)$row['count'];
            $orderProduct->price = (float)$row['price'];
            $orderProduct->weight = (float)$row['weight'];

            // Пытаемся сохранить название заказанного товара, на всякий случай
            if ($product = Product::query()->where('remote_id', $row['product_id'])->first()) {
                $orderProduct->product_id = $product->id;
                /** @var ProductTranslation $translation */
                if (!$orderProduct->title && $translation = $product->translations()->where('lang', 'ru')->first()) {
                    $orderProduct->title = $translation->title;
                }
            }

            // Обрабатываем дополнительные свойства
            if (!empty($row['options']) && $options = json_decode($row['options'], true)) {
                if (isset($options['color'])) {
                    $options['colors'] = $options['color'];
                    unset($options['color']);
                }
                // Вот для этого мы и подключали файл в начале скрипта
                if (isset($options['variants_fr'])) {
                    $options['variants'] = Products::translateVariants($options['variants_fr'], 'fr');
                    unset($options['variants_fr']);
                }
                // Пытаемся обработать скидку, она там в разных форматах
                if (isset($options['discount'])) {
                    // Этот формат мы понимаем, скидка в процентах
                    if (preg_match('#(\d+)%#', $options['discount'], $matches)) {
                        $tmp = $matches[1] / 100;
                        $orderProduct->discount = round($orderProduct->price * $tmp, 2);
                    }
                    // Остальные форматы идут лесом
                    unset($options['discount']);
                }
                $orderProduct->options = $options ?: null;
            }
            $orderProduct->save();
        }
    }
}
Вроде бы ничего особо нового, кроме использования статического метода из другой миграции. Запускаем сидер composer db:seed-one Orders, ждём, и проверяем таблицы:

Обновляем контроллер заказов

Редактируем Controllers/Admin/Orders.php:
<?php

namespace App\Controllers\Admin;

use App\Models\Order;
use App\Models\OrderProduct;
use App\Models\UserAddress;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Psr\Http\Message\ResponseInterface;
use Vesp\Controllers\ModelController;

class Orders extends ModelController
{
    protected $scope = 'orders';
    protected $model = Order::class;

    // Модель заказа грузится вместе с купленными товарами и адресом
    protected function beforeGet(Builder $c): Builder
    {
        $c->with('orderProducts', 'orderProducts.product');
        $c->with('address');

        return $c;
    }

    // Поиск по юзеру и\или номеру
    protected function beforeCount(Builder $c): Builder
    {
        if ($query = trim($this->getProperty('query', ''))) {
            $c->where(static function (Builder $c) use ($query) {
                $c->whereHas('user', static function (Builder $c) use ($query) {
                    $c->where('fullname', 'LIKE', "%$query%");
                    $c->orWhere('username', 'LIKE', "%$query%");
                });
                $c->orWhere('num', 'LIKE', "$query%");
            });
        }

        return $c;
    }

    // Для списка заказов подсчитываем количество заказанного и получаем юзера
    protected function afterCount(Builder $c): Builder
    {
        $c->withCount('orderProducts');
        $c->with('user:id,fullname,username');

        return $c;
    }

    protected function beforeSave(Model $record): ?ResponseInterface
    {
        // Админка может прислать 0 или пустую строку - форсируем null
        if (!$this->getProperty('address_id')) {
            $record->address_id = null;
        }

        return null;
    }

    protected function afterSave(Model $record): Model
    {
        // А вот и обработка массива с присланными товарами
        $items = $this->getProperty('order_products');
        if (is_array($items)) {
            $ids = [];
            foreach ($items as $item) {
                // Добавляем новые
                if (!$orderProduct = $record->orderProducts()->find($item['id'])) {
                    $orderProduct = new OrderProduct();
                    $orderProduct->order_id = $record->id;
                }
                // Пустые JSON массивы на ни к чему
                if (empty($item['options'])) {
                    $item['options'] = null;
                }
                $orderProduct->fill($item);
                $orderProduct->save();

                // Сохраняем id товара в массив
                $ids[] = $orderProduct->id;
            }

            // Чтобы удалить из БД остальные товары заказа, которых больше нет
            $record->orderProducts()->whereNotIn('id', $ids)->delete();
        }

        // Если у заказ нет адреса - добавляем пустой
        if (!$record->address_id && $item = $this->getProperty('address')) {
            if (is_array($item) && !empty($item)) {
                /** @var UserAddress $address */
                $address = $record->user->addresses()->create($item);
                $record->address_id = $address->id;
            }
        }

        // Пересчитываем итоговые цифры заказа после манипуляций с товарами
        $record->calculate();

        return $record;
    }
}
Как видно из контроллера, мы будем управлять товарами заказа из админки. Я сразу добавил еще 2 простейших контроллера для получаения адресов и заказов конкретного юзера, посмотрите в репозитории.

Обновляем админку

В таблице заказов добавляем кнопку создания заказа и набор выводимых колонок:
  data() {
    return {
      // ...
      sort: 'num',
      dir: 'desc',
    }
  },
    headerActions() {
      return [{route: 'orders-create', icon: 'plus', title: this.$t('actions.create')}]
    },
    fields() {
      return [
        {key: 'num', label: this.$t('models.order.num'), sortable: true},
        {key: 'user.fullname', label: this.$t('models.order.user')},
        {key: 'total', label: this.$t('models.order.total'), sortable: true, formatter: this.$price},
        {key: 'discount', label: this.$t('models.order.discount'), formatter: this.$price, sortable: true},
        {key: 'order_products_count', label: this.$t('models.order.products'), sortable: true},
        {key: 'created_at', label: this.$t('models.order.created_at'), formatter: this.$options.filters.datetime, sortable: true},
      ]
    },
Для форматирования цены и веса я добавил пару функций в наши утилиты:
  inject('price', (val) => {
    if (val) {
      return [Number(val).toFixed(2), app.i18n.t('shop.currency')].join(' ')
    }
    return ''
  })

  inject('weight', (val) => {
    if (val) {
      return [Number(val).toFixed(2), app.i18n.t('shop.weight')].join(' ')
    }
    return ''
  })
Конечно, нужно добавить и соответствующие записи в лексиконы.
Самое интересно будет в карточке заказа. Создаём admin/pages/products/create.vue:
<template>
  <vesp-modal v-model="record" :url="url" :title="$t('models.order.title_one')" size="lg">
    <!-- Расширяем слот формы, все вкладки будут внутри тега form -->
    <template #form-fields>
      <b-tabs content-class="pt-3">
        <b-tab :title="$t('models.order.tab_main')">
          <!-- Форма заказа -->
          <form-order v-model="record" />
        </b-tab>
        <b-tab :title="$t('models.order.tab_products')">
          <!-- Компонент товаров  -->
          <order-products v-model="record" />
        </b-tab>
        <!-- Вкладка может работать только если выбран юзер -->
        <b-tab :title="$t('models.order.tab_address')" :disabled="!record.user_id">
          <order-address v-model="record" />
        </b-tab>
      </b-tabs>
    </template>
  </vesp-modal>
</template>

<script>
import {url} from '../orders'
import FormOrder from '@/components/forms/order'
import OrderProducts from '@/components/order/products.vue'
import OrderAddress from '@/components/order/address.vue'

export {url}
export default {
  name: 'OrderCreatePage',
  components: {OrderAddress, OrderProducts, FormOrder},
  data() {
    return {
      url,
      record: {
        num: '',
        // ...
      },
    }
  },
}
</script>
Страницу редактирования заказа нужно переделать так, чтобы она наследовала страницу создания.
В отличие от карточки товара, здесь у нас все 3 вкладки находятся внутри формы, поэтому номер активной вкладки мы не отслеживаем и подвал окошка не прячем.
Далее обновляем форму редактирования товара.
  1. Номер и дату заказа мы можем указать произвольные.
  2. Покупатель выбирается из списка юзеров сайта
  3. Как только выбрали покупателя, включается комбо-бокс для выбора его адресов (он использует новый контроллер адресов юзера)
  4. Корзина управляется на 2й вкладке, там любые операции с товарами
  5. Адрес юзера выводится на 3й. Если адресов еще нет, там можно создать новый
Выбор адреса происходит вот таким образом:
    <b-form-group :label="$t('models.order.address')">
      <vesp-input-combo-box
        <!-- при смене юзера сменится ключ и компонент будет отрендерен заново -->
        :key="'address-' + record.user_id"
        v-model="record.address_id"
        <!-- запрос в API зависит от выбранного юзера -->
        :url="record.user_id ? 'admin/users/' + record.user_id + '/addresses' : null"
        <!-- если юзер не выбран - компонент отключен -->
        :disabled="!record.user_id"
        <!-- колонка для отображения -->
        text-field="full_address"
        sort="id"
        <!-- метод добавления выбранного в форму -->
        @select="onSelectAddress"
      >
        <!-- шаблон оформления -->
        <template #default="{item}">
          {{ item.full_address }}
          <div class="small text-muted">{{ item.receiver }}</div>
        </template>
      </vesp-input-combo-box>
    </b-form-group>
Обратите внимание на изменение :key при смене юзера. Это легальный способ принудительно обновить любой Vue компонент. То есть, убрать его из DOM и добавить заново, выполняя все его хуки. Именно это и происходит при изменении пользователя, чтобы сделать запрос на правильный контроллер и получить адреса нового юзера.
Итоговая стоимость заказа и вес у формы выводятся для красоты, потому что всё будет пересчитано при сохранении заказа на бэкенде.
Самое важное, что нужно помнить, это что данные о товарах и адресе у нас находятся внутри формы самого заказа. Поэтому при выборе нового адреса, он вставляется в this.record.address, чтобы отобразиться на 3й вкладке.
Это одна и та же форма, разбитая на 3 вкладки. Поэтому и манипуляции в корзине на 2й вкладке меняют сумму корзины на 1й.
Дальше идёт управление товарами, они берутся из переменной record.order_products и перебираются, формируя таблицу. В сокращённом виде это выглядит вот так:
      <table v-if="totalAmount > 0" class="table b-table">
        <thead>
          <tr>
            <th>{{ $t('models.order_product.product') }}</th>
            <!--<th>{{ $t('models.order_product.weight') }}</th>-->
            <th style="width: 125px">{{ $t('models.order_product.amount') }}</th>
            <th style="width: 100px">{{ $t('models.order_product.price') }}</th>
            <th style="width: 125px">{{ $t('models.order_product.discount') }}</th>
            <th style="width: 125px">{{ $t('models.order_product.total') }}</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="(item, idx) in products" :key="idx">
            <td class="pl-0">
              {{ item.title }}
              <product-options
                v-model="item.options"
                :options="{colors: item.product.colors, variants: item.product.variants}"
                class="small"
              />
            </td>
            <!--<td>{{ $weight(item.weight) }}</td>-->
            <td>
              <b-form-spinbutton v-model="item.amount" min="1" />
            </td>
            <td>{{ $price(item.price) }}</td>
            <td>
              <b-form-input v-model.number="item.discount" min="0" type="number" :step="0.01" :max="item.price" />
            </td>
            <td>{{ $price(getProductCost(item) || '0') }}</td>
            <td class="p-0 actions">
              <b-button variant="danger" size="sm" @click="onDelete(idx)">
                <fa icon="times" class="fa-fw" />
              </b-button>
            </td>
          </tr>
        </tbody>
        <tfoot>
          <tr>
            <th></th>
            <!--<th>{{ $weight(totalWeight) }}</th>-->
            <th class="text-center">{{ totalAmount }}</th>
            <th>{{ $price(totalCart) }}</th>
            <th>- {{ $price(totalDiscount) }}</th>
            <th colspan="2">{{ $price(total) }}</th>
          </tr>
        </tfoot>
      </table>
Компонент product-options я написал для выбора опций товара, посмотрите в репозитории, особых вопросов возникнуть не должно.
Я закомментировал вывод веса в таблице, потому что ширина ограничена модалкой, но он всё равно учитывается в итоговом значении заказа.
Еще здесь интересные методы работы с корзиной, потому что мы просто меняем внутренний массив напрямую, без запросов на сервер:
  methods: {
    onDelete(idx) {
      this.myValue.order_products.splice(idx, 1)
    },
    onAdd(item) {
      this.myValue.order_products.push({
        id: null,
        product_id: item.id,
        title: this.$translate(item.translations),
        amount: 1,
        price: item.price,
        weight: item.weight,
        cost: item.price,
        discount: 0,
        options: null,
        product: item,
      })
      this.$nextTick(() => {
        this.product_id = null
      })
    },
  },
На вкладке в адресом дублируется комбо-бокс выбора адреса пользователя, он меняет ту же переменную record.address_id, что и в основной вкладке. Если адрес выбран, его можно заменить, выбрав другой, или нажать на кнопку "создать", чтобы сбросить выбор и запролнить форму вручную.
Если у юзера нет ни одного адреса - то доступно лишь заполнение формы.
В самой форме нет ничего интересного, только отключение редактирования полей при выводе уже существующего адреса.

Заключение

Демонстрация работы формы заказа:
То есть, мы можем поменять заказ полностью:
  • поменять номер, дату
  • выбрать другого юзера
  • выбрать другой адрес или создать новый
  • изменить набор товаров, количество, указать произвольную скидку
  • указать дополнительную скидку на весь заказ
По моему, очень круто получается.
Итоговый коммит в репозитории.

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

Спасибо за уроки! Очень интересно наблюдать за всеми нюансами разработки интернет-магазина.
Василий Наумкин
На здоровье!
Кажется, ты единственный читатель -)
Просто так совпало что у меня пока что есть свободное время на изучение. Остальные скорее всего поболее загружены, так что вопрос времени когда подтянутся)
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
Спасибо!