Market Porter sell high-quality meat, cheese, chocolate and charcuterie online and deliver it direct to your door. This site is built on top of Spree although with small scale producers all over the country we've had to heavily customise large parts of the system to provide a robust and flexible supplier management system.
Delivery Date Selection
The nature of delivering perishable stock like food means allowing customers to choose an exact delivery date is important. Spree doesn't have the ability for customers to choose delivery dates so it's something I had to add to the shipment model.
However delivery isn't available on everyday - there's a number of rules we need to take into account:
- No delivery on Sunday or Monday
- No delivery on Bank Holidays
- Next day delivery available (on all other days) on orders placed before 1:30pm
- Restricted delivery products - only available for delivery on selected dates
Excluding Sunday and Monday we could have done using Ruby's powerful internal Date class however I opted to use the recurrence gem as this made the task trivial:
def every_sunday_until(end_date) Recurrence.new( every: :week, on: :sunday, until: end_date ).to_a end def every_monday_until(end_date) Recurrence.new( every: :week, on: :monday, until: end_date ).to_a end
There's some obvious repetition we could have tidied up here by passing the day in as an argument. However I'd probably prefer to maintain the eloquent method signature and consider implementing these as a single ghost method using method_missing something like this:
# Awful variable name used simply for formatting! def method_missing(method_name, *args) if ( m = method_name.match(/^every_([a-z]+)_until$/) ) Recurrence.new( every: :week, on: m, until: args ).to_a else super end end
Bank Holidays that are simply selected manually by the client using a calendar in the admin panel are then concatenated onto this array along with the current date before finally checking if next day delivery is available:
def process_next_day_delivery(unavailable_dates) if @ordered_at > @delivery_cutoff && @ordered_at.wday != 6 unavailable_dates << calculate_next_day(unavailable_dates) end unavailable_dates end
Once we have the full array of unavailable delivery dates we convert this to a string of JSON and output it as a data attribute on the text field that we bind the jQuery data picker to.
When it comes to deciding which supplier should fulfil each shipment, this site has a relatively complicated set of rules behind the scenes making this decision. Fortunately when Spree creates the proposed shipments it provides an extension point where we can customise this prioritisation process.
By leveraging Ruby's operator overloading we can simply re-define the spaceship (combined comparison) operator on the package model - this way prioritising packages becomes nothing more than a simple call to sort on the packages array.
# I absolutely love this code. It's bloody beautiful! def <=>(other) sorters.inject(0) do |value, method| break value unless value == 0 self.public_send(method) <=> other.public_send(method) end end
The sorters method simply returns a series of symbols that can then be dispatched dynamically using public_send each of which returns an integer which can then be compared using the spaceship operator as defined on the integer class.
Once we've got this in place we can add and remove custom logic for sorting easily - here's a few example of what we can now do.
To ensure orders are shipped in the minimum number of shipments we simply need to compare the inverse of the number of items in the package:
def item_count (contents.count / -1) end
If we want to ensure we're selecting the supplier that offers the shortest delivery distance we'd simply need to geolocate the address of each supplier and deliver address using the geocoder gem and then simply calculate the delivery distance using these coordinates:
# Bad variable names just for formatting! def delivery_distance Geocoder::Calculations.distance_between(s_coords, c_coords) end
If you wanted to allow suppliers to be prioritised in the admin panel you could simply add the position field to the stock location model and utilise Spree's update_positions controller method:
def preferred_supplier @stock_location.position end
Margins are always going to be important to any e-commerce site so we might want to select to ship from the supplier who can offer the best cost price. We'd need to model the cost price on a join table between the supplier and the variant and then with that in place, we can simply sum the cost price for each of the items in the package:
def cost_price contents.sum do |content_item| content_item.cost_price_for_location(@stock_location) end end
There's plenty of other code I could delve into on this website not least the product based subscriptions powered by Stripe but I think that probably deserves a blog post of it's own!