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..767e575e85d --- /dev/null +++ b/sale_stock_put_to_order/__manifest__.py @@ -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, +} 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..45817cad164 --- /dev/null +++ b/sale_stock_put_to_order/models/res_config_settings.py @@ -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." + ), + ) 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..9e3ffc88f43 --- /dev/null +++ b/sale_stock_put_to_order/models/stock_location.py @@ -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 + + @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..5e01829d265 --- /dev/null +++ b/sale_stock_put_to_order/models/stock_move_line.py @@ -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() 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..eba6b5a1d39 --- /dev/null +++ b/sale_stock_put_to_order/models/stock_picking.py @@ -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 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..09cd3670f5e --- /dev/null +++ b/sale_stock_put_to_order/models/stock_quant.py @@ -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), + ] + ) 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..1ed8aec829e --- /dev/null +++ b/sale_stock_put_to_order/tests/__init__.py @@ -0,0 +1,6 @@ +from . import test_func_auto_select +from . import test_func_sale_workflow +from . import test_unit_stock_location +from . import test_unit_stock_move_line +from . import test_unit_stock_picking +from . import test_unit_stock_picking_sale 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..f344247e695 --- /dev/null +++ b/sale_stock_put_to_order/tests/common.py @@ -0,0 +1,109 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields +from odoo.tests.common import TransactionCase + + +class TestPtoCommon(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env.company + 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..db03d591bc1 --- /dev/null +++ b/sale_stock_put_to_order/tests/test_func_auto_select.py @@ -0,0 +1,92 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +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..7125e2e8288 --- /dev/null +++ b/sale_stock_put_to_order/tests/test_func_sale_workflow.py @@ -0,0 +1,86 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +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, {}) diff --git a/sale_stock_put_to_order/tests/test_pto_auto_select.py b/sale_stock_put_to_order/tests/test_pto_auto_select.py new file mode 100644 index 00000000000..51f12e8c59b --- /dev/null +++ b/sale_stock_put_to_order/tests/test_pto_auto_select.py @@ -0,0 +1,88 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from .common import TestPtoCommon + + +class TestPtoAutoSelect(TestPtoCommon): + """Tests for the auto-select PTO destination setting.""" + + def _enable_auto_select(self): + self.env["ir.config_parameter"].sudo().set_param( + "sale_stock_put_to_order.auto_select_location", "True" + ) + + def _create_inbound_picking(self): + """Create a picking going INTO the PTO root with stock in supplier.""" + supplier = self.env.ref("stock.stock_location_suppliers") + self.set_quantity(supplier, self.product, 100) + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type.id, + "location_id": supplier.id, + "location_dest_id": self.pto_root.id, + } + ) + self.env["stock.move"].create( + { + "name": "Auto PTO Move", + "picking_id": picking.id, + "product_id": self.product.id, + "product_uom_qty": 1, + "product_uom": self.product.uom_id.id, + "location_id": supplier.id, + "location_dest_id": self.pto_root.id, + } + ) + return picking + + def test_auto_select_disabled(self): + """Move line keeps root destination when auto-select is off.""" + picking = self._create_inbound_picking() + picking.action_confirm() + picking.action_assign() + for ml in picking.move_line_ids: + self.assertNotEqual(ml.location_dest_id, self.pto_bin_1) + self.assertNotEqual(ml.location_dest_id, self.pto_bin_2) + + def test_auto_select_enabled(self): + """Move line destination redirected to PTO bin when enabled.""" + self._enable_auto_select() + picking = self._create_inbound_picking() + 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.assertIn(ml.location_dest_id, pto_bins) + + def test_auto_select_no_pto_root(self): + """Falls back to default when destination is not a PTO zone.""" + self._enable_auto_select() + self.pto_root.is_pto = False + picking = self._create_inbound_picking() + picking.action_confirm() + picking.action_assign() + for ml in picking.move_line_ids: + self.assertNotIn( + ml.location_dest_id, + self.pto_bin_1 | self.pto_bin_2 | self.pto_other, + ) + + def test_auto_select_no_stock(self): + """Falls back when no PTO bin holds matching products.""" + self._enable_auto_select() + self.reset_quantity( + self.pto_bin_1 | self.pto_bin_2 | self.pto_other, + self.product | self.other_product, + ) + picking = self._create_inbound_picking() + 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) + for ml in picking.move_line_ids: + self.assertNotIn( + ml.location_dest_id, + self.pto_bin_1 | self.pto_bin_2 | self.pto_other, + ) diff --git a/sale_stock_put_to_order/tests/test_pto_units.py b/sale_stock_put_to_order/tests/test_pto_units.py new file mode 100644 index 00000000000..837c40f396b --- /dev/null +++ b/sale_stock_put_to_order/tests/test_pto_units.py @@ -0,0 +1,85 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from .common import TestPtoCommon + + +class TestPtoUnits(TestPtoCommon): + """Unit tests for put-to-order resolution logic on stock.picking.""" + + def test_get_pto_root_location(self): + """Root location returned when destination is a PTO zone.""" + root = self.picking._get_pto_root_location() + self.assertEqual(root, self.pto_root) + + def test_get_pto_root_location_not_pto(self): + """Empty recordset returned when destination is not a PTO zone.""" + self.pto_root.is_pto = False + root = self.picking._get_pto_root_location() + self.assertFalse(root) + + 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 test_find_pto_dest_location_with_stock(self): + """Destination found where product already has positive stock.""" + dest = self._first_dest(excluded=self.pto_bin_1) + self.assertEqual(dest, self.pto_bin_2) + + def test_find_pto_dest_location_deterministic_order(self): + """First candidate is the bin with the lowest ID.""" + dest = self._first_dest() + self.assertEqual(dest, self.pto_bin_1) + + def test_find_pto_dest_location_no_stock(self): + """Empty recordset when no location holds matching products.""" + self.reset_quantity( + self.pto_bin_1 | self.pto_bin_2 | self.pto_other, + self.product | self.other_product, + ) + dest = self._first_dest(excluded=self.pto_bin_1) + self.assertFalse(dest) + + def test_find_pto_dest_location_storage_category(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) + + def test_get_pto_source_products_from_moves(self): + """Products sourced from picking moves.""" + products = self.picking._get_pto_source_products() + self.assertEqual(products, self.product) + + def test_get_pto_bin_groups(self): + """Bin groups returned per-product for valid candidate locations.""" + bin_groups = self.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_is_pto_location_valid(self): + """Valid candidate with positive stock passes validation.""" + quants = self.env["stock.quant"].search( + [ + ("location_id", "=", self.pto_bin_1.id), + ("product_id", "=", self.product.id), + ] + ) + self.assertTrue( + self.picking._is_pto_location_valid(self.pto_bin_1, quants), + ) diff --git a/sale_stock_put_to_order/tests/test_unit_stock_location.py b/sale_stock_put_to_order/tests/test_unit_stock_location.py new file mode 100644 index 00000000000..342c6588034 --- /dev/null +++ b/sale_stock_put_to_order/tests/test_unit_stock_location.py @@ -0,0 +1,55 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from .common import TestPtoCommon + + +class TestStockLocation(TestPtoCommon): + """Tests for PTO flag inheritance on stock.location.""" + + def test_is_pto_inheritance(self): + """Child locations inherit PTO flag from parent.""" + self.assertTrue(self.pto_bin_1.is_pto) + self.assertTrue(self.pto_bin_2.is_pto) + self.assertTrue(self.pto_other.is_pto) + + def test_is_pto_clearing(self): + """Clearing PTO on parent cascades to children.""" + self.pto_root.is_pto = False + self.pto_bin_1.invalidate_recordset(["is_pto"]) + self.assertFalse(self.pto_bin_1.is_pto) + + def test_parent_is_pto(self): + """parent_is_pto reflects the parent's PTO state.""" + self.assertTrue(self.pto_bin_1.parent_is_pto) + # Root's parent is not PTO + self.assertFalse(self.pto_root.parent_is_pto) + + def test_new_child_inherits_pto(self): + """Newly created child inherits PTO from existing PTO parent.""" + new_child = self.env["stock.location"].create( + { + "name": "New PTO Child", + "usage": "internal", + "location_id": self.pto_root.id, + } + ) + self.assertTrue(new_child.is_pto) + + def test_search_pto_locations(self): + """All child locations within the PTO root are found.""" + locations = self.pto_root._search_pto() + self.assertIn(self.pto_bin_1, locations) + self.assertIn(self.pto_bin_2, locations) + self.assertIn(self.pto_other, locations) + + def test_search_pto_excluded(self): + """Excluded locations are filtered out.""" + locations = self.pto_root._search_pto(excluded_locations=self.pto_bin_1) + self.assertNotIn(self.pto_bin_1, locations) + self.assertIn(self.pto_bin_2, locations) + + def test_search_pto_no_root(self): + """Empty recordset when called on empty recordset.""" + empty = self.env["stock.location"] + self.assertFalse(empty._search_pto()) diff --git a/sale_stock_put_to_order/tests/test_unit_stock_move_line.py b/sale_stock_put_to_order/tests/test_unit_stock_move_line.py new file mode 100644 index 00000000000..ede00d702b5 --- /dev/null +++ b/sale_stock_put_to_order/tests/test_unit_stock_move_line.py @@ -0,0 +1,83 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from .common import TestPtoCommon + + +class TestStockMoveLine(TestPtoCommon): + """Tests for _apply_putaway_strategy override on stock.move.line.""" + + def _enable_auto_select(self): + self.env["ir.config_parameter"].sudo().set_param( + "sale_stock_put_to_order.auto_select_location", "True" + ) + + def _create_inbound_picking(self): + """Create a picking going INTO the PTO root with stock in supplier.""" + supplier = self.env.ref("stock.stock_location_suppliers") + self.set_quantity(supplier, self.product, 100) + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type.id, + "location_id": supplier.id, + "location_dest_id": self.pto_root.id, + } + ) + self.env["stock.move"].create( + { + "name": "Auto PTO Move", + "picking_id": picking.id, + "product_id": self.product.id, + "product_uom_qty": 1, + "product_uom": self.product.uom_id.id, + "location_id": supplier.id, + "location_dest_id": self.pto_root.id, + } + ) + return picking + + def test_auto_select_disabled(self): + """Move line keeps root destination when auto-select is off.""" + picking = self._create_inbound_picking() + picking.action_confirm() + picking.action_assign() + for ml in picking.move_line_ids: + self.assertNotEqual(ml.location_dest_id, self.pto_bin_1) + self.assertNotEqual(ml.location_dest_id, self.pto_bin_2) + + def test_auto_select_enabled(self): + """Move line destination redirected to PTO bin when enabled.""" + self._enable_auto_select() + picking = self._create_inbound_picking() + 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.assertIn(ml.location_dest_id, pto_bins) + + def test_auto_select_no_pto_root(self): + """Falls back to default when destination is not a PTO zone.""" + self._enable_auto_select() + self.pto_root.is_pto = False + picking = self._create_inbound_picking() + picking.action_confirm() + picking.action_assign() + for ml in picking.move_line_ids: + self.assertNotIn( + ml.location_dest_id, + self.pto_bin_1 | self.pto_bin_2 | self.pto_other, + ) + + def test_auto_select_no_stock(self): + """Falls back when no PTO bin holds matching products.""" + self._enable_auto_select() + self.reset_quantity( + self.pto_bin_1 | self.pto_bin_2 | self.pto_other, + self.product | self.other_product, + ) + picking = self._create_inbound_picking() + 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) 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..e7127c4dbad --- /dev/null +++ b/sale_stock_put_to_order/tests/test_unit_stock_picking.py @@ -0,0 +1,85 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from .common import TestPtoCommon + + +class TestStockPicking(TestPtoCommon): + """Unit tests for put-to-order resolution logic on stock.picking.""" + + def test_get_pto_root_location(self): + """Root location returned when destination is a PTO zone.""" + root = self.picking._get_pto_root_location() + self.assertEqual(root, self.pto_root) + + def test_get_pto_root_location_not_pto(self): + """Empty recordset returned when destination is not a PTO zone.""" + self.pto_root.is_pto = False + root = self.picking._get_pto_root_location() + self.assertFalse(root) + + 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 test_find_pto_dest_location_with_stock(self): + """Destination found where product already has positive stock.""" + dest = self._first_dest(excluded=self.pto_bin_1) + self.assertEqual(dest, self.pto_bin_2) + + def test_find_pto_dest_location_deterministic_order(self): + """First candidate is the bin with the lowest ID.""" + dest = self._first_dest() + self.assertEqual(dest, self.pto_bin_1) + + def test_find_pto_dest_location_no_stock(self): + """Empty recordset when no location holds matching products.""" + self.reset_quantity( + self.pto_bin_1 | self.pto_bin_2 | self.pto_other, + self.product | self.other_product, + ) + dest = self._first_dest(excluded=self.pto_bin_1) + self.assertFalse(dest) + + def test_find_pto_dest_location_storage_category(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) + + def test_get_pto_source_products_from_moves(self): + """Products sourced from picking moves.""" + products = self.picking._get_pto_source_products() + self.assertEqual(products, self.product) + + def test_get_pto_bin_groups(self): + """Bin groups returned per-product for valid candidate locations.""" + bin_groups = self.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_is_pto_location_valid(self): + """Valid candidate with positive stock passes validation.""" + quants = self.env["stock.quant"].search( + [ + ("location_id", "=", self.pto_bin_1.id), + ("product_id", "=", self.product.id), + ] + ) + self.assertTrue( + self.picking._is_pto_location_valid(self.pto_bin_1, quants), + ) diff --git a/sale_stock_put_to_order/tests/test_unit_stock_picking_sale.py b/sale_stock_put_to_order/tests/test_unit_stock_picking_sale.py new file mode 100644 index 00000000000..120df5f28e6 --- /dev/null +++ b/sale_stock_put_to_order/tests/test_unit_stock_picking_sale.py @@ -0,0 +1,80 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from .common import TestSalePtoCommon + + +class TestStockPickingSale(TestSalePtoCommon): + """Tests for sale-order-aware put-to-order resolution on stock.picking.""" + + def test_get_pto_source_products_from_sale_order(self): + """Products sourced from the linked sale order.""" + products = self.picking._get_pto_source_products() + self.assertEqual(products, self.product) + + def test_get_pto_source_products_multi_product_so(self): + """All SO products returned including lines not yet on picking.""" + extra_product = self.env["product.product"].create( + {"name": "Product C", "type": "consu", "is_storable": True} + ) + self.env["sale.order.line"].create( + { + "order_id": self.sale_order.id, + "name": extra_product.name, + "product_id": extra_product.id, + "product_uom_qty": 2, + "product_uom": extra_product.uom_id.id, + "price_unit": 50, + } + ) + products = self.picking._get_pto_source_products() + self.assertIn(self.product, products) + self.assertIn(extra_product, products) + + def test_get_pto_source_products_fallback_no_sale(self): + """Fallback to move products when no sale order is linked.""" + self.picking.move_ids.write({"sale_line_id": False}) + products = self.picking._get_pto_source_products() + self.assertIn(self.product, products) + + def test_get_pto_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() + self.picking.move_ids.write({"sale_line_id": False}) + self.picking.group_id = self.sale_order.procurement_group_id + products = self.picking._get_pto_source_products() + self.assertIn(self.product, products) + + def test_find_pto_dest_location_via_sale_order(self): + """Destination resolution uses full SO product scope.""" + extra_product = self.env["product.product"].create( + {"name": "Product D", "type": "consu", "is_storable": True} + ) + self.env["sale.order.line"].create( + { + "order_id": self.sale_order.id, + "name": extra_product.name, + "product_id": extra_product.id, + "product_uom_qty": 1, + "product_uom": extra_product.uom_id.id, + "price_unit": 75, + } + ) + self.set_quantity(self.pto_other, extra_product, 5) + self.reset_quantity(self.pto_bin_2, self.product) + self.reset_quantity(self.pto_other, self.other_product) + + dest = next( + ( + loc + for loc, _ in self.picking._find_pto_dest_location_and_quants( + excluded_locations=self.pto_bin_1, + ) + ), + self.env["stock.location"], + ) + self.assertEqual(dest, self.pto_other) 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..88aeb23b23b --- /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..3303b42e72d --- /dev/null +++ b/sale_stock_put_to_order/views/stock_location_views.xml @@ -0,0 +1,16 @@ + + + + + stock.location.form.pto + stock.location + + + + + + + + +