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..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 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..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 + + + + + + + + + +