5. Подводим итоги
Исходный кодБлагодаря контекстам Catalog и ShoppingCart мы на собственном опыте убеждаемся, что тщательно продуманные модули и имена функций позволяют писать понятный и удобный в сопровождении код. Последний шаг — дать пользователю возможность начать оформление заказа. Мы не будем интегрировать обработку платежей или выполнение заказов, но поможем вам начать двигаться в этом направлении. Это отличная возможность применить на практике всё, что мы уже узнали.
Как и раньше, нам нужно решить, где должен находиться код для оформления заказа. Относится ли он к каталогу? Очевидно, нет. А как насчёт корзины? Корзины связаны с заказами — в конце концов, пользователю нужно добавить товары, чтобы купить их, — но стоит ли размещать процесс оформления заказа здесь?
Если остановиться и обдумать процесс оформления заказа, станет ясно, что он связан с содержимым корзины, но работает с совершенно другими данными. Кроме того, бизнес-правила оформления заказа сильно отличаются от правил работы с корзиной. Например, мы можем разрешить пользователю добавить в корзину товар, которого временно нет в наличии, но не можем позволить оформить заказ, если товара нет на складе. Также при оформлении заказа нам нужно зафиксировать информацию о товарах на конкретный момент времени, например цену товаров на момент проведения платежа. Это важно, поскольку цена товара может измениться в будущем, но в строках заказа всегда должны сохраняться и отображаться суммы, которые мы взяли при покупке. По этим причинам становится ясно, что обработка заказов может существовать самостоятельно, со своими данными и бизнес-правилами.
Что касается названия, Orders чётко описывает наш контекст, поэтому давайте начнём, снова воспользовавшись генераторами контекстов. Обратите внимание, что область действия user, созданная с помощью mix phx.gen.auth, помечена как область действия по умолчанию (в вашем config/config.exs), поэтому указывать её в команде не нужно. В приложении могут быть разные области действия; в таком случае при запуске генераторов можно использовать параметр --scope. Выполните в консоли следующую команду:
$ mix phx.gen.context Orders Order orders total_price:decimal
* creating lib/hello/orders/order.ex
* creating priv/repo/migrations/20250209214612_create_orders.exs
* creating lib/hello/orders.ex
* injecting lib/hello/orders.ex
* creating test/hello/orders_test.exs
* injecting test/hello/orders_test.exs
* creating test/support/fixtures/orders_fixtures.ex
* injecting test/support/fixtures/orders_fixtures.ex
Remember to update your repository by running migrations:
$ mix ecto.migrate
Мы создали контекст Orders. Заказ автоматически ограничивается текущим пользователем, а также был добавлен столбец total_price. Теперь, когда у нас есть отправная точка, откройте созданную миграцию в priv/repo/migrations/*_create_orders.exs и внесите следующие изменения:
def change do
create table(:orders) do
- add :total_price, :decimal
+ add :total_price, :decimal, precision: 15, scale: 6, null: false
add :user_id, references(:users, type: :id, on_delete: :delete_all)
timestamps(type: :utc_datetime)
end
end
Как и раньше, мы задали подходящие параметры точности и масштаба для десятичного столбца, что позволит хранить денежные суммы без потери точности. Также мы добавили ограничение not-null, чтобы у каждого заказа обязательно была цена.
Таблица заказов сама по себе не содержит много информации, но мы знаем, что нам нужно хранить цены товаров на момент оформления заказа для всех его позиций. Для этого добавим в данный контекст ещё одну структуру с именем LineItem. В позициях заказа будет сохраняться цена товара на момент проведения платежа. Выполните следующую команду:
$ mix phx.gen.context Orders LineItem order_line_items \
price:decimal quantity:integer \
order_id:references:orders product_id:references:products --no-scope
You are generating into an existing context.
...
Would you like to proceed? [Yn] y
* creating lib/hello/orders/line_item.ex
* creating priv/repo/migrations/20250209215050_create_order_line_items.exs
* injecting lib/hello/orders.ex
* injecting test/hello/orders_test.exs
* injecting test/support/fixtures/orders_fixtures.ex
Remember to update your repository by running migrations:
$ mix ecto.migrate
Мы использовали команду phx.gen.context, чтобы создать схему Ecto LineItem и добавить вспомогательные функции в контекст заказов. Как и раньше, изменим миграцию в priv/repo/migrations/*_create_order_line_items.exs, внеся следующие изменения в поля с десятичным типом:
def change do
create table(:order_line_items) do
- add :price, :decimal
+ add :price, :decimal, precision: 15, scale: 6, null: false
add :quantity, :integer
add :order_id, references(:orders, on_delete: :nothing)
add :product_id, references(:products, on_delete: :nothing)
timestamps(type: :utc_datetime)
end
create index(:order_line_items, [:order_id])
create index(:order_line_items, [:product_id])
end
Теперь, когда миграция готова, настроим связи между заказами и позициями заказа в lib/hello/orders/order.ex:
schema "orders" do
field :total_price, :decimal
- field :user_id, :id
+ belongs_to :user, Hello.Accounts.User
+ has_many :line_items, Hello.Orders.LineItem
+ has_many :products, through: [:line_items, :product]
timestamps(type: :utc_datetime)
end
Мы использовали has_many :line_items для связи заказов с позициями заказа, как уже делали ранее. Затем воспользовались возможностью :through в has_many, которая позволяет указать Ecto, как связать ресурсы через другую связь. В данном случае мы можем связать товары заказа, получив все товары через связанные позиции заказа. Теперь настроим связь в обратном направлении в lib/hello/orders/line_item.ex:
schema "order_line_items" do
field :price, :decimal
field :quantity, :integer
- field :order_id, :id
- field :product_id, :id
+ belongs_to :order, Hello.Orders.Order
+ belongs_to :product, Hello.Catalog.Product
timestamps(type: :utc_datetime)
end
Мы использовали belongs_to, чтобы связать позиции заказа с заказами и товарами. Теперь, когда связи настроены, можно интегрировать веб-интерфейс в процесс оформления заказа. Откройте маршрутизатор lib/hello_web/router.ex и добавьте следующую строку:
scope "/", HelloWeb do
pipe_through [:browser, :require_authenticated_user]
resources "/cart_items", CartItemController, only: [:create, :delete]
get "/cart", CartController, :show
put "/cart", CartController, :update
+ resources "/orders", OrderController, only: [:create, :show]
end
Мы настроили маршруты create и show для созданного нами OrderController, поскольку на данный момент нам нужны только эти действия. Теперь, когда маршруты готовы, можно выполнить миграцию:
$ mix ecto.migrate 17:14:37.715 [info] == Running 20250209214612 Hello.Repo.Migrations.CreateOrders.change/0 forward 17:14:37.720 [info] create table orders 17:14:37.755 [info] == Migrated 20250209214612 in 0.0s 17:14:37.784 [info] == Running 20250209215050 Hello.Repo.Migrations.CreateOrderLineItems.change/0 forward 17:14:37.785 [info] create table order_line_items 17:14:37.795 [info] create index order_line_items_order_id_index 17:14:37.796 [info] create index order_line_items_product_id_index 17:14:37.798 [info] == Migrated 20250209215050 in 0.0s
Прежде чем отображать информацию о заказах, нужно убедиться, что данные заказа полностью заполнены и доступны для поиска текущим пользователем. Откройте контекст заказов в lib/hello/orders.ex и измените get_order!/2, добавив предварительную загрузку:
def get_order!(%Scope{} = scope, id) do
- Repo.get_by!(Order, id: id, user_id: scope.user.id)
+ Order
+ |> Repo.get_by!(id: id, user_id: scope.user.id)
+ |> Repo.preload([line_items: [:product]])
end
Чтобы оформить заказ, страница корзины может отправить POST-запрос к действию OrderController.create, но нам нужно реализовать операции и логику, которые действительно оформят заказ. Как и раньше, начнём с веб-интерфейса. Создайте файл lib/hello_web/controllers/order_controller.ex и добавьте в него следующий код:
defmodule HelloWeb.OrderController do
use HelloWeb, :controller
alias Hello.Orders
def create(conn, _) do
case Orders.complete_order(conn.assigns.current_scope, conn.assigns.cart) do
{:ok, order} ->
conn
|> put_flash(:info, "Order created successfully.")
|> redirect(to: ~p"/orders/#{order}")
{:error, _reason} ->
conn
|> put_flash(:error, "There was an error processing your order")
|> redirect(to: ~p"/cart")
end
end
end
Мы написали действие create, которое вызывает пока ещё не реализованную функцию Orders.complete_order/2. Технически наш код «создаёт» заказ, но важно остановиться и обдумать названия интерфейсов. Действие по оформлению заказа чрезвычайно важно для нашей системы. В ходе транзакции переходят деньги, физические товары могут автоматически отправляться и так далее. Такая операция заслуживает более подходящего и понятного имени функции, например complete_order. Если заказ успешно оформлен, мы перенаправляем пользователя на страницу просмотра; в противном случае отображается сообщение об ошибке, и пользователь возвращается на страницу корзины.
Это также хорошая возможность показать, что контексты могут естественным образом работать с данными, определёнными в других контекстах. Особенно часто это бывает необходимо для данных, используемых во всём приложении, таких как корзина в данном случае (но это также может быть текущий пользователь, текущий проект и так далее — в зависимости от вашего проекта).
Теперь можно реализовать функцию Orders.complete_order/2. Для оформления заказа нам потребуется выполнить несколько операций:
- Сохранить новую запись заказа с общей стоимостью заказа
- Преобразовать все товары в корзине в новые записи позиций заказа, указав количество и цену товара на конкретный момент времени
- После успешной вставки заказа (и последующей оплаты) удалить товары из корзины
Уже из этих требований становится понятно, почему универсальная функция create_order нам не подойдёт. Реализуем новую функцию в lib/hello/orders.ex:
alias Hello.Orders.LineItem
alias Hello.ShoppingCart
def complete_order(%Scope{} = scope, %ShoppingCart.Cart{} = cart) do
true = cart.user_id == scope.user.id
line_items =
Enum.map(cart.items, fn item ->
%{
product_id: item.product_id,
price: item.product.price,
quantity: item.quantity
}
end)
order_changeset =
Ecto.Changeset.change(%Order{}, %{
user_id: scope.user.id,
total_price: ShoppingCart.total_cart_price(cart),
line_items: line_items
})
Repo.transact(fn ->
with {:ok, order} <- Repo.insert(order_changeset),
{:ok, _cart} <- ShoppingCart.prune_cart_items(scope, cart) do
{:ok, order}
end
end)
|> case do
{:ok, order} ->
broadcast_order(scope, {:created, order})
{:ok, order}
{:error, reason} ->
{:error, reason}
end
end
Сначала мы преобразовали позиции %ShoppingCart.CartItem{} в корзине покупок в карту структур позиций заказа. Запись позиции заказа должна сохранять цену товара на момент проведения платежа, поэтому здесь мы используем цену товара. Затем создаём пустой changeset заказа с помощью Ecto.Changeset.change/2, связываем его с UUID пользователя, задаём расчёт общей стоимости и добавляем в changeset позиции заказа. Теперь, когда changeset нового заказа готов к вставке, воспользуемся Repo.transact/2, чтобы выполнить операции в транзакции базы данных. Сначала вставляем заказ, затем удаляем все товары из корзины пользователя. Функция, обёрнутая в Repo.transact/2, должна вернуть либо {:ok, result}, либо ошибку, которая остановит транзакцию и откатит её. Запуск транзакции выполнит эти операции последовательно, а после завершения мы вернём результат вызывающему коду.
Чтобы завершить реализацию оформления заказа, нужно реализовать функцию ShoppingCart.prune_cart_items/1 в lib/hello/shopping_cart.ex:
def prune_cart_items(%Scope{} = scope, %Cart{} = cart) do
{_, _} = Repo.delete_all(from(i in CartItem, where: i.cart_id == ^cart.id))
{:ok, get_cart(scope)}
end
Новая функция принимает структуру корзины и выполняет Repo.delete_all, которому передаётся запрос, возвращающий все товары указанной корзины. Мы возвращаем успешный результат, просто повторно загружая очищенную корзину и передавая её вызывающему коду. Теперь, когда контекст готов, нужно показать пользователю оформленный заказ. Вернитесь к контроллеру заказов и добавьте действие show/2:
def show(conn, %{"id" => id}) do
order = Orders.get_order!(conn.assigns.current_scope, id)
render(conn, :show, order: order)
end
Мы добавили действие show, которое передаёт conn.assigns.current_scope в get_order!; оно разрешает просматривать заказы только их владельцам. Теперь можно реализовать представление и шаблон. Создайте файл представления lib/hello_web/controllers/order_html.ex со следующим содержимым:
defmodule HelloWeb.OrderHTML do use HelloWeb, :html embed_templates "order_html/*" end
Затем создадим шаблон в lib/hello_web/controllers/order_html/show.html.heex:
<Layouts.app flash={@flash}>
<.header>
Thank you for your order!
<:subtitle>
<strong>Email: </strong>{@current_scope.user.email}
</:subtitle>
</.header>
<.table id="items" rows={@order.line_items}>
<:col :let={item} label="Title">{item.product.title}</:col>
<:col :let={item} label="Quantity">{item.quantity}</:col>
<:col :let={item} label="Price">
{HelloWeb.CartHTML.currency_to_str(item.price)}
</:col>
</.table>
<strong>Total price:</strong>
{HelloWeb.CartHTML.currency_to_str(@order.total_price)}
<.button navigate={~p"/products"}>Back to products</.button>
</Layouts.app>
Чтобы показать оформленный заказ, мы отобразили пользователя заказа, а затем список позиций с названием товара, количеством и ценой, которую мы «провели» при оформлении заказа, а также общую стоимость.
Последним шагом добавим на страницу корзины кнопку «Оформить заказ». Добавьте следующую кнопку в <.header> шаблона просмотра корзины в lib/hello_web/controllers/cart_html/show.html.heex:
<.header>
My Cart
+ <:actions>
+ <.button href={~p"/orders"} method="post">
+ Complete order
+ </.button>
+ </:actions>
</.header>
Мы добавили ссылку с method="post", чтобы отправить POST-запрос к действию OrderController.create. Если вернуться на страницу корзины по адресу http://localhost:4000/cart и оформить заказ, отобразится следующий шаблон:
Thank you for your order! User uuid: 08964c7c-908c-4a55-bcd3-9811ad8b0b9d Title Quantity Price Metaprogramming Elixir 2 $15.00 Total price: $30.00
Мы ещё не добавили оплату, но уже видим, как разделение контекстов ShoppingCart и Orders помогает нам создать удобное в сопровождении решение. Отделив товары в корзине от позиций заказа, мы подготовили основу для добавления платежей, отслеживания изменения цен в корзине и многого другого.
Отличная работа!
© 2014 Chris McCord
Licensed under the MIT License.
https://phoenix.hexdocs.pm/more_examples.html