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