Оформление заказов
Сегодняшний урок будет довольно скучным, потому что ничего нового вы из него не узнаете.
Будет всё то же самое, что и прежде: пишем миграцию, модели, контроллеры, странички в админке и само оформление заказов на публичном сайте.
Собственно, когда вы понимаете как работает 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, поэтому в заказах вообще нет колонки для метода оплаты. Зато есть связи с разделом и заметкой:
На следующем занятии мы запустим наш проект на каком-нибудь публичном хостинге - и на этом всё, останется только заключительная заметка с подведением итогов.
0
👍
👎
❤️
🔥
😮
😢
😀
😡
423
06.07.2022, 13:06:52
8 комментария
NightRider
08.07.2022, 00:20:06
Спасибо! Тут правда возможно имеет смысл добавить, что без редактирования frontend/src/site/store/index.js ничего работать не будет. Но и пояснить этот момент наверное для информативности тоже не будет лишним)
Василий Наумкин
08.07.2022, 10:43:09
И правда, как-то я этот момент упустил...
Добавил подробности, спасибо за замечание!
Александр Наумов
13.08.2022, 01:19:08
Мощно!!
Для таких, как я, которые пошагово выполняют инструкции, не хватает упоминания composer db:migrate, а то я раньше парился, не мог понять почему у меня в базе ничего не создалось хотя все делал по инструкции.
Василий Наумкин
13.08.2022, 06:03:29
Скажи, да? Прямо чувствуешь, как руки развязываются создавать любой функционал!
Поправил, спасибо!
Александр Наумов
13.08.2022, 14:39:17
Чувства такие, с одной стороны: "все так логично выстроено, что даже глобальное кажется простым", с другой стороны, есть тревога: "неужели, все глобальное, так просто можно взять и сделать".
Да, руки чешутся, кажется: что что-то большое можно взять и сделать очень просто.
Василий Наумкин
13.08.2022, 14:41:06
Да, именно так.
Мелкие затыки, конечно же, будут - но в целом вот так всё просто и логично.
Вася
27.10.2022, 19:42:16
Без этого не работает. - 30 минут жизни :)
core/db/seeds/UserRoles.php
Василий Наумкин
28.10.2022, 05:23:22
В тексте есть подсказка
Так как я уже рассказывал про систему разрешений, то не стал разжёвывать. Ну и при обращении к контроллеру без нужного разрешения будет говорящая ошибка, мол нет такого-то scope.
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Василий Наумкин
23.12.2024, 05:33:00
В MODX сначала создали проблему, автоматически генерируя адреса, а потом "решили" заморозкой.
Так ч...
Дмитрий
14.12.2024, 09:10:38
Василий, прошу прощения, тупанул, не разобрался сразу. Фреймворк отличный! "Чистый лист" на vue, рис...
Василий Наумкин
05.12.2024, 20:01:14
В итоге основная ошибка была в неправильном общем root в Nginx, из-за чего запросы не улетали на фай...
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так.
А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен.
Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500
Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи.
...
Уровни подписки
Спасибо!
500 ₽ в месяц
Эта подписка ничего не даёт, просто возможность сказать спасибо за мои заметки. Подписчики отмечаются зелёненьким цветом в комментариях.
Большое спасибо!
1 000 ₽ в месяц
И эта подписка не даёт ничего, кроме оранжевого цвета в комментариях и возможности сказать спасибо, но уже большое!