diff --git a/setup/stock_location_tray/odoo/addons/stock_location_tray b/setup/stock_location_tray/odoo/addons/stock_location_tray new file mode 120000 index 000000000000..5c2ed71536e6 --- /dev/null +++ b/setup/stock_location_tray/odoo/addons/stock_location_tray @@ -0,0 +1 @@ +../../../../stock_location_tray \ No newline at end of file diff --git a/setup/stock_location_tray/setup.py b/setup/stock_location_tray/setup.py new file mode 100644 index 000000000000..28c57bb64031 --- /dev/null +++ b/setup/stock_location_tray/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_location_tray/README.rst b/stock_location_tray/README.rst new file mode 100644 index 000000000000..d344b147b58a --- /dev/null +++ b/stock_location_tray/README.rst @@ -0,0 +1,118 @@ +============== +Location Trays +============== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |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/13.0/stock_location_tray + :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-13-0/stock-logistics-warehouse-13-0-stock_location_tray + :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/13.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Add an optional Tray Type on Stock Locations. +A tray type defines a number of columns and rows. +A location with a tray type becomes a tray, and sub-locations are automatically +created according to the columns and rows of the tray type + +.. figure:: https://raw.githubusercontent.com/OCA/stock-logistics-warehouse/13.0/stock_location_tray/static/description/location-tray.png + :alt: Location Tray + :width: 600 px + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +General +~~~~~~~ + +In Inventory Settings, you must have: + + * Storage Locations + +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. + +Locations +~~~~~~~~~ + +The tray type can be configured in Stock Locations. + +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. + +The matrix widget on Tray locations can be clicked to reach a sub-location. +Blue squares represent the locations that contain goods. + +Known issues / Roadmap +====================== + +The buttons on operations opens a view with the tray matrix to show operators +where to pick/put goods. The issue is that Odoo allows only one modal popup +to be open at a time. The tray matrix replaces the operations window. We have +to find a way to prevent this. The tray matrix could be displayed through a +tooltip maybe, if we find how to render a widget in a tooltip. + +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_location_tray/__init__.py b/stock_location_tray/__init__.py new file mode 100644 index 000000000000..0650744f6bc6 --- /dev/null +++ b/stock_location_tray/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_location_tray/__manifest__.py b/stock_location_tray/__manifest__.py new file mode 100644 index 000000000000..05fd877e6b11 --- /dev/null +++ b/stock_location_tray/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + "name": "Location Trays", + "summary": "Organize a location as a matrix of cells", + "version": "13.0.1.0.0", + "category": "Stock", + "author": "Camptocamp, Odoo Community Association (OCA)", + "license": "AGPL-3", + "depends": ["stock", "base_sparse_field"], + "website": "https://github.com/OCA/stock-logistics-warehouse", + "demo": ["demo/stock_location_tray_type_demo.xml", "demo/stock_location_demo.xml"], + "data": [ + "views/stock_location_views.xml", + "views/stock_location_tray_type_views.xml", + "views/stock_location_tray_templates.xml", + "views/stock_move_line_views.xml", + "security/ir.model.access.csv", + ], + "installable": True, +} diff --git a/stock_location_tray/demo/stock_location_demo.xml b/stock_location_tray/demo/stock_location_demo.xml new file mode 100644 index 000000000000..fe130035997b --- /dev/null +++ b/stock_location_tray/demo/stock_location_demo.xml @@ -0,0 +1,22 @@ + + + + Tray + TRAY + + + internal + + + + + stock_location_tray + + diff --git a/stock_location_tray/demo/stock_location_tray_type_demo.xml b/stock_location_tray/demo/stock_location_tray_type_demo.xml new file mode 100644 index 000000000000..71aa9d18af3d --- /dev/null +++ b/stock_location_tray/demo/stock_location_tray_type_demo.xml @@ -0,0 +1,63 @@ + + + + Small 32x + B10804 + 4 + 8 + + + Small 16x + B20802 + 2 + 8 + + + Small 8x + B20402 + 2 + 4 + + + Small 16x + B40802 + 2 + 8 + + + Small 16x + B30404 + 4 + 4 + + + Large 32x + B20804 + 4 + 8 + + + Large 16x + B30802 + 2 + 8 + + + Large 8x + B30402 + 2 + 4 + + + Large 4x + B30401 + 1 + 4 + + + Large 16x + B30404 + 4 + 4 + + diff --git a/stock_location_tray/models/__init__.py b/stock_location_tray/models/__init__.py new file mode 100644 index 000000000000..5a84dcfd5757 --- /dev/null +++ b/stock_location_tray/models/__init__.py @@ -0,0 +1,3 @@ +from . import stock_location +from . import stock_location_tray_type +from . import stock_move_line diff --git a/stock_location_tray/models/stock_location.py b/stock_location_tray/models/stock_location.py new file mode 100644 index 000000000000..a365fb5983b0 --- /dev/null +++ b/stock_location_tray/models/stock_location.py @@ -0,0 +1,272 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from collections import defaultdict + +from odoo import _, api, exceptions, fields, models + +from odoo.addons.base_sparse_field.models.fields import Serialized + + +class StockLocation(models.Model): + _inherit = "stock.location" + + tray_type_id = fields.Many2one( + comodel_name="stock.location.tray.type", ondelete="restrict" + ) + cell_in_tray_type_id = fields.Many2one( + string="Cell Tray Type", related="location_id.tray_type_id", readonly=True + ) + tray_cell_contains_stock = fields.Boolean( + compute="_compute_tray_cell_contains_stock", + help="Used to know if a cell of a Tray location is empty.", + ) + tray_matrix = Serialized(string="Cells", compute="_compute_tray_matrix") + cell_name_format = fields.Char( + string="Name Format for Cells", + default=lambda self: self._default_cell_name_format(), + help="Cells sub-locations generated in a tray will be named" + " after this format. Replacement fields between curly braces are used" + " to inject positions. {x}, {y}, and {z} will be replaced by their" + " corresponding position. Complex formatting (such as padding, ...)" + " can be done using the format specification at " + " https://docs.python.org/3/library/string.html#formatstrings", + ) + + def _default_cell_name_format(self): + return "x{x:0>2}y{y:0>2}" + + @api.depends("quant_ids.quantity") + def _compute_tray_cell_contains_stock(self): + for location in self: + if not location.cell_in_tray_type_id: + # Not a tray cell so the value is irrelevant, + # best to skip them for performance. + location.tray_cell_contains_stock = False + continue + quants = location.quant_ids.filtered(lambda r: r.quantity > 0) + location.tray_cell_contains_stock = bool(quants) + + @api.depends("quant_ids.quantity", "tray_type_id", "location_id.tray_type_id") + def _compute_tray_matrix(self): + for location in self: + if not (location.tray_type_id or location.cell_in_tray_type_id): + location.tray_matrix = {} + continue + location.tray_matrix = location._tray_matrix_for_widget() + + def _tray_matrix_for_widget(self): + selected = self._tray_cell_coords() + cells = self._tray_cell_matrix() + return { + # x, y: position of the selected cell + "selected": selected, + # 0 is empty, 1 is not + "cells": cells, + } + + def action_tray_matrix_click(self, coordX, coordY): + self.ensure_one() + if self.cell_in_tray_type_id: + tray = self.location_id + else: + tray = self + location = self.search( + [ + ("id", "child_of", tray.ids), + # we receive positions counting from 0 but they are stored + # in the "human" format starting from 1 + ("posx", "=", coordX + 1), + ("posy", "=", coordY + 1), + ] + ) + location.ensure_one() + view = self.env.ref("stock.view_location_form") + action = self.env.ref("stock.action_location_form").read()[0] + action.update( + { + "res_id": location.id, + "view_mode": "form", + "view_type": "form", + "view_id": view.id, + "views": [(view.id, "form")], + } + ) + return action + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + records._update_tray_sublocations() + return records + + def _check_before_add_tray_type(self): + if not self.tray_type_id and self.child_ids: + raise exceptions.UserError( + _("Location %s has sub-locations, it cannot be converted" " to a tray.") + % (self.display_name) + ) + + def write(self, vals): + for location in self: + trays_to_update = False + if "tray_type_id" in vals: + location._check_before_add_tray_type() + new_tray_type_id = vals.get("tray_type_id") + trays_to_update = location.tray_type_id.id != new_tray_type_id + # short-circuit this check if we already know that we have to + # update trays + if not trays_to_update and "cell_name_format" in vals: + new_format = vals.get("cell_name_format") + trays_to_update = location.cell_name_format != new_format + super(StockLocation, location).write(vals) + if trays_to_update: + self._update_tray_sublocations() + elif "posz" in vals and location.tray_type_id: + # On initial generation (when tray_to_update is true), + # the sublocations are already generated with the pos z. + location.child_ids.write({"posz": vals["posz"]}) + return True + + @api.constrains("active") + def _tray_check_active(self): + for record in self: + if record.active: + continue + # We cannot disable any cell of a tray (entire tray) + # if at least one of the cell contains stock. + # We cannot disable a tray, a shuffle or a view if + # at least one of their tray contain stock. + if record.cell_in_tray_type_id: + parent = record.location_id + else: + parent = record + # Add the record to the search: as it has been set inactive, it + # will not be found by the search. + locs = self.search([("id", "child_of", parent.id)]) | record + if any( + (loc.tray_type_id or loc.cell_in_tray_type_id) + and loc.tray_cell_contains_stock + for loc in locs + ): + raise exceptions.ValidationError( + _( + "Tray locations cannot be archived when " + "they contain products." + ) + ) + + def tray_cell_center_position(self): + """Return the center position in mm of a cell + + The returned position is a tuple with the number of millimeters + from the bottom-left corner. Tuple: (left, bottom) + """ + if not self.cell_in_tray_type_id: + return 0, 0 + posx = self.posx + posy = self.posy + cell_width = self.cell_in_tray_type_id.width_per_cell + cell_depth = self.cell_in_tray_type_id.depth_per_cell + # posx and posy start at one, we want to count from 0 + from_left = (posx - 1) * cell_width + (cell_width / 2) + from_bottom = (posy - 1) * cell_depth + (cell_depth / 2) + return from_left, from_bottom + + def _tray_cell_coords(self): + if not self.cell_in_tray_type_id: + return [] + return [self.posx - 1, self.posy - 1] + + def _tray_cell_matrix(self): + assert self.tray_type_id or self.cell_in_tray_type_id + if self.tray_type_id: + location = self + else: # cell + location = self.location_id + cells = location.tray_type_id._generate_cells_matrix() + for cell in location.child_ids: + if cell.tray_cell_contains_stock: + # 1 means used + cells[cell.posy - 1][cell.posx - 1] = 1 + return cells + + def _format_tray_sublocation_name(self, x, y, z): + template = self.cell_name_format or self._default_cell_name_format() + # using format_map allows to have missing replacement strings + return template.format_map(defaultdict(str, x=x, y=y, z=z)) + + def _update_tray_sublocations(self): + values = [] + for location in self: + tray_type = location.tray_type_id + + try: + location.child_ids.write({"active": False}) + except exceptions.ValidationError: + # trap this check (_tray_check_active) to display a + # contextual error message + raise exceptions.UserError( + _("Trays cannot be modified when " "they contain products.") + ) + + if not tray_type: + continue + + # create accepts several records now + posz = location.posz or 0 + for row in range(1, tray_type.rows + 1): + for col in range(1, tray_type.cols + 1): + cell_name = location._format_tray_sublocation_name(col, row, posz) + subloc_values = { + "name": cell_name, + "posx": col, + "posy": row, + "posz": posz, + "location_id": location.id, + "company_id": location.company_id.id, + } + values.append(subloc_values) + if values: + self.create(values) + + def _create_tray_xmlids(self, module): + """Create external IDs for generated cells + + If the tray location has one. Used for the demo/test data. It will not + handle properly changing the tray format as the former cells will keep + the original xmlid built on x and y, the new ones will not be able to + use them. As these xmlids are meant for the demo data and the tests, + it is not a problem and should not be used for other purposes. + + Called from stock_location_tray/demo/stock_location_demo.xml. + """ + for location in self: + if not location.cell_in_tray_type_id: + continue + tray = location.location_id + tray_external_id = tray.get_external_id().get(tray.id) + if not tray_external_id: + continue + if "." not in tray_external_id: + continue + namespace, tray_name = tray_external_id.split(".") + if module != namespace: + continue + tray_external = self.env["ir.model.data"].browse( + self.env["ir.model.data"]._get_id(module, tray_name) + ) + cell_external_id = "{}_x{}y{}".format( + tray_name, location.posx, location.posy + ) + cell_xmlid = "{}.{}".format(module, cell_external_id) + if not self.env.ref(cell_xmlid, raise_if_not_found=False): + self.env["ir.model.data"].create( + { + "name": cell_external_id, + "module": module, + "model": self._name, + "res_id": location.id, + "noupdate": tray_external.noupdate, + } + ) diff --git a/stock_location_tray/models/stock_location_tray_type.py b/stock_location_tray/models/stock_location_tray_type.py new file mode 100644 index 000000000000..49e77cc9f086 --- /dev/null +++ b/stock_location_tray/models/stock_location_tray_type.py @@ -0,0 +1,112 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, exceptions, fields, models +from odoo.osv import expression + +from odoo.addons.base_sparse_field.models.fields import Serialized + + +class StockLocationTrayType(models.Model): + _name = "stock.location.tray.type" + _description = "Stock Location Tray Type" + + name = fields.Char(required=True) + code = fields.Char(required=True) + rows = fields.Integer(required=True) + cols = fields.Integer(required=True) + + width = fields.Integer(help="Width of the tray in mm") + depth = fields.Integer(help="Depth of the tray in mm") + height = fields.Integer(help="Height of the tray in mm") + + width_per_cell = fields.Float(compute="_compute_width_per_cell") + depth_per_cell = fields.Float(compute="_compute_depth_per_cell") + + active = fields.Boolean(default=True) + tray_matrix = Serialized(compute="_compute_tray_matrix") + location_ids = fields.One2many( + comodel_name="stock.location", inverse_name="tray_type_id" + ) + + @api.depends("width", "cols") + def _compute_width_per_cell(self): + for record in self: + width = record.width + if not width: + record.width_per_cell = 0.0 + continue + record.width_per_cell = width / record.cols + + @api.depends("depth", "rows") + def _compute_depth_per_cell(self): + for record in self: + depth = record.depth + if not depth: + record.depth_per_cell = 0.0 + continue + record.depth_per_cell = depth / record.rows + + @api.depends("rows", "cols") + def _compute_tray_matrix(self): + for record in self: + # As we only want to show the disposition of + # the tray, we generate a "full" tray, we'll + # see all the boxes on the web widget. + # (0 means empty, 1 means used) + cells = self._generate_cells_matrix(default_state=1) + record.tray_matrix = {"selected": [], "cells": cells} + + def _name_search( + self, name, args=None, operator="ilike", limit=100, name_get_uid=None + ): + args = args or [] + domain = [] + if name: + domain = ["|", ("name", operator, name), ("code", operator, name)] + tray_ids = self._search( + expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid + ) + return self.browse(tray_ids).name_get() + + def _generate_cells_matrix(self, default_state=0): + return [[default_state] * self.cols for __ in range(self.rows)] + + @api.constrains("active") + def _location_check_active(self): + for record in self: + if record.active: + continue + if record.location_ids: + location_bullets = [ + " - {}".format(location.display_name) + for location in record.location_ids + ] + raise exceptions.ValidationError( + _( + "The tray type {} is used by the following locations " + "and cannot be archived:\n\n{}" + ).format(record.name, "\n".join(location_bullets)) + ) + + @api.constrains("rows", "cols") + def _location_check_rows_cols(self): + for record in self: + if record.location_ids: + location_bullets = [ + " - {}".format(location.display_name) + for location in record.location_ids + ] + raise exceptions.ValidationError( + _( + "The tray type {} is used by the following locations, " + "it's size cannot be changed:\n\n{}" + ).format(record.name, "\n".join(location_bullets)) + ) + + def open_locations(self): + action = self.env.ref("stock.action_location_form").read()[0] + action["domain"] = [("tray_type_id", "in", self.ids)] + if len(self.ids) == 1: + action["context"] = {"default_tray_type_id": self.id} + return action diff --git a/stock_location_tray/models/stock_move_line.py b/stock_location_tray/models/stock_move_line.py new file mode 100644 index 000000000000..ab3984f57e06 --- /dev/null +++ b/stock_location_tray/models/stock_move_line.py @@ -0,0 +1,53 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, models + +from odoo.addons.base_sparse_field.models.fields import Serialized + + +class StockMoveLine(models.Model): + _inherit = "stock.move.line" + + tray_source_matrix = Serialized( + string="Source Cell", compute="_compute_tray_matrix" + ) + tray_dest_matrix = Serialized( + string="Destination Cell", compute="_compute_tray_matrix" + ) + + @api.depends("location_id", "location_dest_id") + def _compute_tray_matrix(self): + for record in self: + record.tray_source_matrix = record.location_id.tray_matrix + record.tray_dest_matrix = record.location_dest_id.tray_matrix + + def _action_show_tray(self, location_from): + assert location_from in ("source", "dest") + self.ensure_one() + view = self.env.ref("stock_location_tray.view_stock_move_line_tray") + context = self.env.context.copy() + if location_from == "source": + name = _("Source Tray") + context["show_source_tray"] = True + else: + name = _("Destination Tray") + context["show_dest_tray"] = True + return { + "name": name, + "type": "ir.actions.act_window", + "view_type": "form", + "view_mode": "form", + "res_model": "stock.move.line", + "views": [(view.id, "form")], + "view_id": view.id, + "target": "new", + "res_id": self.id, + "context": context, + } + + def action_show_source_tray(self): + return self._action_show_tray("source") + + def action_show_dest_tray(self): + return self._action_show_tray("dest") diff --git a/stock_location_tray/readme/CONFIGURE.rst b/stock_location_tray/readme/CONFIGURE.rst new file mode 100644 index 000000000000..0fc54755fdb3 --- /dev/null +++ b/stock_location_tray/readme/CONFIGURE.rst @@ -0,0 +1,25 @@ +General +~~~~~~~ + +In Inventory Settings, you must have: + + * Storage Locations + +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. + +Locations +~~~~~~~~~ + +The tray type can be configured in Stock Locations. + +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. + +The matrix widget on Tray locations can be clicked to reach a sub-location. +Blue squares represent the locations that contain goods. diff --git a/stock_location_tray/readme/CONTRIBUTORS.rst b/stock_location_tray/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000000..48286263cd35 --- /dev/null +++ b/stock_location_tray/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Guewen Baconnier diff --git a/stock_location_tray/readme/DESCRIPTION.rst b/stock_location_tray/readme/DESCRIPTION.rst new file mode 100644 index 000000000000..e9d57913aae6 --- /dev/null +++ b/stock_location_tray/readme/DESCRIPTION.rst @@ -0,0 +1,8 @@ +Add an optional Tray Type on Stock Locations. +A tray type defines a number of columns and rows. +A location with a tray type becomes a tray, and sub-locations are automatically +created according to the columns and rows of the tray type + +.. figure:: ../static/description/location-tray.png + :alt: Location Tray + :width: 600 px diff --git a/stock_location_tray/readme/ROADMAP.rst b/stock_location_tray/readme/ROADMAP.rst new file mode 100644 index 000000000000..ace2921efddd --- /dev/null +++ b/stock_location_tray/readme/ROADMAP.rst @@ -0,0 +1,5 @@ +The buttons on operations opens a view with the tray matrix to show operators +where to pick/put goods. The issue is that Odoo allows only one modal popup +to be open at a time. The tray matrix replaces the operations window. We have +to find a way to prevent this. The tray matrix could be displayed through a +tooltip maybe, if we find how to render a widget in a tooltip. diff --git a/stock_location_tray/security/ir.model.access.csv b/stock_location_tray/security/ir.model.access.csv new file mode 100644 index 000000000000..1836f4588f95 --- /dev/null +++ b/stock_location_tray/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_stock_location_tray_type_stock_user,access_stock_location_tray_type stock user,model_stock_location_tray_type,stock.group_stock_user,1,0,0,0 +access_stock_location_tray_type_manager,access_stock_location_tray_type stock manager,model_stock_location_tray_type,stock.group_stock_manager,1,1,1,1 diff --git a/stock_location_tray/static/description/index.html b/stock_location_tray/static/description/index.html new file mode 100644 index 000000000000..14023fe65185 --- /dev/null +++ b/stock_location_tray/static/description/index.html @@ -0,0 +1,467 @@ + + + + + + +Location Trays + + + +
+

Location Trays

+ + +

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

+

Add an optional Tray Type on Stock Locations. +A tray type defines a number of columns and rows. +A location with a tray type becomes a tray, and sub-locations are automatically +created according to the columns and rows of the tray type

+
+Location Tray +
+

Table of contents

+ +
+

Configuration

+
+

General

+

In Inventory Settings, you must have:

+
+
    +
  • Storage Locations
  • +
+
+
+
+

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.

+
+
+

Locations

+

The tray type can be configured in Stock Locations.

+

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.

+

The matrix widget on Tray locations can be clicked to reach a sub-location. +Blue squares represent the locations that contain goods.

+
+
+
+

Known issues / Roadmap

+

The buttons on operations opens a view with the tray matrix to show operators +where to pick/put goods. The issue is that Odoo allows only one modal popup +to be open at a time. The tray matrix replaces the operations window. We have +to find a way to prevent this. The tray matrix could be displayed through a +tooltip maybe, if we find how to render a widget in a tooltip.

+
+
+

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_location_tray/static/description/location-tray.png b/stock_location_tray/static/description/location-tray.png new file mode 100644 index 000000000000..a5e1a1303bf3 Binary files /dev/null and b/stock_location_tray/static/description/location-tray.png differ diff --git a/stock_location_tray/static/src/js/stock_location_tray.js b/stock_location_tray/static/src/js/stock_location_tray.js new file mode 100644 index 000000000000..9675b8aa51e7 --- /dev/null +++ b/stock_location_tray/static/src/js/stock_location_tray.js @@ -0,0 +1,269 @@ +odoo.define("stock_location_tray.tray", function(require) { + "use strict"; + + var basicFields = require("web.basic_fields"); + var field_registry = require("web.field_registry"); + var DebouncedField = basicFields.DebouncedField; + + /** + * Shows a canvas with the Tray's cells + * + * An action can be configured which is called when a cell is clicked. + * The action must be an action.multi, it will receive the x and y positions + * of the cell clicked (starting from 0). The action must be configured in + * the options of the field and be on the same model: + * + * + * + */ + var LocationTrayMatrixField = DebouncedField.extend({ + className: "o_field_location_tray_matrix", + tagName: "canvas", + supportedFieldTypes: ["serialized"], + events: { + click: "_onClick", + }, + + cellColorEmpty: "#ffffff", + cellColorNotEmpty: "#4e6bfd", + selectedColor: "#08f46b", + selectedLineWidth: 5, + globalAlpha: 0.8, + cellPadding: 2, + + init: function(parent, name, record, options) { + this._super.apply(this, arguments); + this.nodeOptions = _.defaults(this.nodeOptions, {}); + if ("clickAction" in (options || {})) { + this.clickAction = options.clickAction; + } else { + this.clickAction = this.nodeOptions.click_action; + } + }, + + isSet: function() { + if (Object.keys(this.value).length === 0) { + return false; + } + if (this.value.cells.length === 0) { + return false; + } + return this._super.apply(this, arguments); + }, + + start: function() { + // Setup resize events to redraw the canvas + this._resizeDebounce = this._resizeDebounce.bind(this); + this._resizePromise = null; + $(window).on("resize", this._resizeDebounce); + + var self = this; + return this._super.apply(this, arguments).then(function() { + if (self.clickAction) { + self.$el.css("cursor", "pointer"); + } + // _super calls _render(), but the function + // resizeCanvasToDisplaySize would resize the canvas + // to 0 because the actual canvas would still be unknown. + // Call again _render() here but through a setTimeout to + // let the js renderer thread catch up. + self._ready = true; + return self._resizeDebounce(); + }); + }, + + _onClick: function(ev) { + if (!this.isSet()) { + return; + } + if (!this.clickAction) { + return; + } + var width = this.canvas.width, + height = this.canvas.height, + rect = this.canvas.getBoundingClientRect(); + + var clickX = ev.clientX - rect.left, + clickY = ev.clientY - rect.top; + + var cells = this.value.cells, + cols = cells[0].length, + rows = cells.length; + + // We remove 1 to start counting from 0 + var coordX = Math.ceil((clickX * cols) / width) - 1, + coordY = Math.ceil((clickY * rows) / height) - 1; + // If we click on the last pixel on the bottom or the right + // we would get an offset index + if (coordX >= cols) { + coordX = cols - 1; + } + if (coordY >= rows) { + coordY = rows - 1; + } + + // The coordinate we get when we click is from top, + // but we are looking for the coordinate from the bottom + // to match the user's expectations, invert Y + coordY = Math.abs(coordY - rows + 1); + + var self = this; + this._rpc({ + model: this.model, + method: this.clickAction, + args: [[this.res_id], coordX, coordY], + }).then(function(action) { + self.trigger_up("do_action", {action: action}); + }); + }, + + /** + * Debounce the rendering on resize. + * It is useless to render on each resize event. + * + */ + _resizeDebounce: function() { + clearTimeout(this._resizePromise); + var self = this; + this._resizePromise = setTimeout(function() { + self._render(); + }, 20); + }, + + destroy: function() { + $(window).off("resize", this._resizeDebounce); + this._super.apply(this, arguments); + }, + + /** + * Render the widget only when it is in the DOM. + * We need the width and height of the widget to draw the canvas. + * + * @returns {Promise} + */ + _render: function() { + if (this._ready) { + return this._renderInDOM(); + } + return $.when(); + }, + + /** + * Resize the canvas width and height to the actual size. + * If we don't do that, it will automatically scale to the + * CSS size with blurry squares. + * + * @param {jQueryElement} canvas - the DOM canvas to draw + * @returns {Boolean} + */ + resizeCanvasToDisplaySize: function(canvas) { + // Look up the size the canvas is being displayed + var width = canvas.clientWidth; + var height = canvas.clientHeight; + + // If it's resolution does not match change it + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + return true; + } + + return false; + }, + + /** + * Resize the canvas, clear it and redraw the cells + * Should be called only if the canvas is already in DOM + * + */ + _renderInDOM: function() { + this.canvas = this.$el[0]; + var canvas = this.canvas; + var ctx = canvas.getContext("2d"); + this.resizeCanvasToDisplaySize(ctx.canvas); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.save(); + if (this.isSet()) { + var selected = this.value.selected || []; + var cells = this.value.cells; + this._drawMatrix(canvas, ctx, cells, selected); + } + }, + + /** + * Draw the cells in the canvas. + * + * @param {jQueryElement} canvas - the DOM canvas to draw + * @param {Object} ctx - the canvas 2d context + * @param {List} cells - A 2-dimensional list of cells + * @param {List} selected - A list containing the position (x,y) of the + * selected cell (can be empty if no cell is selected) + */ + _drawMatrix: function(canvas, ctx, cells, selected) { + var colors = { + 0: this.cellColorEmpty, + 1: this.cellColorNotEmpty, + }; + + var cols = cells[0].length; + var rows = cells.length; + var selectedX = null, + selectedY = null; + if (selected.length) { + selectedX = selected[0]; + // We draw top to bottom, but the highlighted cell should + // be a coordinate from bottom to top: reverse the y axis + selectedY = Math.abs(selected[1] - rows + 1); + } + + var padding = this.cellPadding; + var padding_width = padding * cols; + var padding_height = padding * rows; + var w = (canvas.width - padding_width) / cols; + var h = (canvas.height - padding_height) / rows; + + ctx.globalAlpha = this.globalAlpha; + // Again, our matrix is top to bottom (0 is the first line) + // but visually, we want them bottom to top + var reversed_cells = cells.slice().reverse(); + for (var y = 0; y < rows; y++) { + for (var x = 0; x < cols; x++) { + ctx.fillStyle = colors[reversed_cells[y][x]]; + var fillWidth = w; + var fillHeight = h; + // Cheat: remove the padding at bottom and right + // the cells will be a bit larger but not really noticeable + if (x === cols - 1) { + fillWidth += padding; + } + if (y === rows - 1) { + fillHeight += padding; + } + ctx.fillRect( + x * (w + padding), + y * (h + padding), + fillWidth, + fillHeight + ); + if (selected && selectedX === x && selectedY === y) { + ctx.globalAlpha = 1.0; + ctx.strokeStyle = this.selectedColor; + ctx.lineWidth = this.selectedLineWidth; + ctx.strokeRect(x * (w + padding), y * (h + padding), w, h); + ctx.globalAlpha = this.globalAlpha; + } + } + } + ctx.restore(); + }, + }); + + field_registry.add("location_tray_matrix", LocationTrayMatrixField); + + return { + LocationTrayMatrixField: LocationTrayMatrixField, + }; +}); diff --git a/stock_location_tray/static/src/scss/stock_location_tray.scss b/stock_location_tray/static/src/scss/stock_location_tray.scss new file mode 100644 index 000000000000..9c0ca0b4c9a2 --- /dev/null +++ b/stock_location_tray/static/src/scss/stock_location_tray.scss @@ -0,0 +1,4 @@ +.o_field_location_tray_matrix { + background-color: #eeeeee; + border: 2px #000000 solid; +} diff --git a/stock_location_tray/tests/__init__.py b/stock_location_tray/tests/__init__.py new file mode 100644 index 000000000000..3f56218d43f8 --- /dev/null +++ b/stock_location_tray/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_location +from . import test_tray_type diff --git a/stock_location_tray/tests/common.py b/stock_location_tray/tests/common.py new file mode 100644 index 000000000000..88f7f432ad68 --- /dev/null +++ b/stock_location_tray/tests/common.py @@ -0,0 +1,32 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests import common + + +class LocationTrayTypeCase(common.SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.wh = cls.env.ref("stock.warehouse0") + cls.stock_location = cls.env.ref("stock.stock_location_stock") + cls.product = cls.env.ref("product.product_delivery_02") + cls.tray_location = cls.env.ref("stock_location_tray.stock_location_tray_demo") + cls.tray_type_small_8x = cls.env.ref( + "stock_location_tray.stock_location_tray_type_small_8x" + ) + cls.tray_type_small_32x = cls.env.ref( + "stock_location_tray.stock_location_tray_type_small_32x" + ) + + def _cell_for(self, tray, x=1, y=1): + cell = self.env["stock.location"].search( + [("location_id", "=", tray.id), ("posx", "=", x), ("posy", "=", y)] + ) + self.assertEqual( + len(cell), 1, "Cell x{}y{} not found for {}".format(x, y, tray.name) + ) + return cell + + def _update_quantity_in_cell(self, cell, product, quantity): + self.env["stock.quant"]._update_available_quantity(product, cell, quantity) diff --git a/stock_location_tray/tests/test_location.py b/stock_location_tray/tests/test_location.py new file mode 100644 index 000000000000..d25fde09898d --- /dev/null +++ b/stock_location_tray/tests/test_location.py @@ -0,0 +1,196 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import exceptions + +from .common import LocationTrayTypeCase + + +class TestLocation(LocationTrayTypeCase): + def test_create_tray(self): + tray_type = self.tray_type_small_8x + tray_loc = self.env["stock.location"].create( + { + "name": "Tray Z", + "location_id": self.stock_location.id, + "usage": "internal", + "tray_type_id": tray_type.id, + } + ) + + self.assertEqual(len(tray_loc.child_ids), tray_type.cols * tray_type.rows) # 8 + self.assertTrue( + all( + subloc.cell_in_tray_type_id == tray_type + for subloc in tray_loc.child_ids + ) + ) + + def test_tray_has_stock(self): + cell = self.env.ref("stock_location_tray.stock_location_tray_demo_x3y2") + self.assertFalse(cell.quant_ids) + self.assertFalse(cell.tray_cell_contains_stock) + self._update_quantity_in_cell(cell, self.product, 1) + self.assertTrue(cell.quant_ids) + self.assertTrue(cell.tray_cell_contains_stock) + self._update_quantity_in_cell(cell, self.product, -1) + self.assertTrue(cell.quant_ids) + self.assertFalse(cell.tray_cell_contains_stock) + + def test_matrix_empty_tray(self): + self.assertEqual(self.tray_location.tray_type_id.cols, 4) + self.assertEqual(self.tray_location.tray_type_id.rows, 2) + self.assertEqual( + self.tray_location.tray_matrix, + { + # we show the entire tray, not a cell + "selected": [], + # we have no stock in this location + # fmt: off + 'cells': [ + [0, 0, 0, 0], + [0, 0, 0, 0], + ] + # fmt: on + }, + ) + + def test_matrix_stock_tray(self): + self._update_quantity_in_cell( + self._cell_for(self.tray_location, x=1, y=1), self.product, 100 + ) + self._update_quantity_in_cell( + self._cell_for(self.tray_location, x=2, y=1), self.product, 100 + ) + self._update_quantity_in_cell( + self._cell_for(self.tray_location, x=4, y=2), self.product, 100 + ) + self.assertEqual(self.tray_location.tray_type_id.cols, 4) + self.assertEqual(self.tray_location.tray_type_id.rows, 2) + self.assertEqual( + self.tray_location.tray_matrix, + { + # We show the entire tray, not a cell. + "selected": [], + # Note: the coords are stored according to their index in the + # arrays so it is easier to manipulate them. However, we + # display them with the Y axis inverted in the UI to represent + # the view of the operator. + # + # [0, 0, 0, 1], + # [1, 1, 0, 0], + # + # fmt: off + 'cells': [ + [1, 1, 0, 0], + [0, 0, 0, 1], + ] + # fmt: on + }, + ) + + def test_matrix_stock_cell(self): + self.tray_location.tray_type_id = self.env.ref( + "stock_location_tray.stock_location_tray_type_large_32x" + ) + cell = self._cell_for(self.tray_location, x=7, y=3) + self._update_quantity_in_cell(cell, self.product, 100) + self._update_quantity_in_cell( + self._cell_for(self.tray_location, x=1, y=1), self.product, 100 + ) + self._update_quantity_in_cell( + self._cell_for(self.tray_location, x=3, y=2), self.product, 100 + ) + self.assertEqual(self.tray_location.tray_type_id.cols, 8) + self.assertEqual(self.tray_location.tray_type_id.rows, 4) + self.assertEqual( + cell.tray_matrix, + { + # When called on a cell, we expect to have its coords. Worth to + # note: the cell's coordinate are 7 and 3 in the posx and posy + # fields as they make sense for humans. Here, they are offset + # by -1 to have the indexes in the matrix. + "selected": [6, 2], + # fmt: off + 'cells': [ + [1, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 1, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 1, 0], + [0, 0, 0, 0, 0, 0, 0, 0], + ] + # fmt: on + }, + ) + + def test_check_active_empty(self): + cell = self.env.ref("stock_location_tray.stock_location_tray_demo_x3y2") + self.assertFalse(cell.tray_cell_contains_stock) + # allowed to archive empty cell + cell.active = False + + def test_check_active_not_empty(self): + cell = self.env.ref("stock_location_tray.stock_location_tray_demo_x3y2") + self._update_quantity_in_cell(cell, self.product, 1) + self.assertTrue(cell.tray_cell_contains_stock) + + # we cannot archive an empty cell or any parent + location = cell + message = "cannot be archived" + while location: + with self.assertRaisesRegex(exceptions.ValidationError, message): + location.active = False + + # restore state for the next test loop + location.active = True + location = location.location_id + if location == self.wh.lot_stock_id: + # we can't disable the Stock location anyway + break + + def test_change_tray_type_when_empty(self): + tray_type = self.tray_type_small_32x + self.tray_location.tray_type_id = tray_type + self.assertEqual( + len(self.tray_location.child_ids), tray_type.cols * tray_type.rows # 32 + ) + + def test_change_tray_type_error_when_not_empty(self): + self._update_quantity_in_cell( + self._cell_for(self.tray_location, x=1, y=1), self.product, 1 + ) + tray_type = self.tray_type_small_32x + message = "cannot be modified when they contain products" + with self.assertRaisesRegex(exceptions.UserError, message): + self.tray_location.tray_type_id = tray_type + + def test_location_center_pos(self): + cell = self.env.ref("stock_location_tray.stock_location_tray_demo_x3y2") + tray_type = cell.cell_in_tray_type_id + number_of_x = 4 + number_of_y = 2 + self.assertEqual((number_of_x, number_of_y), (tray_type.cols, tray_type.rows)) + + total_width = 80 + total_depth = 30 + tray_type.width = total_width + tray_type.depth = total_depth + + self.assertEqual( + (total_width / number_of_x, total_depth / number_of_y), + (tray_type.width_per_cell, tray_type.depth_per_cell), + ) + from_left, from_bottom = cell.tray_cell_center_position() + # fmt: off + expected_left = ( + (total_width / number_of_x) # width of a cell + * 2 # we want the center of the cell x3, so we want 2 full cells + + ((total_width / number_of_x) / 2) # + the half of our cell + ) + expected_bottom = ( + (total_depth / number_of_y) # depth of a cell + * 1 # we want the center of the cell y2, so we want 1 full cells + + ((total_depth / number_of_y) / 2) # + the half of our cell + ) + # fmt: on + self.assertEqual(from_left, expected_left) + self.assertEqual(from_bottom, expected_bottom) diff --git a/stock_location_tray/tests/test_tray_type.py b/stock_location_tray/tests/test_tray_type.py new file mode 100644 index 000000000000..d75013840d8d --- /dev/null +++ b/stock_location_tray/tests/test_tray_type.py @@ -0,0 +1,78 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import exceptions + +from .common import LocationTrayTypeCase + + +class TestLocationTrayType(LocationTrayTypeCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.used_tray_type = cls.env.ref( + "stock_location_tray.stock_location_tray_type_large_16x" + ) + cls.unused_tray_type = cls.env.ref( + "stock_location_tray.stock_location_tray_type_small_16x_3" + ) + + def test_tray_type(self): + # any location created directly under the view is a shuttle + tray_type = self.env["stock.location.tray.type"].create( + {"name": "Test Type", "code": "🐵", "rows": 4, "cols": 6} + ) + self.assertEqual( + tray_type.tray_matrix, + { + "selected": [], # no selection as this is the "model" + # a "full" matrix is generated for display on the UI + # fmt: off + 'cells': [ + [1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1], + ] + # fmt: on + }, + ) + + def test_check_active(self): + location = self.tray_location + location.tray_type_id = self.used_tray_type + location = self.used_tray_type.location_ids + self.assertTrue(location) + message = "cannot be archived.*{}.*".format(location.name) + # we cannot archive used ones + with self.assertRaisesRegex(exceptions.ValidationError, message): + self.used_tray_type.active = False + # we can archive unused ones + self.unused_tray_type.active = False + + def test_check_cols_rows(self): + location = self.tray_location + location.tray_type_id = self.used_tray_type + location = self.used_tray_type.location_ids + self.assertTrue(location) + message = "size cannot be changed.*{}.*".format(location.name) + # we cannot modify size of used ones + with self.assertRaisesRegex(exceptions.ValidationError, message): + self.used_tray_type.rows = 10 + with self.assertRaisesRegex(exceptions.ValidationError, message): + self.used_tray_type.cols = 10 + # we can modify size of unused ones + self.unused_tray_type.rows = 10 + self.unused_tray_type.cols = 10 + + def test_width_per_cell(self): + tray_type = self.used_tray_type + tray_type.cols = 10 + tray_type.width = 120 + self.assertEqual(tray_type.width_per_cell, 12) + + def test_depth_per_cell(self): + tray_type = self.used_tray_type + tray_type.rows = 10 + tray_type.depth = 120 + self.assertEqual(tray_type.depth_per_cell, 12) diff --git a/stock_location_tray/views/stock_location_tray_templates.xml b/stock_location_tray/views/stock_location_tray_templates.xml new file mode 100644 index 000000000000..aeff61857340 --- /dev/null +++ b/stock_location_tray/views/stock_location_tray_templates.xml @@ -0,0 +1,20 @@ + + +