Skip to content

Commit

Permalink
Computing shipment (packages) for an order (#100)
Browse files Browse the repository at this point in the history
`Order.DefaultMachine`
----------------------

The `add_addresses` event performs:
* persists the addresses
* computes default packages from each location
* finds an optimal shipment
* splits any "too heavy" packages into smaller ship-able ones.
* persists the packages to DB (in a transaction).
  - Using `Domain.Shipment.to_package/2` and `Model.Package.create/1`.

> The computation is short-circuited wherever possible.

The shipment is available in the `Context.state.shipment` field, and
could be an empty list - indicating that there is no ship-able
fulfillment for this order.

Change to `EmbeddedShippingMethod`
----------------------------------

It is not possible to use the Money struct in an embed. Need to open an
issue upstream.

Misc changes
------------
1. Add `:number` to `PackageItem` schema.
2. Add changeset for (partial) order update
   - this greatly simplifies the transition
3. Remove defunct `model/stock.ex`
4. Remove the unused `create/2` clause in `Model.Package`

[delivers #157926007]
[delivers #158236509]
[finishes #155976354]
  • Loading branch information
oyeb authored and pkrawat1 committed Oct 4, 2018
1 parent db31a9a commit b092c1a
Show file tree
Hide file tree
Showing 21 changed files with 967 additions and 432 deletions.
6 changes: 1 addition & 5 deletions apps/snitch_core/lib/core/data/model/package.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,11 @@ defmodule Snitch.Data.Model.Package do
## See also
`Ecto.Changeset.cast_assoc/3`
"""
@spec create(map, [map] | nil) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
@spec create(map) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
def create(params) do
QH.create(Package, params, Repo)
end

def create(params, items) do
QH.create(Package, Map.put(params, :items, items), Repo)
end

@doc """
Updates the `package` with supplied `params`.
Expand Down
14 changes: 0 additions & 14 deletions apps/snitch_core/lib/core/data/model/stock/stock.ex

This file was deleted.

2 changes: 2 additions & 0 deletions apps/snitch_core/lib/core/data/model/stock/stock_location.ex
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ defmodule Snitch.Data.Model.StockLocation do
+ `Variant` struct and its `ShippingCategory`
"""
@spec get_all_with_items_for_variants([non_neg_integer]) :: [StockLocationSchema.t()]
def get_all_with_items_for_variants([]), do: []

def get_all_with_items_for_variants(variant_ids) when is_list(variant_ids) do
# It is unclear if there will be any gains by splitting this into a compound
# query:
Expand Down
22 changes: 11 additions & 11 deletions apps/snitch_core/lib/core/data/schema/order.ex
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ defmodule Snitch.Data.Schema.Order do

# associations
belongs_to(:user, User)
belongs_to(:billing_address, Address)
belongs_to(:shipping_address, Address)
belongs_to(:billing_address, Address, on_replace: :update)
belongs_to(:shipping_address, Address, on_replace: :update)
has_many(:line_items, LineItem, on_delete: :delete_all, on_replace: :delete)

timestamps()
Expand All @@ -40,7 +40,7 @@ defmodule Snitch.Data.Schema.Order do
@required_fields ~w(state user_id)a
@optional_fields ~w(billing_address_id shipping_address_id)a
@create_fields @required_fields
@update_fields ~w(state)a ++ @optional_fields
@update_fields [:state | @optional_fields]

@doc """
Returns a Order changeset with totals for a "new" order.
Expand All @@ -55,7 +55,7 @@ defmodule Snitch.Data.Schema.Order do
order
|> cast(params, @create_fields)
|> validate_required(@required_fields)
|> common_changeset()
|> unique_constraint(:number)
|> foreign_key_constraint(:user_id)
|> cast_assoc(:line_items, with: &LineItem.create_changeset/2, required: true)
|> ensure_unique_line_items()
Expand All @@ -72,18 +72,18 @@ defmodule Snitch.Data.Schema.Order do
def update_changeset(%__MODULE__{} = order, params) do
order
|> cast(params, @update_fields)
|> common_changeset()
|> cast_assoc(:line_items, with: &LineItem.create_changeset/2)
|> ensure_unique_line_items()
|> compute_totals()
end

@spec common_changeset(Ecto.Changeset.t()) :: Ecto.Changeset.t()
defp common_changeset(order_changeset) do
order_changeset
|> unique_constraint(:number)
|> foreign_key_constraint(:billing_address_id)
|> foreign_key_constraint(:shipping_address_id)
@partial_update_fields ~w(state)a
@spec partial_update_changeset(t, map) :: Ecto.Changeset.t()
def partial_update_changeset(%__MODULE__{} = order, params) do
order
|> cast(params, @partial_update_fields)
|> cast_assoc(:billing_address)
|> cast_assoc(:shipping_address)
end

@spec compute_totals(Ecto.Changeset.t()) :: Ecto.Changeset.t()
Expand Down
13 changes: 7 additions & 6 deletions apps/snitch_core/lib/core/data/schema/package_item.ex
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,12 @@ defmodule Snitch.Data.Schema.PackageItem do
"""
@type t :: %__MODULE__{}

# TODO: :backordered can be made a virtual field
schema "snitch_package_items" do
field(:number, Nanoid, autogenerate: true)
field(:state, :string)
field(:quantity, :integer)
field(:delta, :integer)
field(:quantity, :integer, default: 0)
field(:delta, :integer, default: 0)
field(:backordered?, :boolean)

belongs_to(:variant, Variant)
Expand All @@ -80,7 +81,7 @@ defmodule Snitch.Data.Schema.PackageItem do
end

@create_fields ~w(number state delta quantity line_item_id variant_id package_id)a
@required_fields ~w(number state quantity line_item_id variant_id package_id)a
@required_fields ~w(number state quantity line_item_id variant_id)a
@update_fields ~w(state quantity delta)a

@doc """
Expand Down Expand Up @@ -116,11 +117,11 @@ defmodule Snitch.Data.Schema.PackageItem do
end

defp validate_backorder_and_delta(%Ecto.Changeset{valid?: true} = changeset) do
case fetch_change(changeset, :delta) do
{:ok, delta} when delta == 0 ->
case fetch_field(changeset, :delta) do
{_, delta} when delta == 0 ->
put_change(changeset, :backordered?, false)

{:ok, delta} when delta > 0 ->
{_, delta} when delta > 0 ->
put_change(changeset, :backordered?, true)

_ ->
Expand Down
14 changes: 13 additions & 1 deletion apps/snitch_core/lib/core/data/schema/shipping_method.ex
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,18 @@ defmodule Snitch.Data.Schema.Embedded.ShippingMethod do
def changeset(%__MODULE__{} = embedded_sm, params) do
embedded_sm
|> cast(params, @create_fields)
|> validate_amount(:cost)
|> force_money()

# |> validate_amount(:cost)
end

defp force_money(changeset) do
case fetch_change(changeset, :cost) do
{:ok, %{amount: amount, currency: currency}} ->
put_change(changeset, :cost, %{amount: amount, currency: currency})

_ ->
changeset
end
end
end
105 changes: 105 additions & 0 deletions apps/snitch_core/lib/core/domain/order/default_machine.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
defmodule Snitch.Domain.Order.DefaultMachine do
@moduledoc """
The (default) Order state machine.
The state machine is describe using DSL provided by `BeepBop`.
Features:
* handle both cash-on-delivery and credit/debit card payments
## Customizing the state machine
There is no DSL or API to change the `DefaultMachine`, the developer must make
their own module, optionally making use of DSL from `BeepBop`.
This allows the developer to change everything, from the names of the state to
the names of the event-callbacks.
## Writing a new State Machine
The state machine module must define the following functions:
_document this pls!_
### Tips
`BeepBop` is specifically designed to used in defining state-machines for
Snitch. You will find that the design and usage is inspired from
`Ecto.Changeset` and `ExUnit` setups
The functions that it injects conform to some simple rules:
1. signature:
```
@spec the_event(BeepBop.Context.t) :: BeepBop.Context.t
```
2. The events consume and return contexts. BeepBop can manage simple DB
operations for you like,
- accumulating DB updates in an `Ecto.Multi`, and run it only if the
whole event transition goes smoothly without any errors.
Essentially run the event callback in a DB transaction.
- auto updating the `order`'s `:state` as the last step of the callback.
Make use of the helpers provided in `Snitch.Domain.Order.Transitions`! They
are well documented and can be composed really well.
### Additional information
The "states" of an `Order` are known only at compile-time. Hence other
modules/functions that perform some logic based on the state need to be
generated or configured at compile-time as well.
"""
# TODO: How to attach the additional info like ability, etc with the states?
# TODO: make the order state machine a behaviour to simplify things.

use Snitch.Domain
use BeepBop, ecto_repo: Repo

alias Snitch.Domain.Order.Transitions
alias Snitch.Data.Model.Order, as: OrderModel
alias Snitch.Data.Schema.Order, as: OrderSchema

state_machine OrderSchema,
:state,
~w(cart address payment processing rts shipping complete cancelled)a do
event(:add_addresses, %{from: [:cart], to: :address}, fn context ->
context
|> Transitions.associate_address()
|> Transitions.compute_shipments()
|> Transitions.persist_shipment()
end)

event(:add_payment, %{from: [:address], to: :payment}, fn context ->
context
end)

event(:confirm, %{from: [:payment], to: :processing}, fn context ->
context
end)

event(:captured, %{from: [:processing], to: :rts}, fn context ->
context
end)

event(
:payment_pending,
%{from: %{not: ~w(cart address payment cancelled)a}, to: :payment},
fn context ->
context
end
)

event(:ship, %{from: ~w[rts processing]a, to: :shipping}, fn context ->
context
end)

event(:recieved, %{from: [:shipping], to: :complete}, fn context ->
context
end)

event(:cancel, %{from: %{not: ~w(shipping complete cart)a}, to: :cancelled}, fn context ->
context
end)
end

def persist(%OrderSchema{} = order, to_state) do
old_line_items = Enum.map(order.line_items, &Map.from_struct/1)
OrderModel.update(%{state: to_state, line_items: old_line_items}, order)
end
end
127 changes: 127 additions & 0 deletions apps/snitch_core/lib/core/domain/order/transitions.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
defmodule Snitch.Domain.Order.Transitions do
@moduledoc """
Helpers for the `Order` state machine.
The `Snitch.Domain.Order.DefaultMachine` makes direct use of these helpers.
By documenting these handy functions, we encourage the developer of a custom
state machine to use, extend or compose them to build large event transitions.
"""

use Snitch.Domain

alias BeepBop.Context
alias Snitch.Data.Model.Package
alias Snitch.Data.Schema.Order
alias Snitch.Domain.{Shipment, ShipmentEngine, Splitters.Weight}

@doc """
Persists the address and associates them with the `order`.
The following fields are required under the `:state` key:
* `:billing_address` The billing `Address` params
* `:shipping_address` The shipping `Address` params
## Note
This transition is "impure" as it does not use the multi, the addresses are
associated "out-of-band".
"""
@spec associate_address(Context.t()) :: Context.t()
def associate_address(
%Context{
valid?: true,
struct: %Order{} = order,
state: %{
billing_address: billing,
shipping_address: shipping
}
} = context
) do
changeset =
order
|> Repo.preload([:billing_address, :shipping_address])
|> Order.partial_update_changeset(%{
billing_address: billing,
shipping_address: shipping
})

case Repo.update(changeset) do
{:ok, order} ->
Context.new(order, state: context.state)

{:error, errors} ->
struct(context, valid?: false, multi: errors)
end
end

def associate_address(%Context{} = context), do: struct(context, valid?: false)

@doc """
Computes a shipment fulfilling the `order`.
Returns a new `Context.t` struct with the `shipment` under the the [`:state`,
`:shipment`] key-path.
> The `:state` key of the `context` is not utilised here.
## Note
If `shipment` is `[]`, we mark the `context` "invalid" because we could not
find any shipment.
"""
@spec compute_shipments(Context.t()) :: Context.t()
# TODO: This function does not gracefully handle errors, they are raised!
def compute_shipments(
%Context{
valid?: true,
struct: %Order{} = order
} = context
) do
order
|> Shipment.default_packages()
|> ShipmentEngine.run(order)
|> Weight.split()
|> case do
[] ->
struct(context, valid?: false, state: %{shipment: []})

shipment ->
struct(context, state: %{shipment: shipment})
end
end

def compute_shipments(%Context{valid?: false} = context), do: context

@doc """
Persists the computed shipment to the DB.
`Package`s and their `PackageItem`s are inserted together in a DB transaction.
Returns a new `Context.t` struct with the `shipment` under the the [`:state`,
`:shipment`] key-path.
In case of any errors, an invalid Context struct is returned, with the error
under the `:multi`.
"""
@spec persist_shipment(Context.t()) :: Context.t()
def persist_shipment(%Context{valid?: true, struct: %Order{} = order} = context) do
%{state: %{shipment: shipment}, multi: multi} = context

function = fn _ ->
shipment
|> Stream.map(&Shipment.to_package(&1, order))
|> Stream.map(&Package.create/1)
|> Enum.reduce_while({:ok, []}, fn
{:ok, package}, {:ok, acc} ->
{:cont, {:ok, [package | acc]}}

{:error, _} = error, _ ->
{:halt, error}
end)
end

struct(context, multi: Multi.run(multi, :packages, function))
end

def persist_shipment(%Context{valid?: false} = context), do: context
end
Loading

0 comments on commit b092c1a

Please sign in to comment.