Оформление заказа

Сегодня пишем оформление заказа, пока без оплаты.
Тут не очень много изменений, по сравнению с прошлой версией. В основном, работа с пользователями и их адресами.
Например, мне хочется дать возможность юзерам авторизоваться прямо в корзине, а потом выбрать свой адрес доставки из истории заказов.

Исправляем таблицу адресов

При оформлении заказов я понял, что у нас немного неправильная таблица адресов юзера.
По моему, там вовсе не нужен пол получателя, а вот email как раз пригодится. Поэтому я откатился до этой миграции, поправил её, и накатил снова.
Самое забавное, что email в модели был прописан, но в таблице я его создать забыл.
После миграции нужно снова импортировать адреса из старой базы.
composer db:seed-one Orders
Реальный пример исправления мелких косяков в миграциях во время разработки, когда можно откатить данные. На продакшене, конечно, так делать нельзя - там бы я написал новую миграцию.
После отката миграций были удалены и корзины, так что моё приложение начало получать ошибку 404 в ответ на запрос товаров корзины.
Добавил удаление cartId из хранилища в таком случае:
  async loadCart({dispatch, commit}) {
    const cartId = await dispatch('getCartId', false)
    if (cartId) {
      try {
        const {data} = await this.$axios.get('web/cart/' + cartId + '/products')
        commit('cartProducts', data.rows)
      } catch (e) {
        // Вот здесь проверка статуса ответа и удаление
        if (e.status === 404) {
          commit('cartId', null)
          dispatch('saveCartId')
        }
      }
    }
  },
Вряд ли такое будет часто происходить, но лишнее условие не помешает.

Оформление заказа

Форма создания заказа остаётся почти без изменений, я только добавил поддержку лексиконов, возможность авторизации и работу с адресами юзера.
Как вы помните из прошлой заметки, компонент авторизации выводится при клике в навигационной панели. Нам же нужно вызывать его из модальной корзины, а значит пора задействовать хранилище.
Я добавил в state переменную showLogin, мутацию для неё, и заставил <app-navbar /> слушать изменение этой переменной:
<app-login v-if="!$auth.loggedIn && $store.state.showLogin" @hidden="$store.commit('showLogin')" />
Мутация может или принимать конкретное значение, или просто менять true на false и наоборот, если ничего не прислано:
export const mutations = {
  // ...
  showLogin(state, payload = null) {
    state.showLogin = payload === null ? !state.showLogin : Boolean(payload)
  },
}
Теперь прописываем новый блок в форме заказа site/components/order.vue:
    <div v-if="!$auth.loggedIn" class="text-center py-2">
      <b-button variant="primary" @click="$store.commit('showLogin')">{{ $t('order.login') }}</b-button>
    </div>

    <b-form-group v-else>
      <b-select v-model="addressId" :disabled="!addresses.length">
        <b-form-select-option :value="null">{{ $t('order.new_address') }}</b-form-select-option>
        <b-form-select-option v-for="item in addresses" :key="item.id" :value="item.id">
          {{ item.full_address }}
        </b-form-select-option>
      </b-select>
    </b-form-group>
Если юзер не авторизован, ему покажется кнопка входа, при нажатии на которую изменится значение в state и откроется компонент авторизации.
Если же юзер уже залогинился - будет выведены его адреса, загруженные с сервера методом fetch():
  data() {
    return {
      loading: false,
      showSuccess: false,
      // Список всех адресов юзера
      addresses: [],
      // Id выбранного старого адреса юзера
      addressId: null,
      // Форма заказа
      form: {
        receiver: '',
        phone: '',
        email: '',
        company: '',
        address: '',
        country: '',
        city: '',
        zip: '',
        comment: '',
      },
    }
  },
  async fetch() {
    // Работаем только для авторизованных
    if (this.$auth.loggedIn) {
      this.loading = true
      try {
        // Запрос на контроллер адресов юзера
        const {data} = await this.$axios.get('user/addresses')
        this.addresses = data.rows
        // Если есть результаты
        if (data.rows.length > 0) {
          // Выбираем первый адрес из списка
          this.addressId = data.rows[0].id
        } else if (!Object.values(this.form).join('')) {
          // Если результатов нет
          Object.keys(this.form).forEach((key) => {
            // Проставляем данные из профиля в форму заказа
            if (key === 'receiver' && this.$auth.user.fullname) {
              this.form.receiver = this.$auth.user.fullname
            } else if (this.$auth.user[key]) {
              this.form[key] = this.$auth.user[key]
            }
          })
        }
      } catch (e) {
      } finally {
        this.loading = false
      }
    }
  },
У полей формы заказа я задал условие :disabled="addressId !== null", которое будет отключать их при выборе существующего адреса.
Отправка заказа на сервер идёт с указанием uuid корзины и необязательным address_id вместе с полями формы:
  methods: {
    async onSubmit() {
      this.loading = true
      try {
        // Получаем id корзины
        const id = await this.$store.dispatch('getCartId', false)
        // Отправляем заказ
        await this.$axios.put('web/orders/' + id, {...this.form, address_id: this.addressId})
        // Затем удаляем корзину
        await this.$store.dispatch('deleteCart')
        // И выводим сообщение об успехе
        this.showSuccess = true
      } catch (e) {
        console.error(e)
      } finally {
        this.loading = false
      }
    },
  },
address_id имеет приоритет перед полями формы. Он будет использован в первую очередь, если указан и принадлежит текущему юзеру. Если нет, то будет создан новый адрес из полей формы.

Контроллер адресов юзера

Как вы понимаете, здесь вообще ничего сложного, просто форсируем указание user_id:
<?php

namespace App\Controllers\User;

// ...

class Addresses extends ModelGetController
{
    protected $scope = 'profile';
    protected $model = UserAddress::class;

    protected function beforeGet(Builder $c): Builder
    {
        $c->where('user_id', $this->user->id);

        return $c;
    }

    protected function beforeCount(Builder $c): Builder
    {
        $c->where('user_id', $this->user->id);

        return $c;
    }

    protected function addSorting(Builder $c): Builder
    {
        $c->orderByDesc('created_at');

        return $c;
    }
}
Обратите внимание, что сортировка по дате убывания - новые адреса всегда впереди.
Не забываем прописать соответствующий маршрут:
$group->group(
    '/user',
    static function (RouteCollectorProxy $group) {
        $group->any('/profile', App\Controllers\User\Profile::class);
        $group->any('/addresses', App\Controllers\User\Addresses::class);
    }
);

Проверка формы с адресом

При создании нового адреса нам будет нужно проверять присланные данные. Поэтому прописываем в модель UserAddress метод fillData(), по образу и подобию такового у User:
    public function fillData(array $data): UserAddress
    {
        $required = ['receiver', 'address', 'zip', 'city', 'phone', 'email'];
        foreach ($required as $key) {
            if (empty($data[$key])) {
                throw new RuntimeException('errors.address.no_' . $key);
            }
            if ($key === 'email' && !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
                throw new RuntimeException('errors.address.wrong_email');
            }
        }
        $this->fill($data);

        return $this;
    }
Здесь можно легко отключить требование каких-то данных, или усложнить их проверку.
Переходим к собственно созданию заказа.

Контроллер заказа

Контроллер большой, приведу в немного сокращённом виде:
<?php

namespace App\Controllers\Web;

// ...

class Orders extends Controller
{

    // Метод получения существующего заказа по uuid
    public function get(): ResponseInterface
    {
        $uuid = $this->getProperty('uuid');
        /** @var Order $order */
        if (!$uuid || !$order = Order::query()->where('uuid', $uuid)->first()) {
            return $this->failure('Not Found', 404);
        }

        return $this->success($order->getData());
    }

    // Метод создания нового заказа
    public function put(): ResponseInterface
    {
        $cartId = $this->getProperty('uuid');
        // Находим указанную корзину
        if (!$cartId || !$cart = Cart::query()->find($cartId)) {
            return $this->failure('Not Found', 404);
        }

        $properties = $this->getProperties();
        // Проверяем наличие авторизации
        if (!$user = $this->user) {
            // Создаём нового юзера
            try {
                // Проверяем форму адреса, если там ошибка - выйдем
                $address = new UserAddress();
                $address->fillData($properties);

                // Адрес норм, пробуем создать юзера
                $properties['fullname'] = $properties['receiver'] ?? null;
                $properties['username'] = $properties['email'] ?? null;
                $user = User::createUser($properties);

                // Юзер создан, прикручиваем ему новый адрес
                $address->user_id = $user->id;
                $address->save();
            } catch (\Exception $e) {
                return $this->failure($e->getMessage());
            }
        } else {
            // Юзер авторизован - проверяем наличие старого адреса
            if (empty($properties['address_id']) || !$address = $user->addresses()->find($properties['address_id'])) {
                try {
                    // Старый адрес не указан, пробуем создать новый
                    $address = new UserAddress(['user_id' => $user->id]);
                    $address->fillData($properties);
                    $address->save();
                } catch (\Exception $e) {
                    return $this->failure($e->getMessage());
                }
            }
        }

        // Определяем язык пользователя
        $lang = $this->request->getHeaderLine('Content-Language') ?: 'ru';

        // Создаём новый заказ и заполням обязательные значения
        $order = new Order();
        $order->user_id = $user->id;
        // ...

        // Копируем товары из корзины в новый массив для заказа
        $orderProducts = [];
        foreach ($cart->cartProducts()->orderBy('created_at')->cursor() as $cartProduct) {
            /** @var CartProduct $cartProduct */
            $orderProduct = new OrderProduct();
            $orderProduct->product_id = $cartProduct->product_id;
            // Получем название товара на языке юзера, см. ниже
            $orderProduct->title = $cartProduct->product->getTitle($lang);
            $orderProduct->weight = $cartProduct->product->weight;
            // ...
            $orderProducts[] = $orderProduct;

            // Суммируем итоговые значения
            $order->weight += $cartProduct->product->weight * $cartProduct->amount;
            $order->cart += $cartProduct->product->price * $cartProduct->amount;
        }
        // У нас еще нет никакой системы скидок, так что стоимость заказа и корзины равны
        $order->total = $order->cart;

        // Сохраняем сначала заказ
        $order->save();
        // Потом добавдяем ему товары
        $order->orderProducts()->saveMany($orderProducts);

        // И отправляем письма админу и юзеру, с указанием языка
        $order->sendEmails($lang);

        return $this->success($order->toArray());
    }
}
Кода много, но это в основном проверки перед созданием записей. Так-то мы просто копируем товары из корзины в заказ.
В ответ отправляем данные только что созданного заказа, включая его новый uuid. По нему можно будет получить свойства заказа из метода get().
Вы должны были заметить парочку новых методов модели Order, вот они:
    // Это просто вывод нужных данных заказа одним массивом
    // Пригодится для отправки писем и отрисовки заказа на фронте
    public function getData(): array
    {
        return [
            'order' => $this->toArray(),
            'user' => $this->user->toArray(),
            'address' => $this->address->toArray(),
            'products' => $this->orderProducts()
                ->with('product:id,uri', 'product.translations:product_id,lang,title', 'product.firstFile')
                ->get()
                ->toArray(),
        ];
    }

    // Отправка писем о новом заказа
    public function sendEmails(?string $lang = 'ru'): ?string
    {
        $data = $this->getData();
        $data['lang'] = $lang;

        // Отправка письма админу, если указан
        if ($emailAdmin = getenv('EMAIL_ADMIN')) {
            $mail = new Mail();
            $subject = getenv('EMAIL_SUBJECT_ORDER_NEW_ADMIN_' . strtoupper($lang));
            if ($error = $mail->send($emailAdmin, $subject, 'email-order-new-admin', $data)) {
                return $error;
            }
        }

        // Отправка письма заказчику
        $subject = getenv('EMAIL_SUBJECT_ORDER_NEW_USER_' . strtoupper($lang));
        if ($error = $this->user->sendEmail($subject, 'email-order-new-user', $data)) {
            return $error;
        }

        return null;
    }
Для отправки писем, как вы понимаете, я добавил новые переменные окружения в файл .env. В шаблонах писем просто распечатка массивов, без оформления.

Title заказанного товара

Долго думал как поступить со старым функционалом miniShop2 - сохранением названия заказанного товара, на случай его удаления или изменения.
Не знаю, насколько часто подобное происходит в реальных магазинах. На мой взгляд, такого вообще быть не должно, чтобы товар переименовали задним числом и он стал чем-то другим. Но во время поддержки miniShop2 меня неоднократно просили добавить колонку title заказанным товарам, так что она есть и в нашем проекте.
Вопрос только в том, а как быть с мультиязычностью? Прикручивать отдельную таблицу с переводами явный перебор. Можно хранить названия в JSON или сохранять только для текущего языка.
Мы уже импортировали заказанные товары из старой БД и там они простые строки. Если начинать хранить в JSON, то это нужно добавлять условия для проверки и всё усложняется.
Учитывая, что я не считаю эту функцию полезной, дополнительные проверки мне городить не хочется. Поэтому я буду хранить просто название товара на том языке, на котором был сделан заказ.
А для получения названия товара в зависимости от языка, у нас есть вот такой метод в модели Product:
    public function getTitle(string $lang = 'ru'): string
    {
        $title = '';
        $translations = $this->translations();
        if ($lang === 'ru') {
            $translations->where('lang', 'ru');
        } else {
            $translations->whereIn('lang', ['ru', $lang]);
            $translations->orderByRaw("FIELD(`lang`, '$lang', 'ru')");
        }
        foreach ($translations->cursor() as $translation) {
            /** @var ProductTranslation $translation */
            if (!$title) {
                $title = $translation->title;
            }
        }

        return $title;
    }
Здесь мы пробуем получить название на требуемом языке и если не получается - берём язык по умолчанию. В данным случае, это ru.
Если VespShop будет развиваться в дальнейшем, то подобное наследие от miniShop2 я, скорее всего, уберу за ненадобностью.

Страница заказа

После создания заказа было бы неплохо перенаправить юзера на его страницу. Мы уже заложили возможность запрашивать данные заказа по его uuid, так что создаём простенькую страницу site/pages/orders/_uuid.vue:
<template>
  <pre>{{ order }}</pre>
</template>

<script>
export default {
  name: 'OrdersPage',
  validate({params}) {
    return /^(\w{8})-((\w{4}-){3})(\w{12})$/i.test(params.uuid)
  },
  async asyncData({app, params, error}) {
    try {
      const {data} = await app.$axios.get('web/orders/' + params.uuid)
      return {...data}
    } catch (e) {
      error({statusCode: e.statusCode || 404, message: e.data || 'Not Found'})
    }
  },
  data() {
    return {
      order: null,
      user: null,
      address: null,
      products: null,
    }
  },
}
</script>
После редиректа корзина остаётся открытой, у нас же SPA, так что нужно научиться её закрывать. Переносим управление модалкой в хранилище так же, как и управление авторизацией:
export const state = () => ({
  // ...
  showLogin: false,
  showCart: false,
})

export const mutations = {
  // ...
  showLogin(state, payload = null) {
    state.showLogin = payload === null ? !state.showLogin : Boolean(payload)
  },
  showCart(state, payload = null) {
    state.showCart = payload === null ? !state.showCart : Boolean(payload)
  },
}
И теперь сокращённый метод отправки заказа выглядит вот так:
// Соаздём заказ
const id = await this.$store.dispatch('getCartId', false)
const {data} = await this.$axios.put('web/orders/' + id, {...this.form, address_id: this.addressId})

// Удаляем корзину
await this.$store.dispatch('deleteCart')

// Скрываем модалку корзины и редиректим на страницу заказа
if (data.uuid) {
  this.$store.commit('showCart', false)
  this.$router.push({name: 'orders-uuid', params: {uuid: data.uuid}})
}
Вывод "Спасибо за заказ" больше не требуется, вместо этого я немного украсил вывод заказа, используя корзину как образец.
Вывод заказа работает по uuid и не требует авторизацию. На мой взгляд, это достаточно безопасно, потому что uuid генерируется случайно и вы замучаетесь его подбирать из 5,3 ундециллиона возможных вариантов.

Заключение

Каждую следующая заметку писать труднее предыдыдущй, потому что мы перешли уже к конкретной логике работы магазина. А мне не хочется ограничивать базовую логику каким-то чёткими правилами.
Не всем нужно отправлять письма о заказе, не все будут принимать оплату онлайн. Кому-то нужно добавить скидки и доставку, кому-то нет.
В работе с Vesp я стараюсь использовать принцип KISS, поэтому не воспринимайте мой код как что-то непреложное. Всё можно и нужно менять под свои задачи.
Дальше пропробуем так же схематически прикрутим онлайн оплату и статусы заказов.
Итоговый коммит со всеми изменениями.

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

А где можно прописать лимиты, если скрипту PHP не хватает времени на выполнение? А то выдаёт такое:
The process "cd core && vendor/bin/phinx seed:run -s 'Orders'" exceeded the timeout of 300 seconds.
Аналогичная проблема была в сиде ProductFiles, но я там использовал limit и удалось обойти. Может быть есть вариант как решить эту проблему проще.
метод limit имеется ввиду в самом коде. А как бы прописать limit для самого PHP.
Василий Наумкин
Если речь про работу в Docker, то нужно зайти в docker/php-fpm/dev.dockerfile и там прописать в конце
RUN echo "max_execution_time=600" > /usr/local/etc/php/conf.d/docker-php-ext-execution.ini
Но у PHP в Docker вроде бы лимит 30 секунд по умолчанию, а у тебя в ошибке написано 300. Возможно, ты работаешь на хостинге, тогда можно дописать limit в этом выражении внутри сида Orders:
$stmtOrder = $pdo->query('SELECT *  FROM `modx_ms2_orders` ORDER BY `id` LIMIT 100');
Импортировать всё нам необязательно, это просто для примера и чтобы было чем БД забить.
Понял. Спасибо за подробный ответ!
bezumkin.ru
Personal website of Vasily Naumkin
Прямой эфир
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так. А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен. Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. ...
Futuris
04.04.2024, 08:56:12
Я просто немного запутался. Когда в абзаце "Vesp/Core" ты пишешь про "новый trait FileModel", я поду...
Василий Наумкин
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
Спасибо!