-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
[18.0][ADD] sale_stock_put_to_order: put-to-order zone configuration and target resolution #4263
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| from . import models |
| 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, | ||
| } |
| 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 |
| 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." | ||
| ), | ||
| ) |
| 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( | ||
| 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 | ||
|
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) | ||
| 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() |
| 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 |
| 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), | ||
| ] | ||
| ) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. Hope i understood well you concern |
||
Uh oh!
There was an error while loading. Please reload this page.