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

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

Будет всё то же самое, что и прежде: пишем миграцию, модели, контроллеры, странички в админке и само оформление заказов на публичном сайте.

Собственно, когда вы понимаете как работает Vesp, вся работа с ним становится вот такой предсказуемой рутиной. Есть новая задача - сел и сделал! Никаких сюрпризов, борьбы и превозмогания.

Выполняем 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',
            function (Blueprint $table) {
                $table->id();
                // Имя и почта покупателя
                $table->string('name');
                $table->string('email');
                // Почтовый индекс, город, адрес, почта
                $table->char('post', 6)->nullable();
                $table->string('city')->nullable();
                $table->string('address')->nullable();
                // Статус оплаты заказа
                $table->boolean('paid')->default(false);
                // Итоговая стоимость
                $table->unsignedDecimal('total')->nullable();
                // Дата создания и изменения
                $table->timestamps();
                // Дата оплаты
                $table->timestamp('paid_at')->nullable();
            }
        );

        // Таблица заказанных товаров
        $this->schema->create(
            'order_products',
            function (Blueprint $table) {
                $table->id();
                // Связи с заказом и товаром
                $table->foreignId('order_id')
                    ->constrained('orders')->cascadeOnDelete();
                $table->foreignId('product_id')->nullable()
                    ->constrained('products')->nullOnDelete();
                // Название товара на момент заказа
                $table->string('title');
                // Его цена и количество
                $table->unsignedDecimal('price');
                $table->unsignedInteger('amount')->default(1);
                // Итого
                $table->unsignedDecimal('total');
            }
        );
    }

    public function down(): void
    {
        $this->schema->drop('order_products');
        $this->schema->drop('orders');
    }
}

Теперь запускаем эту миграцию командой composer db:migrate.

Модели

Дальше создаём 2 модели. Сначала модель заказа core/src/Models/Order.php:

<?php

namespace App\Models;

use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Order extends Model
{
    protected $casts = ['paid' => 'boolean'];
    protected $guarded = ['id', 'created_at', 'updated_at', 'paid_at'];
    // Модель будет работать с этой колонкой как с датой
    protected $dates = ['paid_at'];

    // Связь с заказанными товарами
    public function orderProducts(): HasMany
    {
        return $this->hasMany(OrderProduct::class);
    }
}

Затем модель core/src/Models/OrderProduct.php:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class OrderProduct extends Model
{
    // Дат в миграции нет, так что отключаем и в модели
    public $timestamps = false;
    protected $guarded = ['id'];

    // Связь с заказом
    public function order(): BelongsTo
    {
        return $this->belongsTo(Order::class);
    }

    // И товаром
    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class);
    }
}

Вы могли заметить, что в отличие от модели ProductFile, здесь первичный ключ не составной, а обычный id. Сделано так потому, что заказанный товар может быть удалён из базы, но запись о нём в заказе останется, просто у неё не будет связи с реальным товаром - колонка product_id станет null.

Именно поэтому мы и сохраняем еще и назавание товара на момент заказа - для вывода его в истории заказов, даже если сам товар уже удалён.

Контроллеры

Скукотища продолжается, пишем контроллер core/src/Controllers/Admin/Orders.php:

<?php

namespace App\Controllers\Admin;

use App\Models\Order;
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');

        return $c;
    }

    // Перед сохранение модели
    protected function beforeSave(Model $record): ?ResponseInterface
    {
        // Проверяем флажок оплаты и меняем колонку даты оплаты
        if ($record->paid && !$record->paid_at) {
            $record->paid_at = time();
        } elseif ($record->paid_at && !$record->paid) {
            $record->paid_at = null;
        }

        return null;
    }

    // Ну и стандартный поиск перед выдачей списка
    protected function beforeCount(Builder $c): Builder
    {
        if ($query = $this->getProperty('query')) {
            $c->where('title', 'LIKE', "%$query%");
        }

        return $c;
    }
}

Контроллер товаров заказа нам пока не нужен, потому что мы не будем удалять товары или менять их количество. Конечно, в будущем этот функционал можно дописать.

Вместо этого давайте сразу напишем контроллер для создания заказа на публичной части сайта core/src/Controllers/Web/Orders.php:

<?php

namespace App\Controllers\Web;

use App\Models\Order;
use App\Models\Product;
use Psr\Http\Message\ResponseInterface;
use Vesp\Controllers\Controller;

class Orders extends Controller
{
    public function put(): ResponseInterface
    {
        // Требуем прислать массив с товарами
        if (!$ordered = $this->getProperty('products')) {
            return $this->failure();
        }
        // Товары будем ожидать в виде массива id => количество

        $orderTotal = 0;
        $orderProducts = [];
        // Проверяем присланные товары и считаем их сумму
        foreach ($ordered as $id => $amount) {
            // Товар должен быть включен
            if ($product = Product::query()->where('active', true)->find($id)) {
                $total = $product->price * $amount;
                $orderTotal += $total;
                // Товар прошёл проверку - сохраняем в список нужные данные
                $orderProducts[] = [
                    'product_id' => $id,
                    'title' => $product->title,
                    'amount' => $amount,
                    'price' => $product->price,
                    'total' => $total,
                ];
            }
        }

        // Создаём заказ
        $order = new Order();
        // Заполняем его присланными данными
        $order->fill($this->getProperties());
        $order->total = $orderTotal;
        // И добавляем товары
        if ($order->save()) {
            // Посмотрите как это удобно можно сделать через связь
            $order->orderProducts()->createMany($orderProducts);
        }

        return $this->success();
    }
}

Теперь прописываем новый маршруты в core/routes.php:

$group->group(
    '/admin',
    static function (RouteCollectorProxy $group) {
        // ...
        $group->any('/orders[/{id}]', App\Controllers\Admin\Orders::class);
    }
);

$group->group(
    '/web',
    static function (RouteCollectorProxy $group) {
        // ...
        $group->any('/orders', App\Controllers\Web\Orders::class);
    }
);

И на этом с PHP частью мы закончили. Запускаем composer node:dev и редактируем публичную часть сайта frontend/src/site

Заказы на сайте

Для оформления заказа нам понадобится как-то получать данные из корзины. При этом ровно в том же виде, как это корзина и отображает - то есть сгруппированные по количеству товары.

Раз нам нужны один и те же данные в двух разных местах, имеет смысл вынести их в общее хранилище, вместо дублирования кода.

Переносим код из корзины в новый getter хранилища frontend/src/site/store/index.js:

export const getters = {
  // ...
  products(state) {
    const products = {}
    state.cart.forEach((i) => {
      if (!products[i.id]) {
        products[i.id] = {...i, amount: 1}
      } else {
        products[i.id].amount += 1
      }
    })
    return Object.values(products)
  },
}

А затем используем этот метод в корзине frontend/src/site/components/cart.vue:

computed: {
    products() {
      return this.$store.getters.products
    },
    // ...
},

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

Создаём новый компонент frontend/src/site/components/order.vue:

<template>
  <!-- Выводим форму заказа только если корзина не пуста -->
  <div v-if="total" class="mt-4 pt-4 border-top">
    <h5>Заказ</h5>
    // Форма отправляется через метод onSubmit()
    <b-form @submit.prevent="onSubmit">
      <!-- Дальше идут обычные поля формы -->
      <b-form-group label="Ваше имя">
        <b-form-input v-model.trim="form.name" autofocus required />
      </b-form-group>

      <b-form-group label="Email">
        <b-form-input v-model.trim="form.email" type="email" required />
      </b-form-group>

      <b-row>
        <b-col md="4">
          <b-form-group label="Индекс">
            <b-form-input v-model.trim="form.post" type="number" min="0" max="999999" />
          </b-form-group>
        </b-col>
        <b-col md="8">
          <b-form-group label="Город">
            <b-form-input v-model.trim="form.city" />
          </b-form-group>
        </b-col>
      </b-row>

      <b-form-group label="Адрес">
        <b-form-input v-model.trim="form.address" />
      </b-form-group>

      <div class="text-right">
        <!-- После отправки формы кнопка отключается на время работы -->
        <b-button variant="primary" type="submit" :disabled="loading">
          Отправить
          <!-- И выводится симпатичный спиннер -->
          <b-spinner v-if="loading" small />
        </b-button>
      </div>
    </b-form>
  </div>
  <!-- При пустой корзине и успешной отправке формы выводим сообщение -->
  <div v-else-if="showSuccess">
    <div class="p-4 text-center font-weight-bold">Спасибо за заказ!</div>
  </div>
</template>

<script>
export default {
  name: 'Order',
  data() {
    // Переменные для работы
    return {
      loading: false,
      showSuccess: false,
      // Адрес контроллера создания заказа
      url: 'web/orders',
      form: {
        name: '',
        email: '',
        post: '',
        city: '',
        address: '',
      },
    }
  },
  computed: {
    // Получение товаров корзины
    products() {
      return this.$store.getters.products
    },
    // И суммы заказа
    total() {
      return this.$store.getters.cartTotal
    },
  },
  methods: {
    // Отправка заказа
    async onSubmit() {
      this.loading = true
      try {
        // Собираем товары корзины в массив id => количество
        const products = {}
        this.products.forEach((i) => {
          products[i.id] = i.amount
        })
        // И добавляем эти товары в заказ
        await this.$axios.put(this.url, {...this.form, products})
        // Усли всё ок - выводим сообщение с благодарностью
        this.showSuccess = true
        this.$store.commit('clearCart')
      } catch (e) {
        console.error(e)
      } finally {
        this.loading = false
      }
    },
  },
}
</script>

Мы использовали несколько новых компонентов из BootstrapVue, так что их нужно подключить в frontend/src/site/nuxt.config.js:

Config.bootstrapVue.componentPlugins = [
  // ...
  'FormPlugin',
  'FormGroupPlugin',
  'FormInputPlugin',
  'SpinnerPlugin',
]

А также добавить стили форм Bootstrap в frontend/src/site/assets/scss/index.scss:

@import '~bootstrap/scss/forms';

Остаётся только подключить нашу новую форму в модалку с корзиной frontend/src/site/components/app/navbar.vue:

<template>
  <b-navbar variant="light" toggleable="md">
    <!-- -->
    <b-modal v-model="showCart" title="Корзина" hide-footer>
      <cart />
      <!-- Компонент с формой заказа -->
      <order />
    </b-modal>
  </b-navbar>
</template>

<script>
import Cart from '../../components/cart'
// Импорт нового компонента
import Order from '../../components/order'

export default {
  name: 'AppNavbar',
  // И регистрация компонента
  components: {Order, Cart},
  data() {
    return {
      showCart: false,
    }
  },
  computed: {
    total() {
      return this.$store.getters.cartTotal
    },
    products() {
      return this.$store.getters.cartProducts
    },
  },
}
</script>

Любуемся результатом:

После отправки заказа видим результат.

Всё довольно примитивно, но усложнить никогда не поздно. Благодаря разделению корзины и заказа на 2 компонента, форму заказа можно выводить в отдельном окне, или прятать по умолчанию и показывать только после клика по кнопке "оформить" в корзине.

В общем, тут всё зависит только от вашей фантазии. Главное, чтобы форма заказа улетала на нужный адрес в API.

Страницы в админке

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

Во-первых, нужно сразу отредактировать группу Administrator и добавить ей новое разрешение orders.

А во-вторых, прописать новый раздел в frontend/src/admin/plugins/menu.js:

export default [
  // ...
  {
    name: 'orders',
    title: 'models.order.title_many',
    scope: 'orders',
  },
  // ...
]

Ну и в-третьих, прописать все колонки заказа в словарях frontend/src/admin/lexicons, пример для великого и могучего:

export default {
// ...
  models: {
    // ...
    order: {
      title_one: 'Заказ',
      title_many: 'Заказы',
      name: 'Имя покупателя',
      email: 'Email',
      post: 'Индекс',
      city: 'Город',
      address: 'Адрес',
      paid: 'Оплачено',
      total: 'Стоимость',
    },
  }
// ...
}

И теперь мы можем создать общую таблицу заказов frontend/src/admin/pages/orders.vue:

<template>
  <div>
    <!-- Всё стандартно, нет только кнопки действия в шапке -->
    <vesp-table
      :url="url"
      :table-actions="tableActions"
      :fields="fields"
      :filters="filters"
      :sort="sort"
      :dir="dir"
      :row-class="rowClass"
    />
    <nuxt-child />
  </div>
</template>

<script>
// Объявляем адрес для запросов
export const url = 'admin/orders'
export default {
  name: 'OrdersPage',
  // Требуем разрешение
  validate({app}) {
    return app.$hasScope('orders')
  },
  data() {
    return {
      url,
      filters: {
        query: '',
      },
      sort: 'id',
      dir: 'asc',
    }
  },
  // Прописываем title страницы
  head() {
    return {
      title: [this.$t('models.order.title_many'), this.$t('project')].join(' / '),
    }
  },
  computed: {
    // 2 действия: редактирование и удаление заказа
    tableActions() {
      return [
        {route: 'orders-edit-id', icon: 'edit', title: this.$t('actions.edit')},
        {function: 'onDelete', icon: 'times', title: this.$t('actions.delete'), variant: 'danger'},
      ]
    },
    fields() {
      return [
        {key: 'id', label: this.$t('components.table.columns.id')},
        {key: 'name', label: this.$t('models.order.name')},
        {key: 'total', label: this.$t('models.order.total')},
        {
          key: 'created_at',
          label: this.$t('components.table.columns.created_at'),
          formatter: this.$options.filters.datetime,
          sortable: true,
        },
      ]
    },
  },
  methods: {
    // Оплаченные заказы будем подсвечивать зелёным цветом
    rowClass(item) {
      return item && item.paid ? 'text-success' : ''
    },
  },
}
</script>

Страницы создания заказа у нас нет, так что прописываем только страницу редактирования frontend/src/admin/pages/orders/edit/_id.vue:

<template>
  <!-- Название модалки включает в себя и номер заказа -->
  <vesp-modal v-model="record" :url="url" :title="$t('models.order.title_one') + ' #' + record.id">
    <!-- В остальном всё как обычно -->
    <template #form-fields>
      <form-order v-model="record" />
    </template>
  </vesp-modal>
</template>

<script>
// Импорт адреса запросов
import {url} from '../../orders'
// И формы, которую мы сейчас создадим
import FormOrder from '../../../components/forms/order'

export {url}
export default {
  name: 'ProductCreatePage',
  components: {FormOrder},
  // Требуем передачи номера заказа
  validate({params}) {
    return /^\d+$/.test(params.id)
  },
  // Обычная загрузка данных из API
  async asyncData({app, params, error}) {
    try {
      const {data: record} = await app.$axios.get(url + '/' + params.id)
      return {record}
    } catch (e) {
      error({statusCode: e.statusCode, message: e.data})
    }
  },
  data() {
    return {
      url,
      record: {},
    }
  },
}
</script>

Теперь создаём форму редактирования заказа с выводом и списка товаров frontend/src/admin/components/forms/order.vue:

<template>
  <div>
    <b-form-group :label="$t('models.order.name')">
      <b-form-input v-model.trim="record.name" required autofocus />
    </b-form-group>

    <!-- Тут email, индекс, город, адрес... -->

    <!-- Итоговую цену менять нельзя -->
    <b-row align-v="center">
      <b-col cols="8">
        <b-form-group :label="$t('models.order.total')">
          <b-form-input v-model.trim="record.total" readonly />
        </b-form-group>
      </b-col>
      <!-- А вот отметку об оплате - можно -->
      <b-col cols="4" class="text-right">
        <b-form-checkbox v-model.trim="record.paid">
          {{ $t('models.order.paid') }}
        </b-form-checkbox>
      </b-col>
    </b-row>

    <!-- Вывод товаров заказа, они тоже не меняются -->
    <div class="mt-2 pt-2 border-top">
      <b-row v-for="product in record.order_products" :key="product.id" class="mt-3" align-v="center">
        <b-col cols="6">
          <!-- Если связь с товаром не нарушена - то выводим ссылку на него -->
          <b-link v-if="product.product_id" :to="{name: 'products-edit-id', params: {id: product.product_id}}">
            {{ product.title }}
          </b-link>
          <!-- Иначе просто название -->
          <div v-else>{{ product.title }}</div>
          <div class="small text-muted">{{ product.price }} руб. x {{ product.amount }}</div>
        </b-col>
        <b-col cols="6" class="text-right">{{ product.total }} руб.</b-col>
      </b-row>
    </div>
  </div>
</template>

<script>
export default {
  name: 'FormProduct',
  props: {
    value: {
      type: Object,
      required: true,
    },
  },
  computed: {
    record: {
      get() {
        return this.value
      },
      set(newValue) {
        this.$emit('input', newValue)
      },
    },
  },
}
</script>

Заключение

Создание заказа расписано в самом минимальном виде, чтобы вы понимали принцип работы. Вот итоговый коммит в репозиторий.

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

Фокус именно в том, что в этом минимальном виде у вас нет ничего лишнего, и вы напишете только то, что вам нужно для текущего проекта. Не получится так, что сайт торгует цифровыми товарами, а у вас в магазине из коробки есть статусы "отправлено" и "доставлено".

На bezumkin.ru, например, ровно один способ оплаты через Qiwi, поэтому в заказах вообще нет колонки для метода оплаты. Зато есть связи с разделом и заметкой:

На следующем занятии мы запустим наш проект на каком-нибудь публичном хостинге - и на этом всё, останется только заключительная заметка с подведением итогов.

← Предыдущая заметка
Корзина товаров
Следующая заметка →
Запуск в продакшн
Комментарии (8)
bezumkinВасилий Наумкин
08.07.2022 07:43

И правда, как-то я этот момент упустил...

Добавил подробности, спасибо за замечание!

bezumkinВасилий Наумкин
13.08.2022 03:03

Мощно!!

Скажи, да? Прямо чувствуешь, как руки развязываются создавать любой функционал!

не хватает упоминания

Поправил, спасибо!

bezumkinВасилий Наумкин
13.08.2022 11:41

неужели, все глобальное, так просто можно взять и сделать

Да, именно так.

Мелкие затыки, конечно же, будут - но в целом вот так всё просто и логично.

bezumkinВасилий Наумкин
28.10.2022 02:23

В тексте есть подсказка

    // Контроллер требует новое разрешение
    protected $scope = 'orders';

Так как я уже рассказывал про систему разрешений, то не стал разжёвывать. Ну и при обращении к контроллеру без нужного разрешения будет говорящая ошибка, мол нет такого-то scope.

futuris
Futuris
26.03.2024 07:39
Страница отдельного поста заработала сразу в том виде, как ты написал.) А вот в ленте постов контент...
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 для бэкенда. Их можно обновлять, но э...
bezumkin
Василий Наумкин
22.11.2023 08:09
Отлично, поздравляю!