diff --git a/oca_dependencies.txt b/oca_dependencies.txt index b255605c13bb..b468c811f54a 100644 --- a/oca_dependencies.txt +++ b/oca_dependencies.txt @@ -1,3 +1,5 @@ product-attribute +server-env server-ux web +wms diff --git a/setup/stock_vertical_lift/odoo/addons/stock_vertical_lift b/setup/stock_vertical_lift/odoo/addons/stock_vertical_lift new file mode 120000 index 000000000000..61bf9f018226 --- /dev/null +++ b/setup/stock_vertical_lift/odoo/addons/stock_vertical_lift @@ -0,0 +1 @@ +../../../../stock_vertical_lift \ No newline at end of file diff --git a/setup/stock_vertical_lift/setup.py b/setup/stock_vertical_lift/setup.py new file mode 100644 index 000000000000..28c57bb64031 --- /dev/null +++ b/setup/stock_vertical_lift/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/stock_vertical_lift_kardex/odoo/addons/stock_vertical_lift_kardex b/setup/stock_vertical_lift_kardex/odoo/addons/stock_vertical_lift_kardex new file mode 120000 index 000000000000..0c99c9c02ea6 --- /dev/null +++ b/setup/stock_vertical_lift_kardex/odoo/addons/stock_vertical_lift_kardex @@ -0,0 +1 @@ +../../../../stock_vertical_lift_kardex \ No newline at end of file diff --git a/setup/stock_vertical_lift_kardex/setup.py b/setup/stock_vertical_lift_kardex/setup.py new file mode 100644 index 000000000000..28c57bb64031 --- /dev/null +++ b/setup/stock_vertical_lift_kardex/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/stock_vertical_lift_packaging_type/odoo/addons/stock_vertical_lift_packaging_type b/setup/stock_vertical_lift_packaging_type/odoo/addons/stock_vertical_lift_packaging_type new file mode 120000 index 000000000000..1a319159a774 --- /dev/null +++ b/setup/stock_vertical_lift_packaging_type/odoo/addons/stock_vertical_lift_packaging_type @@ -0,0 +1 @@ +../../../../stock_vertical_lift_packaging_type \ No newline at end of file diff --git a/setup/stock_vertical_lift_packaging_type/setup.py b/setup/stock_vertical_lift_packaging_type/setup.py new file mode 100644 index 000000000000..28c57bb64031 --- /dev/null +++ b/setup/stock_vertical_lift_packaging_type/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/stock_vertical_lift_server_env/odoo/addons/stock_vertical_lift_server_env b/setup/stock_vertical_lift_server_env/odoo/addons/stock_vertical_lift_server_env new file mode 120000 index 000000000000..38d97a993452 --- /dev/null +++ b/setup/stock_vertical_lift_server_env/odoo/addons/stock_vertical_lift_server_env @@ -0,0 +1 @@ +../../../../stock_vertical_lift_server_env \ No newline at end of file diff --git a/setup/stock_vertical_lift_server_env/setup.py b/setup/stock_vertical_lift_server_env/setup.py new file mode 100644 index 000000000000..28c57bb64031 --- /dev/null +++ b/setup/stock_vertical_lift_server_env/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/stock_vertical_lift_storage_type/odoo/addons/stock_vertical_lift_storage_type b/setup/stock_vertical_lift_storage_type/odoo/addons/stock_vertical_lift_storage_type new file mode 120000 index 000000000000..fc17491a1ac8 --- /dev/null +++ b/setup/stock_vertical_lift_storage_type/odoo/addons/stock_vertical_lift_storage_type @@ -0,0 +1 @@ +../../../../stock_vertical_lift_storage_type \ No newline at end of file diff --git a/setup/stock_vertical_lift_storage_type/setup.py b/setup/stock_vertical_lift_storage_type/setup.py new file mode 100644 index 000000000000..28c57bb64031 --- /dev/null +++ b/setup/stock_vertical_lift_storage_type/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_reserve_rule/demo/stock_reserve_rule_demo.xml b/stock_reserve_rule/demo/stock_reserve_rule_demo.xml index e73a1a42564c..e3edc0aa4b4d 100644 --- a/stock_reserve_rule/demo/stock_reserve_rule_demo.xml +++ b/stock_reserve_rule/demo/stock_reserve_rule_demo.xml @@ -5,6 +5,7 @@ 1 + diff --git a/stock_reserve_rule/readme/USAGE.rst b/stock_reserve_rule/readme/USAGE.rst index 37bd4a31528e..f36f876e18a0 100644 --- a/stock_reserve_rule/readme/USAGE.rst +++ b/stock_reserve_rule/readme/USAGE.rst @@ -25,8 +25,8 @@ A product: Funky Socks Scenario: * Activate Storage Locations and Multi-Warehouses -* You can open Inventory > Configuration > Stock Reservation Rules to see the - rules +* You can open Inventory > Configuration > Stock Reservation Rules to activate + and see the rules (by default in demo, the rules are created inactive) * Open Transfer: Outgoing shipment (reservation rules demo 1) * Check availability: it has 150 units, as it will not empty Zone A, it will not take products there, it should take 100 in B and 50 in C (following the rules diff --git a/stock_reserve_rule/tests/test_reserve_rule.py b/stock_reserve_rule/tests/test_reserve_rule.py index c065f1cb85e6..aefdc4ae1e2c 100644 --- a/stock_reserve_rule/tests/test_reserve_rule.py +++ b/stock_reserve_rule/tests/test_reserve_rule.py @@ -17,6 +17,8 @@ def setUpClass(cls): "code": "WHTEST", } ) + cls.rule = cls.env.ref("stock_reserve_rule.stock_reserve_rule_1_demo") + cls.rule.active = True cls.customer_loc = cls.env.ref("stock.stock_location_customers") diff --git a/stock_vertical_lift/README.rst b/stock_vertical_lift/README.rst new file mode 100644 index 000000000000..a40a55da2046 --- /dev/null +++ b/stock_vertical_lift/README.rst @@ -0,0 +1,134 @@ +============= +Vertical Lift +============= + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fstock--logistics--warehouse-lightgray.png?logo=github + :target: https://github.com/OCA/stock-logistics-warehouse/tree/12.0/stock_vertical_lift + :alt: OCA/stock-logistics-warehouse +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/stock-logistics-warehouse-12-0/stock-logistics-warehouse-12-0-stock_vertical_lift + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/153/12.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Add configuration and dedicated screens to work with Vertical Lift +systems (such as Kardex Remstar, Modula, ...). Drivers for controlling +the lifts physically must be added by additional addons. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +General +~~~~~~~ + +In Inventory Settings, you must have: + + * Storage Locations + * Multi-Warehouses + * Multi-Step Routes + +Locations +~~~~~~~~~ + +Additional configuration parameters are added in Locations: + +* Sub-locations of a location with the "Is a Vertical Lift View Location" + activated are considered as "Shuttles". A shuttle is a vertical lift shelf. +* Sub-locations of shuttles are considered as "Trays", which is a tier of a + shuttle. When a tray is created, a tray type must be selected. When saved, the + tray location will automatically create as many sub-locations - called + "Cells" - as the tray type contains. +* The tray type of a tray can be changed as long as none of its cell contains + products. When changed, it archives the cells and creates new ones as + configured on the new tray type. + +Tray types +~~~~~~~~~~ + +Tray types can be configured in the Inventory settings. +A tray type defines how much cells a tray can hold. It is a square or rectangle +matrix of n cols * m rows. + +Vertical Lift Shuttles +~~~~~~~~~~~~~~~~~~~~~~ + +The Shuttles are the Vertical Lift Trays. One Shuttle entity has to be created +in Odoo for each physical shuttle. Depending of the subsidiary addons installed +(eg. Kardex), different options may be required (host address, ...). The base +addon only includes shuttles of kind "simulation" which will not send orders to +the hardware. + +Known issues / Roadmap +====================== + +* Extract the tray types and matrix widget in a module, they can be used + alone without vertical lift +* Consider merging the 'vertical_lift_kind' with the kind added by + stock_location_zone +* Complete Pick screen and workflow (currently enough for a demo, not for production) +* Implement Put-away screen and workflow +* Implement Inventory screen and workflow + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp + +Contributors +~~~~~~~~~~~~ + +* Guewen Baconnier + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +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. + +This module is part of the `OCA/stock-logistics-warehouse `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/stock_vertical_lift/__init__.py b/stock_vertical_lift/__init__.py new file mode 100644 index 000000000000..f7209b171002 --- /dev/null +++ b/stock_vertical_lift/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/stock_vertical_lift/__manifest__.py b/stock_vertical_lift/__manifest__.py new file mode 100644 index 000000000000..84b26b75926c --- /dev/null +++ b/stock_vertical_lift/__manifest__.py @@ -0,0 +1,41 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + "name": "Vertical Lift", + "summary": "Provides the core for integration with Vertical Lifts", + "version": "13.0.1.0.0", + "category": "Stock", + "author": "Camptocamp, Odoo Community Association (OCA)", + "license": "AGPL-3", + "depends": [ + "stock", + "barcodes", + "base_sparse_field", + "stock_location_tray", # OCA/stock-logistics-warehouse + "web_notify", # OCA/web + "web_ir_actions_act_view_reload", # OCA/web + ], + "website": "https://github.com/OCA/stock-logistics-warehouse", + "demo": [ + "demo/stock_location_demo.xml", + "demo/vertical_lift_shuttle_demo.xml", + "demo/product_demo.xml", + "demo/stock_inventory_demo.xml", + "demo/stock_picking_demo.xml", + ], + "data": [ + "views/stock_location_views.xml", + "views/stock_move_line_views.xml", + "views/vertical_lift_shuttle_views.xml", + "views/vertical_lift_operation_base_views.xml", + "views/vertical_lift_operation_pick_views.xml", + "views/vertical_lift_operation_put_views.xml", + "views/vertical_lift_operation_inventory_views.xml", + "views/stock_vertical_lift_templates.xml", + "views/shuttle_screen_templates.xml", + "security/ir.model.access.csv", + "data/ir_sequence.xml", + ], + "installable": True, + "development_status": "Alpha", +} diff --git a/stock_vertical_lift/controllers/__init__.py b/stock_vertical_lift/controllers/__init__.py new file mode 100644 index 000000000000..12a7e529b674 --- /dev/null +++ b/stock_vertical_lift/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/stock_vertical_lift/controllers/main.py b/stock_vertical_lift/controllers/main.py new file mode 100644 index 000000000000..9a11fb152017 --- /dev/null +++ b/stock_vertical_lift/controllers/main.py @@ -0,0 +1,22 @@ +import logging +import os + +from odoo import http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class VerticalLiftController(http.Controller): + @http.route(["/vertical-lift"], type="http", auth="public", csrf=False) + def vertical_lift(self, answer, secret): + if secret == os.environ.get("VERTICAL_LIFT_SECRET", ""): + rec = request.env["vertical.lift.command"].sudo().record_answer(answer) + return str(rec.id) + else: + _logger.error( + "secret mismatch: %r != %r", + secret, + os.environ.get("VERTICAL_LIFT_SECRET", ""), + ) + raise http.AuthenticationError() diff --git a/stock_vertical_lift/data/ir_sequence.xml b/stock_vertical_lift/data/ir_sequence.xml new file mode 100644 index 000000000000..384d6561c2ee --- /dev/null +++ b/stock_vertical_lift/data/ir_sequence.xml @@ -0,0 +1,10 @@ + + + + Vertical Lift Commands + vertical.lift.command + L + 6 + + + diff --git a/stock_vertical_lift/demo/product_demo.xml b/stock_vertical_lift/demo/product_demo.xml new file mode 100644 index 000000000000..779d09713312 --- /dev/null +++ b/stock_vertical_lift/demo/product_demo.xml @@ -0,0 +1,29 @@ + + + + RS200 + 4491673293664 + Running Socks + product + + 30.0 + 20.0 + 1.0 + none + + + + + RS300 + 2779891103531 + Recovery Socks + product + + 30.0 + 20.0 + 1.0 + none + + + + diff --git a/stock_vertical_lift/demo/stock_inventory_demo.xml b/stock_vertical_lift/demo/stock_inventory_demo.xml new file mode 100644 index 000000000000..9c9e4a4cd107 --- /dev/null +++ b/stock_vertical_lift/demo/stock_inventory_demo.xml @@ -0,0 +1,30 @@ + + + + Starting Vertical Lift Inventory + + + + + + 30.0 + + + + + + + + + diff --git a/stock_vertical_lift/demo/stock_location_demo.xml b/stock_vertical_lift/demo/stock_location_demo.xml new file mode 100644 index 000000000000..be6afec8f250 --- /dev/null +++ b/stock_vertical_lift/demo/stock_location_demo.xml @@ -0,0 +1,131 @@ + + + + Vertical Lift + + + internal + + + + Shuttle 1 + + internal + + + Tray 1A + T1A + + + internal + + + Tray 1B + T1B + + + internal + + + Tray 1C + T1C + + + internal + + + Shuttle 2 + + internal + + + Tray 2A + T2A + + + internal + + + Tray 2B + T2B + + + internal + + + Tray 2C + T2C + + + internal + + + Tray 2D + T2D + + + internal + + + Shuttle 3 + + internal + + + Tray 3A + T3A + + + internal + + + Tray 3B + T3B + + + internal + + + + + stock_vertical_lift + + diff --git a/stock_vertical_lift/demo/stock_picking_demo.xml b/stock_vertical_lift/demo/stock_picking_demo.xml new file mode 100644 index 000000000000..206170bf63f8 --- /dev/null +++ b/stock_vertical_lift/demo/stock_picking_demo.xml @@ -0,0 +1,63 @@ + + + + + Outgoing shipment from Vertical Lift (demo) + + + + + + + + + + + + + + + Incoming shipment from Vertical Lift (demo) + + + + + + + + + + diff --git a/stock_vertical_lift/demo/vertical_lift_shuttle_demo.xml b/stock_vertical_lift/demo/vertical_lift_shuttle_demo.xml new file mode 100644 index 000000000000..c4e8ff582de9 --- /dev/null +++ b/stock_vertical_lift/demo/vertical_lift_shuttle_demo.xml @@ -0,0 +1,18 @@ + + + + Shuttle 1 + + pick + + + Shuttle 2 + + pick + + + Shuttle 3 + + pick + + diff --git a/stock_vertical_lift/images/O-BTN.release.svg b/stock_vertical_lift/images/O-BTN.release.svg new file mode 100644 index 000000000000..3755b9da1cad --- /dev/null +++ b/stock_vertical_lift/images/O-BTN.release.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +O-BTN.release diff --git a/stock_vertical_lift/images/O-BTN.save.svg b/stock_vertical_lift/images/O-BTN.save.svg new file mode 100644 index 000000000000..71a46a893902 --- /dev/null +++ b/stock_vertical_lift/images/O-BTN.save.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +O-BTN.save diff --git a/stock_vertical_lift/models/__init__.py b/stock_vertical_lift/models/__init__.py new file mode 100644 index 000000000000..1db85b28e286 --- /dev/null +++ b/stock_vertical_lift/models/__init__.py @@ -0,0 +1,11 @@ +from . import vertical_lift_shuttle +from . import vertical_lift_operation_base +from . import vertical_lift_operation_pick +from . import vertical_lift_operation_put +from . import vertical_lift_operation_inventory +from . import stock_inventory +from . import stock_location +from . import stock_move +from . import stock_move_line +from . import stock_quant +from . import vertical_lift_command diff --git a/stock_vertical_lift/models/stock_inventory.py b/stock_vertical_lift/models/stock_inventory.py new file mode 100644 index 000000000000..8e87e556e224 --- /dev/null +++ b/stock_vertical_lift/models/stock_inventory.py @@ -0,0 +1,27 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class InventoryLine(models.Model): + _inherit = "stock.inventory.line" + + vertical_lift_done = fields.Boolean(default=False) + # Field used to sort lines by tray on the inventory scan screen, so entire + # trays are processed one after the other + vertical_lift_tray_id = fields.Many2one( + comodel_name="stock.location", + compute="_compute_vertical_lift_tray_id", + readonly=True, + store=True, + ) + + @api.depends("location_id.vertical_lift_kind") + def _compute_vertical_lift_tray_id(self): + for line in self: + if line.location_id.vertical_lift_kind == "cell": + # The parent of the cell is the tray. + line.vertical_lift_tray_id = line.location_id.location_id + else: + line.vertical_lift_tray_id = False diff --git a/stock_vertical_lift/models/stock_location.py b/stock_vertical_lift/models/stock_location.py new file mode 100644 index 000000000000..56e2dc07f65e --- /dev/null +++ b/stock_vertical_lift/models/stock_location.py @@ -0,0 +1,159 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, exceptions, fields, models + + +class StockLocation(models.Model): + _inherit = "stock.location" + + vertical_lift_location = fields.Boolean( + "Is a Vertical Lift View Location?", + default=False, + help="Check this box to use it as the view for Vertical" " Lift Shuttles.", + ) + vertical_lift_kind = fields.Selection( + selection=[ + ("view", "View"), + ("shuttle", "Shuttle"), + ("tray", "Tray"), + ("cell", "Cell"), + ], + compute="_compute_vertical_lift_kind", + store=True, + ) + + # This is a one2one in practice, but this one is not really interesting. + # It's there only to be in the depends of 'vertical_lift_shuttle_id', which + # give the unique shuttle for any location in the tree (whether it's a + # shuttle, a tray or a cell) + inverse_vertical_lift_shuttle_ids = fields.One2many( + comodel_name="vertical.lift.shuttle", inverse_name="location_id", readonly=True + ) + # compute the unique shuttle for any shuttle, tray or cell location, by + # going through the parents + vertical_lift_shuttle_id = fields.Many2one( + comodel_name="vertical.lift.shuttle", + compute="_compute_vertical_lift_shuttle_id", + store=True, + ) + + @api.depends( + "location_id", "location_id.vertical_lift_kind", "vertical_lift_location" + ) + def _compute_vertical_lift_kind(self): + tree = {"view": "shuttle", "shuttle": "tray", "tray": "cell"} + for location in self: + if location.vertical_lift_location: + location.vertical_lift_kind = "view" + continue + kind = tree.get(location.location_id.vertical_lift_kind, False) + location.vertical_lift_kind = kind + + @api.depends( + "inverse_vertical_lift_shuttle_ids", "location_id.vertical_lift_shuttle_id" + ) + def _compute_vertical_lift_shuttle_id(self): + for location in self: + if location.inverse_vertical_lift_shuttle_ids: + # we have a unique constraint on the other side + assert len(location.inverse_vertical_lift_shuttle_ids) == 1 + shuttle = location.inverse_vertical_lift_shuttle_ids + else: + shuttle = location.location_id.vertical_lift_shuttle_id + location.vertical_lift_shuttle_id = shuttle + + def _hardware_vertical_lift_fetch_tray(self, cell_location=None): + payload = self._hardware_vertical_lift_fetch_tray_payload(cell_location) + return self.vertical_lift_shuttle_id._hardware_send_message(payload) + + def _hardware_vertical_lift_fetch_tray_payload(self, cell_location=None): + """Prepare "fetch" message to be sent to the vertical lift hardware + + Private method, this is where the implementation actually happens. + Addons can add their instructions based on the hardware used for + this location. + + The hardware used for a location can be found in: + + ``self.vertical_lift_shuttle_id.hardware`` + + Each addon can implement its own mechanism depending of this value + and must call ``super``. + + The method must send the command to the vertical lift to fetch / open + the tray. If a ``cell_location`` is passed and if the hardware supports + a way to show a cell (such as a laser pointer), it should send this + command as well. + + Useful information that could be needed for the drivers: + + * Any field of `self` (name, barcode, ...) which is the current tray. + * Any field of `cell_location` (name, barcode, ...) which is the cell + in the tray. + * ``self.vertical_lift_shuttle_id`` is the current Shuttle, where we + find details about the hardware, the current mode (pick, put, ...). + * ``self.tray_type_id`` is the kind of tray. + * ``self.tray_type_id.width_per_cell`` and + ``self.tray_type_id.depth_per_cell`` return the size of a cell in mm. + * ``cell_location.posx`` and ``posy`` are the coordinate from the + bottom-left of the tray. + * ``cell_location.tray_cell_center_position()`` returns the central + position of the cell in mm from the bottom-left of a tray. (distance + from left, distance from bottom). Can be used for instance for + highlighting the cell using a laser pointer. + + Returns a message in bytes, that will be sent through + ``VerticalLiftShuttle._hardware_send_message()``. + """ + if self.vertical_lift_shuttle_id.hardware == "simulation": + message = _("Opening tray {}.").format(self.name) + if cell_location: + from_left, from_bottom = cell_location.tray_cell_center_position() + message += _("
Laser pointer on x{} y{} ({}mm, {}mm)").format( + cell_location.posx, cell_location.posy, from_left, from_bottom + ) + return message.encode("utf-8") + else: + raise NotImplementedError() + + def fetch_vertical_lift_tray(self, cell_location=None): + """Send instructions to the vertical lift hardware to fetch a tray + + Public method to use for: + * fetch the vertical lift tray and presenting it to the operator + (physically) + * direct the laser pointer to the cell's location if set + + Depending on the hardware, the laser pointer may not be implemented. + + The actual implementation of the method goes in the private method + ``_hardware_vertical_lift_fetch_tray()``. + """ + self.ensure_one() + if self.vertical_lift_kind == "cell": + if cell_location: + raise ValueError( + "cell_location cannot be set when the location is a cell." + ) + tray = self.location_id + tray.fetch_vertical_lift_tray(cell_location=self) + elif self.vertical_lift_kind == "tray": + self._hardware_vertical_lift_fetch_tray(cell_location=cell_location) + else: + raise exceptions.UserError( + _("Cannot fetch a vertical lift tray on location %s") % (self.name,) + ) + return True + + def button_fetch_vertical_lift_tray(self): + self.ensure_one() + if self.vertical_lift_kind in ("cell", "tray"): + self.fetch_vertical_lift_tray() + return True + + def button_release_vertical_lift_tray(self): + self.ensure_one() + if self.vertical_lift_kind: + self.vertical_lift_shuttle_id.release_vertical_lift_tray() + return True diff --git a/stock_vertical_lift/models/stock_move.py b/stock_vertical_lift/models/stock_move.py new file mode 100644 index 000000000000..b176b73434d0 --- /dev/null +++ b/stock_vertical_lift/models/stock_move.py @@ -0,0 +1,22 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + + +class StockMove(models.Model): + _inherit = "stock.move" + + def write(self, vals): + result = super().write(vals) + if "state" in vals: + # We cannot have fields to depends on to invalidate these computed + # fields on vertical.lift.operation.*. But we know that when the + # state of any move line changes, we can invalidate them as the + # count of assigned move lines may change (and we track this in + # stock.move, not stock.move.line, because the state of the lines + # is a related to this one). + models = ("vertical.lift.operation.pick", "vertical.lift.operation.put") + for model in models: + self.env[model].invalidate_cache(["number_of_ops", "number_of_ops_all"]) + return result diff --git a/stock_vertical_lift/models/stock_move_line.py b/stock_vertical_lift/models/stock_move_line.py new file mode 100644 index 000000000000..433d6f33da6d --- /dev/null +++ b/stock_vertical_lift/models/stock_move_line.py @@ -0,0 +1,21 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + + +class StockMoveLine(models.Model): + _inherit = "stock.move.line" + + def fetch_vertical_lift_tray_source(self): + self.ensure_one() + self.location_id.fetch_vertical_lift_tray() + # We reload mainly because otherwise, it would close + # the popup. This action is provided by the OCA module + # web_ir_actions_act_view_reload + return {"type": "ir.actions.act_view_reload"} + + def fetch_vertical_lift_tray_dest(self): + self.ensure_one() + self.location_dest_id.fetch_vertical_lift_tray() + return {"type": "ir.actions.act_view_reload"} diff --git a/stock_vertical_lift/models/stock_quant.py b/stock_vertical_lift/models/stock_quant.py new file mode 100644 index 000000000000..a31ad9cde114 --- /dev/null +++ b/stock_vertical_lift/models/stock_quant.py @@ -0,0 +1,18 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + + +class StockQuant(models.Model): + _inherit = "stock.quant" + + def _update_available_quantity(self, *args, **kwargs): + result = super()._update_available_quantity(*args, **kwargs) + # We cannot have fields to depends on to invalidate this computed + # fields on vertical.lift.operation.* models. But we know that when the + # quantity of quant changes, we can invalidate the field + models = ("vertical.lift.operation.pick", "vertical.lift.operation.put") + for model in models: + self.env[model].invalidate_cache(["tray_qty"]) + return result diff --git a/stock_vertical_lift/models/vertical_lift_command.py b/stock_vertical_lift/models/vertical_lift_command.py new file mode 100644 index 000000000000..238ca2eff830 --- /dev/null +++ b/stock_vertical_lift/models/vertical_lift_command.py @@ -0,0 +1,50 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + +from odoo import _, api, exceptions, fields, models + +_logger = logging.getLogger(__name__) + + +class VerticalLiftCommand(models.Model): + _name = "vertical.lift.command" + _order = "shuttle_id, name desc" + _description = "commands sent to the shuttle" + + def _default_name(self): + return self.env["ir.sequence"].next_by_code("vertical.lift.command") + + name = fields.Char( + "Name", default=lambda s: s._default_name(), required=True, index=True + ) + command = fields.Char(required=True) + answer = fields.Char() + error = fields.Char() + shuttle_id = fields.Many2one("vertical.lift.shuttle", required=True) + + def record_answer(self, answer): + name = self._get_key(answer) + record = self.search([("name", "=", name)], limit=1) + if not record: + _logger.error("unable to match answer to a command: %r", answer) + raise exceptions.UserError(_("Unknown record %s") % name) + record.answer = answer + record.shuttle_id._hardware_response_callback(record) + return record + + def _get_key(self, answer): + key = answer.split("|")[1:2] + if key: + return key[0] + else: + return "" + + @api.model_create_multi + def create(self, vals_list): + for values in vals_list: + if "name" not in values: + name = self._get_key(values.get("command")) + if name: + values["name"] = name + return super().create(vals_list) diff --git a/stock_vertical_lift/models/vertical_lift_operation_base.py b/stock_vertical_lift/models/vertical_lift_operation_base.py new file mode 100644 index 000000000000..5b9dc84d7ed2 --- /dev/null +++ b/stock_vertical_lift/models/vertical_lift_operation_base.py @@ -0,0 +1,427 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +from collections import namedtuple + +from odoo import _, api, fields, models + +from odoo.addons.base_sparse_field.models.fields import Serialized + +_logger = logging.getLogger(__name__) + + +class VerticalLiftOperationBase(models.AbstractModel): + """Base model for shuttle operations (pick, put, inventory)""" + + _name = "vertical.lift.operation.base" + _inherit = "barcodes.barcode_events_mixin" + _description = "Vertical Lift Operation - Base" + + name = fields.Char(related="shuttle_id.name", readonly=True) + shuttle_id = fields.Many2one( + comodel_name="vertical.lift.shuttle", required=True, readonly=True + ) + location_id = fields.Many2one(related="shuttle_id.location_id", readonly=True) + number_of_ops = fields.Integer( + compute="_compute_number_of_ops", string="Number of Operations" + ) + number_of_ops_all = fields.Integer( + compute="_compute_number_of_ops_all", + string="Number of Operations in all shuttles", + ) + mode = fields.Selection(related="shuttle_id.mode", readonly=True) + + state = fields.Selection( + selection=lambda self: self._selection_states(), + default=lambda self: self._initial_state, + ) + _initial_state = None # to define in sub-classes + + # if there is an action and it's returning True, the transition is done, + # otherwise not + Transition = namedtuple("Transition", "current_state next_state action direct_eval") + # default values to None + Transition.__new__.__defaults__ = (None,) * len(Transition._fields) + + _sql_constraints = [ + ( + "shuttle_id_unique", + "UNIQUE(shuttle_id)", + "One pick can be run at a time for a shuttle.", + ) + ] + + def _selection_states(self): + return [] + + def _transitions(self): + """Define the transitions between the states + + To set in sub-classes. + It is a tuple of a ``Transition`` instances, evaluated in order. + A transition has a source step, a destination step, a function and a + flag ``direct_eval``. + When the function returns True, the transition is applied, otherwise, + the next transition matching the current step is evaluated. + When a transition has no function, it is always applied. + The flag ``direct_eval`` indicates that the workflow should directly + evaluates again the transitions to reach the next step. It allows to + use "virtual" steps that will never be kept for users but be used as + router. + + The initial state must be defined in the attribute ``_initial_state``. + + The transition from a step to another are triggered by a call to + ``next_step()``. This method is called in several places: + + * ``reset_steps()`` (called when the screen opens) + * ``button_save()``, generally used to post the move + * ``button_release()``, generally used to go to the next line + * ``on_barcode_scanned()``, the calls to ``next_step()`` are to + implement in sub-classed if the scanned barcode leads to the next + step + + Example of workflow described below: + + :: + _initial_state = "noop" + + def _selection_states(self): + return [ + ("noop", "No operations"), + ("scan_destination", "Scan New Destination Location"), + ("save", "Put goods in tray and save"), + ("release", "Release"), + ] + + def _transitions(self): + return ( + self.Transition( + "noop", + "scan_destination", + lambda self: self.select_next_move_line() + ), + self.Transition("scan_destination", "save"), + self.Transition("save", "release"), + self.Transition( + "release", + "scan_destination", + lambda self: self.select_next_move_line() + ), + self.Transition("release", "noop"), + ) + + When we arrive on the screen, the ``on_screen_open`` methods resets the + steps (``reset_steps()``). It ensures the current step is ``noop`` and + directly tries to reach the next step (call to ``next_step()``). + + It tries to go from ``noop`` to ``scan_destination``, calling + ``self.select_next_move_line()``. If the method finds a line, it + returns True and the transition is applied, otherwise, the step stays + ``noop``. + + The transitions from ``scan_destination`` and ``save`` and from + ``save`` and ``release`` are always applied when ``next_step()`` is + called (``scan_destination`` → ``save`` from ``on_barcode_scanned`` + when a destination was found, ``save`` → ``release`` from the save + button). + + When ``button_release()`` is called, it calls ``next_step()`` which + first evaluates ``self.select_next_move_line()``: if a move line remains, it + goes to ``scan_destination``, otherwise to ``noop``. + + """ + return () + + def step(self): + return self.state + + def next_step(self, direct_eval=False): + current_state = self.state + for transition in self._transitions(): + if direct_eval and not transition.direct_eval: + continue + if transition.current_state != current_state: + continue + if not transition.action or transition.action(self): + _logger.debug( + "Transition %s → %s", + transition.current_state, + transition.next_state, + ) + self.state = transition.next_state + break + # reevaluate the transitions if we have a new state with direct_eval transitions + if self.state != current_state and any( + transition.direct_eval + for transition in self._transitions() + if transition.current_state == self.state + ): + self.next_step(direct_eval=True) + + def reset_steps(self): + if not self._initial_state: + raise NotImplementedError("_initial_state must be defined") + self.state = self._initial_state + self.next_step() + + def on_barcode_scanned(self, barcode): + self.ensure_one() + # to implement in sub-classes + + def on_screen_open(self): + """Called when the screen is opened""" + self.reset_steps() + + def onchange(self, values, field_name, field_onchange): + if field_name == "_barcode_scanned": + # _barcode_scanner is implemented (in the barcodes module) as an + # onchange, which is really annoying when we want it to act as a + # normal button and actually have side effect in the database + # (update line, go to the next step, ...). This override shorts the + # onchange call and calls the scanner method as a normal method. + self.on_barcode_scanned(values["_barcode_scanned"]) + # We can't know which fields on_barcode_scanned changed, refresh + # everything. + return {"value": self.read()[0]} + else: + return super().onchange(values, field_name, field_onchange) + + @api.depends() + def _compute_number_of_ops(self): + for record in self: + record.number_of_ops = 0 + + @api.depends() + def _compute_number_of_ops_all(self): + for record in self: + record.number_of_ops_all = 0 + + def action_open_screen(self): + return self.shuttle_id.action_open_screen() + + def action_menu(self): + return self.shuttle_id.action_menu() + + def action_manual_barcode(self): + return self.shuttle_id.action_manual_barcode() + + def process_current(self): + """Process the action (pick, put, ...) + + To implement in sub-classes + """ + raise NotImplementedError + + def button_save(self): + """Confirm the operation (set move to done, ...)""" + self.ensure_one() + if not self.step() == "save": + return + self.next_step() + + def button_release(self): + """Release the operation, go to the next""" + self.ensure_one() + if not self.step() == "release": + return + self.next_step() + + def _render_product_packagings(self, product): + if not product: + return "" + return self.env["ir.qweb"].render( + "stock_vertical_lift.packagings", + self._prepare_values_for_product_packaging(product), + ) + + def _prepare_values_for_product_packaging(self, product): + return {"product": product} + + def _get_tray_qty(self, product, location): + quants = self.env["stock.quant"].search( + [("location_id", "=", location.id), ("product_id", "=", product.id)] + ) + return sum(quants.mapped("quantity")) + + def _rainbow_man(self, message=None): + if not message: + message = _("Congrats, you cleared the queue!") + return { + "effect": { + "fadeout": "slow", + "message": message, + "img_url": "/web/static/src/img/smile.svg", + "type": "rainbow_man", + } + } + + def _send_notification_refresh(self): + """Send a refresh notification + + Generally, you want to call the method + _send_notification_refresh() on VerticalLiftShuttle so you + don't need to know the id of the current operation. + + Other notifications can be implemented, they have to be + added in static/src/js/vertical_lift.js and the message + must contain an "action" and "params". + """ + self.ensure_one() + channel = "notify_vertical_lift_screen" + bus_message = { + "action": "refresh", + "params": {"model": self._name, "id": self.id}, + } + self.env["bus.bus"].sendone(channel, bus_message) + + +class VerticalLiftOperationTransfer(models.AbstractModel): + """Base model for shuttle pick and put operations""" + + _name = "vertical.lift.operation.transfer" + _inherit = "vertical.lift.operation.base" + _description = "Vertical Lift Operation - Transfer" + + current_move_line_id = fields.Many2one( + comodel_name="stock.move.line", readonly=True + ) + + tray_location_id = fields.Many2one( + comodel_name="stock.location", + compute="_compute_tray_data", + string="Tray Location", + ) + tray_name = fields.Char(compute="_compute_tray_data", string="Tray Name") + tray_type_id = fields.Many2one( + comodel_name="stock.location.tray.type", + compute="_compute_tray_data", + string="Tray Type", + ) + tray_type_code = fields.Char(compute="_compute_tray_data", string="Tray Code") + tray_x = fields.Integer(string="X", compute="_compute_tray_data") + tray_y = fields.Integer(string="Y", compute="_compute_tray_data") + tray_matrix = Serialized(string="Cells", compute="_compute_tray_data") + tray_qty = fields.Float(string="Stock Quantity", compute="_compute_tray_qty") + + # current operation information + picking_id = fields.Many2one( + related="current_move_line_id.picking_id", readonly=True + ) + picking_origin = fields.Char( + related="current_move_line_id.picking_id.origin", readonly=True + ) + picking_partner_id = fields.Many2one( + related="current_move_line_id.picking_id.partner_id", readonly=True + ) + product_id = fields.Many2one( + related="current_move_line_id.product_id", readonly=True + ) + product_uom_id = fields.Many2one( + related="current_move_line_id.product_uom_id", readonly=True + ) + product_uom_qty = fields.Float( + related="current_move_line_id.product_uom_qty", readonly=True + ) + product_packagings = fields.Html( + string="Packaging", compute="_compute_product_packagings" + ) + qty_done = fields.Float(related="current_move_line_id.qty_done", readonly=True) + lot_id = fields.Many2one(related="current_move_line_id.lot_id", readonly=True) + location_dest_id = fields.Many2one( + string="Destination", + related="current_move_line_id.location_dest_id", + readonly=False, + ) + # TODO add a glue addon with product_expiry to add the field + + def on_barcode_scanned(self, barcode): + self.ensure_one() + self.env.user.notify_info( + "Scanned barcode: {}. Not implemented.".format(barcode) + ) + + @api.depends("current_move_line_id.product_id.packaging_ids") + def _compute_product_packagings(self): + for record in self: + product = record.current_move_line_id.product_id + if not product: + record.product_packagings = "" + continue + content = self._render_product_packagings(product) + record.product_packagings = content + + @api.depends() + def _compute_number_of_ops(self): + for record in self: + record.number_of_ops = record.count_move_lines_to_do() + + @api.depends() + def _compute_number_of_ops_all(self): + for record in self: + record.number_of_ops_all = record.count_move_lines_to_do_all() + + @api.depends("tray_location_id", "current_move_line_id.product_id") + def _compute_tray_qty(self): + for record in self: + if not (record.tray_location_id and record.current_move_line_id): + record.tray_qty = 0.0 + continue + product = record.current_move_line_id.product_id + location = record.tray_location_id + record.tray_qty = self._get_tray_qty(product, location) + + @api.depends("current_move_line_id") + def _compute_tray_data(self): + for record in self: + modes = {"pick": "location_id", "put": "location_dest_id"} + location = record.current_move_line_id[modes[record.mode]] + tray_type = location.location_id.tray_type_id + # this is the current cell + record.tray_location_id = location.id + # name of the tray where the cell is + record.tray_name = location.location_id.name + record.tray_type_id = tray_type.id + record.tray_type_code = tray_type.code + record.tray_x = location.posx + record.tray_y = location.posy + record.tray_matrix = location.tray_matrix + + def _domain_move_lines_to_do(self): + # to implement in sub-classes + return [("id", "=", 0)] + + def _domain_move_lines_to_do_all(self): + # to implement in sub-classes + return [("id", "=", 0)] + + def count_move_lines_to_do(self): + """Count move lines to process in current shuttles""" + self.ensure_one() + return self.env["stock.move.line"].search_count(self._domain_move_lines_to_do()) + + def count_move_lines_to_do_all(self): + """Count move lines to process in all shuttles""" + self.ensure_one() + return self.env["stock.move.line"].search_count( + self._domain_move_lines_to_do_all() + ) + + def process_current(self): + line = self.current_move_line_id + if line.state in ("assigned", "partially_available"): + line.qty_done = line.product_qty + line.move_id._action_done() + return True + + def fetch_tray(self): + raise NotImplementedError + + def reset_steps(self): + self.clear_current_move_line() + super().reset_steps() + + def clear_current_move_line(self): + self.current_move_line_id = False + return True diff --git a/stock_vertical_lift/models/vertical_lift_operation_inventory.py b/stock_vertical_lift/models/vertical_lift_operation_inventory.py new file mode 100644 index 000000000000..c0372494b58c --- /dev/null +++ b/stock_vertical_lift/models/vertical_lift_operation_inventory.py @@ -0,0 +1,266 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models +from odoo.tools import float_compare + +from odoo.addons.base_sparse_field.models.fields import Serialized + +# TODO handle autofocus + easy way to validate for the input field + + +class VerticalLiftOperationInventory(models.Model): + _name = "vertical.lift.operation.inventory" + _inherit = "vertical.lift.operation.base" + _description = "Vertical Lift Operation Inventory" + + _initial_state = "noop" + + def _selection_states(self): + return [ + ("noop", "No inventory in progress"), + ("quantity", "Inventory, please enter the amount"), + ("confirm_wrong_quantity", "The quantity does not match, are you sure?"), + # save is never visible, but save and go to the next or noop directly + ("save", "Save"), + # no need for release and save button here? + # ("release", "Release"), + ] + + def _transitions(self): + return ( + self.Transition( + "noop", + "quantity", + # transition only if inventory lines are found + lambda self: self.select_next_inventory_line(), + ), + self.Transition( + "quantity", "save", lambda self: self._has_identical_quantity(), + ), + self.Transition( + "quantity", + "confirm_wrong_quantity", + lambda self: self._start_confirm_wrong_quantity(), + ), + self.Transition( + "confirm_wrong_quantity", + "save", + lambda self: self.quantity_input == self.last_quantity_input, + ), + # if the confirmation of the quantity is different, cycle back to + # the 'quantity' step + self.Transition( + "confirm_wrong_quantity", + "quantity", + lambda self: self._go_back_to_quantity_input(), + ), + # go to quantity if we have lines in queue, otherwise, go to noop + self.Transition( + "save", + "quantity", + lambda self: self.process_current() + and self.select_next_inventory_line(), + # when we reach 'save', this transition is directly + # evaluated + direct_eval=True, + ), + self.Transition( + "save", + "noop", + lambda self: self.process_current() + and self.clear_current_inventory_line(), + # when we reach 'save', this transition is directly + # evaluated + direct_eval=True, + ), + ) + + current_inventory_line_id = fields.Many2one( + comodel_name="stock.inventory.line", readonly=True + ) + + quantity_input = fields.Float() + # if the quantity is wrong, user has to write 2 times + # the same quantity to really confirm it's correct + last_quantity_input = fields.Float() + + tray_location_id = fields.Many2one( + comodel_name="stock.location", + compute="_compute_tray_data", + string="Tray Location", + ) + tray_name = fields.Char(compute="_compute_tray_data", string="Tray Name") + tray_type_id = fields.Many2one( + comodel_name="stock.location.tray.type", + compute="_compute_tray_data", + string="Tray Type", + ) + tray_type_code = fields.Char(compute="_compute_tray_data", string="Tray Code") + tray_x = fields.Integer(string="X", compute="_compute_tray_data") + tray_y = fields.Integer(string="Y", compute="_compute_tray_data") + tray_matrix = Serialized(string="Cells", compute="_compute_tray_data") + tray_qty = fields.Float(string="Stock Quantity", compute="_compute_tray_qty") + + # current operation information + inventory_id = fields.Many2one( + related="current_inventory_line_id.inventory_id", readonly=True + ) + product_id = fields.Many2one( + related="current_inventory_line_id.product_id", readonly=True + ) + product_uom_id = fields.Many2one( + related="current_inventory_line_id.product_uom_id", readonly=True + ) + product_qty = fields.Float( + related="current_inventory_line_id.product_qty", readonly=True + ) + product_packagings = fields.Html( + string="Packaging", compute="_compute_product_packagings" + ) + package_id = fields.Many2one( + related="current_inventory_line_id.package_id", readonly=True + ) + lot_id = fields.Many2one( + related="current_inventory_line_id.prod_lot_id", readonly=True + ) + + @api.depends("current_inventory_line_id") + def _compute_tray_data(self): + for record in self: + location = record.current_inventory_line_id.location_id + tray_type = location.location_id.tray_type_id + # this is the current cell + record.tray_location_id = location.id + # name of the tray where the cell is + record.tray_name = location.location_id.name + record.tray_type_id = tray_type.id + record.tray_type_code = tray_type.code + record.tray_x = location.posx + record.tray_y = location.posy + record.tray_matrix = location.tray_matrix + + @api.depends("current_inventory_line_id.product_id.packaging_ids") + def _compute_product_packagings(self): + for record in self: + product = record.current_inventory_line_id.product_id + if not product: + record.product_packagings = "" + continue + content = self._render_product_packagings(product) + record.product_packagings = content + + @api.depends("tray_location_id", "current_inventory_line_id.product_id") + def _compute_tray_qty(self): + for record in self: + if not (record.tray_location_id and record.current_inventory_line_id): + record.tray_qty = 0.0 + continue + product = record.current_inventory_line_id.product_id + location = record.tray_location_id + record.tray_qty = self._get_tray_qty(product, location) + + def _compute_number_of_ops(self): + for record in self: + line_model = self.env["stock.inventory.line"] + record.number_of_ops = line_model.search_count( + self._domain_inventory_lines_to_do() + ) + + def _compute_number_of_ops_all(self): + for record in self: + line_model = self.env["stock.inventory.line"] + record.number_of_ops_all = line_model.search_count( + self._domain_inventory_lines_to_do_all() + ) + + def _domain_inventory_lines_to_do(self): + return [ + ("location_id", "child_of", self.location_id.id), + ("state", "=", "confirm"), + ("vertical_lift_done", "=", False), + ] + + def _domain_inventory_lines_to_do_all(self): + shuttle_locations = self.env["stock.location"].search( + [("vertical_lift_kind", "=", "view")] + ) + return [ + ("location_id", "child_of", shuttle_locations.ids), + ("state", "=", "confirm"), + ("vertical_lift_done", "=", False), + ] + + def reset_steps(self): + self.clear_current_inventory_line() + super().reset_steps() + + def _has_identical_quantity(self): + line = self.current_inventory_line_id + return ( + float_compare( + line.theoretical_qty, + self.quantity_input, + precision_rounding=line.product_uom_id.rounding, + ) + == 0 + ) + + def _start_confirm_wrong_quantity(self): + self.last_quantity_input = self.quantity_input + self.quantity_input = 0.0 + return True + + def _go_back_to_quantity_input(self): + self.last_quantity_input = self.quantity_input + self.quantity_input = 0.0 + return True + + def clear_current_inventory_line(self): + self.write( + { + "quantity_input": 0.0, + "last_quantity_input": 0.0, + "current_inventory_line_id": False, + } + ) + return True + + def fetch_tray(self): + location = self.current_inventory_line_id.location_id + location.fetch_vertical_lift_tray() + + def select_next_inventory_line(self): + self.ensure_one() + next_line = self.env["stock.inventory.line"].search( + self._domain_inventory_lines_to_do(), + limit=1, + order="vertical_lift_tray_id, location_id, id", + ) + self.current_inventory_line_id = next_line + if next_line: + self.fetch_tray() + return bool(next_line) + + def process_current(self): + line = self.current_inventory_line_id + if not line.vertical_lift_done: + line.vertical_lift_done = True + if self.quantity_input != line.product_qty: + line.product_qty = self.quantity_input + inventory = line.inventory_id + if all(line.vertical_lift_done for line in inventory.line_ids): + inventory.action_validate() + self.quantity_input = self.last_quantity_input = 0.0 + return True + + def button_save(self): + self.ensure_one() + if not self.step() in ("quantity", "confirm_wrong_quantity"): + return + self.next_step() + if self.step() == "noop": + # close the tray once everything is inventoried + self.shuttle_id.release_vertical_lift_tray() + # sorry not sorry + return self._rainbow_man() diff --git a/stock_vertical_lift/models/vertical_lift_operation_pick.py b/stock_vertical_lift/models/vertical_lift_operation_pick.py new file mode 100644 index 000000000000..b7f11ade77b5 --- /dev/null +++ b/stock_vertical_lift/models/vertical_lift_operation_pick.py @@ -0,0 +1,90 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, models + + +class VerticalLiftOperationPick(models.Model): + _name = "vertical.lift.operation.pick" + _inherit = "vertical.lift.operation.transfer" + _description = "Vertical Lift Operation Pick" + + _initial_state = "noop" + + def _selection_states(self): + return [ + ("noop", "No operations"), + ("scan_destination", "Scan New Destination Location"), + ("save", "Pick goods and save"), + ("release", "Release"), + ] + + def _transitions(self): + return ( + self.Transition( + "noop", "scan_destination", lambda self: self.select_next_move_line() + ), + self.Transition("scan_destination", "save"), + self.Transition("save", "release", lambda self: self.process_current()), + # go to scan_destination if we have lines in queue, otherwise, go to noop + self.Transition( + "release", "scan_destination", lambda self: self.select_next_move_line() + ), + self.Transition("release", "noop"), + ) + + def on_barcode_scanned(self, barcode): + self.ensure_one() + if not self.current_move_line_id or self.current_move_line_id.state == "done": + return + if self.step() == "scan_destination": + location = self.env["stock.location"].search([("barcode", "=", barcode)]) + if location: + self.location_dest_id = location + self.next_step() + else: + self.env.user.notify_warning( + _("No location found for barcode {}").format(barcode) + ) + + def _domain_move_lines_to_do(self): + domain = [ + ("state", "in", ("assigned", "partially_available")), + ("location_id", "child_of", self.location_id.id), + ] + return domain + + def _domain_move_lines_to_do_all(self): + shuttle_locations = self.env["stock.location"].search( + [("vertical_lift_kind", "=", "view")] + ) + domain = [ + ("state", "in", ("assigned", "partially_available")), + ("location_id", "child_of", shuttle_locations.ids), + ] + return domain + + def fetch_tray(self): + self.current_move_line_id.fetch_vertical_lift_tray_source() + + def select_next_move_line(self): + self.ensure_one() + next_move_line = self.env["stock.move.line"].search( + self._domain_move_lines_to_do(), limit=1 + ) + self.current_move_line_id = next_move_line + if next_move_line: + self.fetch_tray() + return True + return False + + def button_release(self): + """Release the operation, go to the next""" + super().button_release() + if self.step() == "noop": + # we don't need to release (close) the tray until we have reached + # the last line: the release is implicit when a next line is + # fetched + self.shuttle_id.release_vertical_lift_tray() + # sorry not sorry + return self._rainbow_man() diff --git a/stock_vertical_lift/models/vertical_lift_operation_put.py b/stock_vertical_lift/models/vertical_lift_operation_put.py new file mode 100644 index 000000000000..e760e03a7acf --- /dev/null +++ b/stock_vertical_lift/models/vertical_lift_operation_put.py @@ -0,0 +1,181 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, fields, models +from odoo.osv.expression import AND + + +class VerticalLiftOperationPut(models.Model): + _name = "vertical.lift.operation.put" + _inherit = "vertical.lift.operation.transfer" + _description = "Vertical Lift Operation Put" + + _initial_state = "scan_source" + + def _selection_states(self): + return [ + ("scan_source", "Scan a package, product or lot to put-away"), + ("scan_tray_type", "Scan Tray Type"), + ("save", "Put goods in tray and save"), + ("release", "Release"), + ] + + def _transitions(self): + return ( + self.Transition( + "scan_source", + "scan_tray_type", + # transition only if a move line has been selected + # (by on_barcode_scanner) + lambda self: self.current_move_line_id, + ), + self.Transition("scan_tray_type", "save"), + self.Transition("save", "release", lambda self: self.process_current()), + self.Transition( + "release", "scan_source", lambda self: self.clear_current_move_line() + ), + ) + + def _domain_move_lines_to_do(self): + domain = [ + ("state", "in", ("assigned", "partially_available")), + ("location_dest_id", "child_of", self.location_id.id), + ] + return domain + + def _domain_move_lines_to_do_all(self): + shuttle_locations = self.env["stock.location"].search( + [("vertical_lift_kind", "=", "view")] + ) + domain = [ + ("state", "in", ("assigned", "partially_available")), + ("location_dest_id", "child_of", shuttle_locations.ids), + ] + return domain + + def on_barcode_scanned(self, barcode): + self.ensure_one() + if self.step() == "scan_source": + self._scan_source_action(barcode) + elif self.step() in ("scan_tray_type", "save"): + # note: we must be able to scan a different tray type when we are + # in the save step too, in case we couldn't put it in the first one + # for some reason. + self._scan_tray_type_action(barcode) + + def _scan_source_action(self, barcode): + line = self._find_move_line(barcode) + if line: + self.current_move_line_id = line + self.next_step() + else: + self.env.user.notify_warning( + _("No move line found for barcode {}").format(barcode) + ) + + def _scan_tray_type_action(self, barcode): + tray_type = self._find_tray_type(barcode) + if tray_type: + if self._assign_available_cell(tray_type): + self.fetch_tray() + if self.step() == "scan_tray_type": + # when we are in "save" step, stay here + self.next_step() + else: + self.env.user.notify_warning( + _('No free space for tray type "{}" in this shuttle.').format( + tray_type.display_name + ) + ) + else: + self.env.user.notify_warning( + _("No tray type found for barcode {}").format(barcode) + ) + + def _find_tray_type(self, barcode): + return self.env["stock.location.tray.type"].search( + [("code", "=", barcode)], limit=1 + ) + + def _find_move_line(self, barcode): + package = self.env["stock.quant.package"].search([("name", "=", barcode)]) + if package: + return self._find_move_line_for_package(package) + + lot = self.env["stock.production.lot"].search([("name", "=", barcode)]) + if lot: + return self._find_move_line_for_lot(package) + + product = self.env["product.product"].search([("barcode", "=", barcode)]) + if not product: + packaging = self.env["product.packaging"].search( + [("product_id", "!=", False), ("barcode", "=", barcode)] + ) + product = packaging.product_id + if product: + return self._find_move_line_for_product(product) + + def _find_move_line_for_package(self, package): + domain = AND( + [self._domain_move_lines_to_do_all(), [("package_id", "in", package.ids)]] + ) + return self.env["stock.move.line"].search(domain, limit=1) + + def _find_move_line_for_lot(self, lot): + domain = AND( + [ + self._domain_move_lines_to_do_all(), + [ + ("lot_id", "=", lot.id), + # if the lot is in a package, the package must be scanned + ("package_id", "=", False), + ], + ] + ) + return self.env["stock.move.line"].search(domain, limit=1) + + def _find_move_line_for_product(self, product): + domain = AND( + [ + self._domain_move_lines_to_do_all(), + [ + ("product_id", "=", product.id), + # if the lot is in a package, the package must be scanned + ("package_id", "=", False), + ], + ] + ) + return self.env["stock.move.line"].search(domain, limit=1) + + def _check_tray_type(self, barcode): + location = self.current_move_line_id.location_dest_id + tray_type = location.cell_in_tray_type_id + return barcode == tray_type.code + + def _assign_available_cell(self, tray_type): + locations = self.env["stock.location"].search( + [ + ("id", "child_of", self.location_id.id), + ("cell_in_tray_type_id", "=", tray_type.id), + ] + ) + location = fields.first( + locations.filtered(lambda loc: not loc.tray_cell_contains_stock) + ) + if location: + self.current_move_line_id.location_dest_id = location + self.current_move_line_id.package_level_id.location_dest_id = location + return True + return False + + def fetch_tray(self): + self.current_move_line_id.fetch_vertical_lift_tray_dest() + + def button_release(self): + # release (close) the tray each time, because for put-away, we + # never know if the operator will scan another line or not + self.shuttle_id.release_vertical_lift_tray() + super().button_release() + if self.count_move_lines_to_do_all() == 0: + # sorry not sorry + return self._rainbow_man() diff --git a/stock_vertical_lift/models/vertical_lift_shuttle.py b/stock_vertical_lift/models/vertical_lift_shuttle.py new file mode 100644 index 000000000000..796ff9b80bd4 --- /dev/null +++ b/stock_vertical_lift/models/vertical_lift_shuttle.py @@ -0,0 +1,277 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging +import socket +import ssl + +from odoo import _, fields, models + +_logger = logging.getLogger(__name__) + + +class VerticalLiftShuttle(models.Model): + _name = "vertical.lift.shuttle" + _inherit = "barcodes.barcode_events_mixin" + _description = "Vertical Lift Shuttle" + + name = fields.Char() + mode = fields.Selection( + [("pick", "Pick"), ("put", "Put"), ("inventory", "Inventory")], + default="pick", + required=True, + ) + location_id = fields.Many2one( + comodel_name="stock.location", + required=True, + domain="[('vertical_lift_kind', '=', 'shuttle')]", + ondelete="restrict", + help="The Shuttle source location for Pick operations " + "and destination location for Put operations.", + ) + hardware = fields.Selection( + selection="_selection_hardware", default="simulation", required=True + ) + server = fields.Char(help="hostname or IP address of the server") + port = fields.Integer( + help="network port of the server on which to send the message" + ) + use_tls = fields.Boolean( + help="set this if the server expects TLS wrapped communication" + ) + command_ids = fields.One2many( + "vertical.lift.command", "shuttle_id", string="Hardware commands" + ) + _sql_constraints = [ + ( + "location_id_unique", + "UNIQUE(location_id)", + "You cannot have two shuttles using the same location.", + ) + ] + + def _selection_hardware(self): + return [("simulation", "Simulation")] + + @property + def _model_for_mode(self): + return { + "pick": "vertical.lift.operation.pick", + "put": "vertical.lift.operation.put", + "inventory": "vertical.lift.operation.inventory", + } + + @property + def _screen_view_for_mode(self): + return { + "pick": ("stock_vertical_lift." "vertical_lift_operation_pick_screen_view"), + "put": ("stock_vertical_lift." "vertical_lift_operation_put_screen_view"), + "inventory": ( + "stock_vertical_lift." "vertical_lift_operation_inventory_screen_view" + ), + } + + def _hardware_send_message(self, payload): + """default implementation for message sending + + If in hardware is 'simulation' then display a simple message. + Otherwise defaults to connecting to server:port using a TCP socket + (optionnally wrapped with TLS) and sending the payload. + + :param payload: a bytes object containing the payload + + """ + self.ensure_one() + _logger.info("send %r", payload) + command_values = {"shuttle_id": self.id, "command": payload.decode()} + + self.env["vertical.lift.command"].sudo().create(command_values) + if self.hardware == "simulation": + self.env.user.notify_info(message=payload, title=_("Lift Simulation")) + return True + else: + conn = self._hardware_get_server_connection() + try: + offset = 0 + while True: + size = conn.send(payload[offset:]) + offset += size + if offset >= len(payload) or not size: + break + finally: + self._hardware_release_server_connection(conn) + + def _hardware_response_callback(self, command): + """should be called when a response is received from the hardware + + :param response: a string + """ + success = self._check_server_response(command) + self._send_notification_refresh(success) + + def _check_server_response(self, command): + """Use this to check if the response is a success or a failure + + :param payload: the payload sent + :param response: the response received + :return: True if the response is a succes, False otherwise + """ + return True + + def _hardware_release_server_connection(self, conn): + conn.close() + + def _hardware_get_server_connection(self): + """This implementation will yield a new connection to the server + and close() it when exiting the context. + Override to match the communication protocol of your hardware""" + conn = socket.create_connection((self.server, self.port)) + if self.use_tls: + ctx = ssl.create_default_context() + self._hardware_update_tls_context(ctx) + conntls = ctx.wrap_socket(conn, server_hostname=self.server) + return conntls + else: + return conn + + def _hardware_update_tls_context(self, context): + """Update the TLS context, e.g. to add a client certificate. + + This method does nothing, override to match your communication + protocol.""" + pass + + def _operation_for_mode(self): + model = self._model_for_mode[self.mode] + record = self.env[model].search([("shuttle_id", "=", self.id)]) + if not record: + record = self.env[model].create({"shuttle_id": self.id}) + return record + + def action_open_screen(self): + self.ensure_one() + assert self.mode in ("pick", "put", "inventory") + screen_xmlid = self._screen_view_for_mode[self.mode] + operation = self._operation_for_mode() + operation.on_screen_open() + return { + "type": "ir.actions.act_window", + "res_model": operation._name, + "views": [[self.env.ref(screen_xmlid).id, "form"]], + "res_id": operation.id, + "target": "fullscreen", + "flags": { + "withControlPanel": False, + "form_view_initial_mode": "edit", + "no_breadcrumbs": True, + }, + } + + def action_menu(self): + menu_xmlid = "stock_vertical_lift.vertical_lift_shuttle_form_menu" + return { + "type": "ir.actions.act_window", + "res_model": "vertical.lift.shuttle", + "views": [[self.env.ref(menu_xmlid).id, "form"]], + "name": _("Menu"), + "target": "new", + "res_id": self.id, + } + + def action_back_to_settings(self): + self.release_vertical_lift_tray() + action_xmlid = "stock_vertical_lift.vertical_lift_shuttle_action" + action = self.env.ref(action_xmlid).read()[0] + action["target"] = "main" + return action + + def action_manual_barcode(self): + return { + "type": "ir.actions.act_window", + "res_model": "vertical.lift.shuttle.manual.barcode", + "view_mode": "form", + "name": _("Barcode"), + "target": "new", + } + + # TODO: should the mode be changed on all the shuttles at the same time? + def switch_pick(self): + self.mode = "pick" + self.release_vertical_lift_tray() + return self.action_open_screen() + + def switch_put(self): + self.mode = "put" + self.release_vertical_lift_tray() + return self.action_open_screen() + + def switch_inventory(self): + self.mode = "inventory" + self.release_vertical_lift_tray() + return self.action_open_screen() + + def _hardware_vertical_lift_release_tray_payload(self): + """Prepare "release" message to be sent to the vertical lift hardware + + Private method, this is where the implementation actually happens. + Addons can add their instructions based on the hardware used for + this location. + + The hardware used for a location can be found in: + + ``self.hardware`` + + Each addon can implement its own mechanism depending of this value + and must call ``super``. + + The method must send the command to the vertical lift to release (close) + the tray. + + Returns a message in bytes, that will be sent through + ``VerticalLiftShuttle._hardware_send_message()``. + """ + if self.hardware == "simulation": + message = _("Releasing tray") + return message.encode("utf-8") + else: + raise NotImplementedError() + + def release_vertical_lift_tray(self): + """Send instructions to the vertical lift hardware to close trays + + The actual implementation of the method goes in the private method + ``_hardware_vertical_lift_release_tray()``. + """ + self.ensure_one() + payload = self._hardware_vertical_lift_release_tray_payload() + return self._hardware_send_message(payload) + + def _send_notification_refresh(self, success): + """Send a refresh notification to the current opened screen + + The form controller on the front-end side will instantaneously + refresh the form with the latest committed data. + + It can be used for instance after a vertical lift hardware + event occurred to inform the user on their screen. + + The method is private only to prevent xml/rpc calls to + interact with the screen. + """ + # XXX do we want to do something special in the notification? + self._operation_for_mode()._send_notification_refresh() + + +class VerticalLiftShuttleManualBarcode(models.TransientModel): + _name = "vertical.lift.shuttle.manual.barcode" + _description = "Action to input a barcode" + + barcode = fields.Char(string="Barcode") + + def button_save(self): + active_id = self.env.context.get("active_id") + model = self.env.context.get("active_model") + record = self.env[model].browse(active_id).exists() + if not record: + return + if self.barcode: + record.on_barcode_scanned(self.barcode) diff --git a/stock_vertical_lift/readme/CONFIGURE.rst b/stock_vertical_lift/readme/CONFIGURE.rst new file mode 100644 index 000000000000..0a3bdb04bf8e --- /dev/null +++ b/stock_vertical_lift/readme/CONFIGURE.rst @@ -0,0 +1,48 @@ +General +~~~~~~~ + +In Inventory Settings, you must have: + + * Storage Locations + * Multi-Warehouses + * Multi-Step Routes + +Locations +~~~~~~~~~ + +Additional configuration parameters are added in Locations: + +* Sub-locations of a location with the "Is a Vertical Lift View Location" + activated are considered as "Shuttles". A shuttle is a vertical lift shelf. +* Sub-locations of shuttles are considered as "Trays", which is a tier of a + shuttle. When a tray is created, a tray type must be selected. When saved, the + tray location will automatically create as many sub-locations - called + "Cells" - as the tray type contains. +* The tray type of a tray can be changed as long as none of its cell contains + products. When changed, it archives the cells and creates new ones as + configured on the new tray type. + +Tray types +~~~~~~~~~~ + +Tray types can be configured in the Inventory settings. +A tray type defines how much cells a tray can hold. It is a square or rectangle +matrix of n cols * m rows. + +Vertical Lift Shuttles +~~~~~~~~~~~~~~~~~~~~~~ + +The Shuttles are the Vertical Lift Trays. One Shuttle entity has to be created +in Odoo for each physical shuttle. Depending of the subsidiary addons installed +(eg. Kardex), different options may be required (host address, ...). The base +addon only includes shuttles of kind "simulation" which will not send orders to +the hardware. + +Put-away configuration +~~~~~~~~~~~~~~~~~~~~~~ + +If you want to use put-away in the vertical lift, the Receipts must have the +vertical lift view as destination. E.g. create put-away rules on the products +so when they arrive in WH/Stock, they are stored in WH/Stock/Vertical Lift. On +the put-away screen, when scanning the tray type to store, the destination will +be updated with an available cell of the same tray type in the current shuttle. diff --git a/stock_vertical_lift/readme/CONTRIBUTORS.rst b/stock_vertical_lift/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000000..48286263cd35 --- /dev/null +++ b/stock_vertical_lift/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Guewen Baconnier diff --git a/stock_vertical_lift/readme/DESCRIPTION.rst b/stock_vertical_lift/readme/DESCRIPTION.rst new file mode 100644 index 000000000000..f1bc969d4689 --- /dev/null +++ b/stock_vertical_lift/readme/DESCRIPTION.rst @@ -0,0 +1,3 @@ +Add configuration and dedicated screens to work with Vertical Lift +systems (such as Kardex Remstar, Modula, ...). Drivers for controlling +the lifts physically must be added by additional addons. diff --git a/stock_vertical_lift/readme/ROADMAP.rst b/stock_vertical_lift/readme/ROADMAP.rst new file mode 100644 index 000000000000..84079ec4c6b4 --- /dev/null +++ b/stock_vertical_lift/readme/ROADMAP.rst @@ -0,0 +1,8 @@ +* Complete screen workflows (currently enough for a demo, not for production) +* Inventory: find a way to have a nice autofocus for quantity, still compatible + with barcode scanner (Odoo disables the autofocus when using barcode, which + makes sense) +* Put-away: handle packages +* Handle "multi-shuttle" put-away +* Create glue module for product_expiry +* Challenge the save + release buttons and workflow diff --git a/stock_vertical_lift/security/ir.model.access.csv b/stock_vertical_lift/security/ir.model.access.csv new file mode 100644 index 000000000000..1b6621da0968 --- /dev/null +++ b/stock_vertical_lift/security/ir.model.access.csv @@ -0,0 +1,7 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_vertical_lift_shuttle_stock_user,access_vertical_lift_shuttle stock user,model_vertical_lift_shuttle,stock.group_stock_user,1,0,0,0 +access_vertical_lift_shuttle_manager,access_vertical_lift_shuttle stock manager,model_vertical_lift_shuttle,stock.group_stock_manager,1,1,1,1 +access_vertical_lift_operation_pick_stock_user,access_vertical_lift_operation_pick stock user,model_vertical_lift_operation_pick,stock.group_stock_user,1,1,1,1 +access_vertical_lift_operation_put_stock_user,access_vertical_lift_operation_put stock user,model_vertical_lift_operation_put,stock.group_stock_user,1,1,1,1 +access_vertical_lift_operation_inventory_stock_user,access_vertical_lift_operation_inventory stock user,model_vertical_lift_operation_inventory,stock.group_stock_user,1,1,1,1 +access_vertical_lift_command,vertical_lift_command,model_vertical_lift_command,base.group_user,1,0,0,0 diff --git a/stock_vertical_lift/static/description/index.html b/stock_vertical_lift/static/description/index.html new file mode 100644 index 000000000000..b8536e3beed3 --- /dev/null +++ b/stock_vertical_lift/static/description/index.html @@ -0,0 +1,490 @@ + + + + + + +Vertical Lift + + + +
+

Vertical Lift

+ + +

Alpha License: AGPL-3 OCA/stock-logistics-warehouse Translate me on Weblate Try me on Runbot

+

Add configuration and dedicated screens to work with Vertical Lift +systems (such as Kardex Remstar, Modula, …). Drivers for controlling +the lifts physically must be added by additional addons.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Configuration

+
+

General

+

In Inventory Settings, you must have:

+
+
    +
  • Storage Locations
  • +
  • Multi-Warehouses
  • +
  • Multi-Step Routes
  • +
+
+
+
+

Locations

+

Additional configuration parameters are added in Locations:

+
    +
  • Sub-locations of a location with the “Is a Vertical Lift View Location” +activated are considered as “Shuttles”. A shuttle is a vertical lift shelf.
  • +
  • Sub-locations of shuttles are considered as “Trays”, which is a tier of a +shuttle. When a tray is created, a tray type must be selected. When saved, the +tray location will automatically create as many sub-locations - called +“Cells” - as the tray type contains.
  • +
  • The tray type of a tray can be changed as long as none of its cell contains +products. When changed, it archives the cells and creates new ones as +configured on the new tray type.
  • +
+
+
+

Tray types

+

Tray types can be configured in the Inventory settings. +A tray type defines how much cells a tray can hold. It is a square or rectangle +matrix of n cols * m rows.

+
+
+

Vertical Lift Shuttles

+

The Shuttles are the Vertical Lift Trays. One Shuttle entity has to be created +in Odoo for each physical shuttle. Depending of the subsidiary addons installed +(eg. Kardex), different options may be required (host address, …). The base +addon only includes shuttles of kind “simulation” which will not send orders to +the hardware.

+
+
+
+

Known issues / Roadmap

+
    +
  • Extract the tray types and matrix widget in a module, they can be used +alone without vertical lift
  • +
  • Consider merging the ‘vertical_lift_kind’ with the kind added by +stock_location_zone
  • +
  • Complete Pick screen and workflow (currently enough for a demo, not for production)
  • +
  • Implement Put-away screen and workflow
  • +
  • Implement Inventory screen and workflow
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

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.

+

This module is part of the OCA/stock-logistics-warehouse project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/stock_vertical_lift/static/src/js/vertical_lift.js b/stock_vertical_lift/static/src/js/vertical_lift.js new file mode 100644 index 000000000000..8efae2985593 --- /dev/null +++ b/stock_vertical_lift/static/src/js/vertical_lift.js @@ -0,0 +1,79 @@ +odoo.define("stock_vertical_lift.vertical_lift", function(require) { + "use strict"; + + var KanbanRecord = require("web.KanbanRecord"); + var FormController = require("web.FormController"); + + KanbanRecord.include({ + _openRecord: function() { + if ( + this.modelName === "vertical.lift.shuttle" && + this.$el.hasClass("open_shuttle_screen") + ) { + var self = this; + this._rpc({ + method: "action_open_screen", + model: self.modelName, + args: [self.id], + }).then(function(action) { + self.trigger_up("do_action", {action: action}); + }); + } else { + this._super.apply(this, arguments); + } + }, + }); + + FormController.include({ + init: function() { + this._super.apply(this, arguments); + if (this.modelName.startsWith("vertical.lift.operation.")) { + this.call("bus_service", "addChannel", "notify_vertical_lift_screen"); + this.call( + "bus_service", + "on", + "notification", + this, + this.vlift_bus_notification + ); + this.call("bus_service", "startPolling"); + } + }, + vlift_bus_notification: function(notifications) { + var self = this; + _.each(notifications, function(notification) { + var channel = notification[0]; + var message = notification[1]; + if (channel === "notify_vertical_lift_screen") { + switch (message.action) { + case "refresh": + self.vlift_bus_action_refresh(message.params); + break; + } + } + }); + }, + vlift_bus_action_refresh: function(params) { + var selectedIds = this.getSelectedIds(); + if (!selectedIds.length) { + return; + } + var currentId = selectedIds[0]; + if (params.id === currentId && params.model === this.modelName) { + this.reload(); + } + }, + destroy: function() { + if (this.modelName.startsWith("vertical.lift.operation.")) { + this.call( + "bus_service", + "deleteChannel", + "notify_vertical_lift_screen" + ); + } + this._super.apply(this, arguments); + }, + }); + + return {}; +}); diff --git a/stock_vertical_lift/static/src/scss/vertical_lift.scss b/stock_vertical_lift/static/src/scss/vertical_lift.scss new file mode 100644 index 000000000000..9ace6b616148 --- /dev/null +++ b/stock_vertical_lift/static/src/scss/vertical_lift.scss @@ -0,0 +1,135 @@ +.o_web_client.o_fullscreen { + $o-shuttle-padding: $o-horizontal-padding; + + .o_form_view.o_vlift_shuttle { + display: flex; + flex-flow: column nowrap; + padding: 0; + + font-size: 16px; + + @include media-breakpoint-up(xl) { + font-size: 18px; + } + + .btn { + font-size: 1em; + padding: 1em; + margin: 0 5px; + } + + .o_shuttle_header { + display: flex; + flex-flow: row wrap; + padding: $o-shuttle-padding; + } + + .o_shuttle_header_content { + display: flex; + flex-flow: row nowrap; + font-size: 2em; + flex: 1 0 auto; + align-items: center; + width: 33%; + + &.o_shuttle_header_right { + justify-content: flex-end; + } + } + + .o_shuttle_actions { + display: flex; + flex-flow: row nowrap; + font-size: 1.2em; + padding: $o-shuttle-padding * 0.5; + } + + .o_shuttle_operation { + text-align: center; + font-size: 2.5em; + padding: 0.5em; + color: #ffffff; + } + + .o_shuttle_content { + display: flex; + flex-flow: row nowrap; + flex: 1 0 auto; + align-items: center; + + &.o_shuttle_content_right { + justify-content: flex-end; + } + } + + .o_shuttle_data { + display: flex; + flex-flow: row wrap; + padding: $o-shuttle-padding * 0.5; + + .o_shuttle_data_content { + flex-flow: row nowrap; + font-size: 1.2em; + flex: 1 0 auto; + align-items: center; + width: 50%; + + &.o_shuttle_tray { + display: flex; + justify-content: flex-end; + + .o_group { + display: block; + } + } + + .o_field_location_tray_matrix { + width: 450px; + } + } + + .o_shuttle_highlight { + padding: 6px; + border-radius: 10px; + } + } + } + + .o_vlift_shuttle_menu { + .btn { + margin-bottom: $o-shuttle-padding; + padding: 1em; + font-size: 2em; + text-transform: uppercase; + } + } + + .o_vlift_shuttle_popup { + table tr { + line-height: 3; + font-size: 1.1em; + + .o_list_record_remove { + width: 50px; + text-align: center; + } + } + + .o_field_char { + padding: 1em; + font-size: 2em; + } + + .btn { + padding: 1em; + font-size: 2em; + text-transform: uppercase; + } + } + + footer .btn { + padding: 1em; + font-size: 2em; + text-transform: uppercase; + } +} diff --git a/stock_vertical_lift/tests/__init__.py b/stock_vertical_lift/tests/__init__.py new file mode 100644 index 000000000000..dd10460b3947 --- /dev/null +++ b/stock_vertical_lift/tests/__init__.py @@ -0,0 +1,4 @@ +from . import test_location +from . import test_inventory +from . import test_pick +from . import test_put diff --git a/stock_vertical_lift/tests/common.py b/stock_vertical_lift/tests/common.py new file mode 100644 index 000000000000..541064c8d217 --- /dev/null +++ b/stock_vertical_lift/tests/common.py @@ -0,0 +1,176 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _ + +from odoo.addons.stock_location_tray.tests import common + + +class VerticalLiftCase(common.LocationTrayTypeCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.shuttle = cls.env.ref( + "stock_vertical_lift.stock_vertical_lift_demo_shuttle_1" + ) + cls.product_socks = cls.env.ref("stock_vertical_lift.product_running_socks") + cls.product_recovery = cls.env.ref("stock_vertical_lift.product_recovery_socks") + cls.vertical_lift_loc = cls.env.ref( + "stock_vertical_lift.stock_location_vertical_lift" + ) + cls.stock_location = cls.env.ref("stock.stock_location_stock") + cls.customers_location = cls.env.ref("stock.stock_location_customers") + cls.location_1a = cls.env.ref( + "stock_vertical_lift." "stock_location_vertical_lift_demo_tray_1a" + ) + cls.location_1a_x1y1 = cls.env.ref( + "stock_vertical_lift." "stock_location_vertical_lift_demo_tray_1a_x1y1" + ) + cls.location_1a_x2y1 = cls.env.ref( + "stock_vertical_lift." "stock_location_vertical_lift_demo_tray_1a_x2y1" + ) + cls.location_1a_x3y1 = cls.env.ref( + "stock_vertical_lift." "stock_location_vertical_lift_demo_tray_1a_x3y1" + ) + cls.location_1a_x1y2 = cls.env.ref( + "stock_vertical_lift." "stock_location_vertical_lift_demo_tray_1a_x1y2" + ) + cls.location_1b = cls.env.ref( + "stock_vertical_lift." "stock_location_vertical_lift_demo_tray_1b" + ) + cls.location_1b_x1y1 = cls.env.ref( + "stock_vertical_lift." "stock_location_vertical_lift_demo_tray_1b_x1y1" + ) + cls.location_1b_x1y2 = cls.env.ref( + "stock_vertical_lift." "stock_location_vertical_lift_demo_tray_1b_x1y2" + ) + cls.location_2a = cls.env.ref( + "stock_vertical_lift." "stock_location_vertical_lift_demo_tray_2a" + ) + cls.location_2a_x1y1 = cls.env.ref( + "stock_vertical_lift." "stock_location_vertical_lift_demo_tray_2a_x1y1" + ) + + @classmethod + def _update_qty_in_location( + cls, location, product, quantity, package=None, lot=None + ): + quants = cls.env["stock.quant"]._gather( + product, location, lot_id=lot, package_id=package, strict=True + ) + # this method adds the quantity to the current quantity, so remove it + quantity -= sum(quants.mapped("quantity")) + cls.env["stock.quant"]._update_available_quantity( + product, location, quantity, package_id=package, lot_id=lot + ) + + def _open_screen(self, mode, shuttle=None): + getattr(shuttle or self.shuttle, "switch_{}".format(mode))() + # opening the screen can do some initialization for the steps + action = (shuttle or self.shuttle).action_open_screen() + return self.env[action["res_model"]].browse(action["res_id"]) + + @classmethod + def _create_simple_picking_out(cls, product, quantity): + stock_loc = cls.env.ref("stock.stock_location_stock") + customer_loc = cls.env.ref("stock.stock_location_customers") + picking_type = cls.env.ref("stock.picking_type_out") + partner = cls.env.ref("base.res_partner_1") + return cls.env["stock.picking"].create( + { + "picking_type_id": picking_type.id, + "partner_id": partner.id, + "location_id": stock_loc.id, + "location_dest_id": customer_loc.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": picking_type.id, + "location_id": stock_loc.id, + "location_dest_id": customer_loc.id, + }, + ) + ], + } + ) + + @classmethod + def _create_simple_picking_in(cls, product, quantity, dest_location): + supplier_loc = cls.env.ref("stock.stock_location_suppliers") + picking_type = cls.env.ref("stock.picking_type_in") + partner = cls.env.ref("base.res_partner_1") + return cls.env["stock.picking"].create( + { + "picking_type_id": picking_type.id, + "partner_id": partner.id, + "location_id": supplier_loc.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": picking_type.id, + "location_id": supplier_loc.id, + "location_dest_id": dest_location.id, + }, + ) + ], + } + ) + + @classmethod + def _create_inventory(cls, products): + """Create a draft inventory + + Products is a list of tuples (bin location, product). + """ + values = { + "name": "Test Inventory", + "line_ids": [ + ( + 0, + 0, + { + "product_id": product.id, + "product_uom_id": product.uom_id.id, + "location_id": location.id, + }, + ) + for location, product in products + ], + } + inventory = cls.env["stock.inventory"].create(values) + inventory.action_start() + return inventory + + def _test_button_release(self, move_line, expected_state): + # for the test, we'll consider our last line has been delivered + move_line.qty_done = move_line.product_qty + move_line.move_id._action_done() + # release, no further operation in queue + operation = self.shuttle._operation_for_mode() + # the release button can be used only in the state... release + operation.state = "release" + result = operation.button_release() + self.assertEqual(operation.state, expected_state) + self.assertFalse(operation.current_move_line_id) + expected_result = { + "effect": { + "fadeout": "slow", + "message": _("Congrats, you cleared the queue!"), + "img_url": "/web/static/src/img/smile.svg", + "type": "rainbow_man", + } + } + self.assertEqual(result, expected_result) diff --git a/stock_vertical_lift/tests/test_inventory.py b/stock_vertical_lift/tests/test_inventory.py new file mode 100644 index 000000000000..b22d4661ddd8 --- /dev/null +++ b/stock_vertical_lift/tests/test_inventory.py @@ -0,0 +1,115 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _ + +from .common import VerticalLiftCase + + +class TestInventory(VerticalLiftCase): + def test_switch_inventory(self): + self.shuttle.switch_inventory() + self.assertEqual(self.shuttle.mode, "inventory") + self.assertEqual( + self.shuttle._operation_for_mode().current_inventory_line_id, + self.env["stock.inventory.line"].browse(), + ) + + def test_inventory_action_open_screen(self): + self.shuttle.switch_inventory() + action = self.shuttle.action_open_screen() + operation = self.shuttle._operation_for_mode() + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "vertical.lift.operation.inventory") + self.assertEqual(action["res_id"], operation.id) + + def test_inventory_count_ops(self): + self._update_qty_in_location(self.location_1a_x1y1, self.product_socks, 10) + self._update_qty_in_location(self.location_1a_x2y1, self.product_recovery, 10) + self._create_inventory( + [ + (self.location_1a_x1y1, self.product_socks), + (self.location_1a_x2y1, self.product_recovery), + ] + ) + self._update_qty_in_location(self.location_2a_x1y1, self.product_socks, 10) + self._create_inventory([(self.location_2a_x1y1, self.product_socks)]) + + operation = self._open_screen("inventory") + self.assertEqual(operation.number_of_ops, 2) + self.assertEqual(operation.number_of_ops_all, 3) + + def test_process_current_inventory(self): + self._update_qty_in_location(self.location_1a_x1y1, self.product_socks, 10) + inventory = self._create_inventory( + [(self.location_1a_x1y1, self.product_socks)] + ) + operation = self._open_screen("inventory") + self.assertEqual(operation.state, "quantity") + self.assertEqual(operation.current_inventory_line_id, inventory.line_ids) + # test the happy path, quantity is correct + operation.quantity_input = 10.0 + result = operation.button_save() + + # state is reset + # noop because we have no further lines + self.assertEqual(operation.state, "noop") + self.assertFalse(operation.current_inventory_line_id) + self.assertTrue(inventory.line_ids.vertical_lift_done) + self.assertEqual(inventory.state, "done") + expected_result = { + "effect": { + "fadeout": "slow", + "message": _("Congrats, you cleared the queue!"), + "img_url": "/web/static/src/img/smile.svg", + "type": "rainbow_man", + } + } + self.assertEqual(result, expected_result) + + def test_wrong_quantity(self): + self._update_qty_in_location(self.location_1a_x1y1, self.product_socks, 10) + inventory = self._create_inventory( + [(self.location_1a_x1y1, self.product_socks)] + ) + operation = self._open_screen("inventory") + line = operation.current_inventory_line_id + self.assertEqual(line, inventory.line_ids) + + operation.quantity_input = 12.0 + operation.button_save() + self.assertEqual(operation.last_quantity_input, 12.0) + self.assertEqual(operation.quantity_input, 0.0) + self.assertEqual(operation.state, "confirm_wrong_quantity") + self.assertEqual(operation.current_inventory_line_id, line) + + # entering the same quantity a second time validates + operation.quantity_input = 12.0 + operation.button_save() + self.assertFalse(operation.current_inventory_line_id) + + self.assertTrue(inventory.line_ids.vertical_lift_done) + self.assertEqual(inventory.state, "done") + + def test_inventory_next_line(self): + self._update_qty_in_location(self.location_1a_x1y1, self.product_socks, 10) + self._update_qty_in_location(self.location_1a_x2y1, self.product_recovery, 10) + inventory = self._create_inventory( + [ + (self.location_1a_x1y1, self.product_socks), + (self.location_1a_x2y1, self.product_recovery), + ] + ) + inventory_lines = inventory.line_ids + operation = self._open_screen("inventory") + operation.quantity_input = 10.0 + line1 = operation.current_inventory_line_id + result = operation.button_save() + self.assertFalse(result) # no rainbow man + + # go to next line + remaining_line = inventory_lines - line1 + self.assertEqual(operation.state, "quantity") + self.assertEqual(operation.current_inventory_line_id, remaining_line) + self.assertEqual(operation.last_quantity_input, 0.0) + self.assertEqual(operation.quantity_input, 0.0) diff --git a/stock_vertical_lift/tests/test_location.py b/stock_vertical_lift/tests/test_location.py new file mode 100644 index 000000000000..712436d73444 --- /dev/null +++ b/stock_vertical_lift/tests/test_location.py @@ -0,0 +1,37 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from .common import VerticalLiftCase + + +class TestVerticalLiftLocation(VerticalLiftCase): + def test_vertical_lift_kind(self): + # this boolean is what defines a "Vertical Lift View", the upper level + # of the tree (View -> Shuttles -> Trays -> Cells) + self.assertTrue(self.vertical_lift_loc.vertical_lift_location) + self.assertEqual(self.vertical_lift_loc.vertical_lift_kind, "view") + + # check types accross the hierarchy + shuttles = self.vertical_lift_loc.child_ids + self.assertTrue( + all(location.vertical_lift_kind == "shuttle" for location in shuttles) + ) + trays = shuttles.mapped("child_ids") + self.assertTrue( + all(location.vertical_lift_kind == "tray" for location in trays) + ) + cells = trays.mapped("child_ids") + self.assertTrue( + all(location.vertical_lift_kind == "cell" for location in cells) + ) + + def test_create_shuttle(self): + # any location created directly under the view is a shuttle + shuttle_loc = self.env["stock.location"].create( + { + "name": "Shuttle 42", + "location_id": self.vertical_lift_loc.id, + "usage": "internal", + } + ) + self.assertEqual(shuttle_loc.vertical_lift_kind, "shuttle") diff --git a/stock_vertical_lift/tests/test_pick.py b/stock_vertical_lift/tests/test_pick.py new file mode 100644 index 000000000000..28f5618d68b0 --- /dev/null +++ b/stock_vertical_lift/tests/test_pick.py @@ -0,0 +1,202 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from .common import VerticalLiftCase + + +class TestPick(VerticalLiftCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.picking_out = cls.env.ref( + "stock_vertical_lift.stock_picking_out_demo_vertical_lift_1" + ) + # we have a move line to pick created by demo picking + # stock_picking_out_demo_vertical_lift_1 + cls.out_move_line = cls.picking_out.move_line_ids + + def test_switch_pick(self): + self.shuttle.switch_pick() + self.assertEqual(self.shuttle.mode, "pick") + self.assertEqual( + self.shuttle._operation_for_mode().current_move_line_id, self.out_move_line + ) + + def test_pick_action_open_screen(self): + self.shuttle.switch_pick() + action = self.shuttle.action_open_screen() + operation = self.shuttle._operation_for_mode() + self.assertTrue(operation.current_move_line_id) + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "vertical.lift.operation.pick") + self.assertEqual(action["res_id"], operation.id) + + def test_pick_select_next_move_line(self): + operation = self._open_screen("pick") + operation.select_next_move_line() + self.assertEqual(operation.current_move_line_id, self.out_move_line) + self.assertEqual(operation.state, "scan_destination") + + def test_pick_save(self): + operation = self._open_screen("pick") + # assume we already scanned the destination, current state is save + operation.state = "save" + operation.current_move_line_id = self.out_move_line + operation.button_save() + self.assertEqual(operation.current_move_line_id.state, "done") + self.assertEqual(operation.state, "release") + + def test_pick_related_fields(self): + operation = self._open_screen("pick") + ml = operation.current_move_line_id = self.out_move_line + + # Trays related fields + # For pick, this is the source location, which is the cell where the + # product is. + self.assertEqual(operation.tray_location_id, ml.location_id) + self.assertEqual( + operation.tray_name, + # parent = tray + ml.location_id.location_id.name, + ) + self.assertEqual( + operation.tray_type_id, + # the tray type is on the parent of the cell (on the tray) + ml.location_id.location_id.tray_type_id, + ) + self.assertEqual( + operation.tray_type_code, ml.location_id.location_id.tray_type_id.code + ) + self.assertEqual(operation.tray_x, ml.location_id.posx) + self.assertEqual(operation.tray_y, ml.location_id.posy) + + # Move line related fields + self.assertEqual(operation.picking_id, ml.picking_id) + self.assertEqual(operation.picking_origin, ml.picking_id.origin) + self.assertEqual(operation.picking_partner_id, ml.picking_id.partner_id) + self.assertEqual(operation.product_id, ml.product_id) + self.assertEqual(operation.product_uom_id, ml.product_uom_id) + self.assertEqual(operation.product_uom_qty, ml.product_uom_qty) + self.assertEqual(operation.qty_done, ml.qty_done) + self.assertEqual(operation.lot_id, ml.lot_id) + + def test_pick_count_move_lines(self): + product1 = self.env.ref("stock_vertical_lift.product_running_socks") + product2 = self.env.ref("stock_vertical_lift.product_recovery_socks") + # cancel the picking from demo data to start from a clean state + self.env.ref( + "stock_vertical_lift.stock_picking_out_demo_vertical_lift_1" + ).action_cancel() + + # ensure that we have stock in some cells, we'll put product1 + # in the first Shuttle and product2 in the second + cell1 = self.env.ref( + "stock_vertical_lift." "stock_location_vertical_lift_demo_tray_1a_x3y2" + ) + self._update_quantity_in_cell(cell1, product1, 50) + cell2 = self.env.ref( + "stock_vertical_lift." "stock_location_vertical_lift_demo_tray_2a_x1y1" + ) + self._update_quantity_in_cell(cell2, product2, 50) + + # create pickings (we already have an existing one from demo data) + pickings = self.env["stock.picking"].browse() + pickings |= self._create_simple_picking_out(product1, 1) + pickings |= self._create_simple_picking_out(product1, 1) + pickings |= self._create_simple_picking_out(product1, 1) + pickings |= self._create_simple_picking_out(product1, 1) + pickings |= self._create_simple_picking_out(product2, 20) + pickings |= self._create_simple_picking_out(product2, 30) + # this one should be 'assigned', so should be included in the operation + # count + unassigned = self._create_simple_picking_out(product2, 1) + pickings |= unassigned + pickings.action_confirm() + # product1 will be taken from the shuttle1, product2 from shuttle2 + pickings.action_assign() + + shuttle1 = self.shuttle + operation1 = shuttle1._operation_for_mode() + shuttle2 = self.env.ref( + "stock_vertical_lift.stock_vertical_lift_demo_shuttle_2" + ) + operation2 = shuttle2._operation_for_mode() + + self.assertEqual(operation1.number_of_ops, 4) + self.assertEqual(operation2.number_of_ops, 2) + self.assertEqual(operation1.number_of_ops_all, 6) + self.assertEqual(operation2.number_of_ops_all, 6) + + # Process a line, should change the numbers. + operation1.select_next_move_line() + operation1.process_current() + self.assertEqual(operation1.number_of_ops, 3) + self.assertEqual(operation2.number_of_ops, 2) + self.assertEqual(operation1.number_of_ops_all, 5) + self.assertEqual(operation2.number_of_ops_all, 5) + + # add stock and make the last one assigned to check the number is + # updated + self._update_quantity_in_cell(cell2, product2, 10) + unassigned.action_assign() + self.assertEqual(operation1.number_of_ops, 3) + self.assertEqual(operation2.number_of_ops, 3) + self.assertEqual(operation1.number_of_ops_all, 6) + self.assertEqual(operation2.number_of_ops_all, 6) + + def test_on_barcode_scanned(self): + operation = self._open_screen("pick") + self.assertEqual(operation.state, "scan_destination") + move_line = operation.current_move_line_id + current_destination = move_line.location_dest_id + stock_location = self.env.ref("stock.stock_location_stock") + self.assertEqual( + current_destination, self.env.ref("stock.stock_location_customers") + ) + operation.on_barcode_scanned(stock_location.barcode) + self.assertEqual(move_line.location_dest_id, stock_location) + self.assertEqual(operation.state, "save") + + def test_button_release(self): + self._open_screen("pick") + self._test_button_release(self.out_move_line, "noop") + + def test_process_current_pick(self): + operation = self._open_screen("pick") + operation.current_move_line_id = self.out_move_line + qty_to_process = self.out_move_line.product_qty + operation.process_current() + self.assertEqual(self.out_move_line.state, "done") + self.assertEqual(self.out_move_line.qty_done, qty_to_process) + + def test_matrix(self): + operation = self._open_screen("pick") + operation.current_move_line_id = self.out_move_line + location = self.out_move_line.location_id + # offset by -1 because the fields are for humans + expected_x = location.posx - 1 + expected_y = location.posy - 1 + self.assertEqual( + operation.tray_matrix, + { + "selected": [expected_x, expected_y], + # fmt: off + 'cells': [ + [0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 1, 0, 0, 0, 0, 0], + ] + # fmt: on + }, + ) + + def test_tray_qty(self): + cell = self.env.ref( + "stock_vertical_lift." "stock_location_vertical_lift_demo_tray_1a_x3y2" + ) + self.out_move_line.location_id = cell + operation = self.shuttle._operation_for_mode() + operation.current_move_line_id = self.out_move_line + self._update_quantity_in_cell(cell, self.out_move_line.product_id, 50) + self.assertEqual(operation.tray_qty, 50) + self._update_quantity_in_cell(cell, self.out_move_line.product_id, -20) + self.assertEqual(operation.tray_qty, 30) diff --git a/stock_vertical_lift/tests/test_put.py b/stock_vertical_lift/tests/test_put.py new file mode 100644 index 000000000000..75f16e03b049 --- /dev/null +++ b/stock_vertical_lift/tests/test_put.py @@ -0,0 +1,154 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from .common import VerticalLiftCase + + +class TestPut(VerticalLiftCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.picking_in = cls.env.ref( + "stock_vertical_lift.stock_picking_in_demo_vertical_lift_1" + ) + cls.picking_in.action_confirm() + cls.in_move_line = cls.picking_in.move_line_ids + cls.in_move_line.location_dest_id = cls.shuttle.location_id + + def test_put_action_open_screen(self): + self.shuttle.switch_put() + action = self.shuttle.action_open_screen() + operation = self.shuttle._operation_for_mode() + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "vertical.lift.operation.put") + self.assertEqual(action["res_id"], operation.id) + + def test_switch_put(self): + self.shuttle.switch_put() + self.assertEqual(self.shuttle.mode, "put") + self.assertEqual( + self.shuttle._operation_for_mode().current_move_line_id, + self.env["stock.move.line"].browse(), + ) + + def test_put_count_move_lines(self): + self.picking_in.action_cancel() + put1 = self._create_simple_picking_in( + self.product_socks, 10, self.location_1a_x1y1 + ) + put1.action_confirm() + put2 = self._create_simple_picking_in( + self.product_recovery, 10, self.vertical_lift_loc + ) + put2.action_confirm() + put3 = self._create_simple_picking_in( + self.product_recovery, 10, self.vertical_lift_loc + ) + put3.action_confirm() + operation = self._open_screen("put") + shuttle2 = self.env.ref( + "stock_vertical_lift.stock_vertical_lift_demo_shuttle_2" + ) + operation2 = self._open_screen("put", shuttle=shuttle2) + + # we don't really care about the "number_of_ops" for the + # put-away, as the move lines are supposed to have the whole + # whole shuttle view as destination + self.assertEqual(operation.number_of_ops, 1) + self.assertEqual(operation.number_of_ops_all, 3) + self.assertEqual(operation2.number_of_ops, 0) + self.assertEqual(operation2.number_of_ops_all, 3) + + def test_transition_start(self): + 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") + + def test_transition_scan_source_to_scan_tray_type(self): + operation = self._open_screen("put") + self.assertEqual(operation.state, "scan_source") + # wrong barcode, nothing happens + operation.on_barcode_scanned("foo") + self.assertEqual(operation.state, "scan_source") + # product scanned, move to next step + operation.on_barcode_scanned(self.product_socks.barcode) + self.assertEqual(operation.state, "scan_tray_type") + self.assertEqual(operation.current_move_line_id, self.in_move_line) + + def test_transition_scan_tray_type_to_save(self): + operation = self._open_screen("put") + # assume we already scanned the product + operation.state = "scan_tray_type" + operation.current_move_line_id = self.in_move_line + # wrong barcode, nothing happens + operation.on_barcode_scanned("foo") + # tray type scanned, move to next step + operation.on_barcode_scanned(self.location_1a.tray_type_id.code) + self.assertEqual(operation.state, "save") + # a cell has been set + self.assertTrue( + self.in_move_line.location_dest_id in self.location_1a.child_ids + ) + + def test_change_tray_type_on_save(self): + operation = self._open_screen("put") + move_line = self.in_move_line + # assume we already scanned the product and the tray type + # and the assigned location was location_1a_x1y1 + operation.current_move_line_id = move_line + move_line.location_dest_id = self.location_1a_x1y1 + operation.state = "save" + # we want to use another tray with a different type though, + # so we scan again + operation.on_barcode_scanned(self.location_1b.tray_type_id.code) + self.assertTrue( + self.in_move_line.location_dest_id + in self.shuttle.location_id.child_ids.child_ids + ) + # we are still in save + self.assertEqual(operation.state, "save") + # a cell has been set in the other tray + self.assertTrue(move_line.location_dest_id in self.location_1b.child_ids) + + def test_transition_scan_tray_type_no_empty_cell(self): + operation = self._open_screen("put") + # assume we already scanned the product + operation.state = "scan_tray_type" + operation.current_move_line_id = self.in_move_line + # create a tray type without location, which is the same as if all the + # locations of a tray type were full + new_tray_type = self.env["stock.location.tray.type"].create( + {"name": "new tray type", "code": "test", "rows": 1, "cols": 1} + ) + operation.on_barcode_scanned(new_tray_type.code) + # should stay the same state + self.assertEqual(operation.state, "scan_tray_type") + # destination not changed + self.assertEqual(self.in_move_line.location_dest_id, self.shuttle.location_id) + + def test_transition_save(self): + operation = self._open_screen("put") + # first steps of the workflow are done + operation.current_move_line_id = self.in_move_line + operation.current_move_line_id.location_dest_id = self.location_1a_x1y1 + operation.state = "save" + qty_to_process = self.in_move_line.product_qty + operation.button_save() + self.assertEqual(self.in_move_line.state, "done") + self.assertEqual(self.in_move_line.qty_done, qty_to_process) + + def test_transition_button_release(self): + operation = self._open_screen("put") + move_line = self.in_move_line + # first steps of the workflow are done + operation.current_move_line_id = move_line + operation.current_move_line_id.location_dest_id = self.location_1a_x1y1 + # for the test, we'll consider our last line has been delivered + move_line.qty_done = move_line.product_qty + move_line.move_id._action_done() + + operation = self._open_screen("put") + operation.button_release() + self.assertEqual(operation.state, "scan_source") + self.assertFalse(operation.current_move_line_id) diff --git a/stock_vertical_lift/views/shuttle_screen_templates.xml b/stock_vertical_lift/views/shuttle_screen_templates.xml new file mode 100644 index 000000000000..396a35a8b623 --- /dev/null +++ b/stock_vertical_lift/views/shuttle_screen_templates.xml @@ -0,0 +1,23 @@ + + + diff --git a/stock_vertical_lift/views/stock_location_views.xml b/stock_vertical_lift/views/stock_location_views.xml new file mode 100644 index 000000000000..4cf630222318 --- /dev/null +++ b/stock_vertical_lift/views/stock_location_views.xml @@ -0,0 +1,58 @@ + + + + stock.location.form.vertical.lift + stock.location + + +
+
+ + + + + + + + {'invisible': [('cell_in_tray_type_id', '!=', False)], + 'required': [('vertical_lift_kind', '=', 'tray')]} + + + +
+ + stock.location.search.vertical.lift + stock.location + + + + + + + + +
diff --git a/stock_vertical_lift/views/stock_move_line_views.xml b/stock_vertical_lift/views/stock_move_line_views.xml new file mode 100644 index 000000000000..56b8c535f7bf --- /dev/null +++ b/stock_vertical_lift/views/stock_move_line_views.xml @@ -0,0 +1,35 @@ + + + + stock.move.line.operations.tree.vertical.lift + stock.move.line + + + + + + + diff --git a/stock_vertical_lift/views/stock_vertical_lift_templates.xml b/stock_vertical_lift/views/stock_vertical_lift_templates.xml new file mode 100644 index 000000000000..b312668890ab --- /dev/null +++ b/stock_vertical_lift/views/stock_vertical_lift_templates.xml @@ -0,0 +1,20 @@ + + +