Optimising Odoo Stock Reservation
By default, Odoo reserves stock only after a Sales Order is confirmed. This ensures inventory is allocated only for confirmed deals. However, some businesses need to reserve stock earlier—for example, when handling pre-orders, quotations for priority customers, or industries where demand changes rapidly.
In this article, we’ll show you how to extend Odoo Sales to reserve stock before confirmation using a custom method. We’ll also explain how to release reserved stock if the quotation is cancelled.
Why Reserve Stock Before Confirmation?
Guarantee availability before the deal is finalised.
Prevent overselling or stock-outs.
Reserve stock while waiting for management/customer approval.
Sales and operations teams know that inventory is already blocked.
Default Odoo Limitation
By default, Odoo only reserves stock after a Sales Order is confirmed. Quotations do not block inventory, which can cause issues when multiple users create quotations simultaneously.
Custom Implementation: Reserve Before Confirmation
We extend Odoo with:
- A Reserve Stock button on quotations.
- A custom action_reserve_stock() method to trigger stock rules early.
- A modified _action_launch_stock_rule() that supports skipping validations.
- A custom action_cancel() method to release stock when a quotation is cancelled.
1. Reserve Stock on Quotation
To reserve stock on a quotation, we define a custom action_reserve_stock() method that checks availability and creates stock moves:
def action_reserve_stock(self):
"""
Reserve stock for all lines in the sales order.
"""
for order in self:
if order.reserved:
raise UserError("Stock already reserved for this quotation.")
# Pre-check: ensure available stock
insufficient_lines = order.order_line.filtered(
lambda line: line.product_id.detailed_type == 'product'
and line.product_uom_qty > line.product_id.free_qty
)
if insufficient_lines:
error_lines = [
f"{line.product_id.display_name}:\n"
f"Requested {line.product_uom_qty}, Available {line.product_id.free_qty}\n"
for line in insufficient_lines
]
raise UserError(
"Insufficient stock for the following products:\n\n" + "\n".join(error_lines)
)
# Process each line
for line in order.order_line:
if line.product_id.detailed_type != 'product':
continue
if line.product_uom_qty <= 0:
continue
line.with_context(skip_validation=True)._action_launch_stock_rule()
# Mark the order as reserved
order.reserved = True
order.state = 'reservation'
Key Highlights of this Method
- Prevents duplicate reservations.
- Pre-checks stock availability (free_qty).
- Only reserves storable products (ignores services/consumables).
- Creates stock moves via _action_launch_stock_rule().
- Updates the order state to reservation.
2. Modification of _action_launch_stock_rule
We override the core method to allow triggering stock moves even before the Sales Order is confirmed:
def _action_launch_stock_rule(self, previous_product_uom_qty=False):
"""
Launch procurement group run method with required/custom fields generated by a
sale order line. procurement group will launch '_run_pull', '_run_buy' or '_run_manufacture'
depending on the sale order line product rule.
"""
if self._context.get("skip_procurement"):
return True
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
procurements = []
for line in self:
line = line.with_company(line.company_id)
# CUSTOM CODE : ADDED skip_validation: This context comes from def action_reserve_stock (Reserve Stock) button action.
if (not self.env.context.get('skip_validation', False) and line.state != 'sale') or not line.product_id.type in ('consu', 'product'):
continue
qty = line._get_qty_procurement(previous_product_uom_qty)
if float_compare(qty, line.product_uom_qty, precision_digits=precision) == 0:
continue
group_id = line._get_procurement_group()
if not group_id:
group_id = self.env['procurement.group'].create(line._prepare_procurement_group_vals())
line.order_id.procurement_group_id = group_id
else:
# Update group if order was cancelled
updated_vals = {}
if group_id.partner_id != line.order_id.partner_shipping_id:
updated_vals.update({'partner_id': line.order_id.partner_shipping_id.id})
if group_id.move_type != line.order_id.picking_policy:
updated_vals.update({'move_type': line.order_id.picking_policy})
if updated_vals:
group_id.write(updated_vals)
values = line._prepare_procurement_values(group_id=group_id)
product_qty = line.product_uom_qty - qty
line_uom = line.product_uom
quant_uom = line.product_id.uom_id
product_qty, procurement_uom = line_uom._adjust_uom_quantities(product_qty, quant_uom)
procurements.append(self.env['procurement.group'].Procurement(
line.product_id, product_qty, procurement_uom,
line.order_id.partner_shipping_id.property_stock_customer,
line.product_id.display_name, line.order_id.name, line.order_id.company_id, values))
if procurements:
procurement_group = self.env['procurement.group']
if self.env.context.get('import_file'):
procurement_group = procurement_group.with_context(import_file=False)
procurement_group.run(procurements)
# Scheduler trigger for pickings
orders = self.mapped('order_id')
for order in orders:
pickings_to_confirm = order.picking_ids.filtered(lambda p: p.state not in ['cancel', 'done'])
if pickings_to_confirm:
pickings_to_confirm.action_confirm()
return True
Normally, Odoo only runs procurement rules if the Sales Order line is in state “sale”. We added:
if not self.env.context.get('skip_validation', False) and line.state != 'sale':
continue
This allows early reservation only if the context is passed (skip_validation=True), preventing unintended side effects in other flows.
3. Cancel Reservation Method
To release reserved stock when a quotation is cancelled, we override the action_cancel() method:
def action_cancel(self):
"""
Cancel the sales order and release any reserved stock.
"""
for order in self:
if order.reserved:
moves = self.env['stock.move'].search([('sale_line_id', 'in', order.order_line.ids)])
moves._do_unreserve()
moves._action_cancel()
order.reserved = False
return super(SalesOrderExtension, self).action_cancel()
- Finds all stock moves linked to the Sales Order.
- Calls _do_unreserve() to release stock.
- Cancels moves so they no longer affect inventory.
- Resets the reserved flag.
- Executes normal Odoo cancellation flow afterwards.
4. Addition of Reserve Stock Button
Finally, we add a Reserve Stock button visible only on draft quotations:
<record id="view_order_form_reservation" model="ir.ui.view">
<field name="name">sale.order.form.reservation</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<header position="inside">
<button name="action_reserve_stock"
type="object"
string="Reserve Stock"
attrs="{'invisible': [('state', 'not in', ('draft'))]}"/>
</header>
</field>
</record>
This adds a 'Reserve Stock' button to the Sales Order form view header, visible only when the order is still a quotation (draft).
- Create Quotation — Add products, quantities, and the customer.
- Click "Reserve Stock" — The system checks the free quantity, creates stock moves, and blocks the inventory.
- State = Reservation — Sales Order is still a quotation, but stock is already reserved.
- Confirm Order — Delivery order is ready with reserved stock.
- Cancel Quotation — Stock moves are unreserved and cancelled, making stock available again.
Benefits of This Approach
- Prevents overbooking products.
- Adds a controlled early reservation mechanism.
- Gives sales teams confidence when quoting customers.
- Flexible: stock is released automatically if the quotation is cancelled.
With these changes, Odoo becomes more flexible for businesses that need early stock allocation. Sales teams can now secure stock at the quotation stage while still keeping a safe rollback mechanism via cancellation.
This customisation bridges the gap between sales flexibility and inventory accuracy, ensuring customers get reliable promises without the risk of overselling.
Found this article useful?
Explore more development guides and solutions from our team.
Check out more posts