Taking a peek under the hood

Claire Austin

Claire Austin Hardy Plants sells irises, perennials and peonies across the country through mail order.  Selling plants online presents some interesting technical challenges, particularly in terms of stock management - not many other goods actively change size and quality over time!


Product Search

The site features a fairly comprehensive product search where you can filter by numerous plant attributes e.g. colour, soil type and planting position, receive instance AJAX updates to the results as well as a faceted breakdown of the results by each attribute.

While Spree comes with it's own search and filtering functionality out of the box it really starts to struggle when you want to filter by multiple attributes.  Rather than fighting against the existing functionality I decided instead to create a custom searcher class powered by Sphinx Search instead.

The thinking sphinx gem is my go to tool on any Ruby site that requires advanced full text search and filtering.  With this installed the bulk of the search becomes as simple as:

def retrieve_products
  Spree::Product.search keywords,
                        with: with,
                        order: order_by,
                        include: [ 
                          :master, 
                          :variants, 
                          { :variants => :prices } 
                        ],
                        page: page,
                        per_page: per_page
end

This is all relatively straight-forward the only thing of note is our call to a method called with which prepares and returns a hash of attributes to filter on - it looks something like this:

def with(override = nil)
  params = {
      colour_id: colour_id,
      soil_type_id: soil_type_id,
      planting_position_id: planting_position_id,
      feature_ids: feature_ids,
      taxon_ids: taxon_ids,
  }
  params[override.to_sym] = [] if override.present?
  params
end

Each value in the hash comes from the return value of a method call that deals with sanitisation of the incoming params making sure it's in a suitable format.  The override parameter allows us to exclude one of the attributes from the search when we come to generate the facet metadata for the results.  In thinking sphinx this looks like:

def retrieve_facets
  attributes.inject({}) do |facets, key|
    name, counts = facets_for_attribute(key)
    facets[name] = counts.inject({}) do |hash, count|
      hash[count[0].to_s.to_sym] = count[1]
      hash
    end
  end
end
def facets_for_attribute(key)
  Spree::Product.facets(keywords, with: with(key), facets: [key])[0]
end

Then to improve performance and avoid an N+1 situation we eager load the associations we're going to need in order to render the results using the include option.

Stock Modifications

As I mentioned before the stock for this website has a few characteristics that make it quite different to that of most e-commerce sites:

  • Plants grow, for example stock which is currently in a 9cm pot may need to repotted into a 1 litre pot in a few months time meaning a change in variant and therefore cost.
  • Plants can be split, a 2 litre potted plant might be cut up and repotted into a 9cm pot - meaning not only the variant but the quantity of stock has altered.
  • Plants vary in quality, whether it's due to pests, weather or damage there will be times when stock is no longer considered suitable for sale so needs to be marked as growing until it reaches a suitable state.

To cater for these different scenarios we first introduced a state attribute onto the StockItem model and updated the composite index to include a state of either available, growing old or growing new.  This approach meant we could have the majority of the system stay completely unaffected simply by updating most of the stock related methods to take a state parameter which a default value of available:

def stock_item_or_create(variant, state='available')
  stock_item(variant, state) || stock_items.create(variant: variant, state: state)
end

This solution means I don't have to worry about updating any of the invocations of the method in the rest of the system as by default they will deal with available stock only.

With some careful planning I also managed to provide the ability to manage changing size, state and quantity from a single screen with a clever HTML structure in the form and this create action:

def create
  variants = params[:variant].map.with_index do |variant_id, i|
    build_variant_hash( variant_id, params, i )
  end

  stock_modification = StockModification.create!( reference: params[:reference] )

  if stock_modification.modify( source_location( params ), variants )
    flash[:success] = Spree.t( :stock_successfully_modified )
    redirect_to admin_stock_modification_path( stock_modification )
  else
    render action: :new
  end
end

Obviously there's a little more complexity to it than that but that's the beauty of the composed method pattern in Ruby.

For this project we also had to deal with postcode based restrictions on delivery that Spree out the box has no provision for.  However as it's an issue that almost all UK companies we deal with will encounter I've abstracted out into a gem of it's own for re-use.  So rather than write about it here instead why no check out the gem for yourself here:   stuartbates/solidus_uk_postcode_zones .