Импорт и управление заказами
Сегодня продолжаем прогружать старые данные в новую базу - дошла очередь и до заказов.
Помимо собственно заказов, miniShop2 хранит ссылки на купленные товары и адреса доставки покупателей. Изначально в нём планировалось сделать выбор адреса при новом заказе, чтобы старый покупатель не вводил его каждый раз, но в стандартный функционал это так и не вошло.
В VespShop мы это исправим.
Таблицы заказов
Создаём миграцию заказов 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',
static function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->string('num')->index();
$table->foreignId('user_id')
->constrained('users')->cascadeOnDelete();
$table->decimal('total')->default(0);
$table->decimal('cart')->default(0);
$table->decimal('discount')->default(0);
$table->decimal('weight')->default(0);
$table->text('comment')->nullable();
$table->timestamps();
}
);
$this->schema->create(
'order_products',
static function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')
->constrained('orders')->cascadeOnDelete();
$table->foreignId('product_id')->nullable()
->constrained('products')->nullOnDelete();
$table->string('title')->nullable();
$table->unsignedInteger('amount')->default(0);
$table->decimal('price')->default(0);
$table->decimal('weight')->default(0);
$table->decimal('discount')->default(0);
$table->json('options')->nullable();
}
);
}
public function down(): void
{
$this->schema->drop('order_products');
$this->schema->drop('orders');
}
}
Колонка uuid пригодится нам на будущее, для запросов пользователей с публичной части сайта. А колонку discount заполним из импорта.
Для генерации uuid нужно установить новую зависимость через composer require ramsey/uuid.
Создаём таблицу адресов пользователей:
<?php
declare(strict_types=1);
use Illuminate\Database\Schema\Blueprint;
use Vesp\Services\Migration;
final class UserAddresses extends Migration
{
public function up(): void
{
$this->schema->create(
'user_addresses',
static function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')
->constrained('users')->cascadeOnDelete();
$table->string('receiver')->nullable();
$table->string('phone')->nullable();
$table->tinyInteger('gender')->nullable();
$table->string('company')->nullable();
$table->string('address')->nullable();
$table->string('country')->nullable();
$table->string('city')->nullable();
$table->string('zip')->nullable();
$table->timestamps();
}
);
$this->schema->table(
'orders',
static function (Blueprint $table) {
$table->foreignId('address_id')->after('user_id')->nullable()
->constrained('user_addresses')->nullOnDelete();
}
);
}
public function down(): void
{
$this->schema->table('orders', static function (Blueprint $table) {
$table->dropConstrainedForeignId('address_id');
});
$this->schema->drop('user_addresses');
}
}
Новая миграция добавляет "задним числом" колонку address_id в таблицу заказов. Обновлённую схему можно посмотреть по ссылке https://drawsql.app/teams/bezumkins-team/diagrams/vesp-shop
Колонки remote_id нам здесь не нужны - будем использовать id старых записей.
В модель заказа добавляем новый метод инициализации, в котором прописываем значения по умолчанию:
protected static function booted(): void
{
static::saving(static function (self $model) {
if (!$model->uuid) {
$model->uuid = Uuid::uuid4();
}
if (!$model->created_at) {
$model->created_at = Carbon::now();
}
if (!$model->num) {
$time = $model->created_at->timestamp;
$count = $model->newQuery()->where('created_at', 'LIKE', date('Y-m-', $time) . '%')->count();
$model->num = implode('/', [date('ym', $time), $count + 1]);
}
});
}
Пустые uuid, дата создания и номер заказа будут присвоены автоматически.
Так же нам потребуется меод пересчёта итоговых цифр заказа:
public function calculate(): void
{
$cart = 0;
$weight = 0;
/** @var OrderProduct $orderProduct */
foreach ($this->orderProducts()->cursor() as $orderProduct) {
$cart += $orderProduct->amount * ($orderProduct->price - $orderProduct->discount);
$weight += $orderProduct->amount * $orderProduct->weight;
}
$total = $cart - $this->discount;
$this->weight = $weight;
$this->cart = max($cart, 0);
$this->total = max($total, 0);
$this->save();
}
Как следует из метода, скидка в заказе просто указывается вручную.
Импорт заказов
Здесь у нас будет здоровенный сид, который за один прогон импортирует заказы, купленные товары и адреса юзеров.
В оригинальном проекте была самодельная система скидок, которая работала в зависимости от количества или веса заказанного. У нас системы скидок пока нет, но старые данные постараемся загрузить.
<?php
use App\Models\Order;
use App\Models\OrderProduct;
use App\Models\Product;
use App\Models\ProductTranslation;
use App\Models\User;
use App\Models\UserAddress;
use Phinx\Seed\AbstractSeed;
use Ramsey\Uuid\Uuid;
use Vesp\Services\Eloquent;
// Подключаем файл миграции товаров для вызова метода translateVariants()
require __DIR__ . '/Products.php';
class Orders extends AbstractSeed
{
public function getDependencies(): array
{
return ['Users', 'Categories', 'Products'];
}
public function run(): void
{
$db = (new Eloquent())->getDatabaseManager();
$pdo = $db->getPdo();
$users = [];
// Генерируем массив для замены id юзеров
foreach (User::query()->select('id', 'remote_id')->cursor() as $user) {
$users[$user->remote_id] = $user->id;
}
// 2 разных запроса в БД: получения заказов
$stmtOrder = $pdo->query('SELECT * FROM `modx_ms2_orders` ORDER BY `id`');
// И получение одного адреса по id
$stmtAddress = $pdo->prepare('SELECT * FROM `modx_ms2_order_addresses` WHERE `id` = :id');
while ($row = $stmtOrder->fetch(PDO::FETCH_ASSOC)) {
// В MODX вполне может быть, что заказавший юзер уже удалён - проверяем
// И пропускаем, если что
if (empty($users[$row['user_id']])) {
continue;
}
// Создаём или обновляем заказ
if (!$order = Order::query()->find($row['id'])) {
$order = new Order();
$order->id = $row['id'];
$order->uuid = Uuid::uuid4();
}
// Заполняем нужные колонки
$order->num = trim($row['num']);
$order->user_id = $users[$row['user_id']];
$order->total = (float)$row['cost'];
$order->cart = (float)$row['cart_cost'];
$order->weight = (float)$row['weight'];
$order->comment = !empty($row['comment']) ? trim($row['comment']) : null;
$order->created_at = $row['createdon'];
$order->updated_at = $row['updatedon'];
// А вот и обработка скидки заказа
if (!empty($row['properties']) && $properties = json_decode($row['properties'], true)) {
$order->discount = isset($properties['extfld_discount']) ? (float)$properties['extfld_discount'] : 0;
}
// Пробуем получить адрес заказа
if ($row['address'] && !$order->address_id && $stmtAddress->execute([':id' => $row['address']])) {
if (!$old = $stmtAddress->fetch(PDO::FETCH_ASSOC)) {
throw new RuntimeException('Could not load address with id = ' . $row['address']);
}
if (!$address = UserAddress::query()->find($old['id'])) {
$address = new UserAddress();
$address->id = $old['id'];
}
// Заполняем нужные колонки
$address->user_id = $users[$old['user_id']];
$address->receiver = $old['receiver'] ? trim($old['receiver']) : null;
$address->phone = $old['phone'] ? trim($old['phone']) : null;
$address->address = trim(
implode(', ', array_filter([$old['metro'], $old['street'], $old['building'], $old['room']]))
);
$address->country = $old['country'] ? trim($old['country']) : null;
$address->city = $old['city'] ? trim($old['city']) : null;
$address->zip = $old['index'] ? trim($old['index']) : null;
$address->created_at = $old['createdon'];
$address->updated_at = $old['updatedon'];
$address->save();
// Добавляем id адреса в заказ
$order->address_id = $address->id;
}
// Сохраняем заказ
$order->save();
}
// Получаем id всех нагих заказов
$orders = Order::query()->pluck('id')->toArray();
// Выбираем все заказанные товары из старой БД
$stmt = $pdo->query("SELECT * FROM `modx_ms2_order_products` ORDER BY `id`");
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
// Проверяем, что у нас есть такой заказ
if (!in_array($row['order_id'], $orders, true)) {
continue;
}
// Создаём или обновляем заказанный товар
if (!$orderProduct = OrderProduct::query()->find($row['id'])) {
$orderProduct = new OrderProduct();
$orderProduct->id = $row['id'];
}
$orderProduct->order_id = $row['order_id'];
$orderProduct->title = !empty($row['name']) ? trim($row['name']) : null;
$orderProduct->amount = (int)$row['count'];
$orderProduct->price = (float)$row['price'];
$orderProduct->weight = (float)$row['weight'];
// Пытаемся сохранить название заказанного товара, на всякий случай
if ($product = Product::query()->where('remote_id', $row['product_id'])->first()) {
$orderProduct->product_id = $product->id;
/** @var ProductTranslation $translation */
if (!$orderProduct->title && $translation = $product->translations()->where('lang', 'ru')->first()) {
$orderProduct->title = $translation->title;
}
}
// Обрабатываем дополнительные свойства
if (!empty($row['options']) && $options = json_decode($row['options'], true)) {
if (isset($options['color'])) {
$options['colors'] = $options['color'];
unset($options['color']);
}
// Вот для этого мы и подключали файл в начале скрипта
if (isset($options['variants_fr'])) {
$options['variants'] = Products::translateVariants($options['variants_fr'], 'fr');
unset($options['variants_fr']);
}
// Пытаемся обработать скидку, она там в разных форматах
if (isset($options['discount'])) {
// Этот формат мы понимаем, скидка в процентах
if (preg_match('#(\d+)%#', $options['discount'], $matches)) {
$tmp = $matches[1] / 100;
$orderProduct->discount = round($orderProduct->price * $tmp, 2);
}
// Остальные форматы идут лесом
unset($options['discount']);
}
$orderProduct->options = $options ?: null;
}
$orderProduct->save();
}
}
}
Вроде бы ничего особо нового, кроме использования статического метода из другой миграции. Запускаем сидер composer db:seed-one Orders, ждём, и проверяем таблицы:
Обновляем контроллер заказов
Редактируем Controllers/Admin/Orders.php:
<?php
namespace App\Controllers\Admin;
use App\Models\Order;
use App\Models\OrderProduct;
use App\Models\UserAddress;
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', 'orderProducts.product');
$c->with('address');
return $c;
}
// Поиск по юзеру и\или номеру
protected function beforeCount(Builder $c): Builder
{
if ($query = trim($this->getProperty('query', ''))) {
$c->where(static function (Builder $c) use ($query) {
$c->whereHas('user', static function (Builder $c) use ($query) {
$c->where('fullname', 'LIKE', "%$query%");
$c->orWhere('username', 'LIKE', "%$query%");
});
$c->orWhere('num', 'LIKE', "$query%");
});
}
return $c;
}
// Для списка заказов подсчитываем количество заказанного и получаем юзера
protected function afterCount(Builder $c): Builder
{
$c->withCount('orderProducts');
$c->with('user:id,fullname,username');
return $c;
}
protected function beforeSave(Model $record): ?ResponseInterface
{
// Админка может прислать 0 или пустую строку - форсируем null
if (!$this->getProperty('address_id')) {
$record->address_id = null;
}
return null;
}
protected function afterSave(Model $record): Model
{
// А вот и обработка массива с присланными товарами
$items = $this->getProperty('order_products');
if (is_array($items)) {
$ids = [];
foreach ($items as $item) {
// Добавляем новые
if (!$orderProduct = $record->orderProducts()->find($item['id'])) {
$orderProduct = new OrderProduct();
$orderProduct->order_id = $record->id;
}
// Пустые JSON массивы на ни к чему
if (empty($item['options'])) {
$item['options'] = null;
}
$orderProduct->fill($item);
$orderProduct->save();
// Сохраняем id товара в массив
$ids[] = $orderProduct->id;
}
// Чтобы удалить из БД остальные товары заказа, которых больше нет
$record->orderProducts()->whereNotIn('id', $ids)->delete();
}
// Если у заказ нет адреса - добавляем пустой
if (!$record->address_id && $item = $this->getProperty('address')) {
if (is_array($item) && !empty($item)) {
/** @var UserAddress $address */
$address = $record->user->addresses()->create($item);
$record->address_id = $address->id;
}
}
// Пересчитываем итоговые цифры заказа после манипуляций с товарами
$record->calculate();
return $record;
}
}
Как видно из контроллера, мы будем управлять товарами заказа из админки. Я сразу добавил еще 2 простейших контроллера для получаения адресов и заказов конкретного юзера, посмотрите в репозитории.
Обновляем админку
В таблице заказов добавляем кнопку создания заказа и набор выводимых колонок:
data() {
return {
// ...
sort: 'num',
dir: 'desc',
}
},
headerActions() {
return [{route: 'orders-create', icon: 'plus', title: this.$t('actions.create')}]
},
fields() {
return [
{key: 'num', label: this.$t('models.order.num'), sortable: true},
{key: 'user.fullname', label: this.$t('models.order.user')},
{key: 'total', label: this.$t('models.order.total'), sortable: true, formatter: this.$price},
{key: 'discount', label: this.$t('models.order.discount'), formatter: this.$price, sortable: true},
{key: 'order_products_count', label: this.$t('models.order.products'), sortable: true},
{key: 'created_at', label: this.$t('models.order.created_at'), formatter: this.$options.filters.datetime, sortable: true},
]
},
Для форматирования цены и веса я добавил пару функций в наши утилиты:
inject('price', (val) => {
if (val) {
return [Number(val).toFixed(2), app.i18n.t('shop.currency')].join(' ')
}
return ''
})
inject('weight', (val) => {
if (val) {
return [Number(val).toFixed(2), app.i18n.t('shop.weight')].join(' ')
}
return ''
})
Конечно, нужно добавить и соответствующие записи в лексиконы.
Самое интересно будет в карточке заказа. Создаём admin/pages/products/create.vue:
<template>
<vesp-modal v-model="record" :url="url" :title="$t('models.order.title_one')" size="lg">
<!-- Расширяем слот формы, все вкладки будут внутри тега form -->
<template #form-fields>
<b-tabs content-class="pt-3">
<b-tab :title="$t('models.order.tab_main')">
<!-- Форма заказа -->
<form-order v-model="record" />
</b-tab>
<b-tab :title="$t('models.order.tab_products')">
<!-- Компонент товаров -->
<order-products v-model="record" />
</b-tab>
<!-- Вкладка может работать только если выбран юзер -->
<b-tab :title="$t('models.order.tab_address')" :disabled="!record.user_id">
<order-address v-model="record" />
</b-tab>
</b-tabs>
</template>
</vesp-modal>
</template>
<script>
import {url} from '../orders'
import FormOrder from '@/components/forms/order'
import OrderProducts from '@/components/order/products.vue'
import OrderAddress from '@/components/order/address.vue'
export {url}
export default {
name: 'OrderCreatePage',
components: {OrderAddress, OrderProducts, FormOrder},
data() {
return {
url,
record: {
num: '',
// ...
},
}
},
}
</script>
Страницу редактирования заказа нужно переделать так, чтобы она наследовала страницу создания.
В отличие от карточки товара, здесь у нас все 3 вкладки находятся внутри формы, поэтому номер активной вкладки мы не отслеживаем и подвал окошка не прячем.
Далее обновляем форму редактирования товара.
- Номер и дату заказа мы можем указать произвольные.
- Покупатель выбирается из списка юзеров сайта
- Как только выбрали покупателя, включается комбо-бокс для выбора его адресов (он использует новый контроллер адресов юзера)
- Корзина управляется на 2й вкладке, там любые операции с товарами
- Адрес юзера выводится на 3й. Если адресов еще нет, там можно создать новый
Выбор адреса происходит вот таким образом:
<b-form-group :label="$t('models.order.address')">
<vesp-input-combo-box
<!-- при смене юзера сменится ключ и компонент будет отрендерен заново -->
:key="'address-' + record.user_id"
v-model="record.address_id"
<!-- запрос в API зависит от выбранного юзера -->
:url="record.user_id ? 'admin/users/' + record.user_id + '/addresses' : null"
<!-- если юзер не выбран - компонент отключен -->
:disabled="!record.user_id"
<!-- колонка для отображения -->
text-field="full_address"
sort="id"
<!-- метод добавления выбранного в форму -->
@select="onSelectAddress"
>
<!-- шаблон оформления -->
<template #default="{item}">
{{ item.full_address }}
<div class="small text-muted">{{ item.receiver }}</div>
</template>
</vesp-input-combo-box>
</b-form-group>
Обратите внимание на изменение :key при смене юзера. Это легальный способ принудительно обновить любой Vue компонент. То есть, убрать его из DOM и добавить заново, выполняя все его хуки. Именно это и происходит при изменении пользователя, чтобы сделать запрос на правильный контроллер и получить адреса нового юзера.
Итоговая стоимость заказа и вес у формы выводятся для красоты, потому что всё будет пересчитано при сохранении заказа на бэкенде.
Самое важное, что нужно помнить, это что данные о товарах и адресе у нас находятся внутри формы самого заказа. Поэтому при выборе нового адреса, он вставляется в this.record.address, чтобы отобразиться на 3й вкладке.
Это одна и та же форма, разбитая на 3 вкладки. Поэтому и манипуляции в корзине на 2й вкладке меняют сумму корзины на 1й.
Дальше идёт управление товарами, они берутся из переменной record.order_products и перебираются, формируя таблицу. В сокращённом виде это выглядит вот так:
<table v-if="totalAmount > 0" class="table b-table">
<thead>
<tr>
<th>{{ $t('models.order_product.product') }}</th>
<!--<th>{{ $t('models.order_product.weight') }}</th>-->
<th style="width: 125px">{{ $t('models.order_product.amount') }}</th>
<th style="width: 100px">{{ $t('models.order_product.price') }}</th>
<th style="width: 125px">{{ $t('models.order_product.discount') }}</th>
<th style="width: 125px">{{ $t('models.order_product.total') }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in products" :key="idx">
<td class="pl-0">
{{ item.title }}
<product-options
v-model="item.options"
:options="{colors: item.product.colors, variants: item.product.variants}"
class="small"
/>
</td>
<!--<td>{{ $weight(item.weight) }}</td>-->
<td>
<b-form-spinbutton v-model="item.amount" min="1" />
</td>
<td>{{ $price(item.price) }}</td>
<td>
<b-form-input v-model.number="item.discount" min="0" type="number" :step="0.01" :max="item.price" />
</td>
<td>{{ $price(getProductCost(item) || '0') }}</td>
<td class="p-0 actions">
<b-button variant="danger" size="sm" @click="onDelete(idx)">
<fa icon="times" class="fa-fw" />
</b-button>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<th></th>
<!--<th>{{ $weight(totalWeight) }}</th>-->
<th class="text-center">{{ totalAmount }}</th>
<th>{{ $price(totalCart) }}</th>
<th>- {{ $price(totalDiscount) }}</th>
<th colspan="2">{{ $price(total) }}</th>
</tr>
</tfoot>
</table>
Компонент product-options я написал для выбора опций товара, посмотрите в репозитории, особых вопросов возникнуть не должно.
Я закомментировал вывод веса в таблице, потому что ширина ограничена модалкой, но он всё равно учитывается в итоговом значении заказа.
Еще здесь интересные методы работы с корзиной, потому что мы просто меняем внутренний массив напрямую, без запросов на сервер:
methods: {
onDelete(idx) {
this.myValue.order_products.splice(idx, 1)
},
onAdd(item) {
this.myValue.order_products.push({
id: null,
product_id: item.id,
title: this.$translate(item.translations),
amount: 1,
price: item.price,
weight: item.weight,
cost: item.price,
discount: 0,
options: null,
product: item,
})
this.$nextTick(() => {
this.product_id = null
})
},
},
На вкладке в адресом дублируется комбо-бокс выбора адреса пользователя, он меняет ту же переменную record.address_id, что и в основной вкладке. Если адрес выбран, его можно заменить, выбрав другой, или нажать на кнопку "создать", чтобы сбросить выбор и запролнить форму вручную.
Если у юзера нет ни одного адреса - то доступно лишь заполнение формы.
В самой форме нет ничего интересного, только отключение редактирования полей при выводе уже существующего адреса.
Заключение
Демонстрация работы формы заказа:
То есть, мы можем поменять заказ полностью:
- поменять номер, дату
- выбрать другого юзера
- выбрать другой адрес или создать новый
- изменить набор товаров, количество, указать произвольную скидку
- указать дополнительную скидку на весь заказ
По моему, очень круто получается.
Итоговый коммит в репозитории.
0
👍
👎
❤️
🔥
😮
😢
😀
😡
219
24.08.2023, 11:01:12
3 комментария
NightRider
29.08.2023, 12:52:02
Спасибо за уроки! Очень интересно наблюдать за всеми нюансами разработки интернет-магазина.
29.08.2023, 13:11:04
На здоровье!
Кажется, ты единственный читатель -)
NightRider
29.08.2023, 13:30:25
Просто так совпало что у меня пока что есть свободное время на изучение. Остальные скорее всего поболее загружены, так что вопрос времени когда подтянутся)
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так.
А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен.
Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500
Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи.
...
russel gal
09.03.2024, 20:17:18
> А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал...
Александр Наумов
27.01.2024, 03:06:18
Василий, спасибо!
Извини, тупанул.