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

Сегодняшний урок будет довольно скучным, потому что ничего нового вы из него не узнаете.
Будет всё то же самое, что и прежде: пишем миграцию, модели, контроллеры, странички в админке и само оформление заказов на публичном сайте.
Собственно, когда вы понимаете как работает 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 комментариев

Спасибо! Тут правда возможно имеет смысл добавить, что без редактирования frontend/src/site/store/index.js ничего работать не будет. Но и пояснить этот момент наверное для информативности тоже не будет лишним)
Василий Наумкин
И правда, как-то я этот момент упустил...
Добавил подробности, спасибо за замечание!
Александр Наумов
Мощно!!
Для таких, как я, которые пошагово выполняют инструкции, не хватает упоминания composer db:migrate, а то я раньше парился, не мог понять почему у меня в базе ничего не создалось хотя все делал по инструкции.
Василий Наумкин
Мощно!!
Скажи, да? Прямо чувствуешь, как руки развязываются создавать любой функционал!
не хватает упоминания
Поправил, спасибо!
Александр Наумов
Скажи, да? Прямо чувствуешь, как руки развязываются создавать любой функционал!
Чувства такие, с одной стороны: "все так логично выстроено, что даже глобальное кажется простым", с другой стороны, есть тревога: "неужели, все глобальное, так просто можно взять и сделать".
Да, руки чешутся, кажется: что что-то большое можно взять и сделать очень просто.
Василий Наумкин
неужели, все глобальное, так просто можно взять и сделать
Да, именно так.
Мелкие затыки, конечно же, будут - но в целом вот так всё просто и логично.
Без этого не работает. - 30 минут жизни :)
core/db/seeds/UserRoles.php
   $roles = [
            'Administrator' => [
                'scope' => ['profile', 'users', 'products', 'orders'],
            ],
            'User' => [
                'scope' => ['profile'],
            ],
        ];
Василий Наумкин
В тексте есть подсказка
    // Контроллер требует новое разрешение
    protected $scope = 'orders';
Так как я уже рассказывал про систему разрешений, то не стал разжёвывать. Ну и при обращении к контроллеру без нужного разрешения будет говорящая ошибка, мол нет такого-то scope.
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
Спасибо!