Do you run a manufacturing or assembling business?
Do you use Odoo already?
Do you plan to implement an ERP?
Let me share some insights and code for those running Manufacturing with Odoo.
This odoo module deals with how products - material is turned into products for sell. There are multiple area where odoo will help
Odoo can also help with the human resource part, but in this article we will focus on products.
The heart of manufacturing is the Manufacturing order. It holds all the information such as what needs to be build, and for whom, using what material and by which date.
How does Odoo know what material is needed? Well you have to tell it. The ingredients are called the BoM or Bill of Materials. And it truly is not much more than a list of product-materials and quantities needed to build a product.
Kit - a kit BoM means the product is not manufactured but the listed products are to be added to a sales-order (or higher level manufacturing order) directly.
For the manufacturing module to work, you have to create one or more BoMs for each product you wish to manufacture.
Variant BoM is a powerful feature of Odoo. If your products have variants (like color) and the BoMs for them a very similar, the Variant BoM will make your life easier. Instead of creating one BoM for each color variant of your product, you can create one BoM and make some material on the BoM list conditional on a variant attribute.
Whether to automatically replenish a product, either by buying from a vendor or manufacturing is controlled by a simple setting.
If you choose to go without automation, or you only want to automate one option, you can see all the replenishment Odoo needs in the scheduler.
This is convenience feature, when you only build to order. When a sales order is created in Odoo (and the products are not available on stock) a new manufacturing order is created and scheduled. To enable this functionality a simple checkbox has to be set on the product.
The manufacturing module was a great fit for our client. Being able to manufacture sub-components ahead of time and to build final products on order was a must - which is fully supported by Odoo. There was only a number issue. A big number issue - namely hundreds of final products, each built from subcomponents which had to be pre-built.
Unfortunately we could not use variants as there were various exceptions in the build.
What were the final products our client sold? Finished bottles with spray or pipette head and various accessories. Bottles come in 5,10,15,20,30,50,100ml volumes, of either transparent or amber glass. There are two colors for heads, black and white and 3 different types of heads. Finally 6 or 12 packs of bottles are on sales. Thats 252 combinations, and then there are large volume packs, with additional rules. Of course economic reasons dictate to source from multiple vendors, which due to small size differences lead to specific builds for some vendor-size combinations.
We have experimented with various excel solutions, like tables where columns are material and rows the finished products. But dealing with a sheet with hundreds of columns and hundreds of columns is not really humanly possible. Updates were always riddled with errors.
Finally we settled on creating a python application, that contains all the quirky rules and by utilizing the Odoo api to create and update BoMs.
Let’s look at some code.
First some preliminary base methods:
import xmlrpc.client
import ssl
def connect(staging=True):
url = ""
user = ""
pwd = ""
db = ""
if(not staging):
url = ""
pwd = ""
db = ""
common = xmlrpc.client.ServerProxy('{}/xmlrpc/2/common'.format(url), context=ssl._create_unverified_context())
uid = common.authenticate(db, user, pwd, {})
models = xmlrpc.client.ServerProxy('{}/xmlrpc/2/object'.format(url), context=ssl._create_unverified_context())
return { 'models': models, 'db': db, 'uid': uid, 'pwd': pwd }
def connect_to_server(url, db, userEmail, pwd):
common = xmlrpc.client.ServerProxy('{}/xmlrpc/2/common'.format(url), context=ssl._create_unverified_context())
uid = common.authenticate(db, userEmail, pwd, {})
models = xmlrpc.client.ServerProxy('{}/xmlrpc/2/object'.format(url), context=ssl._create_unverified_context())
return { 'models': models, 'db': db, 'uid': uid, 'pwd': pwd }
def load_records(service, objectName, fields):
return load_records_with_filter(service, objectName, fields, [[]])
def load_records_with_filter(service, objectName, fields, filter):
products = []
limit = 10000
offset = 0
has_more = True
while has_more:
prods = service['models'].execute_kw(service['db'], service['uid'], service['pwd'], objectName, 'search_read', filter, {'fields': fields, 'offset': offset, 'limit': limit})
has_more = len(prods) >= limit
offset+=limit
for p in prods:
products.append(p)
return products
def load_products(service):
products = []
limit = 10000
offset = 0
has_more = True
while has_more:
prods = service['models'].execute_kw(service['db'], service['uid'], service['pwd'], 'product.product', 'search_read', [[]], {'fields': ['id', 'name', 'default_code', 'product_tmpl_id'], 'offset': offset, 'limit': limit})
has_more = len(prods) >= limit
offset+=limit
for p in prods:
d = {'name':p['name'], 'id':p['default_code'], 'internal_id':p['id'], 'product_tmpl_id': p['product_tmpl_id'][0]}
products.append(d)
return products
def read_records(service, objectName, fields, ids):
service['models'].execute_kw(service['db'], service['uid'], service['pwd'], objectName, 'read', ids, {'fields': fields})
def write_record(service, objectName, id, toWrite):
service['models'].execute_kw(service['db'], service['uid'], service['pwd'], objectName, 'write', [[id], toWrite])
def create_record(service, objectName, toWrite):
return service['models'].execute_kw(service['db'], service['uid'], service['pwd'], objectName, 'create', [toWrite])
def delete_record(service, objectName, ids):
service['models'].execute_kw(service['db'], service['uid'], service['pwd'], objectName, 'unlink', [ids])
We also used the api to mark product for mto and auto-replenishment.
from common import create_record, load_records, connect, load_records_with_filter, write_record
def assign_product_routes():
dry_run = False
service = connect(False)
products = load_records(service, 'product.template', [
'id', 'name', 'default_code', 'uom_id', 'route_ids'])
replenish_on_order_id = 0
manufacture_id = 0
routes = load_records(service, 'stock.location.route', ['id', 'name'])
for r in routes:
if(r['name'] == "Replenish on Order (MTO)"):
replenish_on_order_id = r['id']
if(r['name'] == "Manufacture"):
manufacture_id = r['id']
if( not replenish_on_order_id or not manufacture_id):
print('routes not fount!!!')
return
max_count = -1
for prod in products:
code = prod['default_code']
if not code or (not code.startswith('ST') and not code.startswith('SP')):
continue
pr = prod['route_ids']
if pr and len(pr) == 2 and (
(pr[0]==replenish_on_order_id and pr[1]==manufacture_id)or
(pr[1]==replenish_on_order_id and pr[0]==manufacture_id)):
continue
max_count-=1
if max_count == 0:
break
d = {
"route_ids": [(4,replenish_on_order_id),(4,manufacture_id)]
}
if(pr):
for z in pr:
if(z != replenish_on_order_id and z != manufacture_id):
d['route_ids'].append((3,z))
print("UPDATE: "+ code+" "+prod['name'] +" "+ str(pr)+"->"+str([replenish_on_order_id, manufacture_id]))
if (not dry_run):
write_record(service, "product.template", prod['id'], d)
return ""
assign_product_routes()
The generator code, calls specific methods for generating BoMs for products based on rules expressed in the python code.
These methods add BoM lines (product and quantity) with the BomWriter
.
class BomWriter:
def __init__(self):
self.arr = []
self.bom_map = dict()
self.last_bom_name = ""
def writerow(self, row, kit=False):
product_id, product_name, product_tmpl_id, line_id, line_name, qty, bom_name, internal_id = row[0], row[1], row[2], row[3],row[4], row[5], row[6], row[7]
if(not bom_name):
bom_name = product_name
bom_name = "AUTO " + bom_name
self.arr.append(row)
if (not self.bom_map.get(bom_name)):
self.bom_map[bom_name] = {'product_ref': product_id,
'type': 'phantom' if kit else 'normal',
'product_name':product_name,
'code':bom_name,
'lines': dict(), 'bom_lines': dict(), 'product_tmpl_id': product_tmpl_id}
bom = self.bom_map[bom_name]
line = {'ref':line_id, 'name':line_name, 'product_qty':float(qty), 'internal_id': internal_id}
bom['lines'][line_name] = line
bom['bom_lines'][internal_id] = line
def generate_boms(writer, products):
productsByRef = dict()
for p in products:
productsByRef[p['id']] = p
generate_bom_for_sets(writer, products, productsByRef)
def synchronize_boms():
service = connect(False)
products = common.load_products(service)
writer = BomWriter()
generate_boms(writer, products)
Now let's look at how the BoMs are made. To add a specifc product to the BoM we have to find it by name or code.
Since the names of the products encode the variable attributes, we look for products with regex.
def findByName(products, pattern, optional = False):
for p in products:
m2 = re.match(pattern, p['name']);
if m2 != None:
return p
if not optional:
print("Pattern not found:"+pattern)
return None
def findByInternalReference(productsByRef, r):
p = productsByRef.get(r)
if p:
return p
print("Reference not found:"+r)
return None
VOLUMES = [5,10,15,20,30,50,100]
UNITS = [6,12]
COLORS = ["transparent", "amber"]
CAP_COLORS = ["black", "white"]
CAP_TYPES = ["spray", "pipette", "insert"]
def generate_bom_for_set(writer, products, productsByRef, volume, pcs, color, cap_color, cap_type, vendor):
volume_str = f'{volume:03d}'
pcs_str = f'{pcs:03d}'
name = "Set "+volume_str+"ml-"+pcs_str+"pcs "+color+ " bottle(s?) "+cap_color +" "+ cap_type
nn = findByName(products, name)
if(nn == None):
print("skipping")
return
bom_name = nn['name']
if(vendor != "CH"):
bom_name = bom_name + " (" + vendor + ")"
def x(prod, count="1"):
writer.writerow([nn['id'], nn['name'], nn['product_tmpl_id'], prod['id'], prod['name'], count, bom_name, prod['internal_id']])
bottlesName =
bottles = findByName("^bottle.*"+volume_str+"ml-"+pcs_str+".*"+color+".*" + vendor, bottlesName)
x(bottles)
x(findByInternalReference(productsByRef, "SP002" if volume <= 20 else "SP001")) # accessories
if (cap_type == "spray"):
#...
elif (cap_type == "pipette"):
#...
elif (cap_type == "insert"):
#...
x(findByInternalReference(productsByRef, "ACS003"))
x(findByInternalReference(productsByRef, "LBL-002" if volume <= 20 else "LBL-003"))
def generate_bom_for_bottles(writer, products, productsByRef, volume, pcs, color, lang):
volume_str = f'{volume:03d}'
pcs_str = f'{pcs:03d}'
bottlesName = "bottle.*"+volume_str+"ml-"+pcs_str+".*"+color+".*" + lang;
nn = findByName(products, bottlesName)
if (nn == None):
return
def x(prod, count="1"):
writer.writerow([nn['id'], nn['name'], nn['product_tmpl_id'], prod['id'], prod['name'], count, False, prod['internal_id']])
x(findByName(products, "^Bottle.*"+volume_str+".*"+color+" glass.*" + lang), pcs)
bagPe = findByName(products, "^Bag PE "+volume_str+"ml-"+pcs_str+"[^\(]+$") # any character but not (
x(bagPe, 1)
def generate_bom_for_bottles_and_sets(writer, products, productsByRef, volume, unit, color, vendor):
cap_colors = CAP_COLORS
cap_types = CAP_TYPES
generate_bom_for_bottles(writer, products, productsByRef, volume, unit, color, vendor)
for cap_color in cap_colors:
for cap_type in cap_types:
generate_bom_for_set(writer, products, productsByRef, volume, unit, color, cap_color, cap_type, vendor)
def generate_bom_for_sets(writer, products, productsByRef):
volumes = VOLUMES
units = UNITS
colors = COLORS
for volume in volumes:
for unit in units:
for color in colors:
generate_bom_for_bottles_and_sets(writer, products, productsByRef, volume, unit, color, "CH")
Now that we have the BoMs we need to update the Odoo records. The algorithm has two phases.
def get_product(p):
return p['product_id'][0]
def synchronize_boms():
service = connect(False)
products = common.load_products(service)
writer = BomWriter() # collect generated BoMs
generate_boms(writer, products)
local_bom_map = dict()
for key in writer.bom_map:
local_bom_map[key] = writer.bom_map[key]
# Update server BoM we have in Odoo
# 1. delete those that are not in local (made with our rules)
# 2. update the quantity of BoM lines.
srv_boms = load_records_with_filter(service, 'mrp.bom',['id', 'product_id', 'code', 'type', 'product_qty', 'bom_line_ids'], [[['code','=like','AUTO %']]], )
for bom in srv_boms:
lines = bom['bom_line_ids']
bom_lines = read_records(service, 'mrp.bom.line', ['id', 'product_id', 'product_qty'], [lines])
products_ids = list(map(get_product, bom_lines))
products = read_records(service, 'product.product', ['name', 'default_code'], [products_ids])
local_bom = local_bom_map.get(bom['code'])
if(not local_bom):
delete_record(service, 'mrp.bom', [bom['id']])
continue
local_bom['found'] = True
local_bom['bom_id'] = bom['id']
for line_prod in products:
bom_line = bom_lines[idx]
local_line = local_bom['bom_lines'].get(bom_line['product_id'][0])
if not local_line:
delete_record(service, 'mrp.bom.line', [bom_line['id']])
continue
local_line['found'] = True
if bom_line['product_qty'] != local_line['product_qty']:
write_record(service, 'mrp.bom.line', bom_line['id'], {'product_qty': local_line['product_qty']})
continue
Let's add missing BoMs that we've generated but do not exists on the server (odoo).
def synchronize_boms():
# ... prev code
local_bom_map = dict()
for key in local_bom_map:
bom = local_bom_map[key]
if not bom.get('found'):
toCreate = {'code': bom['code'], 'product_tmpl_id': bom['product_tmpl_id'], 'product_qty': 1, 'type': bom['type']}
bom['bom_id'] = create_record(service, 'mrp.bom', toCreate)
for key2 in bom['lines']:
bom_line = bom['lines'][key2]
if not bom_line.get('found'):
prodId = bom_line['internal_id']
toCreate = {'product_id':prodId}
toCreate['bom_id'] = bom['bom_id']
toCreate['product_qty'] = bom_line['product_qty']
create_record(service, 'mrp.bom.line', toCreate)
We are still searching for a good way how to enable the client to write or adjust the rules without changes to the python code.
Once we have a solution, we'll share it here.
Finally the complexity is under control. The hundreds of BoMs with a combined several thousands of BoM Lines are generated with a relatively easy to follow script. Thanks to the Odoo manufacturing and our BoM rules the client has the whole business process covered.
Now that the system contains all the information, we can move ahead and make estimations on how much stock to procure and when, what our average prices are based on materials, and so on.
When we connect selling and purchasing via manufucturing, many additional business optimization become possible.
Happy hacking!