From 76eccd3a40185d0f635a702782b2c0c7cd8158bb Mon Sep 17 00:00:00 2001 From: Patrick Tombez Date: Fri, 1 Nov 2019 15:51:29 +0100 Subject: [PATCH] Add stock_cubiscan --- stock_cubiscan/__init__.py | 2 + stock_cubiscan/__manifest__.py | 24 +++ stock_cubiscan/models/__init__.py | 2 + stock_cubiscan/models/cubiscan.py | 88 ++++++++++ stock_cubiscan/models/stock.py | 36 ++++ stock_cubiscan/security/ir.model.access.csv | 3 + .../static/src/scss/cubiscan_wizard.scss | 33 ++++ stock_cubiscan/tests/__init__.py | 2 + stock_cubiscan/tests/test_cubiscan.py | 41 +++++ stock_cubiscan/tests/test_cubiscan_wizard.py | 110 ++++++++++++ stock_cubiscan/views/assets.xml | 8 + stock_cubiscan/views/cubiscan_view.xml | 55 ++++++ .../views/product_packaging_views.xml | 24 +++ stock_cubiscan/wizard/__init__.py | 1 + stock_cubiscan/wizard/cubiscan_wizard.py | 159 ++++++++++++++++++ stock_cubiscan/wizard/cubiscan_wizard.xml | 40 +++++ 16 files changed, 628 insertions(+) create mode 100644 stock_cubiscan/__init__.py create mode 100644 stock_cubiscan/__manifest__.py create mode 100644 stock_cubiscan/models/__init__.py create mode 100644 stock_cubiscan/models/cubiscan.py create mode 100644 stock_cubiscan/models/stock.py create mode 100644 stock_cubiscan/security/ir.model.access.csv create mode 100644 stock_cubiscan/static/src/scss/cubiscan_wizard.scss create mode 100644 stock_cubiscan/tests/__init__.py create mode 100644 stock_cubiscan/tests/test_cubiscan.py create mode 100644 stock_cubiscan/tests/test_cubiscan_wizard.py create mode 100644 stock_cubiscan/views/assets.xml create mode 100644 stock_cubiscan/views/cubiscan_view.xml create mode 100644 stock_cubiscan/views/product_packaging_views.xml create mode 100644 stock_cubiscan/wizard/__init__.py create mode 100644 stock_cubiscan/wizard/cubiscan_wizard.py create mode 100644 stock_cubiscan/wizard/cubiscan_wizard.xml diff --git a/stock_cubiscan/__init__.py b/stock_cubiscan/__init__.py new file mode 100644 index 000000000..9b4296142 --- /dev/null +++ b/stock_cubiscan/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/stock_cubiscan/__manifest__.py b/stock_cubiscan/__manifest__.py new file mode 100644 index 000000000..99f50d747 --- /dev/null +++ b/stock_cubiscan/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + 'name': 'Stock Cubiscan', + 'summary': 'Implement inteface with Cubiscan devices for packaging', + 'version': '12.0.1.0.0', + 'category': 'Stock', + 'author': 'Camptocamp', + 'license': 'AGPL-3', + 'depends': [ + 'barcodes', + 'stock', + 'web_tree_dynamic_colored_field' + ], + 'website': 'http://www.camptocamp.com', + 'data': [ + 'views/assets.xml', + 'views/cubiscan_view.xml', + 'views/product_packaging_views.xml', + 'wizard/cubiscan_wizard.xml', + 'security/ir.model.access.csv', + ], + 'installable': True, +} diff --git a/stock_cubiscan/models/__init__.py b/stock_cubiscan/models/__init__.py new file mode 100644 index 000000000..65f9afe94 --- /dev/null +++ b/stock_cubiscan/models/__init__.py @@ -0,0 +1,2 @@ +from . import stock +from . import cubiscan diff --git a/stock_cubiscan/models/cubiscan.py b/stock_cubiscan/models/cubiscan.py new file mode 100644 index 000000000..01b383662 --- /dev/null +++ b/stock_cubiscan/models/cubiscan.py @@ -0,0 +1,88 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from ipaddress import ip_address + +from cubiscan.cubiscan import CubiScan +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + + +class CubiscanDevice(models.Model): + _name = 'cubiscan.device' + _description = 'Cubiscan Device' + _order = 'warehouse_id, name' + + name = fields.Char('Name', required=True) + device_address = fields.Char('Device IP Address', required=True) + port = fields.Integer('Port', required=True) + timeout = fields.Integer( + 'Timeout', help="Timeout in seconds", required=True, default=30 + ) + warehouse_id = fields.Many2one('stock.warehouse', 'Warehouse') + state = fields.Selection( + [('not_ready', 'Not Ready'), ('ready', 'Ready')], + default='not_ready', + readonly=True, + ) + + @api.multi + @api.constrains('device_address', 'port') + def _check_connection_infos(self): + self.ensure_one() + if not 1 <= self.port <= 65535: + raise ValidationError('Port must be in range 1-65535') + + try: + ip_address(self.device_address) + except ValueError: + raise ValidationError('Device IP Address is not valid') + + @api.multi + def copy(self, default=None): + if not default: + default = dict() + default['state'] = 'not_ready' + return super().copy(default) + + @api.multi + def open_wizard(self): + self.ensure_one() + return { + 'name': _('CubiScan Wizard'), + 'res_model': 'cubiscan.wizard', + 'type': 'ir.actions.act_window', + 'view_id': False, + 'view_mode': 'form', + 'view_type': 'form', + 'context': {'default_device_id': self.id}, + 'target': 'fullscreen', + 'flags': { + 'headless': True, + 'form_view_initial_mode': 'edit', + 'no_breadcrumbs': True, + }, + } + + @api.multi + def _get_interface(self): + self.ensure_one() + return CubiScan(self.device_address, self.port, self.timeout) + + @api.multi + def test_device(self): + for device in self: + res = device._get_interface().test() + if res and 'error' not in res and device.state == 'not_ready': + device.state = 'ready' + elif res and 'error' in res and device.state == 'ready': + device.state = 'not_ready' + + @api.multi + def get_measure(self): + self.ensure_one() + if self.state != 'ready': + raise UserError( + "Device is not ready. Please use the 'Test' button before using the device." + ) + return self._get_interface().measure() diff --git a/stock_cubiscan/models/stock.py b/stock_cubiscan/models/stock.py new file mode 100644 index 000000000..9867091a7 --- /dev/null +++ b/stock_cubiscan/models/stock.py @@ -0,0 +1,36 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class StockWarehouse(models.Model): + _inherit = 'stock.warehouse' + + cubiscan_device_ids = fields.One2many( + 'cubiscan.device', 'warehouse_id', string="Cubiscan Devices" + ) + + +class ProductPackaging(models.Model): + _inherit = "product.packaging" + + # TODO move these in an addon. Warning: + # * 'delivery' defines the same fields and add them in the 'Delivery + # Packages' view + # * our put-away modules (wms/stock_putaway_storage_type_strategy) will + # need these fields as well + max_weight = fields.Float() + length = fields.Integer() + width = fields.Integer() + height = fields.Integer() + volume = fields.Float( + compute='_compute_volume', readonly=True, store=False + ) + + @api.depends('length', 'width', 'height') + def _compute_volume(self): + for pack in self: + pack.volume = ( + pack.length * pack.width * pack.height + ) / 1000.0 ** 3 diff --git a/stock_cubiscan/security/ir.model.access.csv b/stock_cubiscan/security/ir.model.access.csv new file mode 100644 index 000000000..b2358bd70 --- /dev/null +++ b/stock_cubiscan/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_cubiscan_device_inventory_manager,cubiscan.device.inventory.manager,stock_cubiscan.model_cubiscan_device,stock.group_stock_manager,1,1,1,1 +access_cubiscan_device_inventory_user,cubiscan.device.inventory.user,stock_cubiscan.model_cubiscan_device,stock.group_stock_user,1,0,0,0 diff --git a/stock_cubiscan/static/src/scss/cubiscan_wizard.scss b/stock_cubiscan/static/src/scss/cubiscan_wizard.scss new file mode 100644 index 000000000..17872685e --- /dev/null +++ b/stock_cubiscan/static/src/scss/cubiscan_wizard.scss @@ -0,0 +1,33 @@ +.o_web_client.o_fullscreen { + .o_form_view.cubiscan_wizard { + font-size: 16px; + + @include media-breakpoint-up(x1) { + font-size: 18px; + } + + .btn { + font-size: 1em; + padding: 1em; + margin: 0 5px; + } + + .o_data_cell:not(.o_list_button) { + padding: 0.75em; + font-size: 1.5em; + margin: 0 5px; + } + + .table-responsive { + overflow: hidden; + } + + .o_field_many2one input.o_input { + font-size: 1.5em; + } + + .o_form_statusbar { + display: none; + } + } +} diff --git a/stock_cubiscan/tests/__init__.py b/stock_cubiscan/tests/__init__.py new file mode 100644 index 000000000..110afeaef --- /dev/null +++ b/stock_cubiscan/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_cubiscan +from . import test_cubiscan_wizard diff --git a/stock_cubiscan/tests/test_cubiscan.py b/stock_cubiscan/tests/test_cubiscan.py new file mode 100644 index 000000000..c4ec4e70c --- /dev/null +++ b/stock_cubiscan/tests/test_cubiscan.py @@ -0,0 +1,41 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from cubiscan.cubiscan import CubiScan +from mock import patch +from odoo.exceptions import ValidationError +from odoo.tests.common import SavepointCase + + +class TestCubiscan(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.device_obj = cls.env['cubiscan.device'] + + def test_constraints(self): + vals = {'name': 'Test Device'} + + # Wrong port + vals.update({'device_address': '10.10.0.42', 'port': -42}) + with self.assertRaises(ValidationError): + self.device_obj.create(vals) + + # Wrong IP + vals.update({'device_address': '999.261.42.42', 'port': 5982}) + with self.assertRaises(ValidationError): + self.device_obj.create(vals) + + def test_device_test(self): + vals = { + 'name': 'Test Device', + 'device_address': '10.10.0.42', + 'port': 5982, + } + device = self.device_obj.create(vals) + self.assertEquals(device.state, 'not_ready') + + with patch.object(CubiScan, '_make_request') as mocked: + mocked.return_value = {'identifier': 42} + device.test_device() + + self.assertEquals(device.state, 'ready') diff --git a/stock_cubiscan/tests/test_cubiscan_wizard.py b/stock_cubiscan/tests/test_cubiscan_wizard.py new file mode 100644 index 000000000..70579a5df --- /dev/null +++ b/stock_cubiscan/tests/test_cubiscan_wizard.py @@ -0,0 +1,110 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from cubiscan.cubiscan import CubiScan +from mock import patch +from odoo.tests.common import SavepointCase + + +class TestCubiscanWizard(SavepointCase): + @staticmethod + def get_measure_result(length, width, height, weight): + return { + 'origin': '1', + 'location': 'dev001', + 'length': (length, None), + 'width': (width, None), + 'height': (height, None), + 'space_metric': True, + 'weight': (weight, None), + 'dim_weight': (weight, None), + 'weight_metric': True, + 'factor': 1, + 'intl_unit': True, + } + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + + cls.device_obj = cls.env['cubiscan.device'] + cls.cs_wizard = cls.env['cubiscan.wizard'] + + cls.device = cls.device_obj.create( + { + 'name': 'Test Device', + 'device_address': '192.168.21.42', + 'port': 4242, + 'state': 'ready', + } + ) + + cls.wizard = cls.cs_wizard.create({'device_id': cls.device.id}) + + cls.product_1 = cls.env.ref( + 'product.product_product_6' + ).product_tmpl_id + cls.product_2 = cls.env.ref( + 'product.product_product_7' + ).product_tmpl_id + + cls.product_1.barcode = '424242' + + def test_product_onchange(self): + self.wizard.product_id = self.product_1.id + + self.assertEqual(len(self.wizard.line_ids), 0) + self.wizard.onchange_product_id() + self.assertEqual(len(self.wizard.line_ids), 5) + + def test_product_onchange_barcode(self): + self.assertFalse(self.wizard.product_id) + self.assertFalse(self.wizard.line_ids) + + self.wizard.on_barcode_scanned('424242') + + self.assertEqual(self.wizard.product_id, self.product_1) + self.assertEqual(len(self.wizard.line_ids), 5) + + def test_cubiscan_measures(self): + self.wizard.product_id = self.product_1.id + self.wizard.onchange_product_id() + + with patch.object(CubiScan, '_make_request') as request: + for idx, line in enumerate(self.wizard.line_ids): + request.return_value = TestCubiscanWizard.get_measure_result( + 2 ** idx, 1, 1, 2 ** idx + ) + line.cubiscan_measure() + self.assertEqual( + line.read( + ['length', 'width', 'height', 'max_weight', 'volume'] + )[0], + { + 'id': line.id, + 'length': (2 ** idx) * 1000, + 'width': 1000, + 'height': 1000, + 'max_weight': 2.0 ** idx, + 'volume': 2.0 ** idx, + }, + ) + + self.wizard.action_save() + + packagings = self.product_1.packaging_ids + self.assertEqual(len(packagings), 5) + for idx, packaging in enumerate(packagings): + self.assertEqual( + packaging.read( + ['length', 'width', 'height', 'max_weight', 'volume'] + )[0], + { + 'id': packaging.id, + 'length': (2 ** idx) * 1000, + 'width': 1000, + 'height': 1000, + 'max_weight': 2.0 ** idx, + 'volume': 2.0 ** idx, + }, + ) diff --git a/stock_cubiscan/views/assets.xml b/stock_cubiscan/views/assets.xml new file mode 100644 index 000000000..36dd76c40 --- /dev/null +++ b/stock_cubiscan/views/assets.xml @@ -0,0 +1,8 @@ + + + + diff --git a/stock_cubiscan/views/cubiscan_view.xml b/stock_cubiscan/views/cubiscan_view.xml new file mode 100644 index 000000000..93e9131b2 --- /dev/null +++ b/stock_cubiscan/views/cubiscan_view.xml @@ -0,0 +1,55 @@ + + + + cubiscan.device.form + cubiscan.device + +
+
+
+ + + + + + + + + + + + + +
+
+
+ + + cubiscan.device.tree + cubiscan.device + + + + + + + + + + + + + + CubiScan Devices + cubiscan.device + form + tree,form + + + +
diff --git a/stock_cubiscan/views/product_packaging_views.xml b/stock_cubiscan/views/product_packaging_views.xml new file mode 100644 index 000000000..676762eb8 --- /dev/null +++ b/stock_cubiscan/views/product_packaging_views.xml @@ -0,0 +1,24 @@ + + + + + + product.packaging.form.view + product.packaging + + + + + + + + + + + + + + + diff --git a/stock_cubiscan/wizard/__init__.py b/stock_cubiscan/wizard/__init__.py new file mode 100644 index 000000000..d1937e573 --- /dev/null +++ b/stock_cubiscan/wizard/__init__.py @@ -0,0 +1 @@ +from . import cubiscan_wizard diff --git a/stock_cubiscan/wizard/cubiscan_wizard.py b/stock_cubiscan/wizard/cubiscan_wizard.py new file mode 100644 index 000000000..6e7b8c4e0 --- /dev/null +++ b/stock_cubiscan/wizard/cubiscan_wizard.py @@ -0,0 +1,159 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import _, api, fields, models + + +class CubiscanWizard(models.TransientModel): + _name = 'cubiscan.wizard' + _inherit = 'barcodes.barcode_events_mixin' + _description = 'Cubiscan Wizard' + _rec_name = 'device_id' + + PACKAGING_UNITS = ['Unit', 'kfVE', 'DhVE', 'KrVE', 'PAL'] + + device_id = fields.Many2one('cubiscan.device', readonly=True) + product_id = fields.Many2one('product.template') + line_ids = fields.One2many('cubiscan.wizard.line', 'wizard_id') + + @api.onchange('product_id') + def onchange_product_id(self): + if self.product_id: + to_create = [] + for seq, name in enumerate(self.PACKAGING_UNITS): + pack = self.product_id.packaging_ids.filtered( + lambda rec: rec.name == name + ) + vals = { + 'wizard_id': self.id, + 'sequence': seq + 1, + 'name': name, + } + if pack: + vals.update( + { + 'qty': pack.qty, + 'max_weight': pack.max_weight, + 'length': pack.length, + 'width': pack.width, + 'height': pack.height, + 'barcode': pack.barcode, + } + ) + to_create.append(vals) + recs = self.env['cubiscan.wizard.line'].create(to_create) + self.line_ids = [(6, 0, recs.ids)] + else: + self.line_ids = [(5, 0, 0)] + + @api.multi + def action_reopen_fullscreen(self): + # Action to reopen wizard in fullscreen (e.g. after page refresh) + self.ensure_one() + res = self.device_id.open_wizard() + res['res_id'] = self.id + return res + + def action_search_barcode(self): + return { + "type": "ir.actions.act_window", + "res_model": "cubiscan.wizard.barcode", + "view_mode": "form", + "name": _("Barcode"), + "target": "new", + } + + @api.multi + def on_barcode_scanned(self, barcode): + self.ensure_one() + prod = self.env['product.template'].search([('barcode', '=', barcode)]) + self.product_id = prod + self.onchange_product_id() + + @api.multi + def action_save(self): + self.ensure_one() + actions = [] + for line in self.line_ids: + vals = { + 'sequence': line.sequence, + 'name': line.name, + 'qty': line.qty, + 'max_weight': line.max_weight, + 'length': line.length, + 'width': line.width, + 'height': line.height, + 'barcode': line.barcode, + } + pack = self.product_id.packaging_ids.filtered( + lambda rec: rec.name == line.name + ) + if pack: + actions.append((1, pack.id, vals)) + else: + actions.append((0, 0, vals)) + self.product_id.packaging_ids = actions + + @api.multi + def action_close(self): + self.ensure_one() + action = self.env.ref( + 'stock_cubiscan.action_cubiscan_device_form' + ).read()[0] + action.update( + { + 'res_id': self.device_id.id, + 'target': 'main', + 'views': [ + ( + self.env.ref( + 'stock_cubiscan.view_cubiscan_device_form' + ).id, + 'form', + ) + ], + 'flags': {'headless': False, 'clear_breadcrumbs': True}, + } + ) + return action + + +class CubiscanWizardLine(models.TransientModel): + _name = 'cubiscan.wizard.line' + _description = 'Cubiscan Wizard Line' + _order = 'sequence' + + wizard_id = fields.Many2one('cubiscan.wizard') + sequence = fields.Integer() + name = fields.Char("Packaging", readonly=True) + qty = fields.Float("Quantity") + max_weight = fields.Float("Weight (kg)", readonly=True) + length = fields.Integer("Length (mm)", readonly=True) + width = fields.Integer("Width (mm)", readonly=True) + height = fields.Integer("Height (mm)", readonly=True) + volume = fields.Float( + "Volume (m3)", compute='_compute_volume', readonly=True, store=False + ) + barcode = fields.Char("GTIN") + + @api.depends('length', 'width', 'height') + def _compute_volume(self): + for line in self: + line.volume = ( + line.length * line.width * line.height + ) / 1000.0 ** 3 + + @api.multi + def cubiscan_measure(self): + self.ensure_one() + measures = self.wizard_id.device_id.get_measure() + measures = { + k: ( + v[0] if k in ['length', 'width', 'height', 'weight'] else False + ) + for k, v in measures.items() + } + weight = measures.pop('weight') + measures = {k: int(v * 1000) for k, v in measures.items()} + measures['max_weight'] = weight + self.write(measures) diff --git a/stock_cubiscan/wizard/cubiscan_wizard.xml b/stock_cubiscan/wizard/cubiscan_wizard.xml new file mode 100644 index 000000000..0a702271c --- /dev/null +++ b/stock_cubiscan/wizard/cubiscan_wizard.xml @@ -0,0 +1,40 @@ + + + + cubiscan.wizard.form + cubiscan.wizard + +
+
+
+ + + + + + + + + + + + + + + + + + +