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

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.