Maintainers
+Maintainers
This module is maintained by the OCA.
- + + +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.
diff --git a/stock_picking_batch_creation/tests/test_clustering_conditions.py b/stock_picking_batch_creation/tests/test_clustering_conditions.py index 73d2313e14..cb513cfbb3 100644 --- a/stock_picking_batch_creation/tests/test_clustering_conditions.py +++ b/stock_picking_batch_creation/tests/test_clustering_conditions.py @@ -432,7 +432,7 @@ def test_pickings_with_different_partners(self): batch2 = self.make_picking_batch._create_batch() self.assertEqual(self.pick1 | self.pick2, batch2.picking_ids) - def test_picking_with_maximum_number_of_lines_exceed(self): + def test_picking_split_with_maximum_number_of_lines_exceed(self): # pick 3 has 2 lines # create a batch picking with maximum number of lines = 1 self.pick1.action_cancel() @@ -441,12 +441,144 @@ def test_picking_with_maximum_number_of_lines_exceed(self): self.make_picking_batch.write( { "maximum_number_of_preparation_lines": 1, - "no_line_limit_if_no_candidate": False, + "split_picking_exceeding_limits": False, } ) with self.assertRaises(PickingCandidateNumberLineExceedError): self.make_picking_batch._create_batch(raise_if_not_possible=True) - self.make_picking_batch.no_line_limit_if_no_candidate = True + self.make_picking_batch.split_picking_exceeding_limits = True + batch = self.make_picking_batch._create_batch() + self.assertEqual(self.pick3, batch.picking_ids) + self.assertEqual(len(batch.move_line_ids), 1) + + def test_picking_split_with_weight_exceed(self): + # pick 3 has 2 lines + # we will set a weight by line under the maximum weight of the device + # but the total weight of the picking will exceed the maximum weight of the device + # when the batch is created, the picking 3 should be split and the batch + # should contain only pick3 with 1 line + + self.pick1.action_cancel() + self.pick2.action_cancel() + self.assertEqual(len(self.pick3.move_line_ids), 2) + max_weight = 200 + device = self.env["stock.device.type"].create( + { + "name": "test volume null devices and one bin", + "min_volume": 0, + "max_volume": 200, + "max_weight": max_weight, + "nbr_bins": 1, + "sequence": 50, + } + ) + + self.make_picking_batch.write( + { + "split_picking_exceeding_limits": False, + "stock_device_type_ids": [(6, 0, [device.id])], + } + ) + self.pick3.move_ids.product_id.weight = max_weight - 1 + self.pick3.move_ids._cal_move_weight() + with self.assertRaises(NoSuitableDeviceError): + self.make_picking_batch._create_batch(raise_if_not_possible=True) + self.make_picking_batch.split_picking_exceeding_limits = True + batch = self.make_picking_batch._create_batch() + self.assertEqual(self.pick3, batch.picking_ids) + self.assertEqual(len(batch.move_line_ids), 1) + + def test_picking_split_with_volume_exceed(self): + # pick 3 has 2 lines + # we will set a volume by line under the maximum volume of the device + # but the total volume of the picking will exceed the maximum volume of the device + # when the batch is created, the picking 3 should be split and the batch + # should contain only pick3 with 1 line + + self.pick1.action_cancel() + self.pick2.action_cancel() + self.assertEqual(len(self.pick3.move_line_ids), 2) + + max_volume = 200 + device = self.env["stock.device.type"].create( + { + "name": "test volume null devices and one bin", + "min_volume": 0, + "max_volume": max_volume, + "max_weight": 300, + "nbr_bins": 1, + "sequence": 50, + } + ) + + self.make_picking_batch.write( + { + "split_picking_exceeding_limits": False, + "stock_device_type_ids": [(6, 0, [device.id])], + } + ) + # each product has a volume of 120 + self.pick3.move_ids.product_id.write( + { + "product_length": 12, + "product_height": 5, + "product_width": 2, + } + ) + self.pick3.move_ids._compute_volume() + with self.assertRaises(NoSuitableDeviceError): + self.make_picking_batch._create_batch(raise_if_not_possible=True) + self.make_picking_batch.split_picking_exceeding_limits = True + batch = self.make_picking_batch._create_batch() + self.assertEqual(self.pick3, batch.picking_ids) + self.assertEqual(len(batch.move_line_ids), 1) + + def test_picking_split_priority(self): + # We ensure than even if a picking with a higher priority has a volume + # exceeding the device capacity, it will be split and processed first + # if the split_picking_exceeding_limits is set to True + # the processing order for picks of type 1 will be: + # pick3 (priority), pick1 (lower id), pick2 + self.assertEqual(len(self.pick3.move_line_ids), 2) + + max_volume = 200 + device = self.env["stock.device.type"].create( + { + "name": "test volume null devices and one bin", + "min_volume": 0, + "max_volume": max_volume, + "max_weight": 300, + "nbr_bins": 1, + "sequence": 50, + } + ) + + self.make_picking_batch.write( + { + "split_picking_exceeding_limits": False, + "stock_device_type_ids": [(6, 0, [device.id])], + } + ) + # each product has a volume of 120 + self.pick3.move_ids.product_id.write( + { + "product_length": 12, + "product_height": 5, + "product_width": 2, + } + ) + self.pick3.move_ids._compute_volume() + + # since pick3 exceeds the device capacity and + # the split_picking_exceeding_limits is set to False + # the next picking to process should be pick1 + batch = self.make_picking_batch._create_batch() + self.assertEqual(self.pick1, batch.picking_ids) + + batch.unlink() + + # if the split_picking_exceeding_limits is set to True. + # then pick3 should be split and processed first + self.make_picking_batch.split_picking_exceeding_limits = True batch = self.make_picking_batch._create_batch() self.assertEqual(self.pick3, batch.picking_ids) - self.assertEqual(len(batch.move_line_ids), 2) diff --git a/stock_picking_batch_creation/wizards/make_picking_batch.py b/stock_picking_batch_creation/wizards/make_picking_batch.py index 9c4e445558..94653aaf17 100644 --- a/stock_picking_batch_creation/wizards/make_picking_batch.py +++ b/stock_picking_batch_creation/wizards/make_picking_batch.py @@ -1,6 +1,6 @@ # Copyright 2021 ACSONE SA/NV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - +import logging import math import threading from collections import defaultdict @@ -13,8 +13,11 @@ NoPickingCandidateError, NoSuitableDeviceError, PickingCandidateNumberLineExceedError, + PickingSplitNotPossibleError, ) +_logger = logging.getLogger(__name__) + class MakePickingBatch(models.TransientModel): @@ -80,16 +83,14 @@ class MakePickingBatch(models.TransientModel): "by default.", ) - no_line_limit_if_no_candidate = fields.Boolean( - default=True, - string="No line limit if no candidate", - help="If checked, the maximum number of lines will not be applied if there is " - "no candidate to add to the batch with a number of lines less than the maximum " - "number of lines. This option is useful if you want relax the maximum number " - "of lines to allow to create a batch even if there is no candidate to add to " - "the batch at first. This will avoid to manually create a batch with a single " - "picking for the sole case where a device is suitable for the picking but the " - "picking has more lines than the maximum number of lines.", + split_picking_exceeding_limits = fields.Boolean( + default=False, + string="Split pickings exceeding limits", + help="If checked, the pickings exceeding the maximum number of lines, " + "volume or weight of available devices will be split into multiple pickings " + "to respect the limits. If unchecked, the pickings exceeding the limits will not " + "be added to the batch. The limits are defined by the limits of the last available " + "devices.", ) __slots__ = ( @@ -260,15 +261,73 @@ def _execute_search_pickings(self, domain, limit=None): domain, order=self._get_picking_order_by(), limit=limit ) - def _get_first_picking(self, no_nbr_lines_limit=False): - domain = self._get_picking_domain_for_first( - no_nbr_lines_limit=no_nbr_lines_limit + def _get_picking_max_dimensions(self): + self.ensure_one() + nbr_lines = self.maximum_number_of_preparation_lines + last_device = self.stock_device_type_ids[-1] + volume = last_device.max_volume + weight = last_device.max_weight + return nbr_lines, volume, weight + + def _split_first_picking_for_limit(self, picking): + nbr_lines, volume, weight = self._get_picking_max_dimensions() + wizard = self.env["stock.split.picking"].with_context(active_ids=picking.ids) + wizard.create( + { + "mode": "dimensions", + "max_nbr_lines": nbr_lines, + "max_volume": volume, + "max_weight": weight, + } + ).action_apply() + return picking + + def _is_picking_exceeding_limits(self, picking): + """Check if the picking exceeds the limits of the available devices. + + :param picking: the picking to check + """ + nbr_lines, volume, weight = self._get_picking_max_dimensions() + return ( + picking.nbr_picking_lines > nbr_lines + or picking.volume > volume + or picking.weight > weight ) - device_domains = [] - for device in self.stock_device_type_ids: - device_domains.append(self._get_picking_domain_for_device(device)) - domain = AND([domain, OR(device_domains)]) - return self._execute_search_pickings(domain, limit=1) + + def _get_first_picking(self, raise_if_not_found=False): + """Get the first picking to add to the batch. + + If the split_picking_exceeding_limits is set, we try to find the first picking + without taking into account the limit on the number of lines and we split it + if it exceeds the limits. If the split is not possible, we raise an error. + + Otherwise, we try to find the first picking taking into account the limit on the + number of lines. + """ + no_limit = self.split_picking_exceeding_limits + domain = self._get_picking_domain_for_first(no_nbr_lines_limit=no_limit) + if not no_limit: + device_domains = [] + for device in self.stock_device_type_ids: + device_domains.append(self._get_picking_domain_for_device(device)) + domain = AND([domain, OR(device_domains)]) + picking = self._execute_search_pickings(domain, limit=1) + if not picking and not no_limit and raise_if_not_found: + self._raise_create_batch_not_possible() + # at this stage we have the first picking to add to the batch but it could + # exceed the limits of the available devices. In this case we split the + # picking and return the picking to add to the batch. The split is done only + # if the split_picking_exceeding_limits is set to True. + if ( + picking + and self.split_picking_exceeding_limits + and self._is_picking_exceeding_limits(picking) + ): + split_picking = self._split_first_picking_for_limit(picking) + if not split_picking and raise_if_not_found: + raise PickingSplitNotPossibleError(picking) + picking = split_picking + return picking def _get_additional_picking(self): """Get the next picking to add to the batch.""" @@ -334,13 +393,17 @@ def _raise_create_batch_not_possible(self): # constrains. If not, we raise an error to inform the user that there # is no picking to process otherwise we raise an error to inform the # user that there is not suitable device to process the pickings. - if not self.no_line_limit_if_no_candidate: - domain = self._get_picking_domain_for_first(no_nbr_lines_limit=True) - candidates = self.env["stock.picking"].search(domain, limit=1) - if candidates: - raise PickingCandidateNumberLineExceedError( - candidates, self.maximum_number_of_preparation_lines - ) + domain = self._get_picking_domain_for_first(no_nbr_lines_limit=True) + device_domains = [] + for device in self.stock_device_type_ids: + device_domains.append(self._get_picking_domain_for_device(device)) + domain = AND([domain, OR(device_domains)]) + candidates = self.env["stock.picking"].search(domain, limit=1) + if candidates: + raise PickingCandidateNumberLineExceedError( + candidates, self.maximum_number_of_preparation_lines + ) + domain = self._get_picking_domain_for_first() limit = 1 if self.add_picking_list_in_error: @@ -348,21 +411,23 @@ def _raise_create_batch_not_possible(self): candidates = self.env["stock.picking"].search(domain, limit=limit) if candidates: pickings = candidates if self.add_picking_list_in_error else None - raise NoSuitableDeviceError(pickings=pickings) - raise NoPickingCandidateError() + raise NoSuitableDeviceError(self.env, pickings=pickings) + raise NoPickingCandidateError(self.env) def _create_batch(self, raise_if_not_possible=False): """Create a batch transfer.""" self._reset_counters() # first we try to get the first picking for the user - first_picking = self._get_first_picking() - if not first_picking and self.no_line_limit_if_no_candidate: - first_picking = self._get_first_picking(no_nbr_lines_limit=True) + first_picking = self._get_first_picking( + raise_if_not_found=raise_if_not_possible + ) if not first_picking: - if raise_if_not_possible: - self._raise_create_batch_not_possible() return self.env["stock.picking.batch"].browse() device = self._compute_device_to_use(first_picking) + if not device: + if raise_if_not_possible: + raise NoSuitableDeviceError(self.env, pickings=first_picking) + return self.env["stock.picking.batch"].browse() self._init_counters(first_picking, device) self._apply_limits() vals = self._create_batch_values() diff --git a/stock_picking_batch_creation/wizards/make_picking_batch.xml b/stock_picking_batch_creation/wizards/make_picking_batch.xml index c28c1fb197..6cd8962406 100644 --- a/stock_picking_batch_creation/wizards/make_picking_batch.xml +++ b/stock_picking_batch_creation/wizards/make_picking_batch.xml @@ -10,11 +10,10 @@