Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions sale_stock_put_to_order/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
=======================
Sale Stock Put-to-Order
=======================

..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:7576ab5cad6bc2dbd9aced52f62da9a2319a33d1e3ddf237a6f2d1d788a2d248
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsale--workflow-lightgray.png?logo=github
:target: https://github.com/OCA/sale-workflow/tree/18.0/sale_stock_put_to_order
:alt: OCA/sale-workflow
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/sale-workflow-18-0/sale-workflow-18-0-sale_stock_put_to_order
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/sale-workflow&target_branch=18.0
:alt: Try me on Runboat

|badge1| |badge2| |badge3| |badge4| |badge5|

Configure locations as **put-to-order** zones to direct incoming goods
to bins that already hold products from the same order.

When a picking targets a PTO root location, the system examines child
bins for existing stock of the relevant products and proposes a
destination bin that passes storage-category validation.

**Key features:**

- Recursive ``is_pto`` boolean on ``stock.location`` — set on a parent
and all children inherit the flag automatically.
- **Sale-order awareness** — when a picking is linked to a sale order,
the resolution considers all products from the order (including lines
not yet delivered) for more accurate bin selection on partial
deliveries and backorders.
- Optional **auto-select** mode that automatically sets the destination
on move lines during reservation (via ``_apply_putaway_strategy``).
- Deterministic candidate ordering for reproducible bin selection.
- Extensible hook methods for custom validation rules
(``_is_pto_location_valid``, ``_get_pto_source_products``,
``_prepare_pto_bin_group_vals``).

**Table of contents**

.. contents::
:local:

Configuration
=============

1. Navigate to **Inventory ‣ Configuration ‣ Warehouses ‣ Locations**.
2. Open (or create) the parent location that represents the put-to-order
area (e.g. *PTO Zone*).
3. Tick the **Is PTO** checkbox. All child locations inherit the flag.
4. On the relevant **Operation Type** (e.g. *Receipts*), set the
*Default Destination Location* to the PTO root.
5. Optionally, go to **Inventory ‣ Configuration ‣ Settings** and enable
**Auto-select PTO destination** under the *Put to Order* section.
When enabled, the system automatically assigns the proposed PTO bin
as the destination on move lines during reservation
(``action_assign``). When disabled, the destination is only proposed
and must be applied manually.

Usage
=====

**Manual resolution**

Call
``picking._find_pto_dest_location_and_quants(excluded_locations=source)``
to iterate over candidate bins. The generator yields
``(location, quants)`` pairs for locations with positive stock that pass
storage-category validation.

Use ``picking._get_pto_bin_groups()`` to obtain a mapping of
``{product_id: {"name": bin_name}}`` for all products that already have
stock in a valid PTO bin.

**Automatic destination assignment**

When the *Auto-select PTO destination* setting is enabled, the module
overrides ``_apply_putaway_strategy()`` on ``stock.move.line``. During
reservation (``action_assign``), each move line whose picking targets a
PTO zone is automatically redirected to the first valid bin. Lines
without a PTO match fall back to the standard putaway strategy.

**Extending the resolution logic:**

- Override ``_get_pto_source_products()`` to widen or narrow the product
scope (e.g. include all products from a linked sale order).
- Override ``_is_pto_location_valid()`` to add custom checks (lot
compatibility, weight limit, expiry date).
- Override ``_prepare_pto_bin_group_vals()`` to enrich the bin group
payload with additional data.

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/OCA/sale-workflow/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/OCA/sale-workflow/issues/new?body=module:%20sale_stock_put_to_order%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

Do not contact contributors directly about support or help with technical issues.

Credits
=======

Authors
-------

* ACSONE SA/NV

Contributors
------------

- `ACSONE SA/NV <https://acsone.eu/>`__

- Stéphane Mangin <[email protected]>

Maintainers
-----------

This module is maintained by the OCA.

.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org

OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.

This module is part of the `OCA/sale-workflow <https://github.com/OCA/sale-workflow/tree/18.0/sale_stock_put_to_order>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
1 change: 1 addition & 0 deletions sale_stock_put_to_order/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
21 changes: 21 additions & 0 deletions sale_stock_put_to_order/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Copyright 2026 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

{
"name": "Sale Stock Put-to-Order",
"summary": "Sale-order-aware put-to-order zone configuration"
" and target location resolution.",
"version": "18.0.1.0.0",
"license": "AGPL-3",
"author": "ACSONE SA/NV, Odoo Community Association (OCA)",
"website": "https://github.com/OCA/sale-workflow",
"depends": [
# Odoo Community
"sale_stock",
],
"data": [
"views/stock_location_views.xml",
"views/res_config_settings_views.xml",
],
"installable": True,
}
5 changes: 5 additions & 0 deletions sale_stock_put_to_order/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from . import res_config_settings
from . import stock_location
from . import stock_move_line
from . import stock_picking
from . import stock_quant
17 changes: 17 additions & 0 deletions sale_stock_put_to_order/models/res_config_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Copyright 2026 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from odoo import fields, models


class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"

pto_auto_select_location = fields.Boolean(
string="Auto-select PTO destination",
config_parameter="sale_stock_put_to_order.auto_select_location",
help=(
"Automatically assign the put-to-order proposed bin as the "
"destination on move lines during reservation."
),
)
56 changes: 56 additions & 0 deletions sale_stock_put_to_order/models/stock_location.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Copyright 2026 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from odoo import api, fields, models


class StockLocation(models.Model):
_inherit = "stock.location"

is_pto = fields.Boolean(
compute="_compute_is_pto",
inverse="_inverse_is_pto",
store=True,
recursive=True,
readonly=False,
help=(
"Technical field indicating whether this location"
" belongs to a put-to-order area."
),
)
parent_is_pto = fields.Boolean(
Comment thread
yvaucher marked this conversation as resolved.
compute="_compute_parent_is_pto",
help=(
"Technical field indicating whether the parent"
" location is a put-to-order area."
),
)

@api.depends("location_id.is_pto")
def _compute_is_pto(self):
for location in self:
if location.location_id:
location.is_pto = location.location_id.is_pto

def _inverse_is_pto(self):
return
Comment thread
yvaucher marked this conversation as resolved.

@api.depends("location_id.is_pto")
def _compute_parent_is_pto(self):
for location in self:
location.parent_is_pto = bool(location.location_id.is_pto)

def _search_pto(
self, excluded_locations=None, company=None, extra_domain=None, limit=None
):
"""Search child locations of this PTO root."""
if not self:
return self.browse()
domain = [("id", "child_of", self.id)]
if excluded_locations:
domain.append(("id", "not in", excluded_locations.ids))
if company:
domain.append(("company_id", "=", company.id))
if extra_domain:
domain += extra_domain
return self.search(domain, limit=limit)
46 changes: 46 additions & 0 deletions sale_stock_put_to_order/models/stock_move_line.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Copyright 2026 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from odoo import models


class StockMoveLine(models.Model):
_inherit = "stock.move.line"

def _apply_putaway_strategy(self):
auto_select = (
self.env["ir.config_parameter"]
.sudo()
.get_param("sale_stock_put_to_order.auto_select_location")
== "True"
)
if not auto_select:
return super()._apply_putaway_strategy()

pto_handled = self.browse()
resolved_cache = {}
for sml in self:
picking = sml.picking_id
if not picking:
continue
if picking.id not in resolved_cache:
root = picking._get_pto_root_location()
if root:
dest = next(
(
loc
for loc, _ in (picking._find_pto_dest_location_and_quants())
),
None,
)
else:
dest = None
resolved_cache[picking.id] = dest
dest = resolved_cache[picking.id]
if dest:
sml.location_dest_id = dest
pto_handled |= sml

remaining = self - pto_handled
if remaining:
super(StockMoveLine, remaining)._apply_putaway_strategy()
78 changes: 78 additions & 0 deletions sale_stock_put_to_order/models/stock_picking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Copyright 2026 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from collections import defaultdict

from odoo import models


class StockPicking(models.Model):
_inherit = "stock.picking"

def _get_pto_root_location(self):
"""Return the configured put-to-order root location."""
self.ensure_one()
dest = self.picking_type_id.default_location_dest_id
return dest if dest and dest.is_pto else self.env["stock.location"]

def _get_pto_source_products(self):
"""Return products relevant to PTO resolution.

When a sale order is linked (via sale_line_id or procurement group),
returns the full SO product list (including lines not yet delivered)
so that bin selection considers the complete order context.
Falls back to move products otherwise.
"""
self.ensure_one()
sale_orders = self.move_ids.mapped("sale_line_id.order_id")
if not sale_orders and self.group_id:
sale_orders = self.env["sale.order"].search(
[("procurement_group_id", "=", self.group_id.id)], limit=1
)
source_sale_order = sale_orders[:1]
if source_sale_order:
return source_sale_order.order_line.mapped("product_id")
return self.move_ids.product_id

def _is_pto_location_valid(self, location, quants):
"""Check whether *location* can accept the products in *candidate_quants*."""
location_qty = sum(quants.mapped("quantity"))
return all(
location._check_can_be_used(
q.product_id, quantity=0, location_qty=location_qty
)
for q in quants
)

def _find_pto_dest_location_and_quants(self, excluded_locations=None):
"""Find a compatible PTO bin that already holds relevant products."""
self.ensure_one()
root = self._get_pto_root_location()
locations = root._search_pto(
excluded_locations=excluded_locations, company=self.company_id
)
products = self._get_pto_source_products()
if not products or not locations:
return
quants = self.env["stock.quant"]._search_pto(locations, products)
quants_by_location = defaultdict(lambda: self.env["stock.quant"])
for quant in quants:
quants_by_location[quant.location_id] |= quant
for location in sorted(quants_by_location, key=lambda loc: loc.id):
location_quants = quants_by_location[location]
if self._is_pto_location_valid(location, location_quants):
yield location, location_quants

def _prepare_pto_bin_group_vals(self, location):
"""Build the bin group dict for a single *location*."""
return {"name": location.name}

def _get_pto_bin_groups(self):
"""Return ``{product_id: bin_group_vals}`` for valid PTO bins."""
self.ensure_one()
bin_groups = {}
for location, quants in self._find_pto_dest_location_and_quants():
vals = self._prepare_pto_bin_group_vals(location)
for quant in quants:
bin_groups.setdefault(quant.product_id.id, vals)
return bin_groups
18 changes: 18 additions & 0 deletions sale_stock_put_to_order/models/stock_quant.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Copyright 2026 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from odoo import models


class StockQuant(models.Model):
_inherit = "stock.quant"

def _search_pto(self, locations, products):
"""Return quants with positive stock for *products* in *locations*."""
return self.search(
[
("location_id", "in", locations.ids),
("product_id", "in", products.ids),
("quantity", ">", 0),
]
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a kindly reminder, I've not read the whole PR !

as far I remember, at least in previous versions, quant are not meant to be unique to avoid some concurrency issues. So if you really needs products with available quantities you should consider group by.

Also, I'm wonder if you consider physical stock or available stock ?

Copy link
Copy Markdown
Author

@StephaneMangin StephaneMangin Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the physical quantity indeed. I think this is sufficient for this behavior because we just want to know where certain products are to select the correct location for the put-to-order action.

    quantity = fields.Float(
        'Quantity',
        help='Quantity of products in this quant, in the default unit of measure of the product',
        readonly=True, digits='Product Unit of Measure')

    reserved_quantity = fields.Float(
        'Reserved Quantity',
        default=0.0,
        help='Quantity of reserved products in this quant, in the default unit of measure of the product',
        readonly=True, required=True, digits='Product Unit of Measure')

    available_quantity = fields.Float(
        'Available Quantity',
        help="On hand quantity which hasn't been reserved on a transfer, in the default unit of measure of the product",
        compute='_compute_available_quantity', digits='Product Unit of Measure')

Hope i understood well you concern

Loading
Loading