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