Spec-Zone.ru › Phoenix

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. Для оформления заказа нам потребуется выполнить несколько операций:

  1. Сохранить новую запись заказа с общей стоимостью заказа
  2. Преобразовать все товары в корзине в новые записи позиций заказа, указав количество и цену товара на конкретный момент времени
  3. После успешной вставки заказа (и последующей оплаты) удалить товары из корзины

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

Отличная работа!

← Предыдущая страница 4. Границы между контекстами
Следующая страница → 6. Часто задаваемые вопросы

Пакет Hex Предпросмотр Hex (текущий файл) Скачать версию ePub

Создано с помощью ExDoc (v0.40.3) для языка программирования Elixir

© 2014 Chris McCord
Licensed under the MIT License.
https://phoenix.hexdocs.pm/more_examples.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API