Logistics operations are the backbone of any product-based business. From warehouse management to shipment tracking, every movement impacts cost, efficiency, and customer satisfaction.

Yet many companies still manage logistics through:

  • Spreadsheets
  • WhatsApp communication
  • Disconnected tools

The result?

  • No real-time visibility
  • Delayed shipments
  • Manual coordination errors

To compete in today’s fast-moving market, businesses need a centralized, automated logistics system. This is where Odoo Logistics ERP (WMS) transforms operations.

Why Logistics Operations Break Down Without ERP

Modern logistics involves:

  • Multiple warehouses
  • High-volume shipments
  • Complex routing and fulfillment

When systems are disconnected:

  • Inventory data becomes inaccurate
  • Teams work in silos
  • Decisions are delayed

In fact, fragmented systems are one of the biggest causes of inefficiencies in logistics operations.

What is Odoo Logistics ERP (WMS)?

Odoo WMS is a fully integrated warehouse and logistics management system that connects:

  • Inventory
  • Warehouse operations
  • Transportation & delivery
  • Sales & invoicing

All within a single platform and database.

Unlike traditional systems, Odoo eliminates the need for multiple tools by providing a unified logistics ecosystem.

Key Features of Odoo for Logistics & Warehousing

1. Centralized Warehouse Management

Odoo manages:

  • Receiving & putaway
  • Picking, packing, and shipping
  • Internal transfers

With optimized workflows, businesses can reduce errors and improve efficiency.

Warehouse Dashboard

2. Real-Time Inventory & Shipment Tracking

Odoo provides:

  • Live stock updates
  • Shipment status tracking
  • Multi-warehouse visibility

This ensures accurate and real-time operational control across all locations.

Live stock updates across multiple locations and warehouses

3. Automated Logistics Workflows

Automation includes:

  • Replenishment rules
  • Order fulfillment
  • Route optimization
  • Barcode scanning

This reduces manual work and improves speed and accuracy.

Automatic replenishment of stock to locations and warehouses

4. Smart Routing (Push & Pull Rules)

Odoo uses:

  • Push rules → move products automatically after receiving
  • Pull rules → trigger delivery based on demand

This ensures smooth product movement across the supply chain.

Put away rules to move products automatically into different racks or shelfs after receiving stock in warehouse

5. Full Traceability with Lots & Serial Numbers

Track every product:

  • From supplier to customer
  • Across warehouses and locations

This improves accountability and reduces losses.

Track stock movement with lots and serial numbers

6. Integrated Invoicing & Accounting

Odoo connects logistics with finance:

  • Automatic invoice generation
  • Cost tracking per shipment
  • Real-time financial reporting

No more manual reconciliation.

Odoo connects logistics with finance

Solving Your Key Logistics Challenges

1. Still managing shipments in spreadsheets and WhatsApp?

Odoo replaces manual tools with:

  • Centralized dashboards
  • Automated workflows
  • Real-time communication between teams

No more scattered information across platforms

2. Multiple shipments. Multiple files. No real-time visibility.

Odoo consolidates everything into one system:

  • All shipments tracked in one dashboard
  • Inventory updated in real-time
  • Live status of every order

One system. One source of truth

3. Delays, missed updates, manual coordination everywhere

Odoo eliminates delays through:

  • Automated picking, packing, and dispatch workflows
  • Barcode scanning for accuracy
  • Real-time updates from warehouse teams

Faster operations with fewer errors

4. Odoo WMS centralizes shipments, warehouse, and invoicing in one system

Odoo integrates:

  • Warehouse operations
  • Delivery management
  • Sales and invoicing

End-to-end logistics managed in a single platform

Benefits of Odoo Logistics ERP

Businesses using Odoo can:

  • Improve delivery speed and accuracy
  • Reduce operational costs
  • Eliminate manual errors
  • Gain real-time visibility
  • Scale logistics operations easily

Odoo enables faster fulfillment, better control, and higher efficiency across the entire supply chain.

Why Choose Odoo for Logistics in Qatar?

Odoo is ideal for logistics companies in Qatar because:

  • Supports multi-warehouse and multi-location operations
  • Scales with growing logistics demand
  • Provides real-time operational visibility
  • Integrates with all business functions

Conclusion

Logistics success depends on visibility, speed, and coordination.

Without the right system, operations become:

  • Slow
  • Error-prone
  • Difficult to scale

Odoo Logistics ERP transforms your operations by:

  • Centralizing data
  • Automating workflows
  • Providing real-time insights

Helping you move from operational chaos to complete control.

Ready to Streamline Your Logistics Operations?

Book a personalized demo today

The construction industry in Qatar and across the GCC operates in a highly dynamic environment—multiple sites, fluctuating material costs, and tight project deadlines.

Yet many construction companies still rely on spreadsheets and disconnected systems.

The result?

  • No clear visibility of project costs
  • Delayed reporting
  • Budget overruns
  • Poor coordination across teams

To stay competitive, construction companies need a centralized, real-time system. This is where Odoo Construction ERP transforms operations.

Why Construction Companies Struggle Without ERP

Construction projects involve:

  • Large capital investments
  • Multiple vendors and subcontractors
  • Complex workflows across sites

Without an integrated system:

  • Financial data is scattered
  • Site updates are delayed
  • Decisions are based on outdated information

This leads to inefficiencies, revenue leakage, and project delays.

What is Odoo Construction ERP?

Odoo is a fully integrated ERP platform that connects:

  • Project Management
  • Accounting & Job Costing
  • Procurement & Inventory
  • HR & Workforce Management
  • CRM & Client Communication

Instead of managing operations in silos, Odoo provides a single source of truth for your entire construction business.

Key Features of Odoo ERP for Construction Companies

1. Real-Time Project Cost Tracking





Odoo enables you to:

  • Track budgets vs actual costs
  • Monitor expenses at task and project level
  • Identify cost leaks instantly

This ensures better financial control and profitability

Real time project cost tracking using odoo

2. Multi-Site Visibility

Manage all your construction sites from one system:

  • Site-wise cost tracking
  • Centralized dashboards
  • Real-time updates from field teams

No more waiting for manual reports

real time cost updates from field teams in odoo ERP

3. Smart Procurement & Inventory Management

Odoo helps you:

  • Track material usage across sites
  • Automate purchase orders
  • Avoid stock shortages and over-purchasing

Reduce delays and material wastage

View stock movement across multiple sites in odoo

4. Workforce & Resource Management

Efficiently manage:

  • Labor allocation
  • Work schedules
  • Productivity tracking

Optimize workforce utilization and reduce idle time

Track man hours of employee and labourer in all projects in odoo

5. Live Dashboards & Reporting

Get instant insights into:

  • Project progress
  • Financial performance
  • Resource usage

Make faster, data-driven decisions

Live project dashboard in odoo

Solving the Biggest Construction Challenges

1. Where is your project money really going?

Odoo provides detailed job costing, breaking down expenses into:

  • Materials
  • Labor
  • Equipment
  • Subcontractors

You get complete transparency on every expense

2. Costs across sites are not visible in real-time

Odoo centralizes all site data into one platform:

  • Real-time cost updates
  • Consolidated financial reports
  • Live dashboards

Track all projects without delays

3. Overruns happen slowly — until it’s too late

Odoo prevents this by:

  • Monitoring budgets continuously
  • Sending alerts when limits are exceeded
  • Highlighting deviations early

Shift from reactive to proactive cost control

4. Odoo tracks budgets, expenses, and progress live

Everything is connected in Odoo:

  • Budget planning
  • Expense tracking
  • Task progress

Finance and operations work together seamlessly

5. Control every riyal across every site

With Odoo, you can:

  • Approve expenses through workflows
  • Track financials in real-time
  • Monitor profitability per project

Achieve complete financial control

Benefits of Implementing Odoo Construction ERP

Construction companies using Odoo can:

  • Improve cost accuracy and reduce overruns
  • Increase project visibility and control
  • Automate manual processes
  • Enhance collaboration across departments
  • Deliver projects on time and within budget

Why Choose Odoo for Construction in Qatar?

Odoo is ideal for construction companies in Qatar because:

  • It supports multi-project and multi-site operations
  • It is fully customizable based on your workflow
  • It scales as your business grows
  • It ensures compliance with local business requirements

Conclusion

Construction success depends on visibility, control, and efficiency. Without the right system, managing costs and projects becomes unpredictable.

Odoo Construction ERP provides:

  • Real-time insights
  • Centralized control
  • Scalable operations

Helping you move from operational chaos to structured growth.

Ready to Take Control of Your Construction Projects?

Book a personalized demo today and see how Odoo can transform your business

In Odoo 15, confirming a sales order automatically creates a delivery order. While this default behavior works for many businesses, it can be limiting for organizations that require tighter operational control. Warehouses with complex workflows, approval processes, or external logistics dependencies often prefer to decide exactly when delivery notes are generated.

This article explains how to build a custom Odoo 15 module that disables automatic delivery creation and introduces a manual “Create Delivery” button on the sales order form. You will learn how to extend core models, adjust confirmation logic, customize views, and use context flags to manage stock picking generation.

Why Use Manual Delivery Notes in Odoo?

Consider a high-volume warehouse that processes hundreds of confirmed sales orders each day. Automatically generating delivery orders for every confirmation can overwhelm warehouse staff and complicate picking operations. A manual approach allows teams to review confirmed orders first and create delivery notes in controlled batches.

Another common scenario involves businesses that sell both stocked items and products that are manufactured or purchased on demand. These companies may need to confirm sales orders immediately for financial or reporting reasons, while postponing delivery creation until inventory is physically available.

Manual delivery note control allows Odoo to adapt to these workflows by separating sales confirmation from inventory execution.

Technical Approach Overview

The solution is based on three core components:

  • Model Extensions: New computed fields are added to the sale.order model, and confirmation behavior is modified to prevent automatic stock rule execution.
  • View Customization: A custom action button is added to the sales order form to allow manual delivery creation when conditions are met.
  • Context Flags: Odoo’s context mechanism is used to control when procurement and stock rules should be triggered.

This approach integrates cleanly with standard Odoo functionality, adding flexibility without breaking existing processes.

Step-by-Step Implementation

Step 1: Create the Module Structure

Start by creating a new custom module in your Odoo addons directory. The module should follow a standard structure:

manual_delivery/
├── __init__.py
├── __manifest__.py
├── models/
│   ├── __init__.py
│   └── sale_order.py
└── views/
    └── sale_order_views.xml

The manifest file defines the module metadata and dependencies required for proper operation:

{
    'name': 'Manual Delivery Note Control',
    'version': '1.0',
    'category': 'Sales',
    'author': 'Your Company',
    'depends': ['sale', 'stock', 'sale_stock'],
    'data': [
        'views/sale_order_views.xml',
    ],
    'installable': True,
    'auto_install': False,
}

Step 2: Extend the Sale Order Model

Extend the sale.order model to determine when manual delivery creation is allowed. This logic ensures that delivery notes can only be created for confirmed orders without active pickings.

from odoo import models, fields, api, _
from odoo.exceptions import UserError
import logging

_logger = logging.getLogger(__name__)


class SaleOrder(models.Model):
    _inherit = 'sale.order'

    manual_delivery_allowed = fields.Boolean(
        string="Manual Delivery Allowed",
        compute='_compute_manual_delivery_allowed',
        store=True
    )

    manual_delivery_created = fields.Boolean(
        string="Manual Delivery Created",
        default=False,
        copy=False
    )

    @api.depends('state', 'picking_ids.state')
    def _compute_manual_delivery_allowed(self):
        for order in self:
            if order.state not in ('sale', 'done'):
                order.manual_delivery_allowed = False
            else:
                active_pickings = order.picking_ids.filtered(
                    lambda p: p.state not in ('cancel', 'done')
                )
                order.manual_delivery_allowed = not bool(active_pickings)

Step 3: Prevent Automatic Delivery Creation

Override the sales order confirmation method to stop Odoo from creating delivery orders automatically. A context flag is passed to signal that stock rules should be skipped.

def action_confirm(self):
    return super(
        SaleOrder,
        self.with_context(skip_auto_delivery=True)
    ).action_confirm()

Step 4: Add Manual Delivery Creation Logic

This method validates the sales order, checks for existing deliveries, prepares procurement groups if needed, and manually triggers stock rules.

def create_delivery_note_manually(self):
    for order in self:
        if order.state not in ('sale', 'done'):
            raise UserError(_('Only confirmed orders can create deliveries.'))

        existing_pickings = order.picking_ids.filtered(
            lambda p: p.state not in ('cancel', 'done')
        )
        if existing_pickings:
            raise UserError(_('Active delivery orders already exist.'))

        if not order.procurement_group_id:
            vals = order._prepare_procurement_group_vals()
            order.procurement_group_id = self.env['procurement.group'].create(vals)

        deliverable_lines = order.order_line.filtered(
            lambda l: l.product_id
            and l.product_id.type == 'product'
            and l.product_uom_qty > 0
        )

        if not deliverable_lines:
            raise UserError(_('No deliverable products found.'))

        deliverable_lines.with_context(
            manual_delivery_override=True
        )._action_launch_stock_rule()

    return {'type': 'ir.actions.client', 'tag': 'reload'}

Step 5: Control Stock Rule Execution

Extend sale.order.line to ensure stock rules are executed only when manual delivery creation is explicitly triggered. Buy routes remain unaffected so that purchase orders continue to generate automatically.

Step 6: Add the Sales Order Button

The final step is to update the sales order form view and add a “Create Delivery” button that appears only when manual delivery creation is allowed.

Testing the Customization

After installing the module, confirm a sales order and verify that no delivery is created automatically. The manual delivery button should appear once the order is confirmed. Clicking the button should generate a single delivery order and hide the button afterward.

Test edge cases such as service-only orders, duplicate delivery attempts, and Buy-route products to ensure expected behavior.

Conclusion

Manual delivery note control in Odoo 15 provides businesses with precise control over inventory operations. By separating sales confirmation from delivery creation, organizations can adapt Odoo to complex warehouse workflows while maintaining compatibility with standard functionality.

In Odoo’s flexible interface, the Kanban view serves as a powerful visual tool for managing records through different workflow stages. However, standard Odoo implementations often present all stages to all users, which can create confusion when different user roles need to see only relevant workflow stages. This comprehensive guide walks you through implementing conditional Kanban stage visibility in Odoo 15, allowing you to tailor the user experience based on specific criteria like user roles, record states, or custom business rules.

Why Control Kanban Stage Visibility?

Before diving into implementation, let’s understand why this capability matters:

  • Reduced Cognitive Load: Users only see stages relevant to their current responsibilities
  • Streamlined Workflows: Prevents users from attempting actions they shouldn’t perform
  • Enhanced Security: Hides stages that might expose sensitive workflow information
  • Improved User Experience: Creates a cleaner, more focused interface for each user type

Unlike simply hiding fields or buttons, stage visibility control operates at the data level—ensuring users never even see stages they shouldn’t interact with, rather than just disabling them after they appear.

Behind the Scenes: How Kanban Stage Visibility Works

Understanding Odoo’s underlying architecture is crucial for implementing this feature correctly. Here’s what happens behind the scenes when controlling Kanban stage visibility:

The Core Approach: Intercepting Data Retrieval

Odoo’s Kanban view displays stages by retrieving data through two critical methods:

  • read_group() – Used to fetch the grouped data for Kanban columns
  • search() – Used to fetch records within each stage

This happens by overriding these methods to filter out unwanted stages before the data reaches the user interface. This approach ensures:

  • Stages are hidden at the data layer, not just visually
  • No unnecessary database queries for hidden stages
  • Complete separation of concerns (business logic stays in Python)
  • Compatibility with Odoo’s standard caching mechanisms

Visual Representation of the Data Flow

The approach is

User Request


Odoo Controller


[read_group() & search() methods] ← Your custom logic lives here
(Data filtering happens here)

Filtered Data


Kanban View Renderer


Browser (Only shows permitted stages)


This approach provides significant advantages over client-side filtering:

  • Security: Hidden stages never reach the browser
  • Performance: Reduced data payload improves loading times
  • Consistency: Works across all entry points (not just the main view)

Implementation Guide

Let’s walk through implementing conditional stage visibility in your Odoo 15 module. We’ll focus on a common use case: hiding specific stages based on user roles.

Step 1: Identifying the Target Model

First, identify which model’s Kanban view you want to modify. In our example, we’ll work with a custom model called project.task (though the same principles apply to any model with stages).
Create a new file in your module: models/project_task.py

Step 2: Overriding read_group() Method

The read_group() method is responsible for retrieving the stage groups that become your Kanban columns. This is where we’ll filter out unwanted stages:

from odoo import api, models, _

class ProjectTask(models.Model):
    _inherit = 'project.task'
    
    @api.model
    def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
        # First, call the parent method to get the standard results
        result = super(ProjectTask, self).read_group(
            domain, fields, groupby, offset=offset, 
            limit=limit, orderby=orderby, lazy=lazy
        )
        
        # Only apply our filtering when grouping by stage_id
        if 'stage_id' in groupby:
            # Get the stage we want to potentially hide
            restricted_stage = self.env.ref('your_module_name.restricted_stage_key', raise_if_not_found=False)
            
            # Determine if current user should see this stage
            # In this example, we're checking if user is in a specific group
            user_can_see_restricted = self.env.user.has_group('your_module_name.group_admin')
            
            # If we found the stage and user shouldn't see it, filter it out
            if restricted_stage and not user_can_see_restricted:
                # Filter out the restricted stage from results
                result = [r for r in result 
                         if not r.get('stage_id') or r['stage_id'][0] != restricted_stage.id]
        
        return result

Step 3: Overriding search() Method

The read_group() method handles the column headers, but the search() method retrieves the actual records within each stage. We need to override this too to prevent records from appearing in hidden stages:

@api.model
def search(self, domain, offset=0, limit=None, order=None, count=False):
    # Get the stage we want to potentially hide
    restricted_stage = self.env.ref('your_module_name.restricted_stage_key', raise_if_not_found=False)
    
    # Determine if current user should see this stage
    user_can_see_restricted = self.env.user.has_group('your_module_name.group_admin')
    
    # If stage exists and user shouldn't see it, modify the domain
    if restricted_stage and not user_can_see_restricted:
        # Add condition to exclude the restricted stage
        new_domain = domain + [('stage_id', '!=', restricted_stage.id)]
        return super(ProjectTask, self).search(
            new_domain, offset=offset, 
            limit=limit, order=order, count=count
        )
    
    # Otherwise, proceed with standard search
    return super(ProjectTask, self).search(
        domain, offset=offset, 
        limit=limit, order=order, count=count
    )

Key Design Decisions Explained:

  • Two-Point Filtering: By overriding both read_group() and search(), we ensure:
    • The stage column never appears (read_group)
    • Records never appear in that column (search)
  • Reference-Based Stage Identification: Using env.ref() with XML IDs makes the code:
    • More maintainable (no hardcoded IDs)
    • Safer (fails gracefully if stage doesn’t exist)
    • Clearer (shows the stage’s purpose via its key)
  • Conditional Application: The filtering only applies when:
    • The stage exists in the database
    • The specific condition (user group check) is met

Step 4: Creating Custom Action with Pre-Filtered Domain

Sometimes you need a dedicated action that always shows filtered stages. Here’s how to create one:

@api.model
def action_filtered_tasks(self):
    """Action to open tasks with restricted stage filtered out"""
    # Determine if current user should see restricted stage
    user_can_see_restricted = self.env.user.has_group('your_module_name.group_admin')
    
    # Get the restricted stage
    restricted_stage = self.env.ref('your_module_name.restricted_stage_key', raise_if_not_found=False)
    
    # Build domain
    domain = []
    if restricted_stage and not user_can_see_restricted:
        domain = [('stage_id', '!=', restricted_stage.id)]
    
    # Build context
    context = {
        'group_by': 'stage_id',
        'default_view_mode': 'kanban',
        'user_can_see_restricted': user_can_see_restricted,
    }
    
    return {
        'name': _('My Tasks'),
        'type': 'ir.actions.act_window',
        'res_model': 'project.task',
        'view_mode': 'kanban,tree,form',
        'views': [
            (self.env.ref('your_module_name.view_task_kanban').id, 'kanban'),
            (False, 'tree'),
            (False, 'form')
        ],
        'domain': domain,
        'context': context,
        'help': '''<p class="o_view_nocontent_smiling_face">No tasks found!</p>
                <p>Only tasks in visible stages are displayed here.</p>''',
    }

Advanced Implementation Scenarios

Scenario 1: Hiding Stages Based on Record Properties

Sometimes you need to hide stages based on properties of the records themselves, not just user roles:

@api.model
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
    result = super().read_group(domain, fields, groupby, offset, limit, orderby, lazy)
    
    if 'stage_id' in groupby:
        # Get current user's department
        user_department = self.env.user.department_id
        
        # Find stages associated with departments
        department_stages = self.env['project.task.stage'].search([
            ('department_ids', '!=', False)
        ])
        
        # Filter out stages not relevant to user's department
        if department_stages and user_department:
            result = [
                r for r in result 
                if not r.get('stage_id') or 
                not self.env['project.task.stage'].browse(r['stage_id'][0]).department_ids or
                user_department in self.env['project.task.stage'].browse(r['stage_id'][0]).department_ids
            ]
    
    return result

Scenario 2: Context-Based Stage Visibility

You might want different visibility rules depending on how the view is accessed:

@api.model
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
    result = super().read_group(domain, fields, groupby, offset, limit, orderby, lazy)
    
    if 'stage_id' in groupby:
        # Check if we're in a specific context
        if self.env.context.get('hide_restricted_stages', True):
            restricted_stage = self.env.ref('your_module_name.restricted_stage', False)
            if restricted_stage:
                result = [r for r in result 
                         if not r.get('stage_id') or r['stage_id'][0] != restricted_stage.id]
    
    return result


This allows you to control visibility through URL parameters or action contexts.

Best Practices and Considerations

Performance Optimization

  • Cache Reference Lookups: If you’re using the same stage references repeatedly, cache them at the class level
  • Minimize Database Queries: Avoid making database calls inside loops
  • Use Efficient Filtering: Prefer list comprehensions over multiple filter calls
# Good practice: Cache the stage reference
RESTRICTED_STAGE_ID = None

@api.model
def _get_restricted_stage_id(self):
    global RESTRICTED_STAGE_ID
    if RESTRICTED_STAGE_ID is None:
        stage = self.env.ref('your_module_name.restricted_stage', raise_if_not_found=False)
        RESTRICTED_STAGE_ID = stage.id if stage else None
    return RESTRICTED_STAGE_ID

@api.model
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
    result = super().read_group(domain, fields, groupby, offset, limit, orderby, lazy)
    
    if 'stage_id' in groupby:
        restricted_stage_id = self._get_restricted_stage_id()
        if restricted_stage_id and not self.user_can_access_restricted():
            result = [r for r in result 
                     if not r.get('stage_id') or r['stage_id'][0] != restricted_stage_id]
    
    return result

Error Handling and Graceful Degradation

Always design your code to work even if stages are missing:

@api.model
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
    result = super().read_group(domain, fields, groupby, offset, limit, orderby, lazy)
    
    if 'stage_id' in groupby:
        try:
            # Attempt to get the stage
            restricted_stage = self.env.ref('your_module_name.restricted_stage')
            
            # Only filter if user shouldn't see it
            if not self.user_can_see_restricted():
                result = [r for r in result 
                         if not r.get('stage_id') or r['stage_id'][0] != restricted_stage.id]
        except ValueError:
            # Stage doesn't exist - proceed without filtering
            pass
    
    return result

Conclusion

Implementing conditional Kanban stage visibility in Odoo 15 is a powerful way to tailor the user experience to specific roles and scenarios. By understanding and properly overriding the read_group() and search() methods, you can create a more focused, efficient interface that guides users through appropriate workflows while hiding irrelevant options.

The key to success lies in:

  • Understanding Odoo’s underlying data flow
  • Implementing filtering at the appropriate data layer
  • Handling edge cases and missing references gracefully
  • Optimizing for performance in your filtering logic

When done correctly, this technique significantly enhances user experience without compromising data integrity or security. Your users will appreciate the cleaner interface, and your business processes will benefit from reduced errors and more focused workflows.

Remember that the most effective implementations are those that blend seamlessly into the Odoo experience—users shouldn’t even notice the filtering is happening, they’ll just find their interface more intuitive and relevant to their daily tasks.

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:

  1. Single Storage Layer: Every product line from every quotation variation gets stored in the standard sale.order.line table
  2. Linking Mechanism: Each sale order line has a multi_quotation_line_id field that links it to its parent quotation
  3. Visibility Control: The active field determines which lines are currently visible and counted in totals
  4. 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:

  1. Multi-Quotation Line Model (x_multi.quotation.line)
  2. Quotation Wizard (x_add.quotation.line.wizard)
  3. Finalization Wizard (x_finalize.quotation.wizard)
  4. 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:

  1. 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”.
  2. 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.
  3. 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.
  4. 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:

  1. Three-State System: The combination of is_multi_quotation and finalized_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
  2. 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.
  3. 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:

  1. During Multi-Quotation Phase: All lines have active=True but are grouped by quotation for display
  2. After Finalization:
    • Selected quotation lines: active=True (visible, counted in totals)
    • Other quotation lines: active=False (hidden, zero contribution)
  3. 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:

  1. 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.
  2. Two-Phase Data Model:
    • Phase 1: User works with transient wizard.line recordsPhase 2: On save, transient data gets converted to permanent sale.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
  3. 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:

  1. Validation Step: Verify that the user filled in the necessary data
  2. Creating Quotation Record: Generate or update the x_multi.quotation.line record
  3. Delete Existing Records: In case of edits, erase all the existing sale.order.line records
  4. Convert Lines: Change each transient wizard line into a permanent sale.order.line
  5. Connect: Check that all new lines have been properly linked to both the sale order and the quotation
  6. Remove Unnecessary Items: The wizard is closed, and the user goes back to the main interface

Reasoning About Design Choices:

  1. 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.
  2. 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)
  3. 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

  1. Draft Multi-Quotation: is_multi_quotation=True, finalized_quotation_line_id=False
  2. Finalized: is_multi_quotation=False, finalized_quotation_line_id=ID
  3. 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

  1. if you have some calculations which you need to access very often, then you should calculate them using computed fields with store=True
  2. Index foreign key fields such as multi_quotation_line_id
  3. within loop, minimize the number of database queries by using recordset operations

Data Integrity

  1. Use ondelete=”cascade” to ensure that there won&#039;t be any orphan references
  2. Put copy=False on those fields which are not eligible for duplication
  3. Implement the right validation in the corresponding action methods

User Experience

  1. Dynamic UI that changes according to the current mode
  2. One can find visual cues very easily for those quotations which have been finalized
  3. The workflow is very logical and it makes the transition from creation to finalization very smooth

Testing and Validation

Key Test Scenarios

  1. Creating multi-quotation with a variety of product lines
  2. Editing quotations and checking if data is still there
  3. Changing the state by finalizing the quotation and then verifying that state has actually changed
  4. Copying sales orders and checking if the copies are really done in the right way
  5. 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.