diff --git a/sale_stock_put_to_order/README.rst b/sale_stock_put_to_order/README.rst new file mode 100644 index 00000000000..09ae8fa3948 --- /dev/null +++ b/sale_stock_put_to_order/README.rst @@ -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 `_. +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 `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* ACSONE SA/NV + +Contributors +------------ + +- `ACSONE SA/NV `__ + + - Stéphane Mangin + +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 `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/sale_stock_put_to_order/__init__.py b/sale_stock_put_to_order/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/sale_stock_put_to_order/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/sale_stock_put_to_order/__manifest__.py b/sale_stock_put_to_order/__manifest__.py new file mode 100644 index 00000000000..6edaa2e86cf --- /dev/null +++ b/sale_stock_put_to_order/__manifest__.py @@ -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, +} diff --git a/sale_stock_put_to_order/models/__init__.py b/sale_stock_put_to_order/models/__init__.py new file mode 100644 index 00000000000..550f3043109 --- /dev/null +++ b/sale_stock_put_to_order/models/__init__.py @@ -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 diff --git a/sale_stock_put_to_order/models/res_config_settings.py b/sale_stock_put_to_order/models/res_config_settings.py new file mode 100644 index 00000000000..2f073986d8c --- /dev/null +++ b/sale_stock_put_to_order/models/res_config_settings.py @@ -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." + ), + ) diff --git a/sale_stock_put_to_order/models/stock_location.py b/sale_stock_put_to_order/models/stock_location.py new file mode 100644 index 00000000000..f1c76a0587f --- /dev/null +++ b/sale_stock_put_to_order/models/stock_location.py @@ -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) diff --git a/sale_stock_put_to_order/models/stock_move_line.py b/sale_stock_put_to_order/models/stock_move_line.py new file mode 100644 index 00000000000..99610066dd9 --- /dev/null +++ b/sale_stock_put_to_order/models/stock_move_line.py @@ -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() diff --git a/sale_stock_put_to_order/models/stock_picking.py b/sale_stock_put_to_order/models/stock_picking.py new file mode 100644 index 00000000000..b9404a612b5 --- /dev/null +++ b/sale_stock_put_to_order/models/stock_picking.py @@ -0,0 +1,115 @@ +# Copyright 2026 ACSONE SA/NV +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +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): + """Yield ``(location, quants)`` for PTO bins holding relevant products. + + Bins are yielded most-recently-updated first. When the picking has a + procurement group only bins where products were placed by a validated + move from the **same** group are considered. + """ + 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 = self._filter_quants_by_group(quants, locations, products) + + quants_by_location, max_dates = self._group_quants_by_location(quants) + for location in sorted(max_dates, key=max_dates.__getitem__, reverse=True): + location_quants = quants_by_location[location] + if self._is_pto_location_valid(location, location_quants): + yield location, location_quants + + def _filter_quants_by_group(self, quants, locations, products): + """Keep only quants in bins where this picking's group placed stock.""" + if not self.group_id: + return quants + done_destinations = set( + self.env["stock.move.line"] + .search( + [ + ("location_dest_id", "in", locations.ids), + ("product_id", "in", products.ids), + ("move_id.group_id", "=", self.group_id.id), + ("state", "=", "done"), + ] + ) + .mapped("location_dest_id") + ) + return quants.filtered(lambda q: q.location_id in done_destinations) + + @staticmethod + def _group_quants_by_location(quants): + """Return ``(quants_by_location, max_write_dates)`` dicts.""" + quants_by_location = defaultdict(lambda: quants.browse()) + max_dates = {} + for quant in quants: + loc = quant.location_id + quants_by_location[loc] |= quant + wd = quant.write_date + if loc not in max_dates or wd > max_dates[loc]: + max_dates[loc] = wd + return quants_by_location, max_dates + + 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 diff --git a/sale_stock_put_to_order/models/stock_quant.py b/sale_stock_put_to_order/models/stock_quant.py new file mode 100644 index 00000000000..8fa099151db --- /dev/null +++ b/sale_stock_put_to_order/models/stock_quant.py @@ -0,0 +1,18 @@ +# Copyright 2026 ACSONE SA/NV +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +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), + ] + ) diff --git a/sale_stock_put_to_order/pyproject.toml b/sale_stock_put_to_order/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/sale_stock_put_to_order/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/sale_stock_put_to_order/readme/CONFIGURE.md b/sale_stock_put_to_order/readme/CONFIGURE.md new file mode 100644 index 00000000000..25064f07f7b --- /dev/null +++ b/sale_stock_put_to_order/readme/CONFIGURE.md @@ -0,0 +1,12 @@ +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. diff --git a/sale_stock_put_to_order/readme/CONTRIBUTORS.md b/sale_stock_put_to_order/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..95fd17df06f --- /dev/null +++ b/sale_stock_put_to_order/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [ACSONE SA/NV](https://acsone.eu/) + - Stéphane Mangin \ diff --git a/sale_stock_put_to_order/readme/DESCRIPTION.md b/sale_stock_put_to_order/readme/DESCRIPTION.md new file mode 100644 index 00000000000..bc6329db3e1 --- /dev/null +++ b/sale_stock_put_to_order/readme/DESCRIPTION.md @@ -0,0 +1,21 @@ +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`). diff --git a/sale_stock_put_to_order/readme/USAGE.md b/sale_stock_put_to_order/readme/USAGE.md new file mode 100644 index 00000000000..03938ce2130 --- /dev/null +++ b/sale_stock_put_to_order/readme/USAGE.md @@ -0,0 +1,28 @@ +**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. diff --git a/sale_stock_put_to_order/static/description/index.html b/sale_stock_put_to_order/static/description/index.html new file mode 100644 index 00000000000..a8fb08687b1 --- /dev/null +++ b/sale_stock_put_to_order/static/description/index.html @@ -0,0 +1,491 @@ + + + + + +Sale Stock Put-to-Order + + + +
+

Sale Stock Put-to-Order

+ + +

Beta License: AGPL-3 OCA/sale-workflow Translate me on Weblate Try me on Runboat

+

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

+ +
+

Configuration

+
    +
  1. Navigate to Inventory ‣ Configuration ‣ Warehouses ‣ Locations.
  2. +
  3. Open (or create) the parent location that represents the put-to-order +area (e.g. PTO Zone).
  4. +
  5. Tick the Is PTO checkbox. All child locations inherit the flag.
  6. +
  7. On the relevant Operation Type (e.g. Receipts), set the +Default Destination Location to the PTO root.
  8. +
  9. 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.
  10. +
+
+
+

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. +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.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • ACSONE SA/NV
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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 project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/sale_stock_put_to_order/tests/__init__.py b/sale_stock_put_to_order/tests/__init__.py new file mode 100644 index 00000000000..638eb1da92e --- /dev/null +++ b/sale_stock_put_to_order/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_func_auto_select +from . import test_func_sale_workflow +from . import test_unit_stock_picking diff --git a/sale_stock_put_to_order/tests/common.py b/sale_stock_put_to_order/tests/common.py new file mode 100644 index 00000000000..9cde250eb6a --- /dev/null +++ b/sale_stock_put_to_order/tests/common.py @@ -0,0 +1,108 @@ +# Copyright 2026 ACSONE SA/NV +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from odoo import fields +from odoo.tests.common import TransactionCase + + +class TestPtoCommon(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.product = cls.env["product.product"].create( + {"name": "Product A", "type": "consu", "is_storable": True} + ) + cls.other_product = cls.env["product.product"].create( + {"name": "Product B", "type": "consu", "is_storable": True} + ) + cls.warehouse = cls.env["stock.warehouse"].search([], limit=1) + cls.picking_type = cls.warehouse.in_type_id + cls.stock_location = cls.warehouse.lot_stock_id + cls.pto_root = cls.env["stock.location"].create( + {"name": "PTO Root", "usage": "internal"} + ) + cls.pto_bin_1 = cls.env["stock.location"].create( + { + "name": "PTO Bin 1", + "usage": "internal", + "location_id": cls.pto_root.id, + } + ) + cls.pto_bin_2 = cls.env["stock.location"].create( + { + "name": "PTO Bin 2", + "usage": "internal", + "location_id": cls.pto_root.id, + } + ) + cls.pto_other = cls.env["stock.location"].create( + { + "name": "PTO Other", + "usage": "internal", + "location_id": cls.pto_root.id, + } + ) + + cls.pto_root.is_pto = True + cls.picking_type.default_location_dest_id = cls.pto_root + + cls.picking = cls.env["stock.picking"].create( + { + "picking_type_id": cls.picking_type.id, + "location_id": cls.pto_bin_1.id, + "location_dest_id": cls.stock_location.id, + } + ) + + cls.env["stock.move"].create( + { + "name": "PTO Move", + "picking_id": cls.picking.id, + "product_id": cls.product.id, + "product_uom_qty": 1, + "product_uom": cls.product.uom_id.id, + "location_id": cls.pto_bin_1.id, + "location_dest_id": cls.stock_location.id, + } + ) + cls.set_quantity(cls.pto_bin_1, cls.product, 10) + cls.set_quantity(cls.pto_bin_2, cls.product, 1) + cls.set_quantity(cls.pto_other, cls.other_product, 1) + + @classmethod + def reset_quantity(cls, locations, products): + cls.env["stock.quant"].search( + [("location_id", "in", locations.ids), ("product_id", "in", products.ids)] + ).unlink() + + @classmethod + def set_quantity(cls, location, product, quantity): + cls.env["stock.quant"]._update_available_quantity(product, location, quantity) + + +class TestSalePtoCommon(TestPtoCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create({"name": "Customer"}) + cls.sale_order = cls.env["sale.order"].create( + { + "partner_id": cls.partner.id, + "date_order": fields.Datetime.now(), + "order_line": [ + ( + 0, + 0, + { + "name": cls.product.name, + "product_id": cls.product.id, + "product_uom_qty": 1, + "product_uom": cls.product.uom_id.id, + "price_unit": 100, + }, + ) + ], + } + ) + cls.sale_line = cls.sale_order.order_line[0] + cls.picking.move_ids.write({"sale_line_id": cls.sale_line.id}) diff --git a/sale_stock_put_to_order/tests/test_func_auto_select.py b/sale_stock_put_to_order/tests/test_func_auto_select.py new file mode 100644 index 00000000000..aded1779deb --- /dev/null +++ b/sale_stock_put_to_order/tests/test_func_auto_select.py @@ -0,0 +1,92 @@ +# Copyright 2026 ACSONE SA/NV +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from .common import TestPtoCommon + + +class TestPtoFunctionalAutoSelect(TestPtoCommon): + """End-to-end tests for auto-select PTO destination on reception.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env["ir.config_parameter"].sudo().set_param( + "sale_stock_put_to_order.auto_select_location", "True" + ) + cls.supplier = cls.env.ref("stock.stock_location_suppliers") + + def _create_receipt(self, product, qty=1): + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type.id, + "location_id": self.supplier.id, + "location_dest_id": self.pto_root.id, + } + ) + self.env["stock.move"].create( + { + "name": product.name, + "picking_id": picking.id, + "product_id": product.id, + "product_uom_qty": qty, + "product_uom": product.uom_id.id, + "location_id": self.supplier.id, + "location_dest_id": self.pto_root.id, + } + ) + return picking + + def test_reception_routes_to_pto_bin(self): + """Incoming goods redirected to the PTO bin holding matching stock.""" + self.set_quantity(self.supplier, self.product, 100) + picking = self._create_receipt(self.product) + picking.action_confirm() + picking.action_assign() + pto_bins = self.pto_bin_1 | self.pto_bin_2 + for ml in picking.move_line_ids: + self.assertIn(ml.location_dest_id, pto_bins) + + def test_reception_unknown_product_falls_back(self): + """Product absent from all PTO bins keeps the root destination.""" + unknown = self.env["product.product"].create( + {"name": "Unknown", "type": "consu", "is_storable": True} + ) + self.set_quantity(self.supplier, unknown, 10) + picking = self._create_receipt(unknown) + picking.action_confirm() + picking.action_assign() + pto_bins = self.pto_bin_1 | self.pto_bin_2 | self.pto_other + for ml in picking.move_line_ids: + self.assertNotIn(ml.location_dest_id, pto_bins) + + def test_reception_two_products_same_bin(self): + """Two products stocked in the same bin: both move lines go there.""" + self.set_quantity(self.pto_bin_1, self.other_product, 3) + self.set_quantity(self.supplier, self.product, 50) + self.set_quantity(self.supplier, self.other_product, 50) + + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type.id, + "location_id": self.supplier.id, + "location_dest_id": self.pto_root.id, + } + ) + for prod in (self.product, self.other_product): + self.env["stock.move"].create( + { + "name": prod.name, + "picking_id": picking.id, + "product_id": prod.id, + "product_uom_qty": 1, + "product_uom": prod.uom_id.id, + "location_id": self.supplier.id, + "location_dest_id": self.pto_root.id, + } + ) + picking.action_confirm() + picking.action_assign() + dests = picking.move_line_ids.mapped("location_dest_id") + self.assertTrue( + all(d in (self.pto_bin_1 | self.pto_bin_2 | self.pto_other) for d in dests) + ) diff --git a/sale_stock_put_to_order/tests/test_func_sale_workflow.py b/sale_stock_put_to_order/tests/test_func_sale_workflow.py new file mode 100644 index 00000000000..b79162de268 --- /dev/null +++ b/sale_stock_put_to_order/tests/test_func_sale_workflow.py @@ -0,0 +1,99 @@ +# Copyright 2026 ACSONE SA/NV +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from .common import TestSalePtoCommon + + +class TestPtoFunctionalSaleOrder(TestSalePtoCommon): + """End-to-end tests: sale order → reception → PTO bin resolution.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.supplier = cls.env.ref("stock.stock_location_suppliers") + + def _create_receipt_for_sale(self, sale_order): + """Create a reception picking linked to a sale order.""" + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type.id, + "location_id": self.supplier.id, + "location_dest_id": self.pto_root.id, + } + ) + for line in sale_order.order_line: + self.env["stock.move"].create( + { + "name": line.product_id.name, + "picking_id": picking.id, + "product_id": line.product_id.id, + "product_uom_qty": line.product_uom_qty, + "product_uom": line.product_uom.id, + "location_id": self.supplier.id, + "location_dest_id": self.pto_root.id, + "sale_line_id": line.id, + } + ) + return picking + + def test_bin_groups_from_sale_order(self): + """Bin groups reflect the full SO product scope.""" + picking = self._create_receipt_for_sale(self.sale_order) + bin_groups = picking._get_pto_bin_groups() + self.assertIn(self.product.id, bin_groups) + self.assertEqual(bin_groups[self.product.id]["name"], self.pto_bin_1.name) + + def test_bin_groups_multi_product_sale_order(self): + """All SO products mapped to the bin that holds them.""" + self.set_quantity(self.pto_bin_1, self.other_product, 5) + self.env["sale.order.line"].create( + { + "order_id": self.sale_order.id, + "name": self.other_product.name, + "product_id": self.other_product.id, + "product_uom_qty": 1, + "product_uom": self.other_product.uom_id.id, + "price_unit": 50, + } + ) + picking = self._create_receipt_for_sale(self.sale_order) + bin_groups = picking._get_pto_bin_groups() + self.assertIn(self.product.id, bin_groups) + self.assertIn(self.other_product.id, bin_groups) + self.assertEqual( + bin_groups[self.product.id]["name"], + bin_groups[self.other_product.id]["name"], + ) + + def test_auto_select_with_sale_order(self): + """Auto-select routes reception to PTO bin using SO product scope.""" + self.env["ir.config_parameter"].sudo().set_param( + "sale_stock_put_to_order.auto_select_location", "True" + ) + self.set_quantity(self.supplier, self.product, 100) + picking = self._create_receipt_for_sale(self.sale_order) + picking.action_confirm() + picking.action_assign() + pto_bins = self.pto_bin_1 | self.pto_bin_2 + for ml in picking.move_line_ids: + self.assertIn(ml.location_dest_id, pto_bins) + + def test_no_bin_group_when_pto_disabled(self): + """No bin groups returned when PTO flag is cleared.""" + self.pto_root.is_pto = False + picking = self._create_receipt_for_sale(self.sale_order) + bin_groups = picking._get_pto_bin_groups() + self.assertEqual(bin_groups, {}) + + def test_source_products_via_group_id(self): + """SO found via procurement group when sale_line_id is missing. + + Internal transfers (e.g. Dispatch -> PTO) share the procurement + group with the originating SO but don't carry sale_line_id. + """ + self.sale_order.action_confirm() + picking = self._create_receipt_for_sale(self.sale_order) + picking.move_ids.write({"sale_line_id": False}) + picking.group_id = self.sale_order.procurement_group_id + products = picking._get_pto_source_products() + self.assertIn(self.product, products) diff --git a/sale_stock_put_to_order/tests/test_unit_stock_picking.py b/sale_stock_put_to_order/tests/test_unit_stock_picking.py new file mode 100644 index 00000000000..ce766b54990 --- /dev/null +++ b/sale_stock_put_to_order/tests/test_unit_stock_picking.py @@ -0,0 +1,116 @@ +# Copyright 2026 ACSONE SA/NV +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from datetime import datetime + +from .common import TestPtoCommon + + +class TestStockPicking(TestPtoCommon): + """Unit tests for behaviors not testable through functional tests.""" + + def _first_dest(self, excluded=None): + """Helper: return the first candidate from the generator.""" + return next( + ( + loc + for loc, _ in self.picking._find_pto_dest_location_and_quants( + excluded_locations=excluded or None + ) + ), + self.env["stock.location"], + ) + + def _set_write_dates(self, bin1_date, bin2_date): + """Force write_date on quants via SQL (ORM ignores manual timestamps).""" + for location, date in ( + (self.pto_bin_1, bin1_date), + (self.pto_bin_2, bin2_date), + ): + self.env.cr.execute( + "UPDATE stock_quant SET write_date = %s WHERE location_id = %s", + (date, location.id), + ) + self.env["stock.quant"].invalidate_model(["write_date"]) + + # -- Write-date ordering --------------------------------------------------- + + def test_most_recent_bin_first(self): + """First candidate is the bin with the most recently updated quants.""" + self._set_write_dates(datetime(2026, 1, 1), datetime(2026, 1, 2)) + dest = self._first_dest() + self.assertEqual(dest, self.pto_bin_2) + + # -- Storage category ------------------------------------------------------ + + def test_storage_category_rejects_bin(self): + """Candidate rejected when storage category forbids mixed products.""" + storage_category = self.env["stock.storage.category"].create( + { + "name": "Limited PTO", + "allow_new_product": "empty", + } + ) + self.pto_bin_2.storage_category_id = storage_category + dest = self._first_dest(excluded=self.pto_bin_1) + self.assertNotEqual(dest, self.pto_bin_2) + + # -- Procurement group filtering ------------------------------------------- + + def _create_done_move_line(self, product, location_dest, group): + """Create a validated move line that placed *product* in *location_dest*.""" + move = self.env["stock.move"].create( + { + "name": "Done PTO Move", + "product_id": product.id, + "product_uom_qty": 1, + "product_uom": product.uom_id.id, + "location_id": self.stock_location.id, + "location_dest_id": location_dest.id, + "group_id": group.id, + } + ) + self.env["stock.move.line"].create( + { + "move_id": move.id, + "product_id": product.id, + "product_uom_id": product.uom_id.id, + "location_id": self.stock_location.id, + "location_dest_id": location_dest.id, + "quantity": 1, + } + ) + move.state = "done" + + def test_no_group_returns_all_bins(self): + """Without procurement group all bins with stock are considered.""" + self.assertFalse(self.picking.group_id) + locations = [ + loc for loc, _ in self.picking._find_pto_dest_location_and_quants() + ] + self.assertIn(self.pto_bin_1, locations) + self.assertIn(self.pto_bin_2, locations) + + def test_group_restricts_to_matching_bins(self): + """Only bins where the same procurement group placed products.""" + group = self.env["procurement.group"].create({"name": "SO-001"}) + self.picking.group_id = group + self._create_done_move_line(self.product, self.pto_bin_1, group) + locations = [ + loc for loc, _ in self.picking._find_pto_dest_location_and_quants() + ] + self.assertIn(self.pto_bin_1, locations) + self.assertNotIn(self.pto_bin_2, locations) + + def test_group_no_done_moves_returns_nothing(self): + """Group set but no done moves for that group yields nothing.""" + group = self.env["procurement.group"].create({"name": "SO-003"}) + self.picking.group_id = group + locations = [ + loc for loc, _ in self.picking._find_pto_dest_location_and_quants() + ] + self.assertFalse(locations) + locations = [ + loc for loc, _ in self.picking._find_pto_dest_location_and_quants() + ] + self.assertFalse(locations) diff --git a/sale_stock_put_to_order/views/res_config_settings_views.xml b/sale_stock_put_to_order/views/res_config_settings_views.xml new file mode 100644 index 00000000000..083c52fe47d --- /dev/null +++ b/sale_stock_put_to_order/views/res_config_settings_views.xml @@ -0,0 +1,24 @@ + + + + + res.config.settings.view.form.inherit.sale_stock_put_to_order + res.config.settings + + + + + + + + + + + + diff --git a/sale_stock_put_to_order/views/stock_location_views.xml b/sale_stock_put_to_order/views/stock_location_views.xml new file mode 100644 index 00000000000..78f0d01d8ce --- /dev/null +++ b/sale_stock_put_to_order/views/stock_location_views.xml @@ -0,0 +1,16 @@ + + + + + stock.location.form.pto + stock.location + + + + + + + + +