Reserve Stock in Odoo Before Sales Order Confirmation

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?

Priority customers

Guarantee availability before the deal is finalised.

High-demand items

Prevent overselling or stock-outs.

Internal approvals

Reserve stock while waiting for management/customer approval.

Better planning

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
Key Change

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()
Key Highlights
  • 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).

Workflow in Action

  1. Create Quotation — Add products, quantities, and the customer.
  2. Click "Reserve Stock" — The system checks the free quantity, creates stock moves, and blocks the inventory.
  3. State = Reservation — Sales Order is still a quotation, but stock is already reserved.
  4. Confirm Order — Delivery order is ready with reserved stock.
  5. 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.

Key Takeaways

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
Sign in to leave a comment
How to Export Excel Files from Odoo Backend