July 26, 2024

Manufacturing with Odoo

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.

Manufacturing Overview

manufacturing

This odoo module deals with how products - material is turned into products for sell. There are multiple area where odoo will help

  1. Keep track of inventory as products are manufactured.
  2. Automatic replenishment - manufacture components of product, or even order base material automatically.
  3. Manufacture to order - odoo can be configured to start manufacturing when a sales order is confirmed.

Odoo can also help with the human resource part, but in this article we will focus on products.

Manufacturing order

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.

BoM (Bill of Materials)

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.

bom

Replenishment

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.

Inventory Replenishment

Manufacturing to order

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.

Made to Order

Problem intro

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.

How did we solve the problem?

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.

  1. We update server (odoo) BoMs.
  2. We add BoMs that are not on server (odoo) yet.
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)

What's next

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!

Continue reading

Customizing reports in Odoo

November 07, 2024
Customizing reports in Odoo

Sadly printing on dead trees or PDFs is still a thing in 2024. Luckily Odoo comes with pre-built reports for many possible scenarios. Whether we need to print a purchase order, a quote, an invoice, there is a report for that. Usually they only need a small adjustment, a field here or there, to make them fit our need. Read on, to see how to achieve that.

Selling with Odoo

Real estate Lease management

January 05, 2025
Real estate Lease management

What is missing in a housing market with demand for rental properties and many owners with a single extra property to rent?
Services. Keeping the property occupied, choosing the right tenants, ongoing maintenance, inspections and all the legal paperwork.

©2022-2025 Inuko s.r.o
Privacy PolicyCloud
ENSKCZ
ICO 54507006
VAT SK2121695400
Vajanskeho 5
Senec 90301
Slovakia EU

contact@inuko.net