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

Сегодня продолжаем прогружать старые данные в новую базу - дошла очередь и до заказов.

Помимо собственно заказов, 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, что и в основной вкладке. Если адрес выбран, его можно заменить, выбрав другой, или нажать на кнопку "создать", чтобы сбросить выбор и запролнить форму вручную.

Если у юзера нет ни одного адреса - то доступно лишь заполнение формы.

В самой форме нет ничего интересного, только отключение редактирования полей при выводе уже существующего адреса.

Заключение

Демонстрация работы формы заказа:

То есть, мы можем поменять заказ полностью:

  • поменять номер, дату
  • выбрать другого юзера
  • выбрать другой адрес или создать новый
  • изменить набор товаров, количество, указать произвольную скидку
  • указать дополнительную скидку на весь заказ

По моему, очень круто получается.

Итоговый коммит в репозитории.

← Предыдущая заметка
Связи товара и мультикатегории
Следующая заметка →
Вывод товаров по uri
Комментарии (3)
bezumkinВасилий Наумкин
29.08.2023 10:11

На здоровье!

Кажется, ты единственный читатель -)

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 для бэкенда. Их можно обновлять, но э...