From 48f3d6010286c9fb98005a4cec5bbf0d69f80ee3 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 2 Jul 2020 08:24:49 +0200 Subject: [PATCH] Add stock_vertical_lift_storage_type Compatibility module between stock_vertical_lift and stock_storage_type (in OCA/wms). In the vertical lift's Putaway screen, when a good is scanned for a putaway, the user has to scan the tray type of the corresponding size, so an empty place in a matching tray is found. When we use storage types, we should know what tray is compatible with the storage type. Changes with this module: * The storage types of trays cannot be selected in the locations form, they have to be set in the Tray types. * In the lift put-away screen, when a package has a storage type, the user isn't asked to scan a tray type, instead, the putaway of the Package Storage Type is applied. --- stock_vertical_lift_storage_type/__init__.py | 1 + .../__manifest__.py | 21 +++ .../models/__init__.py | 3 + .../models/stock_location.py | 50 ++++++++ .../models/stock_location_tray_type.py | 29 +++++ .../models/vertical_lift_operation_put.py | 87 +++++++++++++ .../readme/CONTRIBUTORS.rst | 1 + .../readme/DESCRIPTION.rst | 14 ++ .../tests/__init__.py | 3 + .../tests/common.py | 27 ++++ .../tests/test_put.py | 120 ++++++++++++++++++ .../tests/test_stock_location.py | 90 +++++++++++++ .../tests/test_tray_type.py | 38 ++++++ .../views/stock_location_tray_type_views.xml | 18 +++ .../views/stock_location_views.xml | 15 +++ 15 files changed, 517 insertions(+) create mode 100644 stock_vertical_lift_storage_type/__init__.py create mode 100644 stock_vertical_lift_storage_type/__manifest__.py create mode 100644 stock_vertical_lift_storage_type/models/__init__.py create mode 100644 stock_vertical_lift_storage_type/models/stock_location.py create mode 100644 stock_vertical_lift_storage_type/models/stock_location_tray_type.py create mode 100644 stock_vertical_lift_storage_type/models/vertical_lift_operation_put.py create mode 100644 stock_vertical_lift_storage_type/readme/CONTRIBUTORS.rst create mode 100644 stock_vertical_lift_storage_type/readme/DESCRIPTION.rst create mode 100644 stock_vertical_lift_storage_type/tests/__init__.py create mode 100644 stock_vertical_lift_storage_type/tests/common.py create mode 100644 stock_vertical_lift_storage_type/tests/test_put.py create mode 100644 stock_vertical_lift_storage_type/tests/test_stock_location.py create mode 100644 stock_vertical_lift_storage_type/tests/test_tray_type.py create mode 100644 stock_vertical_lift_storage_type/views/stock_location_tray_type_views.xml create mode 100644 stock_vertical_lift_storage_type/views/stock_location_views.xml diff --git a/stock_vertical_lift_storage_type/__init__.py b/stock_vertical_lift_storage_type/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/stock_vertical_lift_storage_type/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_vertical_lift_storage_type/__manifest__.py b/stock_vertical_lift_storage_type/__manifest__.py new file mode 100644 index 000000000..99d91fd3e --- /dev/null +++ b/stock_vertical_lift_storage_type/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + "name": "Vertical Lift - Storage Type", + "summary": "Compatibility layer for storage types on vertical lifts", + "version": "13.0.1.0.0", + "category": "Stock", + "author": "Camptocamp, Odoo Community Association (OCA)", + "license": "AGPL-3", + "depends": [ + "stock_vertical_lift", # OCA/stock-logistics-warehouse + "stock_storage_type", # OCA/wms + ], + "website": "https://github.com/OCA/stock-logistics-warehouse", + "data": [ + "views/stock_location_tray_type_views.xml", + "views/stock_location_views.xml", + ], + "installable": True, + "development_status": "Alpha", +} diff --git a/stock_vertical_lift_storage_type/models/__init__.py b/stock_vertical_lift_storage_type/models/__init__.py new file mode 100644 index 000000000..029c1ede2 --- /dev/null +++ b/stock_vertical_lift_storage_type/models/__init__.py @@ -0,0 +1,3 @@ +from . import stock_location +from . import stock_location_tray_type +from . import vertical_lift_operation_put diff --git a/stock_vertical_lift_storage_type/models/stock_location.py b/stock_vertical_lift_storage_type/models/stock_location.py new file mode 100644 index 000000000..38b086abe --- /dev/null +++ b/stock_vertical_lift_storage_type/models/stock_location.py @@ -0,0 +1,50 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, exceptions, models + + +class StockLocation(models.Model): + _inherit = "stock.location" + + @api.model_create_multi + def create(self, vals_list): + if not self.env.context.get("_sync_tray_type"): + for vals in vals_list: + if vals.get("tray_type_id") and vals.get("location_storage_type_ids"): + raise exceptions.UserError( + _( + "Error creating '{}': Location storage" + " type must be set on the tray type" + ).format(vals.get("name")) + ) + + records = super().create(vals_list) + records._sync_tray_type_storage_types() + return records + + def write(self, values): + if not self.env.context.get("_sync_tray_type"): + if values.get("location_storage_type_ids"): + if values.get("tray_type_id"): + has_tray_type = self + else: + has_tray_type = self.filtered("tray_type_id") + if has_tray_type: + raise exceptions.UserError( + _( + "Error updating {}: Location storage" + " type must be set on the tray type" + ).format(", ".join(has_tray_type.mapped("name"))) + ) + res = super().write(values) + if values.get("tray_type_id"): + self._sync_tray_type_storage_types() + return res + + def _sync_tray_type_storage_types(self): + for location in self.with_context(_sync_tray_type=True): + if not location.tray_type_id: + continue + storage_types = location.tray_type_id.location_storage_type_ids + location.write({"location_storage_type_ids": [(6, 0, storage_types.ids)]}) diff --git a/stock_vertical_lift_storage_type/models/stock_location_tray_type.py b/stock_vertical_lift_storage_type/models/stock_location_tray_type.py new file mode 100644 index 000000000..15fbcff51 --- /dev/null +++ b/stock_vertical_lift_storage_type/models/stock_location_tray_type.py @@ -0,0 +1,29 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class StockLocationTrayType(models.Model): + _inherit = "stock.location.tray.type" + + location_storage_type_ids = fields.Many2many( + comodel_name="stock.location.storage.type", + help="Location storage types applied on the location using " "this tray type.", + ) + + def write(self, values): + res = super().write(values) + if values.get("location_storage_type_ids"): + self._sync_location_storage_type_ids() + return res + + def _sync_location_storage_type_ids(self): + for tray_type in self: + tray_type.location_ids.with_context(_sync_tray_type=True).write( + { + "location_storage_type_ids": [ + (6, 0, tray_type.location_storage_type_ids.ids) + ] + } + ) diff --git a/stock_vertical_lift_storage_type/models/vertical_lift_operation_put.py b/stock_vertical_lift_storage_type/models/vertical_lift_operation_put.py new file mode 100644 index 000000000..79a081531 --- /dev/null +++ b/stock_vertical_lift_storage_type/models/vertical_lift_operation_put.py @@ -0,0 +1,87 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, models + + +class VerticalLiftOperationPut(models.Model): + _inherit = "vertical.lift.operation.put" + + # In the base module, when a good is scanned for a put-away, the user must + # then scan a tray type, which will be used to find an available cell of + # this type. When we use storage types (OCA/wms::stock_storage_type), we + # shouldn't need to scan a tray type if we already have a storage type, + # that could be used to find an available cell location for this type + # (applying all the possible restrictions from storage type). + + def _transitions(self): + transitions = super()._transitions() + updated_transitions = [] + for transition in transitions: + states = (transition.current_state, transition.next_state) + if states == ("scan_tray_type", "save"): + # insert new transitions just before the normal transition + # scanning the tray type, that will bypass it when we have + # a storage type + updated_transitions.append( + self.Transition( + "scan_tray_type", + "save", + lambda self: self._has_storage_type() + and self._putaway_with_storage_type(), + # this is the trick that makes the transition applies + # its function and directly jumps to save + direct_eval=True, + ) + ) + updated_transitions.append( + self.Transition( + "scan_tray_type", + "scan_source", + # the transition above returned False because it could + # not find a free space, in that case, abort the + # put-away for this line in this shuttle + lambda self: self._has_storage_type() + and self._put_away_with_storage_type_failed() + and self.clear_current_move_line(), + # this is the trick that makes the transition applies + # its function and directly jumps to save + direct_eval=True, + ) + ) + # if none of the 2 transitions above is applied (because + # self._has_storage_type() is False), the state remains + # `scan_tray_type`, for the base transition doesn't have + # `direct_eval=True` + updated_transitions.append(transition) + + return tuple(updated_transitions) + + def _has_storage_type(self): + move_line = self.current_move_line_id + storage_type = move_line.package_id.package_storage_type_id + return bool(storage_type) + + def _putaway_with_storage_type(self): + move_line = self.current_move_line_id + # Trigger the put-away application to place it somewhere inside + # the current shuttle's location. + new_destination = move_line.location_dest_id._get_pack_putaway_strategy( + self.location_id, move_line.package_id.quant_ids, move_line.product_id + ) + if new_destination and new_destination.vertical_lift_kind == "cell": + move_line.location_dest_id = new_destination + move_line.package_level_id.location_dest_id = new_destination + self.fetch_tray() + return True + return False + + def _put_away_with_storage_type_failed(self): + move_line = self.current_move_line_id + storage_type = move_line.package_id.package_storage_type_id + self.env.user.notify_warning( + _("No free space found for storage type '{}' in shuttle '{}'").format( + storage_type.name, self.name + ) + ) + return True diff --git a/stock_vertical_lift_storage_type/readme/CONTRIBUTORS.rst b/stock_vertical_lift_storage_type/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..48286263c --- /dev/null +++ b/stock_vertical_lift_storage_type/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Guewen Baconnier diff --git a/stock_vertical_lift_storage_type/readme/DESCRIPTION.rst b/stock_vertical_lift_storage_type/readme/DESCRIPTION.rst new file mode 100644 index 000000000..c8ee6394c --- /dev/null +++ b/stock_vertical_lift_storage_type/readme/DESCRIPTION.rst @@ -0,0 +1,14 @@ +Compatibility layer between Stock Vertical Lift and Putaway Storage Types (OCA/wms). + +In the vertical lift's Putaway screen, when a good is scanned for a putaway, the +user has to scan the tray type of the corresponding size, so an empty place in a +matching tray is found. When we use storage types, we should know what tray is +compatible with the storage type. + +Changes with this module: + +* The storage types of trays cannot be selected in the locations form, they have + to be set in the Tray types. +* In the lift put-away screen, when a package has a storage type, the user isn't + asked to scan a tray type, instead, the putaway of the Package Storage Type is + applied. diff --git a/stock_vertical_lift_storage_type/tests/__init__.py b/stock_vertical_lift_storage_type/tests/__init__.py new file mode 100644 index 000000000..91db0a261 --- /dev/null +++ b/stock_vertical_lift_storage_type/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_put +from . import test_stock_location +from . import test_tray_type diff --git a/stock_vertical_lift_storage_type/tests/common.py b/stock_vertical_lift_storage_type/tests/common.py new file mode 100644 index 000000000..8948f7c30 --- /dev/null +++ b/stock_vertical_lift_storage_type/tests/common.py @@ -0,0 +1,27 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.stock_vertical_lift.tests.common import VerticalLiftCase + + +class TrayTypeCommonCase(VerticalLiftCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.TrayType = cls.env["stock.location.tray.type"] + cls.location_2b = cls.env.ref( + "stock_vertical_lift." "stock_location_vertical_lift_demo_tray_2b" + ) + cls.location_2d = cls.env.ref( + "stock_vertical_lift." "stock_location_vertical_lift_demo_tray_2d" + ) + LocStorageType = cls.env["stock.location.storage.type"] + cls.location_storage_type_buffer = LocStorageType.create( + {"name": "VLift Buffer"} + ) + cls.location_storage_type_small_8x = LocStorageType.create( + {"name": "Small 8x", "only_empty": True} + ) + cls.storage_types = ( + cls.location_storage_type_small_8x | cls.location_storage_type_buffer + ) diff --git a/stock_vertical_lift_storage_type/tests/test_put.py b/stock_vertical_lift_storage_type/tests/test_put.py new file mode 100644 index 000000000..083266d12 --- /dev/null +++ b/stock_vertical_lift_storage_type/tests/test_put.py @@ -0,0 +1,120 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.stock_vertical_lift.tests.common import VerticalLiftCase + + +class TestPut(VerticalLiftCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.wh = cls.env.ref("stock.warehouse0") + cls.wh.wh_input_stock_loc_id.active = True + cls.wh.int_type_id.active = True + + # used on the vertical lift top level + LocStorageType = cls.env["stock.location.storage.type"] + cls.location_storage_type_buffer = LocStorageType.create( + {"name": "VLift Buffer"} + ) + cls.location_storage_type_small_8x = LocStorageType.create( + {"name": "Small 8x", "only_empty": True} + ) + + # storage type used for Tray 1A + PackageStorageType = cls.env["stock.package.storage.type"] + cls.package_storage_type_small_8x = PackageStorageType.create( + { + "name": "Small 8x", + "location_storage_type_ids": [ + (4, cls.location_storage_type_small_8x.id), + (4, cls.location_storage_type_buffer.id), + ], + } + ) + + cls.location_shuttle1 = cls.shuttle.location_id + cls.vertical_lift_loc.location_storage_type_ids = ( + cls.location_storage_type_buffer + ) + cls.vertical_lift_loc.pack_putaway_strategy = "none" + cls.location_shuttle1.location_storage_type_ids = ( + cls.location_storage_type_small_8x + ) + cls.location_shuttle1.pack_putaway_strategy = "ordered_locations" + + cls.env["stock.storage.location.sequence"].create( + { + "package_storage_type_id": cls.package_storage_type_small_8x.id, + "sequence": 1, + "location_id": cls.vertical_lift_loc.id, + } + ) + cls.env["stock.storage.location.sequence"].create( + { + "package_storage_type_id": cls.package_storage_type_small_8x.id, + "sequence": 2, + "location_id": cls.location_shuttle1.id, + } + ) + + cls.package = cls.env["stock.quant.package"].create( + {"package_storage_type_id": cls.package_storage_type_small_8x.id} + ) + cls._update_qty_in_location( + cls.wh.wh_input_stock_loc_id, cls.product_socks, 10, package=cls.package + ) + + cls.int_picking = cls._create_simple_picking_int( + cls.product_socks, 10, cls.vertical_lift_loc + ) + cls.int_picking.action_confirm() + cls.int_picking.action_assign() + + @classmethod + def _create_simple_picking_int(cls, product, quantity, dest_location): + return cls.env["stock.picking"].create( + { + "picking_type_id": cls.wh.int_type_id.id, + "location_id": cls.wh.wh_input_stock_loc_id.id, + "location_dest_id": dest_location.id, + "move_lines": [ + ( + 0, + 0, + { + "name": product.name, + "product_id": product.id, + "product_uom": product.uom_id.id, + "product_uom_qty": quantity, + "picking_type_id": cls.wh.int_type_id.id, + "location_id": cls.wh.wh_input_stock_loc_id.id, + "location_dest_id": dest_location.id, + }, + ) + ], + } + ) + + def test_storage_type_put_away(self): + move_line = self.int_picking.move_line_ids + self.assertEqual(move_line.location_dest_id, self.vertical_lift_loc) + self.assertEqual( + move_line.package_level_id.location_dest_id, self.vertical_lift_loc + ) + + operation = self._open_screen("put") + # we begin with an empty screen, user has to scan a package, product, + # or lot + self.assertEqual(operation.state, "scan_source") + operation.on_barcode_scanned(self.package.name) + + self.assertEqual(operation.current_move_line_id, move_line) + # the dest location was Vertical Lift, it has been change to Vertical + # Lift/Shuttle 1, and the computation from there took the first cell + # available, we should be the pos x1 and y1 in the tray A. + self.assertTrue(move_line.location_dest_id, self.location_1a_x1y1) + + # the state goes straight to "save", as we don't need to scan the tray type + # when a putaway is available + self.assertEqual(operation.state, "save") diff --git a/stock_vertical_lift_storage_type/tests/test_stock_location.py b/stock_vertical_lift_storage_type/tests/test_stock_location.py new file mode 100644 index 000000000..082ac1e49 --- /dev/null +++ b/stock_vertical_lift_storage_type/tests/test_stock_location.py @@ -0,0 +1,90 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import exceptions + +from .common import TrayTypeCommonCase + + +class TestTrayTypeLocation(TrayTypeCommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.tray_type = cls.env.ref( + "stock_location_tray.stock_location_tray_type_small_8x" + ) + cls.tray_type.write( + {"location_storage_type_ids": [(6, 0, cls.storage_types.ids)]} + ) + cls.locations = cls.location_2a | cls.location_2b + + def test_location_create_sync(self): + locations = self.env["stock.location"].create( + [ + { + "name": "tray test 1", + "location_id": self.shuttle.location_id.id, + "usage": "internal", + "tray_type_id": self.tray_type.id, + }, + { + "name": "tray test 2", + "location_id": self.shuttle.location_id.id, + "usage": "internal", + "tray_type_id": self.tray_type.id, + }, + ] + ) + self.assertEqual(locations[0].location_storage_type_ids, self.storage_types) + self.assertEqual(locations[1].location_storage_type_ids, self.storage_types) + + def test_location_write_sync(self): + self.locations.tray_type_id = self.tray_type + self.assertEqual(self.location_2a.location_storage_type_ids, self.storage_types) + self.assertEqual(self.location_2b.location_storage_type_ids, self.storage_types) + + def test_location_create_error(self): + with self.assertRaisesRegex(exceptions.UserError, "Error creating.*"): + self.env["stock.location"].create( + [ + { + "name": "tray test 1", + "location_id": self.shuttle.location_id.id, + "usage": "internal", + "tray_type_id": self.tray_type.id, + "location_storage_type_ids": [ + (6, 0, self.location_storage_type_buffer.ids) + ], + }, + { + "name": "tray test 2", + "location_id": self.shuttle.location_id.id, + "usage": "internal", + "tray_type_id": self.tray_type.id, + "location_storage_type_ids": [ + (6, 0, self.location_storage_type_buffer.ids) + ], + }, + ] + ) + + def test_location_write_both_fields_error(self): + with self.assertRaisesRegex(exceptions.UserError, "Error updating.*"): + self.locations.write( + { + "tray_type_id": self.tray_type.id, + "location_storage_type_ids": [ + (6, 0, self.location_storage_type_buffer.ids) + ], + } + ) + + def test_location_write_storage_type_error(self): + with self.assertRaisesRegex(exceptions.UserError, "Error updating.*"): + self.locations.write( + { + "location_storage_type_ids": [ + (6, 0, self.location_storage_type_buffer.ids) + ], + } + ) diff --git a/stock_vertical_lift_storage_type/tests/test_tray_type.py b/stock_vertical_lift_storage_type/tests/test_tray_type.py new file mode 100644 index 000000000..81559dce1 --- /dev/null +++ b/stock_vertical_lift_storage_type/tests/test_tray_type.py @@ -0,0 +1,38 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from .common import TrayTypeCommonCase + + +class TestTrayType(TrayTypeCommonCase): + def test_tray_type_write_sync(self): + # both tray 1A and tray 2D use stock_location_tray_type_small_8x + tray_type = self.env.ref( + "stock_location_tray.stock_location_tray_type_small_8x" + ) + + tray_type.write({"location_storage_type_ids": [(6, 0, self.storage_types.ids)]}) + + self.assertEqual(self.location_1a.location_storage_type_ids, self.storage_types) + self.assertEqual(self.location_2d.location_storage_type_ids, self.storage_types) + + def test_location_create_sync(self): + # both tray 1A and tray 2D use stock_location_tray_type_small_8x + tray_type = self.env.ref( + "stock_location_tray.stock_location_tray_type_small_8x" + ) + tray_type.write({"location_storage_type_ids": [(6, 0, self.storage_types.ids)]}) + + locations = self.location_2a | self.location_2b + + self.assertNotEqual( + self.location_2a.location_storage_type_ids, self.storage_types + ) + self.assertNotEqual( + self.location_2b.location_storage_type_ids, self.storage_types + ) + + locations.tray_type_id = tray_type + + self.assertEqual(self.location_2a.location_storage_type_ids, self.storage_types) + self.assertEqual(self.location_2b.location_storage_type_ids, self.storage_types) diff --git a/stock_vertical_lift_storage_type/views/stock_location_tray_type_views.xml b/stock_vertical_lift_storage_type/views/stock_location_tray_type_views.xml new file mode 100644 index 000000000..008a213da --- /dev/null +++ b/stock_vertical_lift_storage_type/views/stock_location_tray_type_views.xml @@ -0,0 +1,18 @@ + + + + stock.location.tray.type.form + stock.location.tray.type + + + + + + + + + + diff --git a/stock_vertical_lift_storage_type/views/stock_location_views.xml b/stock_vertical_lift_storage_type/views/stock_location_views.xml new file mode 100644 index 000000000..8b1c5232d --- /dev/null +++ b/stock_vertical_lift_storage_type/views/stock_location_views.xml @@ -0,0 +1,15 @@ + + + + stock.location.form.vertical.lift.storage.type + stock.location + + + + {"readonly": [("vertical_lift_kind", "in", ("tray", "cell"))]} + + + +