Are you a vendor or reseller?
Do you want to place your products onto more virtual shelves?
Let us tell you about our experience with Odoo and Shopify.
The are two main strategies for selling products online, your own E-shop and online marketplace(s).
E-shops are no longer something special or unique. It is par for the course for anyone selling products.
Good news is that as e-shops became ubiquitous, so did the solutions that help you open or operate one.
Even better some e-shop providers also slap an website onto your e-shop as well.
Unless you have been living in a cave, you probalbly heard of the e-shop provider Shopify.
Marketplaces are of course the household names Amazon, E-bay. But there is a whole plentora of smaller national or local players too.
Recently Shopify acquired the best Amazon & E-bay integration plugin. Thus, if you are looking for covering both strategies fast, your strategy just became use Shopify. You list your products on Shopify and it will list them on marketplaces too, straight from the same app.
Now this blog is not how and why you should use shopify. This is about our lessons learned.
Our client is using the open source erp Odoo for warehouse&stock management. Previously we have been assisting with integrating selling between Odoo and marketplaces (amazon and ebay). There are odoo addons available, however we always had to go in and tweak or directly change the code of the plugins to work around issues (taxes for example).
Finally, since our customer wanted to open their own eshop and had good experience with Codisto (now called Shopify Marketplace Connect), the decision was made to cut Odoo - marketplace connections and run everything via Shopify.
Unfortunately, there were also issues with the Odoo shopify integration plugin. Let's look at what we had to do yo get stuff running smoothly.
Shopify has a rather nice and easy to use api. You can find documentation here.
The data model might look a bit complex, especially for smaller vendors who don’t use product variants or multiple stock-locations (warehouses).
Once you imagine it in a example it becomes much clearer. For example let's say your product is a t-shirt with a tiger print. The variants are the colors blue, red and black, or maybe even the sizes S,m,l,xl etc.
Remember that customers always buy a particular variant, the product is basically just a way to group the concrete thing people buy together.
Now it should obvious that to indicate stock availability (how many t-shirts you as a vendor have in the warehouse) we need a combination of variant-id and warehouse-id.
To query the current levels we have in shopify we can use the https://YOUR_ORG.myshopify.com/admin/api/2023-10/products.json?limit=250
api.
def fetch_products():
hdrs = {'X-Shopify-Access-Token':"YOUR_ACCESS_TOKEN" }
nextLink = "https://YOUR_ORG.myshopify.com/admin/api/2023-10/products.json?limit=250"
while nextLink:
resp = fetch_json(nextLink, None, hdrs, None)
nextLink:str = resp.info().get("link")
if nextLink and nextLink.find('rel="next"')<0:
nextLink = None
if nextLink:
p = nextLink.find(', ')
if p>0:
nextLink = nextLink[p+2:]
end = nextLink.find(">;")
if(end):
nextLink = nextLink[1:end]
bytes = resp.read()
r = json.loads(bytes)
for p in r['products']:
for v in p['variants']:
print(str(v["id"]) + "\t" + p['title'] + "\t" + v['title'] + "\t" + str(v['inventory_quantity']))
def fetch_json(q, payload = None, headers = None, decodeAs = None, encodeAs = None):
addJsonHeader = False
data = None
if payload:
if not encodeAs or encodeAs == "json":
data = json.dumps(payload).encode("utf-8")
addJsonHeader = True
elif encodeAs == "urlencode":
data = parse.urlencode(payload).encode()
else:
raise Exception("Unsupported encode option:"+encodeAs)
#ssl._create_default_https_context = ssl._create_unverified_context
req = request.Request(q, data, method="POST" if data else "GET")
if addJsonHeader:
req.add_header('Content-Type', 'application/json')
if headers:
for key, value in headers.items():
req.add_header(key, value)
resp = request.urlopen(req)
if not decodeAs:
return resp
bytes = resp.read()
if decodeAs == "bytes":
return bytes
if decodeAs == "text":
return bytes.decode("utf-8")
if decodeAs == "json":
return json.loads(bytes)
raise Exception("Unsupported decode option:"+decodeAs)
This is all quite straightforward, with the exception of paging. Paging is way to deal with very large sets. It will break down the list into pages, we can “page” through over the api.
Lets decode whats going on here. After each query, shopify will give us the exact url to use for the next page of results. (It also gives us the url to the previous page…) The url is encoded in a http response header, so we need to extract it. And then loop until there is no more next page.
nextLink = resp.info().get("link")
reads the HTTP response header. The header might contain a only a next link (for the very first page),
only a prev-link (for the last page) or both next and prev links. Thus we have to do a bit of parsing to find what we need.
Updating the quantity of a product, we need the variant, warehouse and amount. This information is stored in Shopify as a InventoryItem. If we want to adjust the stock quantity for a product we have to find the right inventoryItem and issue a change with the set inventory level api.
This is a rather useful plugin for warehouse integration. It offers to import products (and variants) to Shopify. It will also monitor Odoo stock changes and update shopify quantity for us.
Unfortunately the monitor is not reliable, due to technical and mostly user reasons (there are many ways products quantity can change that the plugin won't catch, or it will take too long and will be stopped).
Enter our updater, as a odoo scheduled action.
# Available variables:
# - env: Odoo Environment on which the action is triggered
# - model: Odoo Model of the record on which the action is triggered; is a void recordset
# - record: record on which the action is triggered; may be void
# - records: recordset of all records on which the action is triggered in multi-mode; may be void
# - time, datetime, dateutil, timezone: useful Python libraries
# - float_compare: Odoo function to compare floats based on specific precisions
# - log: log(message, level='info'): logging function to record debug information in ir.logging table
# - UserError: Warning Exception to use with raise
# To return an action, assign: action = {...}
initial_channel_ids = env['multi.channel.sale'].search([]).ids
channel_ids = initial_channel_ids #.copy()
count = 0
msg = ""
mappings = env['channel.product.mappings'].search([('channel_id', '=', 6)])
log("mapping_count: "+str(len(mappings)))
for mapping in mappings:
# adjuct depending on your odoo cron timeout
# this action will be scheduled again so we don't mind if don't catch all
if count > 40:
break
channel_id = mapping.channel_id
product_id = mapping.product_name
qty = channel_id.get_quantity(product_id)
# shopify_qty is a custom field on product, where we record what the last updated value was.
# so we don't upload if not needed.
if abs(qty - product_id.shopify_qty) < 0.1:
continue
count = count + 1
msg = msg +"\n"+product_id.name + " qty:" + str(qty) + " shopify qty:"+str(product_id.shopify_qty)
#_logger.info("update:" + channel_id.channel + " prod:" +product_id.name + " qty:" + str(qty))
try:
product_id.write({'shopify_qty': qty})
# call the plugin method to call shopify
channel_id.sync_quantity_shopify(mapping, qty)
except Exception as e:
msg = msg +"\n"+product_id.name + " ERROR " + str(e)
pass
log("Updated :" + str(count)+msg)
The technical hurdle with selling online is primarily integration. There are APIs for everything, but often it feels like putting a sqaure peg through a round hole.
And the landscape is continuosly evolving.
Whether you want to make your life easier with Shopify or you wish to do a custom integration, get in touch. We can help.
Happy shopping!