The problem

A distributor we’ve been working with runs three warehouses — one in Richmond, one in Surrey, and a small consignment depot on Vancouver Island. Their sales team is split regionally. Each rep should only quote stock from the warehouse they’re responsible for; the Surrey rep shouldn’t promise units sitting in Richmond, because by the time the order ships, those units are usually already spoken for.

The default Odoo setup doesn’t enforce this. A salesperson can run a stock report against any warehouse and quote whatever’s available. That’s a feature when you’re a small single-site shop. It’s a bug when you have regional managers fighting over the same pallet.

You can solve this with a record rule. It’s twenty lines of XML and you don’t need to fork any of Odoo’s core modules.

What we’re going to build

  1. A new field on res.usersallowed_warehouse_ids — that lists which warehouses each rep can see.
  2. An ir.rule on stock.quant that filters stock records to those warehouses for users in the Sales / User group.
  3. A second rule on stock.warehouse itself, so the warehouse dropdowns in quotations only show the allowed warehouses.
  4. An exception for the Inventory Manager group, who should still see everything.

The data model

Start with a small module. Call it aphid_warehouse_acl. Manifest, models, security, data — standard layout.

# models/res_users.py
from odoo import fields, models


class ResUsers(models.Model):
    _inherit = "res.users"

    allowed_warehouse_ids = fields.Many2many(
        comodel_name="stock.warehouse",
        relation="res_users_allowed_warehouse_rel",
        column1="user_id",
        column2="warehouse_id",
        string="Allowed Warehouses",
        help="Warehouses this user is permitted to see stock for. "
             "Leave empty for full access (controlled by group instead).",
    )

Nothing exciting. A many-to-many to stock.warehouse. The help text matters — we want admins to know that “empty” doesn’t mean “no access,” it means “fall back to the group rules.” We’ll wire that up in the rule’s domain.

The record rule

This is the heart of it. We’re scoping stock.quant — the on-hand quantities table — to records whose warehouse falls inside the user’s allowed list.

<!-- security/ir_rules.xml -->
<record id="stock_quant_warehouse_acl" model="ir.rule">
    <field name="name">Stock quants: restrict to allowed warehouses</field>
    <field name="model_id" ref="stock.model_stock_quant"/>
    <field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
    <field name="domain_force">
        [
            '|',
            ('user.allowed_warehouse_ids', '=', False),
            ('location_id.warehouse_id', 'in', user.allowed_warehouse_ids.ids),
        ]
    </field>
    <field name="perm_read" eval="True"/>
    <field name="perm_write" eval="False"/>
    <field name="perm_create" eval="False"/>
    <field name="perm_unlink" eval="False"/>
</record>

A few things worth noting in that domain:

  • The leading '|' is an OR. If the user has no allowed warehouses configured, they fall through to a “see everything in your group” default. That keeps the rule additive — turning it on doesn’t lock everyone out by accident.
  • We resolve location_id.warehouse_id rather than going straight to a warehouse field on the quant, because not every stock location belongs to a warehouse (think: transit, virtual, scrap). Quants in those locations will have warehouse_id = False, and the rule will filter them out for restricted users. That’s the correct behaviour — a regional rep shouldn’t see transit stock either.
  • The rule is read-only. Salespeople shouldn’t be modifying quants anyway, but we’re explicit about it.

Warehouse dropdowns

The stock quant rule is the meat. But if you stop there, your sales team will still see the other warehouses in dropdowns — they’ll just get empty results when they pick one. Cleaner is to filter the warehouse list itself.

<record id="stock_warehouse_acl" model="ir.rule">
    <field name="name">Warehouses: restrict visibility for sales</field>
    <field name="model_id" ref="stock.model_stock_warehouse"/>
    <field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
    <field name="domain_force">
        [
            '|',
            ('user.allowed_warehouse_ids', '=', False),
            ('id', 'in', user.allowed_warehouse_ids.ids),
        ]
    </field>
</record>

The Inventory Manager exception

Inventory managers need the full picture. Odoo handles this elegantly: rules attached to specific groups are only evaluated against users in those groups. A user who is both a Salesperson and an Inventory Manager will have both rule sets applied, ORed together. So if your inventory manager has no allowed_warehouse_ids set, they’ll see everything through the manager group’s implicit unrestricted access. If they do have a list, you’ll restrict them — which is probably not what you want.

The clean fix is: only assign allowed_warehouse_ids values for users who should be restricted. Don’t populate the field for inventory managers, admins, or directors. The “empty means full access” branch we built into the domain handles it.

Verifying it works

Three checks to run before you call it done:

  1. Log in as a restricted salesperson and open the Inventory » Reporting » Stock at Date report. You should see only the configured warehouses.
  2. Create a quotation and pick a product. The “Available” hint under the quantity field should reflect only stock at the allowed warehouses.
  3. Try the URL trick. Take a known stock.quant ID for a forbidden warehouse and try to open it directly via /odoo/action-stock.quant_action/<id>. Odoo should redirect to the list view, empty — not throw a permission error, not show the record. If it shows the record, your rule isn’t loaded.

Gotchas we hit

Three things to watch:

  • Reservations. A salesperson can still create a quotation that reserves stock from a warehouse they technically can’t see, if you don’t also lock down the warehouse selection on the quotation itself. Belt and braces: set a domain on sale.order.warehouse_id in a view extension.
  • Forecasts. Odoo’s forecasting widget queries across all warehouses by default. The same record rule covers it, but the UI will quietly hide warehouses you don’t have access to. That’s usually fine, but flag it to the team so they don’t think the forecast is broken.
  • Reports. Custom QWeb reports that bypass the ORM (via raw SQL) will not honour record rules. Always go through the ORM if you can. If you can’t, scope the query manually with the same domain.

Wrap-up

Record rules are one of the most powerful and most underused features in Odoo’s permission model. A short rule like the one above replaces what most teams try to solve with custom modules, view overrides, or — worst case — trusting people to do the right thing. The whole module sits at about thirty lines of code and survives Odoo upgrades cleanly because it doesn’t touch any core logic.

If your team is wrestling with multi-warehouse permissions in Odoo and the patterns above don’t quite fit your situation, drop us a line. We’ve done variants of this for inventory, accounting and HR contexts.


About the author. Shane runs Aphid Consulting, an Odoo Silver Partner in Victoria, BC. He has been implementing ERP systems since the OpenERP 7 days and has the scars to prove it.

Keep reading

More from the blog

All articles

Need help with this?

We do this kind of work daily.

Talk to a real Odoo consultant about your inventory permission model.

Book a consultation