diff --git a/addons/product_expiry/models/stock_quant.py b/addons/product_expiry/models/stock_quant.py
index 82841407efd..29a6fee3cba 100644
--- a/addons/product_expiry/models/stock_quant.py
+++ b/addons/product_expiry/models/stock_quant.py
@@ -28,7 +28,7 @@ class StockQuant(models.Model):
return res
@api.model
- def _get_removal_strategy_order(self, removal_strategy):
+ def _get_removal_strategy_domain_order(self, domain, removal_strategy, qty):
if removal_strategy == 'fefo':
- return 'removal_date, in_date, id'
- return super(StockQuant, self)._get_removal_strategy_order(removal_strategy)
+ return domain, 'removal_date, in_date, id'
+ return super(StockQuant, self)._get_removal_strategy_domain_order(domain, removal_strategy, qty)
diff --git a/addons/stock/data/stock_data.xml b/addons/stock/data/stock_data.xml
index 64df6f4007c..98ee8a8ec64 100644
--- a/addons/stock/data/stock_data.xml
+++ b/addons/stock/data/stock_data.xml
@@ -13,6 +13,10 @@
Closest Location
closest
+
+ Least Packages
+ least_packages
+
diff --git a/addons/stock/models/stock_quant.py b/addons/stock/models/stock_quant.py
index 73d037efcc4..e2ec581e51a 100644
--- a/addons/stock/models/stock_quant.py
+++ b/addons/stock/models/stock_quant.py
@@ -1,7 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-
+import heapq
import logging
+from collections import namedtuple
from ast import literal_eval
from collections import defaultdict
@@ -564,20 +565,126 @@ class StockQuant(models.Model):
loc = loc.location_id
return 'fifo'
+ def _run_least_packages_removal_strategy_astar(self, domain, qty):
+ # Fetch the available packages and contents
+ query = self._where_calc(domain)
+ query_str, params = query.select('package_id', 'SUM(quantity - reserved_quantity) AS available_qty')
+ query_str += ' GROUP BY package_id ORDER BY available_qty DESC'
+ self._cr.execute(query_str, params)
+ qty_by_package = self._cr.fetchall()
+
+ pkg_found = False
+ # Items that do not belong to a package are added individually to the list, any empty packages get removed.
+ for idx, elem in enumerate(qty_by_package):
+ if elem[0] is None:
+ del qty_by_package[idx]
+ qty_by_package.extend([(None, 1) for _ in range(int(elem[1]))])
+ elif elem[1] == 0:
+ del qty_by_package[idx]
+ else:
+ pkg_found = True
+
+ if not pkg_found:
+ return domain
+ size = len(qty_by_package)
+
+ class PriorityQueue:
+ def __init__(self):
+ self.elements = []
+
+ def empty(self) -> bool:
+ return not self.elements
+
+ def put(self, item, priority):
+ heapq.heappush(self.elements, (priority, item))
+
+ def get(self):
+ return heapq.heappop(self.elements)[1]
+
+ def heuristic(node):
+ if node.next_index < size:
+ return len(node.taken_packages) + node.count_remaining / qty_by_package[node.next_index][1]
+ return len(node.taken_packages)
+
+ def generate_domain(node):
+ selected_single_items = []
+ single_item_ids = False
+ for pkg in node.taken_packages:
+ if pkg[0] is None:
+ # Lazily retrieve ids for single items
+ if not single_item_ids:
+ single_item_ids = self.search(expression.AND([[('package_id', '=', None)], domain])).mapped('id')
+ selected_single_items.append(single_item_ids.pop())
+
+ expr = [('package_id', 'in', [elem[0] for elem in node.taken_packages if elem[0] is not None])]
+ if selected_single_items:
+ expr = expression.OR([expr, [('id', 'in', selected_single_items)]])
+ return expression.AND([expr, domain])
+
+ Node = namedtuple("Node", "count_remaining taken_packages next_index")
+
+ frontier = PriorityQueue()
+ frontier.put(Node(qty, (), 0), 0)
+
+ best_leaf = Node(qty, (), 0)
+
+ try:
+ while not frontier.empty():
+ current = frontier.get()
+
+ if current.count_remaining <= 0:
+ return generate_domain(current)
+
+ # Keep track of processed package amounts to only generate one branch for the same amount
+ last_count = None
+ i = current.next_index
+ while i < size:
+ pkg = qty_by_package[i]
+ i += 1
+ if pkg[1] == last_count:
+ continue
+ last_count = pkg[1]
+
+ count = current.count_remaining - pkg[1]
+ taken = current.taken_packages + (pkg,)
+ node = Node(count, taken, i)
+
+ if count < 0:
+ # Overselect case
+ if best_leaf.count_remaining > 0 or len(node.taken_packages) < len(best_leaf.taken_packages) or (len(node.taken_packages) == len(best_leaf.taken_packages) and node.count_remaining > best_leaf.count_remaining):
+ best_leaf = node
+ continue
+
+ if i >= size and count != 0:
+ # Not enough packages case
+ if node.count_remaining < best_leaf.count_remaining:
+ best_leaf = node
+ continue
+
+ frontier.put(node, heuristic(node))
+ except MemoryError:
+ _logger.info('Ran out of memory while trying to use the least_packages strategy to get quants. Domain: %s', domain)
+ return domain
+
+ # no exact matching possible, use best leaf
+ return generate_domain(best_leaf)
+
@api.model
- def _get_removal_strategy_order(self, removal_strategy):
+ def _get_removal_strategy_domain_order(self, domain, removal_strategy, qty):
if removal_strategy == 'fifo':
- return 'in_date ASC, id'
+ return domain, 'in_date ASC, id'
elif removal_strategy == 'lifo':
- return 'in_date DESC, id DESC'
+ return domain, 'in_date DESC, id DESC'
elif removal_strategy == 'closest':
- return 'location_id ASC, id DESC'
+ return domain, 'location_id ASC, id DESC'
+ elif removal_strategy == 'least_packages':
+ if qty > 0:
+ return self._run_least_packages_removal_strategy_astar(domain, qty), 'in_date ASC, id'
+ return domain, 'in_date ASC, id'
raise UserError(_('Removal strategy %s not implemented.') % (removal_strategy,))
- def _gather(self, product_id, location_id, lot_id=None, package_id=None, owner_id=None, strict=False):
+ def _gather(self, product_id, location_id, lot_id=None, package_id=None, owner_id=None, strict=False, qty=0):
removal_strategy = self._get_removal_strategy(product_id, location_id)
- removal_strategy_order = self._get_removal_strategy_order(removal_strategy)
-
domain = [('product_id', '=', product_id.id)]
if not strict:
if lot_id:
@@ -593,7 +700,8 @@ class StockQuant(models.Model):
domain = expression.AND([[('owner_id', '=', owner_id and owner_id.id or False)], domain])
domain = expression.AND([[('location_id', '=', location_id.id)], domain])
- return self.search(domain, order=removal_strategy_order).sorted(lambda q: not q.lot_id)
+ domain, order = self._get_removal_strategy_domain_order(domain, removal_strategy, qty)
+ return self.search(domain, order=order).sorted(lambda q: not q.lot_id)
@api.model
def _get_available_quantity(self, product_id, location_id, lot_id=None, package_id=None, owner_id=None, strict=False, allow_negative=False):
@@ -647,7 +755,7 @@ class StockQuant(models.Model):
"""
self = self.sudo()
rounding = product_id.uom_id.rounding
- quants = self or self._gather(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=strict)
+ quants = self or self._gather(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=strict, qty=quantity)
reserved_quants = []
if float_compare(quantity, 0, precision_rounding=rounding) > 0:
diff --git a/addons/stock/tests/test_quant.py b/addons/stock/tests/test_quant.py
index 71cebe479b9..a17b140670d 100644
--- a/addons/stock/tests/test_quant.py
+++ b/addons/stock/tests/test_quant.py
@@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-
from contextlib import closing
from datetime import datetime, timedelta
from unittest.mock import patch
@@ -796,6 +795,184 @@ class StockQuant(TransactionCase):
quant.invalidate_recordset(['quantity'])
self.assertEqual(quant.quantity, 11)
+class StockQuantRemovalStrategy(TransactionCase):
+ def setUp(self):
+ super().setUp()
+ self.least_package_strategy = self.env['product.removal'].search(
+ [('method', '=', 'least_packages')])
+ self.product = self.env['product.product'].create({
+ 'name': 'Product',
+ 'type': 'product',
+ })
+ self.product.categ_id.removal_strategy_id = self.least_package_strategy.id
+ self.stock_location = self.env['stock.location'].create({
+ 'name': 'stock_location',
+ 'usage': 'internal',
+ })
+
+ def _generate_data(self, packages_data):
+ move = self.env['stock.move'].create({
+ 'name': 'Test Least Package',
+ 'product_id': self.product.id,
+ 'product_uom': self.product.uom_id.id,
+ 'location_id': self.ref('stock.stock_location_suppliers'),
+ 'location_dest_id': self.stock_location.id,
+ })
+ move._action_confirm()
+
+ ml_vals_list = []
+ ml_common_vals = {
+ 'move_id': move.id,
+ 'product_id': self.product.id,
+ 'product_uom_id': self.product.uom_id.id,
+ 'location_id': self.ref('stock.stock_location_suppliers'),
+ 'location_dest_id': self.stock_location.id,
+ }
+
+ packages = self.env['stock.quant.package'].create(
+ [{}] * sum(p[1] for p in packages_data if p[0]))
+ for package_size, number_of_packages in packages_data:
+ if not package_size:
+ ml_vals_list.append(dict(**ml_common_vals, **{
+ 'qty_done': number_of_packages,
+ }))
+ continue
+ for dummy in range(number_of_packages):
+ package = packages[0]
+ packages = packages[1:]
+ ml_vals_list.append(dict(**ml_common_vals, **{
+ 'qty_done': package_size,
+ 'result_package_id': package.id,
+ }))
+ self.env['stock.move.line'].create(ml_vals_list)
+ move._action_done()
+
+ def test_least_package_removal_strategy_priority_to_package(self):
+ """
+ Tests the least package removal strategy in a use case where only one package needs to be selected.
+ It should only return the quantity of a single size 1000 package.
+ """
+ packages_data = [
+ (False, 2000),
+ (5, 10),
+ (50, 10),
+ (1000, 2),
+ ]
+ self._generate_data(packages_data)
+
+ # Out 1000 should selecte a package with 1000 units inside
+ move = self.env['stock.move'].create({
+ 'name': 'Test Least Package',
+ 'product_id': self.product.id,
+ 'product_uom': self.product.uom_id.id,
+ 'location_id': self.stock_location.id,
+ 'location_dest_id': self.ref('stock.stock_location_customers'),
+ 'product_uom_qty': 1000,
+ })
+ move._action_confirm()
+ move._action_assign()
+ self.assertEqual(len(move.move_line_ids), 1, 'Only one pack could be use')
+ self.assertTrue(
+ move.move_line_ids.package_id,
+ 'A package should be selected, priority to package even if there is enough quantity without package'
+ )
+
+ def test_least_package_removal_strategy_simple_usecase(self):
+ """
+ Tests the least package removal strategy in a simple "typical" use case.
+ It should return a minimal exact matching for the requested quantity.
+ """
+ packages_data = [
+ (5, 10),
+ (50, 10),
+ (1000, 2),
+ ]
+ self._generate_data(packages_data)
+
+ # Out 1000 should select a package with 1000 units inside
+ move = self.env['stock.move'].create({
+ 'name': 'Test Least Package',
+ 'product_id': self.product.id,
+ 'product_uom': self.product.uom_id.id,
+ 'location_id': self.stock_location.id,
+ 'location_dest_id': self.ref('stock.stock_location_customers'),
+ 'product_uom_qty': 1280,
+ })
+ move._action_confirm()
+ move._action_assign()
+ self.assertEqual(len(move.move_line_ids), 12)
+ self.assertRecordValues(
+ move.move_line_ids,
+ [{'reserved_qty': 1000}] +
+ [{'reserved_qty': 50}] * 5 +
+ [{'reserved_qty': 5}] * 6
+ )
+
+ def test_least_package_removal_strategy_not_possible(self):
+ """
+ Tests the least package removal strategy in the case where an exact matching
+ of packages is not possible for the requested amount.
+ It should return the best leaf from the A* search.
+ """
+ packages_data = [
+ (False, 2),
+ (5, 2),
+ (10, 5),
+ ]
+ self._generate_data(packages_data)
+
+ move = self.env['stock.move'].create({
+ 'name': 'Test Least Package',
+ 'product_id': self.product.id,
+ 'product_uom': self.product.uom_id.id,
+ 'location_id': self.stock_location.id,
+ 'location_dest_id': self.ref('stock.stock_location_customers'),
+ 'product_uom_qty': 13,
+ })
+ move._action_confirm()
+ move._action_assign()
+ self.assertEqual(len(move.move_line_ids), 2)
+ self.assertRecordValues(
+ move.move_line_ids,
+ [{'reserved_qty': 10}] + [{'reserved_qty': 3}]
+ )
+ # Make sure it selects the smallest possible package as best leaf.
+ self.assertEqual(
+ move.move_line_ids[1].package_id.quant_ids.quantity,
+ 5
+ )
+
+ def test_least_package_removal_strategy_not_enough(self):
+ """
+ Tests the least package removal strategy in the case where not enough quantity
+ is available to fill the requested amount.
+ It should just return all the quantities in the domain.
+ """
+ packages_data = [
+ (False, 2),
+ (5, 2),
+ (10, 5),
+ ]
+ self._generate_data(packages_data)
+
+ move = self.env['stock.move'].create({
+ 'name': 'Test Least Package',
+ 'product_id': self.product.id,
+ 'product_uom': self.product.uom_id.id,
+ 'location_id': self.stock_location.id,
+ 'location_dest_id': self.ref('stock.stock_location_customers'),
+ 'product_uom_qty': 90,
+ })
+ move._action_confirm()
+ move._action_assign()
+ self.assertEqual(len(move.move_line_ids), 8)
+ self.assertRecordValues(
+ move.move_line_ids,
+ [{'reserved_qty': 2}] +
+ [{'reserved_qty': 10}] * 5 +
+ [{'reserved_qty': 5}] * 2
+ )
+
def test_quant_reserve(self):
""" Tests the reserve stock wizard which allows to choose a specific quant to reserve from
also checks if editing the reserved_uom_qty on stock.move.line updates the quants