In complex sales scenarios, businesses often need to present multiple quotation options to their clients within a single sales order. This comprehensive guide walks you through building a custom multi-quotation module for Odoo 15 that Behind the Scenes: How Multi-Quotations Work allows sales teams to create, manage, and finalize multiple quotation alternatives seamlessly.
Overview of the Multi-Quotation System
The multi-quotation system extends Odoo’s standard sales workflow by introducing the concept of “quotation lines” – separate quotations that exist within a single sales order. Users can create multiple quotation variations, compare them, and ultimately finalize one option while keeping the others for reference.
Behind the Scenes: How Multi-Quotations Work
Understanding the underlying data structure is crucial for implementing this system effectively. Here’s what actually happens behind the scenes:
The Core Approach: Unified Storage with Organized Display
All quotation lines are stored as regular Odoo sale.order.line records – there’s no separate storage mechanism. The magic lies in how we organize and display them:
- Single Storage Layer: Every product line from every quotation variation gets stored in the standard
sale.order.line
table - Linking Mechanism: Each sale order line has a
multi_quotation_line_id
field that links it to its parent quotation - Visibility Control: The
active
field determines which lines are currently visible and counted in totals - Organized Presentation: The UI groups these lines by their parent quotation for organized display
Visual Representation of Data Structure

Sale Order SO001
├── Standard order_line (when not in multi-quotation mode)
└── Multi-Quotation Lines (when in multi-quotation mode)
├── Quotation SO001-Op1 (x_multi.quotation.line)
│ ├── Product A (sale.order.line with multi_quotation_line_id = Op1)
│ ├── Product B (sale.order.line with multi_quotation_line_id = Op1)
│ └── Product C (sale.order.line with multi_quotation_line_id = Op1)
├── Quotation SO001-Op2 (x_multi.quotation.line)
│ ├── Product X (sale.order.line with multi_quotation_line_id = Op2)
│ └── Product Y (sale.order.line with multi_quotation_line_id = Op2)
└── Quotation SO001-Op3 (x_multi.quotation.line)
├── Product P (sale.order.line with multi_quotation_line_id = Op3)
├── Product Q (sale.order.line with multi_quotation_line_id = Op3)
└── Product R (sale.order.line with multi_quotation_line_id = Op3)
State Management Through Finalization
When a quotation is finalized:
- Lines from the selected quotation:
active = True
(visible and counted) - Lines from other quotations:
active = False
(hidden but preserved) - The sale order appears as a normal quotation with only the selected products
This approach provides several advantages:
- Data Preservation: No quotation data is ever lost
- Standard Compatibility: Finalized quotations work with all existing Odoo features
- Audit Trail: Complete history of all quotation options remains accessible
- Performance: No complex joins or custom reporting needed
Key Features
- Toggle Mode: Enable/disable multi-quotation functionality per sales order
- Multiple Quotations: Create unlimited quotation variations within one sales order
- Product Management: Full product selection with pricing, taxes, and margins
- Finalization Process: Convert multi-quotation back to standard sales order
- Clean Interface: Dynamic UI that adapts based on the current mode
System Architecture
The module consists of four main components:
- Multi-Quotation Line Model (
x_multi.quotation.line
) - Quotation Wizard (
x_add.quotation.line.wizard
) - Finalization Wizard (
x_finalize.quotation.wizard
) - Extended Sale Order with new fields and behavior
Implementation Guide
Step 1: Creating the Multi-Quotation Line Model
The foundation of our system is the multi-quotation line model that represents individual quotation options:
class MultiQuotationLine(models.Model):
_name = "x_multi.quotation.line"
_description = "Multi Quotation Line"
_rec_name = "order_reference" # This field will be used for display names
_order = "sale_order_id, sequence, id" # Default ordering for records
# Sequence field allows manual reordering of quotations
sequence = fields.Integer(string='Sequence', default=10)
# Parent sale order - required link, cascade delete when SO is deleted
sale_order_id = fields.Many2one("sale.order", required=True, ondelete="cascade")
# Auto-generated reference like "SO001-Op1", "SO001-Op2"
order_reference = fields.Char("Order Reference", readonly=True, copy=False, default="/")
# User-entered description for this quotation variation
description = fields.Text("Description")
# One2many relationship - links to sale.order.line records that belong to this quotation
# copy=False prevents automatic duplication during record copying
order_lines = fields.One2many(
"sale.order.line", "multi_quotation_line_id",
string="Order Lines", copy=False
)
# Currency from parent sale order - stored for performance
currency_id = fields.Many2one(
"res.currency", related="sale_order_id.currency_id",
store=True, readonly=True
)
# Financial totals - computed from linked order lines
untaxed_amount = fields.Monetary(
"Untaxed Amount", currency_field="currency_id",
compute="_compute_amounts", store=True
)
taxes = fields.Monetary(
"Taxes", currency_field="currency_id",
compute="_compute_amounts", store=True
)
total = fields.Monetary(
"Total", currency_field="currency_id",
compute="_compute_amounts", store=True
)
@api.depends("order_lines.price_total", "order_lines.price_tax", "order_lines.price_subtotal")
def _compute_amounts(self):
"""
Calculate financial totals by summing amounts from all linked order lines.
This method runs whenever the dependent fields change.
"""
for rec in self:
# Sum subtotals (amounts before tax) from all active order lines
untaxed = sum(line.price_subtotal for line in rec.order_lines)
# Sum tax amounts from all active order lines
taxes = sum(line.price_tax for line in rec.order_lines)
# Update the computed fields
rec.untaxed_amount = untaxed
rec.taxes = taxes
rec.total = untaxed + taxes
Key Design Decisions Explained:
- Using
_rec_name = "order_reference"
: This tells Odoo which field to use when displaying this record in selection fields or references. Instead of showing “MultiQuotationLine,1”, users see “SO001-Op1”. copy=False
on order_lines: This prevents Odoo from automatically duplicating the relationship when copying records. We handle duplication manually to maintain proper links between quotations and their order lines.store=True
on computed fields: While computed fields are normally calculated on-the-fly, storing them in the database improves performance for frequently accessed totals.- Currency field relationship: By linking to the parent sale order’s currency and storing it, we avoid repeated database queries when displaying monetary amounts.
Step 2: Extending the Sale Order Model
The sale order needs new fields and behavior to support multi-quotation mode:
class SaleOrder(models.Model):
_inherit = "sale.order"
# Boolean flag to enable/disable multi-quotation mode for this sale order
is_multi_quotation = fields.Boolean(
string="Multi Quotation",
help="Enable multi quotation mode to use quotation lines",
)
# One2many relationship to all quotation variations for this sale order
multi_quotation_line_ids = fields.One2many(
"x_multi.quotation.line", "sale_order_id",
string="Quotation Lines"
)
# Reference to the quotation that was selected during finalization
# This field being set indicates the sale order is no longer in multi-quotation mode
finalized_quotation_line_id = fields.Many2one(
"x_multi.quotation.line",
string="Finalized Quotation",
readonly=True,
help="The quotation line that was finalized",
)
def action_confirm(self):
"""
Override the standard confirmation method to add multi-quotation validation.
Prevents users from confirming a multi-quotation order without finalizing first.
"""
self.ensure_one() # Ensure we're working with a single record
# Check if this is a multi-quotation that hasn't been finalized yet
if self.is_multi_quotation and not self.finalized_quotation_line_id:
raise UserError(_("Please finalize a quotation first before confirming the Sale Order."))
# If validation passes, proceed with standard Odoo confirmation process
return super(SaleOrder, self).action_confirm()
Key Design Decisions Explained:
- Three-State System: The combination of
is_multi_quotation
andfinalized_quotation_line_id
creates three distinct states:- Standard Mode:
is_multi_quotation=False
,finalized_quotation_line_id=False
- Multi-Quotation Mode:
is_multi_quotation=True
,finalized_quotation_line_id=False
- Finalized Mode:
is_multi_quotation=False
,finalized_quotation_line_id=ID
- Standard Mode:
- Validation at Confirmation: By overriding
action_confirm()
, we ensure business rules are enforced at the critical moment when a quotation becomes an order. This prevents incomplete multi-quotations from entering the fulfillment process. - Readonly Finalized Field: Making
finalized_quotation_line_id
readonly prevents accidental changes after finalization, maintaining data integrity throughout the order lifecycle.
Step 3: Sale Order Line Extension
Sale order lines need to link back to their parent multi-quotation line:
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
# Controls visibility of this line - hidden lines don't contribute to totals
# Default True ensures standard behavior for non-multi-quotation orders
active = fields.Boolean("Active", default=True)
# Foreign key linking this order line to its parent quotation
# ondelete='cascade': Delete this line if parent quotation is deleted
# copy=False: Don't duplicate this relationship when copying records
multi_quotation_line_id = fields.Many2one(
'x_multi.quotation.line',
string='Multi Quotation Line',
ondelete='cascade',
index=True, # Database index for faster queries
copy=False
)
@api.depends('product_uom_qty', 'price_unit', 'tax_id', 'discount', 'product_id')
def _compute_amount(self):
"""
Override Odoo's standard amount calculation to handle inactive lines.
Inactive lines (from non-finalized quotations) should contribute $0 to totals.
"""
# First, run Odoo's standard calculation logic
super()._compute_amount()
# Then, zero out amounts for inactive lines
for line in self:
# Check if this line is inactive (hidden)
if not getattr(line, 'active', True):
# Set all monetary fields to zero for inactive lines
line.update({
'price_subtotal': 0.0, # Subtotal before taxes
'price_tax': 0.0, # Tax amount
'price_total': 0.0, # Total including taxes
})
Why the Active Field? Understanding Visibility Control
The active
field is the cornerstone of how multi-quotations work:
- During Multi-Quotation Phase: All lines have
active=True
but are grouped by quotation for display - After Finalization:
- Selected quotation lines:
active=True
(visible, counted in totals) - Other quotation lines:
active=False
(hidden, zero contribution)
- Selected quotation lines:
- Data Preservation: Setting
active=False
hides lines without deleting them, preserving audit trail
This approach allows the same sale order to seamlessly transition between multi-quotation mode (showing all options organized by quotation) and standard mode (showing only the selected quotation’s products).
Step 4: The Quotation Creation Wizard
The wizard provides the interface for creating and editing quotations:
class AddQuotationLineWizard(models.TransientModel):
_name = "x_add.quotation.line.wizard"
_description = "Wizard to add or edit a multi quotation line"
# Parent sale order - populated from context when wizard is opened
sale_order_id = fields.Many2one('sale.order', readonly=True, required=True)
# If editing existing quotation, this field holds the quotation being edited
# If creating new quotation, this remains empty
quotation_line_id = fields.Many2one('x_multi.quotation.line', readonly=True)
# Transient lines - temporary records that exist only during wizard session
# These get converted to permanent sale.order.line records when saved
order_lines = fields.One2many(
'x_add.quotation.line.wizard.line', 'wizard_id',
string='Order Lines'
)
# User-entered description for this quotation variation
description = fields.Text(string='Description')
# Computed summary fields - calculated from transient order_lines
untaxed_amount = fields.Monetary(
string='Untaxed Amount', currency_field='currency_id',
compute='_compute_summary'
)
taxes = fields.Monetary(
string='Taxes', currency_field='currency_id',
compute='_compute_summary'
)
total = fields.Monetary(
string="Total", compute="_compute_summary", readonly=True
)
@api.depends('order_lines.price_subtotal', 'order_lines.price_tax')
def _compute_summary(self):
"""
Calculate wizard totals by summing transient line amounts.
This provides real-time feedback as users build their quotation.
"""
for rec in self:
# Sum amounts from all transient wizard lines
untaxed = sum(line.price_subtotal for line in rec.order_lines)
taxes = sum(line.price_tax for line in rec.order_lines)
# Update computed summary fields
rec.untaxed_amount = untaxed
rec.taxes = taxes
rec.total = untaxed + taxes
Key Design Decisions Explained:
- Transient Model Pattern: Wizards use
models.TransientModel
which automatically cleans up old records. This is perfect for temporary user interfaces that don’t need permanent storage. - Two-Phase Data Model:
- Phase 1: User works with transient
wizard.line
recordsPhase 2: On save, transient data gets converted to permanentsale.order.line
records This separation provides several benefits:
- Users can abandon changes without affecting real data
- Complex validation can happen before committing to database
- Wizard can have different field requirements than final records
- Phase 1: User works with transient
- Real-Time Calculations: The
_compute_summary
method provides immediate feedback as users add products, helping them understand the quotation’s value before saving.
Step 5: Wizard Line Model for Product Selection
The wizard needs its own line model to handle product selection:
class AddQuotationLineWizardLine(models.TransientModel):
_name = "x_add.quotation.line.wizard.line"
_description = "Wizard Order Line for Multi Quotation"
wizard_id = fields.Many2one(
'x_add.quotation.line.wizard',
string='Quotation Wizard',
required=True,
ondelete='cascade'
)
# Product fields
product_id = fields.Many2one('product.product', string='Product')
name = fields.Text(string='Description')
product_uom_qty = fields.Float(string='Quantity', default=1.0)
product_uom = fields.Many2one('uom.uom', string='UoM')
price_unit = fields.Float(string='Unit Price', required=True)
# Computed totals
price_subtotal = fields.Monetary(
string='Subtotal', compute='_compute_line_totals',
store=True, currency_field='currency_id'
)
tax_id = fields.Many2many('account.tax', string='Taxes')
@api.depends('product_uom_qty', 'price_unit', 'tax_id', 'product_id')
def _compute_line_totals(self):
for line in self:
if not line.product_id:
line.price_tax = 0.0
line.price_subtotal = 0.0
line.price_total = 0.0
continue
taxes = line.tax_id.compute_all(
line.price_unit, line.currency_id, line.product_uom_qty,
product=line.product_id,
partner=line.wizard_id.sale_order_id.partner_id
)
line.price_tax = sum(t.get('amount', 0.0) for t in taxes.get('taxes', []))
line.price_subtotal = taxes.get('total_excluded', 0.0)
line.price_total = taxes.get('total_included', 0.0)
Step 6: Saving Quotations – The Action Method
The most critical method in the action class is the one responsible for saving the wizard data to the persistent models:
def action_add_line(self):
"""
Main action method that converts transient wizard data into permanent records.
This method handles both creating new quotations and editing existing ones.
"""
# Validation: Ensure user has added at least one product
if not self.order_lines:
raise UserError(_("Please add at least one product line."))
# Prepare data for the multi-quotation line record
vals = {
"sale_order_id": self.sale_order_id.id, # Link to parent sale order
"description": self.description, # User-entered description
# Note: order_reference will be auto-generated in create() method
# Note: financial totals will be computed automatically from linked lines
}
ql = False # Variable to hold the quotation line record
# EDIT vs CREATE logic
if self.quotation_line_id:
# EDITING EXISTING QUOTATION
ql = self.quotation_line_id
ql.write(vals) # Update existing record with new description
# Remove all existing sale.order.line records for this quotation
# This ensures a clean slate - we'll recreate all lines from wizard data
lines_to_unlink = self.env['sale.order.line'].search([
('multi_quotation_line_id', '=', ql.id)
])
lines_to_unlink.unlink() # Permanently delete old lines
else:
# CREATING NEW QUOTATION
ql = self.env["x_multi.quotation.line"].create(vals)
# Link the new quotation to the parent sale order
self.sale_order_id.write({"multi_quotation_line_ids": [(4, ql.id)]})
# Convert transient wizard lines to permanent sale.order.line records
for line in self.order_lines:
# Prepare data for permanent sale order line
new_so_line_vals = {
# Links - connect this line to both sale order and quotation
"order_id": self.sale_order_id.id,
"multi_quotation_line_id": ql.id,
# Product information
"product_id": line.product_id.id,
"name": line.name, # Product description
"product_uom_qty": line.product_uom_qty, # Quantity ordered
"product_uom": line.product_uom.id, # Unit of measure
"price_unit": line.price_unit, # Unit price
# Taxes - convert many2many relationship format
"tax_id": [(6, 0, line.tax_id.ids)] if line.tax_id else False,
# Additional fields that might be set by user
"discount": line.discount if hasattr(line, 'discount') else 0.0,
"sequence": line.sequence if hasattr(line, 'sequence') else 10,
}
# Create the permanent sale order line record
self.env["sale.order.line"].create(new_so_line_vals)
# Close the wizard window - user returns to main sale order form
return {"type": "ir.actions.act_window_close"}
Data Flow Overview:
- Validation Step: Verify that the user filled in the necessary data
- Creating Quotation Record: Generate or update the x_multi.quotation.line record
- Delete Existing Records: In case of edits, erase all the existing sale.order.line records
- Convert Lines: Change each transient wizard line into a permanent sale.order.line
- Connect: Check that all new lines have been properly linked to both the sale order and the quotation
- Remove Unnecessary Items: The wizard is closed, and the user goes back to the main interface
Reasoning About Design Choices:
- Complete Replacement Workflow: In the case of editing, we purge all the existing lines and reestablish them from the wizard data. This way is less complicated than trying to match, update, and sync individual lines.
- A Two-Step Linking Process: Each sale.order.line is equipped with two foreign keys:
- order_id: Connects to the main sale order (standard Odoo field)
- multi_quotation_line_id: Connects to the specific quotation (our custom field)
- Many2many Tax Format: The [(6, 0, ids)] format is Odoo’s method of completely replacing a many2many relationship with the given list of records.
Step 7: The Finalization Process
When users are ready, they pick one quotation to finalize:
class FinalizeQuotationWizard(models.TransientModel):
_name = "x_finalize.quotation.wizard"
_description = "Wizard to finalize a Multi Quotation"
sale_order_id = fields.Many2one("sale.order", string="Sale Order", required=True, readonly=True)
finalized_line_id = fields.Many2one(
"x_multi.quotation.line",
string="Finalized Quotation",
required=True,
domain="[('id', 'in', multi_quotation_line_ids)]"
)
def action_finalize(self):
self.ensure_one()
sale_order = self.sale_order_id
finalized_line = self.finalized_line_id
# Get all sale order lines
all_so_lines = sale_order.order_line
multi_quotation_lines = all_so_lines.filtered(
lambda line: line.multi_quotation_line_id
)
# Identify finalized vs non-finalized lines
lines_from_finalized = multi_quotation_lines.filtered(
lambda line: line.multi_quotation_line_id == finalized_line
)
non_finalized_lines = multi_quotation_lines - lines_from_finalized
# Hide non-finalized lines
if non_finalized_lines:
non_finalized_lines.write({'active': False})
# Ensure finalized lines are active
if lines_from_finalized.filtered(lambda l: not l.active):
lines_from_finalized.write({'active': True})
# Update sale order
sale_order.write({
'finalized_quotation_line_id': finalized_line.id,
'is_multi_quotation': False,
})
return {
'type': 'ir.actions.act_window',
'name': 'Sale Order',
'res_model': 'sale.order',
'res_id': sale_order.id,
'view_mode': 'form',
'target': 'current',
}
User Interface Design
Dynamic Tab Visibility
The XML views implement conditional visibility to allow the user to see or hide tabs that are relevant:
<xpath expr="//page[@name='order_lines']" position="attributes">
<attribute name="attrs">{'invisible': [('is_multi_quotation', '=', True)]}</attribute>
</xpath>
<page string="Quotation Lines" name="quotation_lines"
attrs="{'invisible': [('is_multi_quotation', '=', False)]}">
<button name="%(action_add_quotation_line_wizard)d"
type="action"
string="Add Quotation"
class="btn-primary"/>
<field name="multi_quotation_line_ids" nolabel="1">
<tree string="Quotation Lines">
<field name="order_reference"/>
<field name="description"/>
<field name="untaxed_amount" string="Subtotal"/>
<field name="taxes"/>
<field name="total"/>
<button name="action_open_wizard" type="object"
string="" icon="fa-pencil"/>
</tree>
</field>
</page>
Wizard Interface
The wizard utilizes the section_and_note widget of Odoo for a clean interface in choosing a product:
<field name="order_lines" widget="section_and_note_one2many">
<tree editable="bottom" create="true" delete="true">
<control>
<create name="add_product_control" string="Add a line"/>
<create name="add_section_control" string="Add a section"
context="{'default_display_type': 'line_section'}"/>
</control>
<field name="product_id" string="Product"/>
<field name="name" string="Description"/>
<field name="product_uom_qty" string="Quantity"/>
<field name="price_unit" string="Unit Price"/>
<field name="price_subtotal" string="Subtotal" readonly="1"/>
</tree>
</field>
Data Flow and State Management
State Transitions
- Draft Multi-Quotation: is_multi_quotation=True, finalized_quotation_line_id=False
- Finalized: is_multi_quotation=False, finalized_quotation_line_id=ID
- Back to Draft: Erase the ID of the finalized state, switch back to multi-quotation mode
Data Relationships
SaleOrder
├── multi_quotation_line_ids (One2many)
│ └── MultiQuotationLine
│ └── order_lines (One2many)
│ └── SaleOrderLine (with multi_quotation_line_id link)
└── finalized_quotation_line_id (Many2one)
Advanced Features
Automatic Reference Generation
Every single quotation is assigned a unique reference automatically:
@api.model
def create(self, vals):
if not vals.get("order_reference") or vals.get("order_reference") == "/":
so_id = vals.get("sale_order_id")
if so_id:
sale_order = self.env["sale.order"].browse(so_id)
prefix = f"{sale_order.name}-Op"
existing_refs = sale_order.multi_quotation_line_ids.filtered(
lambda m: m.order_reference and m.order_reference.startswith(prefix)
).mapped("order_reference")
indices = []
for ref in existing_refs:
try:
idx = int(ref.replace(prefix, ""))
indices.append(idx)
except ValueError:
continue
next_idx = (max(indices) + 1) if indices else 1
vals["order_reference"] = f"{prefix}{next_idx}"
return super(MultiQuotationLine, self).create(vals)
Copy and Duplication Handling
The proper duplication of the sales order ensures that the copied data is maintained error-free:
def copy(self, default=None):
if default is None:
default = {}
original_id = self.id
duplicated_mql = super(MultiQuotationLine, self).copy(default)
# Copy all child lines including inactive ones
all_children = self.env['sale.order.line'].with_context(active_test=False).search([
('multi_quotation_line_id', '=', original_id)
])
for old_line in all_children:
line_vals = old_line.copy_data()[0]
line_vals.update({
'order_id': duplicated_mql.sale_order_id.id,
'multi_quotation_line_id': duplicated_mql.id,
'active': True,
})
self.env['sale.order.line'].create(line_vals)
return duplicated_mql
Best Practices and Considerations
Performance Optimization
- if you have some calculations which you need to access very often, then you should calculate them using computed fields with store=True
- Index foreign key fields such as multi_quotation_line_id
- within loop, minimize the number of database queries by using recordset operations
Data Integrity
- Use ondelete=”cascade” to ensure that there won't be any orphan references
- Put copy=False on those fields which are not eligible for duplication
- Implement the right validation in the corresponding action methods
User Experience
- Dynamic UI that changes according to the current mode
- One can find visual cues very easily for those quotations which have been finalized
- The workflow is very logical and it makes the transition from creation to finalization very smooth
Testing and Validation
Key Test Scenarios
- Creating multi-quotation with a variety of product lines
- Editing quotations and checking if data is still there
- Changing the state by finalizing the quotation and then verifying that state has actually changed
- Copying sales orders and checking if the copies are really done in the right way
- Going back to draft and verifying the multi-quotation mode has been restored
Validation Points
- The point at which the action of confirming without finalization has to be prevented
- Only active lines should be allowed to contribute the totals
- Proper reference generation should be confirmed
- Set up different tax configurations and then verify
Conclusion
This system of multi-quotation allows complex sales scenarios that do not violate the basic Odoo workflow patterns to be created in a very stable way. Plus, the modular conception makes it very convenient to extend and customize depending on your business needs.
Success really depends on a solid comprehension of data relationships, proper state management, and giving the user a fluid experience which lets them easily move the quotation creation and finalization process forward.
Adherence to this manual would make it so that you would have a multi-quotation system that not only works really well with the existing sales module of Odoo but also gives you the flexibility for current sales processes.