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.
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.
3. Automated Logistics Workflows
Automation includes:
Replenishment rules
Order fulfillment
Route optimization
Barcode scanning
This reduces manual work and improves speed and accuracy.
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.
5. Full Traceability with Lots & Serial Numbers
Track every product:
From supplier to customer
Across warehouses and locations
This improves accountability and reduces losses.
6. Integrated Invoicing & Accounting
Odoo connects logistics with finance:
Automatic invoice generation
Cost tracking per shipment
Real-time financial reporting
No more manual reconciliation.
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.
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:
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.
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.
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, _classProjectTask(models.Model): _inherit ='project.task'@api.modeldefread_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_idif'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 outif restricted_stage andnot user_can_see_restricted:# Filter out the restricted stage from results result =[r for r in result ifnot 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.modeldefsearch(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 domainif restricted_stage andnot user_can_see_restricted:# Add condition to exclude the restricted stage new_domain = domain +[('stage_id','!=', restricted_stage.id)]returnsuper(ProjectTask,self).search( new_domain,offset=offset,limit=limit,order=order,count=count)# Otherwise, proceed with standard searchreturnsuper(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.modeldefaction_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 andnot 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.modeldefread_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 departmentif department_stages and user_department: result =[ r for r in result ifnot r.get('stage_id')ornotself.env['project.task.stage'].browse(r['stage_id'][0]).department_ids or user_department inself.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.modeldefread_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 contextifself.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 ifnot 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 referenceRESTRICTED_STAGE_ID =None@api.modeldef_get_restricted_stage_id(self):global RESTRICTED_STAGE_IDif RESTRICTED_STAGE_ID isNone: stage =self.env.ref('your_module_name.restricted_stage',raise_if_not_found=False) RESTRICTED_STAGE_ID = stage.id if stage elseNonereturn RESTRICTED_STAGE_ID@api.modeldefread_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 andnotself.user_can_access_restricted(): result =[r for r in result ifnot 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.modeldefread_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 itifnotself.user_can_see_restricted(): result =[r for r in result ifnot r.get('stage_id')or r['stage_id'][0]!= restricted_stage.id]exceptValueError:# Stage doesn't exist - proceed without filteringpassreturn 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.
We tailor to your workflows so your team can focus on what matters