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

Сегодня пишем оформление заказа, пока без оплаты.

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

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

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

При оформлении заказов я понял, что у нас немного неправильная таблица адресов юзера.

По моему, там вовсе не нужен пол получателя, а вот 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)
bezumkinВасилий Наумкин
12.09.2023 12:11

Если речь про работу в 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
Василий Наумкин
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 для бэкенда. Их можно обновлять, но э...