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..ec88176e0f 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,8 +95,8 @@ 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 ) @@ -80,20 +104,53 @@ def measure_current_packaging(self): error_msg = _("No default device set, please configure one.") _logger.error(error_msg) self._notify(error_msg) - return UserError(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) + raise UserError(error_msg) - self.product_packaging_id._measuring_device_assign(device) + packaging._measuring_device_assign(device) 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): """Show a gentle notification on the wizard""" 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 @@