From 2472013e0074e1500a7a057f41773030b4a7aa22 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 19 Sep 2019 11:34:24 +0200 Subject: [PATCH] Add stock_location_tray Extracted from stock_vertical_lift (https://github.com/OCA/stock-logistics-warehouse/pull/633) Add tray types to stock locations, automatically generates sub-locations. Present them nicely with a custom widget. --- stock_location_tray/README.rst | 109 +++++ stock_location_tray/__init__.py | 1 + stock_location_tray/__manifest__.py | 26 + .../demo/stock_location_demo.xml | 21 + .../demo/stock_location_tray_type_demo.xml | 74 +++ stock_location_tray/models/__init__.py | 2 + stock_location_tray/models/stock_location.py | 259 ++++++++++ .../models/stock_location_tray_type.py | 89 ++++ stock_location_tray/readme/CONFIGURE.rst | 25 + stock_location_tray/readme/CONTRIBUTORS.rst | 1 + stock_location_tray/readme/DESCRIPTION.rst | 8 + .../security/ir.model.access.csv | 3 + .../static/description/index.html | 458 ++++++++++++++++++ .../static/description/location-tray.png | Bin 0 -> 36950 bytes .../static/src/js/stock_location_tray.js | 249 ++++++++++ .../static/src/scss/stock_location_tray.scss | 4 + stock_location_tray/tests/__init__.py | 3 + stock_location_tray/tests/common.py | 39 ++ stock_location_tray/tests/test_location.py | 170 +++++++ stock_location_tray/tests/test_tray_type.py | 72 +++ .../views/stock_location_tray_templates.xml | 11 + .../views/stock_location_tray_type_views.xml | 85 ++++ .../views/stock_location_views.xml | 48 ++ 23 files changed, 1757 insertions(+) create mode 100644 stock_location_tray/README.rst create mode 100644 stock_location_tray/__init__.py create mode 100644 stock_location_tray/__manifest__.py create mode 100644 stock_location_tray/demo/stock_location_demo.xml create mode 100644 stock_location_tray/demo/stock_location_tray_type_demo.xml create mode 100644 stock_location_tray/models/__init__.py create mode 100644 stock_location_tray/models/stock_location.py create mode 100644 stock_location_tray/models/stock_location_tray_type.py create mode 100644 stock_location_tray/readme/CONFIGURE.rst create mode 100644 stock_location_tray/readme/CONTRIBUTORS.rst create mode 100644 stock_location_tray/readme/DESCRIPTION.rst create mode 100644 stock_location_tray/security/ir.model.access.csv create mode 100644 stock_location_tray/static/description/index.html create mode 100644 stock_location_tray/static/description/location-tray.png create mode 100644 stock_location_tray/static/src/js/stock_location_tray.js create mode 100644 stock_location_tray/static/src/scss/stock_location_tray.scss create mode 100644 stock_location_tray/tests/__init__.py create mode 100644 stock_location_tray/tests/common.py create mode 100644 stock_location_tray/tests/test_location.py create mode 100644 stock_location_tray/tests/test_tray_type.py create mode 100644 stock_location_tray/views/stock_location_tray_templates.xml create mode 100644 stock_location_tray/views/stock_location_tray_type_views.xml create mode 100644 stock_location_tray/views/stock_location_views.xml diff --git a/stock_location_tray/README.rst b/stock_location_tray/README.rst new file mode 100644 index 000000000000..6d7664f8cfde --- /dev/null +++ b/stock_location_tray/README.rst @@ -0,0 +1,109 @@ +============== +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/12.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-12-0/stock-logistics-warehouse-12-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/12.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/12.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. + +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..d4755d0cc8a7 --- /dev/null +++ b/stock_location_tray/__manifest__.py @@ -0,0 +1,26 @@ +# 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': '12.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', + '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..2d7ba981816a --- /dev/null +++ b/stock_location_tray/demo/stock_location_demo.xml @@ -0,0 +1,21 @@ + + + + + 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..28879ea07104 --- /dev/null +++ b/stock_location_tray/demo/stock_location_tray_type_demo.xml @@ -0,0 +1,74 @@ + + + + + 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..4346fea39dff --- /dev/null +++ b/stock_location_tray/models/__init__.py @@ -0,0 +1,2 @@ +from . import stock_location +from . import stock_location_tray_type diff --git a/stock_location_tray/models/stock_location.py b/stock_location_tray/models/stock_location.py new file mode 100644 index 000000000000..9677b0779f8f --- /dev/null +++ b/stock_location_tray/models/stock_location.py @@ -0,0 +1,259 @@ +# 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/2/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: + # we skip the others only for performance + 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): + continue + location.tray_matrix = { + "selected": location._tray_cell_coords(), + "cells": location._tray_cell_matrix(), + } + + @api.multi + 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) + ) + + @api.multi + 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_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)) + + @api.multi + 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) + + @api.multi + 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..a2ec2383e3f8 --- /dev/null +++ b/stock_location_tray/models/stock_location_tray_type.py @@ -0,0 +1,89 @@ +# 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) + 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("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} + + @api.model + 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)) + ) + + @api.multi + 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/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/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..681833883a64 --- /dev/null +++ b/stock_location_tray/static/description/index.html @@ -0,0 +1,458 @@ + + + + + + +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.

+
+
+
+

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 0000000000000000000000000000000000000000..a5e1a1303bf3a999c61d6c70098a901abe6ae787 GIT binary patch literal 36950 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 literal 0 HcmV?d00001 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..a4ba05959ff3 --- /dev/null +++ b/stock_location_tray/static/src/js/stock_location_tray.js @@ -0,0 +1,249 @@ +odoo.define('stock_location_tray.tray', function (require) { +"use strict"; + +var core = require('web.core'); +var KanbanRecord = require('web.KanbanRecord'); +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, {}); + this.clickAction = 'clickAction' in (options || {}) ? options.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. + * + */ + _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. + * + */ + 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.find('canvas').context; + 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. + * + */ + _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, selectedY; + 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 w = ((canvas.width - padding * cols) / cols); + var h = ((canvas.height - padding * rows) / 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..ec80502cdc81 --- /dev/null +++ b/stock_location_tray/tests/__init__.py @@ -0,0 +1,3 @@ +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..f2b13bc2bf64 --- /dev/null +++ b/stock_location_tray/tests/common.py @@ -0,0 +1,39 @@ +# 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.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..29272cfd33ac --- /dev/null +++ b/stock_location_tray/tests/test_location.py @@ -0,0 +1,170 @@ +# 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 + + 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 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..c228a9f1f9d1 --- /dev/null +++ b/stock_location_tray/tests/test_tray_type.py @@ -0,0 +1,72 @@ +# 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': '🐵', + 'usage': 'internal', + '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 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..96f427560f5f --- /dev/null +++ b/stock_location_tray/views/stock_location_tray_templates.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/stock_location_tray/views/stock_location_tray_type_views.xml b/stock_location_tray/views/stock_location_tray_type_views.xml new file mode 100644 index 000000000000..46a1873886c6 --- /dev/null +++ b/stock_location_tray/views/stock_location_tray_type_views.xml @@ -0,0 +1,85 @@ + + + + + stock.location.tray.type.form + stock.location.tray.type + +
+
+ +
+
+
+ + + stock.location.tray.type.search + stock.location.tray.type + + + + + + + + + + + + stock.location.tray.type + stock.location.tray.type + + + + + + + + + + + + Location Tray Types + stock.location.tray.type + ir.actions.act_window + form + + + + +

+ Add a Location Tray Type +

+ Define the number of rows and cols on a tray, depending of the boxes +size. +

+
+
+ + + +
diff --git a/stock_location_tray/views/stock_location_views.xml b/stock_location_tray/views/stock_location_views.xml new file mode 100644 index 000000000000..538693126d79 --- /dev/null +++ b/stock_location_tray/views/stock_location_views.xml @@ -0,0 +1,48 @@ + + + + + stock.location.form.tray.type + stock.location + + + + + + + + + + + + + {'readonly': [('cell_in_tray_type_id', '!=', False)]} + + + {'readonly': [('cell_in_tray_type_id', '!=', False)]} + + + {'readonly': [('cell_in_tray_type_id', '!=', False)]} + + + + + + stock.location.search.tray.type + stock.location + + + + + + + + + +