From cd6ee21735ee33f22ca9519f771a30fe93ad7242 Mon Sep 17 00:00:00 2001 From: Mmequignon Date: Thu, 5 Sep 2024 11:15:11 +0200 Subject: [PATCH] stock_reception_screen_measuring_device: measure smaller packages When goods are received, triggers measurements also for smaller packagings that weren't ordered or received. This behavior is not triggered when received packagings are not the same as ordered packagings. --- .../__manifest__.py | 2 +- .../migrations/14.0.2.0.0/post-migration.py | 25 ++ .../migrations/14.0.2.0.0/pre-migration.py | 29 ++ .../models/stock_reception_screen.py | 95 ++++- .../tests/__init__.py | 1 + .../tests/fake_components.py | 14 + .../tests/fake_models.py | 14 + .../test_reception_screen_measurement.py | 360 ++++++++++++++++++ .../views/stock_reception_screen_view.xml | 13 +- 9 files changed, 535 insertions(+), 18 deletions(-) create mode 100644 stock_reception_screen_measuring_device/migrations/14.0.2.0.0/post-migration.py create mode 100644 stock_reception_screen_measuring_device/migrations/14.0.2.0.0/pre-migration.py create mode 100644 stock_reception_screen_measuring_device/tests/__init__.py create mode 100644 stock_reception_screen_measuring_device/tests/fake_components.py create mode 100644 stock_reception_screen_measuring_device/tests/fake_models.py create mode 100644 stock_reception_screen_measuring_device/tests/test_reception_screen_measurement.py diff --git a/stock_reception_screen_measuring_device/__manifest__.py b/stock_reception_screen_measuring_device/__manifest__.py index 6b7cd6bf04..32e8d7068f 100644 --- a/stock_reception_screen_measuring_device/__manifest__.py +++ b/stock_reception_screen_measuring_device/__manifest__.py @@ -4,7 +4,7 @@ "name": "Stock Measuring Device on Reception Screen", "summary": "Allow to use a measuring device from a reception screen." "for packaging measurement", - "version": "14.0.1.0.0", + "version": "14.0.2.0.0", "category": "Warehouse", "author": "Camptocamp, Odoo Community Association (OCA)", "license": "AGPL-3", diff --git a/stock_reception_screen_measuring_device/migrations/14.0.2.0.0/post-migration.py b/stock_reception_screen_measuring_device/migrations/14.0.2.0.0/post-migration.py new file mode 100644 index 0000000000..58d38dca17 --- /dev/null +++ b/stock_reception_screen_measuring_device/migrations/14.0.2.0.0/post-migration.py @@ -0,0 +1,25 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo import SUPERUSER_ID, api + +_logger = logging.getLogger(__name__) + + +def populate_new_field(env): + records_to_update = ( + env["stock.reception.screen"].search([("current_step", "!=", "done")]).exists() + ) + _logger.info( + "Set smaller_package_has_missing_dimensions on ongoing reception screens" + ) + records_to_update._compute_smaller_package_has_missing_dimensions() + + +def migrate(cr, version): + if not version: + return + env = api.Environment(cr, SUPERUSER_ID, {}) + populate_new_field(env) diff --git a/stock_reception_screen_measuring_device/migrations/14.0.2.0.0/pre-migration.py b/stock_reception_screen_measuring_device/migrations/14.0.2.0.0/pre-migration.py new file mode 100644 index 0000000000..1f0ff7fcc8 --- /dev/null +++ b/stock_reception_screen_measuring_device/migrations/14.0.2.0.0/pre-migration.py @@ -0,0 +1,29 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +_logger = logging.getLogger(__name__) + + +def create_and_populate_new_fields(cr): + cr.execute( + """ + ALTER TABLE stock_reception_screen + ADD COLUMN IF NOT EXISTS smaller_package_has_missing_dimensions BOOLEAN; + """ + ) + # Set value to False for done reception screens. + # Otherwise, let the ORM do its job in post migration + _logger.info("Set smaller_package_has_missing_dimensions on done reception screens") + cr.execute( + """ + UPDATE stock_reception_screen + SET smaller_package_has_missing_dimensions = FALSE + WHERE current_step = 'done'; + """ + ) + + +def migrate(cr, version): + create_and_populate_new_fields(cr) diff --git a/stock_reception_screen_measuring_device/models/stock_reception_screen.py b/stock_reception_screen_measuring_device/models/stock_reception_screen.py index 443fd4a36e..3c6ca2d0c8 100644 --- a/stock_reception_screen_measuring_device/models/stock_reception_screen.py +++ b/stock_reception_screen_measuring_device/models/stock_reception_screen.py @@ -18,6 +18,12 @@ class StockReceptionScreen(models.Model): store=True, help="Indicates if the package have any measurement missing.", ) + smaller_package_has_missing_dimensions = fields.Boolean( + "Smaller Package Requires Measures?", + compute="_compute_smaller_package_has_missing_dimensions", + store=True, + help="Indicates if any smaller package have any measurement missing.", + ) display_package_dimensions = fields.Char( string="Dimensions (lxhxw)", compute="_compute_package_dimensions", @@ -30,13 +36,17 @@ class StockReceptionScreen(models.Model): store=True, ) - @api.depends("product_packaging_id", "product_packaging_id.measuring_device_id") + @api.depends( + "current_move_product_id.packaging_ids.measuring_device_id", + ) def _compute_scan_requested(self): for record in self: - record.scan_requested = ( - record.product_packaging_id - and record.product_packaging_id.measuring_device_id - ) + all_product_packagings = record.current_move_product_id.packaging_ids + record.scan_requested = False + for packaging in all_product_packagings: + if packaging.measuring_device_id: + record.scan_requested = True + break @api.depends( "product_packaging_id.packaging_length", @@ -54,6 +64,20 @@ def _compute_package_dimensions(self): else: record.display_package_dimensions = False + @api.depends( + "product_packaging_id", + "product_packaging_id.qty", + "current_move_product_id.packaging_ids.max_weight", + "current_move_product_id.packaging_ids.packaging_length", + "current_move_product_id.packaging_ids.width", + "current_move_product_id.packaging_ids.height", + ) + def _compute_smaller_package_has_missing_dimensions(self): + for record in self: + record.smaller_package_has_missing_dimensions = bool( + record._get_smaller_package_without_dimensions() + ) + @api.depends( "product_packaging_id.max_weight", "product_packaging_id.packaging_length", @@ -71,35 +95,76 @@ def _compute_package_has_missing_dimensions(self): else: record.package_has_missing_dimensions = False - def measure_current_packaging(self): - self.ensure_one() + @api.model + def _measure_packaging(self, packaging): device = self.env["measuring.device"].search( [("is_default", "=", True)], limit=1 ) if not device: error_msg = _("No default device set, please configure one.") _logger.error(error_msg) - self._notify(error_msg) - return UserError(error_msg) + self._notify_warning(error_msg) + raise UserError(error_msg) if device._is_being_used(): error_msg = _("Measurement machine already in use.") _logger.error(error_msg) - self._notify(error_msg) - return UserError(error_msg) + self._notify_warning(error_msg) + raise UserError(error_msg) - self.product_packaging_id._measuring_device_assign(device) + packaging._measuring_device_assign(device) + message = "MEASURE {}".format(packaging.name) + # Letting the info on the screen, so the user knows which packaging to + # needs to be measured + self._notify_info(message, sticky=True) return True + def measure_current_packaging(self): + self.ensure_one() + return self._measure_packaging(self.product_packaging_id) + + def _get_smaller_package_without_dimensions_domain(self): + self.ensure_one() + return [ + ("product_id", "=", self.current_move_product_id.id), + ("qty", "<", self.product_packaging_id.qty), + "|", + "|", + "|", + ("packaging_length", "=", 0), + ("width", "=", 0), + ("height", "=", 0), + ("max_weight", "=", 0), + ] + + def _get_smaller_package_without_dimensions(self): + self.ensure_one() + domain = self._get_smaller_package_without_dimensions_domain() + return self.env["product.packaging"].search(domain, order="qty desc", limit=1) + + def measure_smaller_packaging(self): + self.ensure_one() + pack_without_dimensions = self._get_smaller_package_without_dimensions() + if not pack_without_dimensions: + error_msg = _("No available packaging without measurements.") + raise UserError(error_msg) + return self._measure_packaging(pack_without_dimensions) + def cancel_measure_current_packaging(self): self.ensure_one() - self.product_packaging_id._measuring_device_release() - return True + assigned_packaging = self.current_move_product_id.packaging_ids.filtered( + lambda p: p.measuring_device_id + ) + assigned_packaging._measuring_device_release() - def _notify(self, message): + def _notify_warning(self, message): """Show a gentle notification on the wizard""" self.ensure_one() self.create_uid.with_user(self.create_uid.id).notify_warning(message=message) + def _notify_info(self, message, **kwargs): + self.ensure_one() + self.create_uid.with_user(self.create_uid.id).notify_info(message=message, **kwargs) + def reload(self): self.cancel_measure_current_packaging() return { diff --git a/stock_reception_screen_measuring_device/tests/__init__.py b/stock_reception_screen_measuring_device/tests/__init__.py new file mode 100644 index 0000000000..7863dd1075 --- /dev/null +++ b/stock_reception_screen_measuring_device/tests/__init__.py @@ -0,0 +1 @@ +from . import test_reception_screen_measurement diff --git a/stock_reception_screen_measuring_device/tests/fake_components.py b/stock_reception_screen_measuring_device/tests/fake_components.py new file mode 100644 index 0000000000..ba1cafde55 --- /dev/null +++ b/stock_reception_screen_measuring_device/tests/fake_components.py @@ -0,0 +1,14 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo.addons.component.core import Component + + +class FakeDevice(Component): + _name = "device.component.fake" + _inherit = "measuring.device.base" + _usage = "fake" + + def post_update_packaging_measures(self, measures, packaging, wizard_line): + # Unassign measuring device when measuring is done + packaging._measuring_device_release() diff --git a/stock_reception_screen_measuring_device/tests/fake_models.py b/stock_reception_screen_measuring_device/tests/fake_models.py new file mode 100644 index 0000000000..05b77f02dc --- /dev/null +++ b/stock_reception_screen_measuring_device/tests/fake_models.py @@ -0,0 +1,14 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class FakeMeasuringDevice(models.Model): + _inherit = "measuring.device" + + device_type = fields.Selection(selection_add=[("fake", "FAKE")]) + + def mocked_measure(self, measurements): + self.ensure_one() + self._update_packaging_measures(measurements) diff --git a/stock_reception_screen_measuring_device/tests/test_reception_screen_measurement.py b/stock_reception_screen_measuring_device/tests/test_reception_screen_measurement.py new file mode 100644 index 0000000000..eb0eccbc3e --- /dev/null +++ b/stock_reception_screen_measuring_device/tests/test_reception_screen_measurement.py @@ -0,0 +1,360 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +from odoo_test_helper import FakeModelLoader + +from odoo.exceptions import UserError +from odoo.tests import tagged + +from odoo.addons.component.tests.common import SavepointComponentRegistryCase + +from .fake_components import FakeDevice + + +@tagged("-at_install", "post_install") +class TestReceptionScreenMeasurement(SavepointComponentRegistryCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.setUpModels() + cls.setUpClassStorageType() + cls.setUpClassProduct() + cls.location_dest = cls.env.ref("stock.stock_location_stock") + cls.location_src = cls.env.ref("stock.stock_location_suppliers") + cls.warehouse = cls.env.ref("stock.warehouse0") + cls.partner = cls.env.ref("base.res_partner_1") + cls.setUpClassMeasuringDevice() + cls.setUpClassComponents() + + @classmethod + def setUpClassComponents(cls): + cls._setup_registry(cls) + cls._build_components(cls, FakeDevice) + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + return super().tearDownClass() + + @classmethod + def setUpModels(cls): + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + from .fake_models import FakeMeasuringDevice + + cls.loader.update_registry((FakeMeasuringDevice,)) + + @classmethod + def setUpClassMeasuringDevice(cls): + cls.measuring_device = cls.env["measuring.device"].create( + { + "name": "TestDevice", + "warehouse_id": cls.warehouse.id, + "device_type": "fake", + "is_default": True, + } + ) + + @classmethod + def setUpClassStorageType(cls): + cls.location_storage_model = cls.env["stock.location.storage.type"] + cls.location_boxes = cls.location_storage_model.create( + { + "name": "Boxes location", + } + ) + cls.location_pallets = cls.location_storage_model.create( + { + "name": "Pallets location", + } + ) + cls.storage_type_model = cls.env["stock.package.storage.type"] + cls.storage_type_box = cls.storage_type_model.create( + { + "name": "BOX", + "location_storage_type_ids": [(4, cls.location_boxes.id)], + } + ) + cls.storage_type_box_of_boxes = cls.storage_type_model.create( + { + "name": "BOX_OF_BOXES", + "location_storage_type_ids": [(4, cls.location_boxes.id)], + } + ) + cls.storage_type_pallet = cls.storage_type_model.create( + { + "name": "PALLET", + "location_storage_type_ids": [(4, cls.location_pallets.id)], + } + ) + + @classmethod + def setUpClassProduct(cls): + cls.product_screw = cls.env["product.product"].create( + { + "name": "SCREWS", + "type": "product", + } + ) + cls.packaging_model = cls.env["product.packaging"] + + # Set default dimensions to mimick what is done from the UI + # -> Numerical values evaluated to False are converted to 0.0 + default_dimensions = { + "packaging_length": 0, + "width": 0, + "height": 0, + "max_weight": 0, + } + cls.packaging_smaller_box = cls.packaging_model.create( + dict( + { + "name": "SMALLER BOX OF SCREWS", + "product_id": cls.product_screw.id, + "qty": 5, + "package_storage_type_id": cls.storage_type_box.id, + }, + **default_dimensions, + ) + ) + cls.packaging_regular_box = cls.packaging_model.create( + dict( + { + "name": "REGULAR BOX OF SCREWS", + "product_id": cls.product_screw.id, + "qty": 50, + "package_storage_type_id": cls.storage_type_box.id, + }, + **default_dimensions, + ) + ) + cls.packaging_huge_box = cls.packaging_model.create( + dict( + { + "name": "HUGE BOX OF SCREWS", + "product_id": cls.product_screw.id, + "qty": 500, + "package_storage_type_id": cls.storage_type_box_of_boxes.id, + }, + **default_dimensions, + ) + ) + cls.packaging_pallet = cls.packaging_model.create( + dict( + { + "name": "PALLET OF SCREWS", + "product_id": cls.product_screw.id, + "qty": 5000, + "package_storage_type_id": cls.storage_type_pallet.id, + "type_is_pallet": True, + }, + **default_dimensions, + ) + ) + cls.all_packages_no_pallet = ( + cls.packaging_smaller_box + | cls.packaging_regular_box + | cls.packaging_huge_box + ) + cls.all_packages = cls.all_packages_no_pallet | cls.packaging_pallet + + @classmethod + def _create_picking_get_move_vals(cls, product_matrix): + move_vals = [] + defaults = { + "location_id": cls.location_src.id, + "location_dest_id": cls.location_dest.id, + } + for product, qty in product_matrix: + product_vals = { + "product_id": product.id, + "name": product.name, + "product_uom_qty": qty, + "product_uom": product.uom_id.id, + } + move_vals.append((0, 0, dict(defaults, **product_vals))) + return move_vals + + @classmethod + def _create_picking_get_values(cls, product_matrix): + return { + "partner_id": cls.partner.id, + "location_id": cls.location_src.id, + "location_dest_id": cls.location_dest.id, + "picking_type_id": cls.env.ref("stock.picking_type_in").id, + "move_lines": cls._create_picking_get_move_vals(product_matrix), + } + + @classmethod + def _create_picking_in(cls, product_matrix): + picking_values = cls._create_picking_get_values(product_matrix) + return cls.env["stock.picking"].create(picking_values) + + @classmethod + def _picking_get_reception_screen(cls, picking): + picking.action_confirm() + picking.action_reception_screen_open() + return picking.reception_screen_id + + @classmethod + def _packaging_flush_dimensions(cls, packagings): + field_names = ["max_weight", "packaging_length", "width", "height"] + packagings.write({key: 0.0 for key in field_names}) + + @classmethod + def _packaging_get_default_dimensions(cls): + field_names = ["max_weight", "packaging_length", "width", "height"] + return {key: 42 for key in field_names} + + @classmethod + def _packaging_set_dimensions(cls, packagings): + packagings.write(cls._packaging_get_default_dimensions()) + + def get_screen_at_packaging_selection(self, picking): + move_screw = picking.move_lines[0] + reception_screen = self._picking_get_reception_screen(picking) + self.assertEqual(reception_screen.current_step, "select_product") + move_screw.action_select_product() + # Product isn't tracked by serial, next step is set_quantity + self.assertEqual(reception_screen.current_step, "set_quantity") + # receiving 800 out of 1000.0 + reception_screen.current_move_line_qty_done = 800 + self.assertEqual(reception_screen.current_move_line_qty_status, "lt") + # Check package data (automatically filled normally) + reception_screen.button_save_step() + self.assertEqual(reception_screen.current_step, "select_packaging") + return reception_screen + + def test_current_package_needs_measurement(self): + picking = self._create_picking_in([(self.product_screw, 1000.0)]) + picking.action_confirm() + reception_screen = self.get_screen_at_packaging_selection(picking) + # No dimension is set on the selected package + reception_screen.product_packaging_id = self.packaging_huge_box + self.assertTrue(reception_screen.package_has_missing_dimensions) + # Setting dimensions sets the package_has_missing_dimensions to False + self._packaging_set_dimensions(self.packaging_huge_box) + reception_screen.invalidate_cache(["package_has_missing_dimensions"]) + self.assertFalse(reception_screen.package_has_missing_dimensions) + + def test_smaller_package_needs_measurement(self): + picking = self._create_picking_in([(self.product_screw, 1000.0)]) + picking.action_confirm() + reception_screen = self.get_screen_at_packaging_selection(picking) + # Select bigger package, set measurements, package_has_missing_dimensions + # is set to False + reception_screen.product_packaging_id = self.packaging_huge_box + self._packaging_set_dimensions(self.packaging_huge_box) + self.assertFalse(reception_screen.package_has_missing_dimensions) + # However, regular and smaller boxes are missing measurements + # smaller_package_has_missing_dimensions should be set to True + self.assertTrue(reception_screen.smaller_package_has_missing_dimensions) + # Set dimensions on regular box, smaller box is still missing dimensions + self._packaging_set_dimensions(self.packaging_regular_box) + reception_screen.invalidate_cache(["smaller_package_has_missing_dimensions"]) + self.assertTrue(reception_screen.smaller_package_has_missing_dimensions) + # Set dimensions on smaller box, all dimensions are set on packages + # smaller than the selected one + self._packaging_set_dimensions(self.packaging_smaller_box) + reception_screen.invalidate_cache(["smaller_package_has_missing_dimensions"]) + self.assertFalse(reception_screen.smaller_package_has_missing_dimensions) + + def test_measure_from_biggest_packaging_to_smallest(self): + picking = self._create_picking_in([(self.product_screw, 1000.0)]) + picking.action_confirm() + reception_screen = self.get_screen_at_packaging_selection(picking) + # select biggest package + reception_screen.product_packaging_id = self.packaging_huge_box + # Click the measure current package button + reception_screen.measure_current_packaging() + # Measuring device is assigned to the current package + self.assertEqual( + self.packaging_huge_box.measuring_device_id, self.measuring_device + ) + # set dimensions on packaging using mocked measuring device + measurement_values = { + k: 42 for k in ("max_weight", "packaging_length", "width", "height") + } + self.measuring_device.mocked_measure(measurement_values) + # Now measuring device is unassigned + self.assertFalse(self.packaging_huge_box.measuring_device_id) + # and all measurements are set on packaging + for key, value in measurement_values.items(): + self.assertEqual(self.packaging_huge_box[key], value) + # There's still smaller packages missing measurements + self.assertTrue(reception_screen.smaller_package_has_missing_dimensions) + # Click the measure smaller package button + reception_screen.measure_smaller_packaging() + # Among the 2 smaller packagings without dimension, + # the biggest has the priority and should have been selected + self.assertEqual( + self.packaging_regular_box.measuring_device_id, self.measuring_device + ) + # Set quantity, we still have a smaller packaging without measurement + self.measuring_device.mocked_measure(measurement_values) + for key, value in measurement_values.items(): + self.assertEqual(self.packaging_regular_box[key], value) + reception_screen.invalidate_cache(["smaller_package_has_missing_dimensions"]) + self.assertTrue(reception_screen.smaller_package_has_missing_dimensions) + # Select next smaller packaging for measurement, the smallest should be selected + reception_screen.measure_smaller_packaging() + self.assertEqual( + self.packaging_smaller_box.measuring_device_id, self.measuring_device + ) + # Set quantity, no more smaller packaging to measure, + # smaller_package_has_missing_dimensions should be False + self.measuring_device.mocked_measure(measurement_values) + for key, value in measurement_values.items(): + self.assertEqual(self.packaging_smaller_box[key], value) + reception_screen.invalidate_cache(["smaller_package_has_missing_dimensions"]) + self.assertFalse(reception_screen.smaller_package_has_missing_dimensions) + + def test_measuring_device_skips_measured_packagings(self): + picking = self._create_picking_in([(self.product_screw, 1000.0)]) + picking.action_confirm() + reception_screen = self.get_screen_at_packaging_selection(picking) + reception_screen.product_packaging_id = self.packaging_huge_box + # Set dimensions on bigger and regular box + self._packaging_set_dimensions( + self.packaging_huge_box | self.packaging_regular_box + ) + self.assertFalse(reception_screen.package_has_missing_dimensions) + self.assertTrue(reception_screen.smaller_package_has_missing_dimensions) + # Selected packaging for measurement should be packaging_smaller_box + reception_screen.measure_smaller_packaging() + self.assertEqual( + self.packaging_smaller_box.measuring_device_id, self.measuring_device + ) + + def test_measurement_device_cancel_package_measurement(self): + picking = self._create_picking_in([(self.product_screw, 1000.0)]) + picking.action_confirm() + reception_screen = self.get_screen_at_packaging_selection(picking) + reception_screen.product_packaging_id = self.packaging_huge_box + # Select huge packaging + reception_screen.measure_current_packaging() + self.assertTrue(self.packaging_huge_box.measuring_device_id) + reception_screen.cancel_measure_current_packaging() + self.assertFalse(self.packaging_huge_box.measuring_device_id) + # Assigning measuring device to regular packaging then pressing the cancel + # should unassign the measuring device + reception_screen.measure_smaller_packaging() + self.assertTrue(self.packaging_regular_box.measuring_device_id) + reception_screen.cancel_measure_current_packaging() + self.assertFalse(self.packaging_regular_box.measuring_device_id) + + def test_measuring_device_cannot_be_assigned_twice(self): + picking = self._create_picking_in([(self.product_screw, 1000.0)]) + picking.action_confirm() + reception_screen = self.get_screen_at_packaging_selection(picking) + # Select huge box as packaging + reception_screen.product_packaging_id = self.packaging_huge_box + # Assign device to huge box packaging + reception_screen.measure_current_packaging() + self.assertTrue(self.packaging_huge_box.measuring_device_id) + # Then try to assign device to regular box packaging, and check + # that it had no effect + message = r"Measurement machine already in use." + with self.assertRaisesRegex(UserError, message): + reception_screen.measure_smaller_packaging() + self.assertFalse(self.packaging_regular_box.measuring_device_id) diff --git a/stock_reception_screen_measuring_device/views/stock_reception_screen_view.xml b/stock_reception_screen_measuring_device/views/stock_reception_screen_view.xml index 04a3986cd1..eabfbf42ef 100644 --- a/stock_reception_screen_measuring_device/views/stock_reception_screen_view.xml +++ b/stock_reception_screen_measuring_device/views/stock_reception_screen_view.xml @@ -12,15 +12,24 @@