Skip to content
Open
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 <stephane.mangin@acsone.eu>

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 LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).

{
"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": "LGPL-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 LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).

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 LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).

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(
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

@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 LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).

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()
Loading
Loading