diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..a6b412c3a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +cubiscan +mock diff --git a/setup/stock_cubiscan/odoo/addons/stock_cubiscan b/setup/stock_cubiscan/odoo/addons/stock_cubiscan new file mode 120000 index 000000000..7771cae90 --- /dev/null +++ b/setup/stock_cubiscan/odoo/addons/stock_cubiscan @@ -0,0 +1 @@ +../../../../stock_cubiscan \ No newline at end of file diff --git a/setup/stock_cubiscan/setup.py b/setup/stock_cubiscan/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/stock_cubiscan/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) 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..ddee1889d --- /dev/null +++ b/stock_cubiscan/__manifest__.py @@ -0,0 +1,27 @@ +# 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": "13.0.1.0.0", + "category": "Warehouse", + "author": "Camptocamp, Odoo Community Association (OCA)", + "license": "AGPL-3", + "depends": [ + "barcodes", + "stock", + "web_tree_dynamic_colored_field", + "product_packaging_dimension", + "product_packaging_type_required", + ], + "external_dependencies": {"python": ["cubiscan"]}, + "website": "https://github.com/OCA/stock-logistics-warehouse", + "data": [ + "views/assets.xml", + "views/cubiscan_view.xml", + "wizard/cubiscan_wizard.xml", + "security/ir.model.access.csv", + ], + "installable": True, + "development_status": "Alpha", +} 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..8966f3895 --- /dev/null +++ b/stock_cubiscan/models/cubiscan.py @@ -0,0 +1,98 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +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, + copy=False, + ) + + @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")) + + 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", + "context": {"default_device_id": self.id}, + "target": "fullscreen", + "flags": { + "withControlPanel": False, + "form_view_initial_mode": "edit", + "no_breadcrumbs": True, + }, + } + + def _get_interface_client_args(self): + """Prepare the arguments to instanciate the CubiScan client + + Can be overriden to change the parameters. + + Example, adding a ssl certificate:: + + args, kwargs = super()._get_interface_client_args() + ctx = SSL.create_default_context() + ctx.load_cert_chain("/usr/lib/ssl/certs/my_cert.pem") + kwargs['ssl'] = ctx + return args, kwargs + + Returns a 2 items tuple with: (args, kwargs) where args + is a list and kwargs a dict. + """ + return ([self.device_address, self.port, self.timeout], {}) + + def _get_interface(self): + """Return the CubiScan client + + Can be overrided to customize the way it is instanciated + """ + self.ensure_one() + args, kwargs = self._get_interface_client_args() + return CubiScan(*args, **kwargs) + + def test_device(self): + """Check connection with the Cubiscan device""" + 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" + + def get_measure(self): + """Return a measure from the Cubiscan device""" + 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..e0ce1e610 --- /dev/null +++ b/stock_cubiscan/models/stock.py @@ -0,0 +1,26 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import 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" + # FIXME: move this constraint in product_packaging_type + # https://github.com/OCA/product-attribute/tree/13.0/product_packaging_type + _sql_constraints = [ + ( + "product_packaging_type_unique", + "unique (product_id, packaging_type_id)", + "It is forbidden to have different packagings " + "with the same type for a given product.", + ) + ] diff --git a/stock_cubiscan/readme/CONFIGURE.rst b/stock_cubiscan/readme/CONFIGURE.rst new file mode 100644 index 000000000..f94238301 --- /dev/null +++ b/stock_cubiscan/readme/CONFIGURE.rst @@ -0,0 +1,4 @@ +The first step is to configure the Packaging Types (Pallet, Box, ...) in Inventory > Configuration > Product Packaging Types. + +Configure the Cubiscan device in Inventory > Configuration > Cubiscan Devices. +Use the "Test Device" to check the connection with the hardware. diff --git a/stock_cubiscan/readme/CONTRIBUTORS.rst b/stock_cubiscan/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..78f10eddd --- /dev/null +++ b/stock_cubiscan/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Patrick Tombez diff --git a/stock_cubiscan/readme/DESCRIPTION.rst b/stock_cubiscan/readme/DESCRIPTION.rst new file mode 100644 index 000000000..984464fcb --- /dev/null +++ b/stock_cubiscan/readme/DESCRIPTION.rst @@ -0,0 +1,5 @@ +Cubiscan_ are dimensioners for cubing and weighing in warehouses. +This module implements the communication with the dimensioners as well +as a screen to measure and weight packaging of the products. + +.. _Cubiscan: https://cubiscan.com/ diff --git a/stock_cubiscan/readme/ROADMAP.rst b/stock_cubiscan/readme/ROADMAP.rst new file mode 100644 index 000000000..e3e052ae0 --- /dev/null +++ b/stock_cubiscan/readme/ROADMAP.rst @@ -0,0 +1,3 @@ +* The UI could get some improvements +* Being able to open the Cubiscan screen from a product would be nice +* The wizard should allow to set weight and size for a single unit in addition to the packaging diff --git a/stock_cubiscan/readme/USAGE.rst b/stock_cubiscan/readme/USAGE.rst new file mode 100644 index 000000000..aecdada67 --- /dev/null +++ b/stock_cubiscan/readme/USAGE.rst @@ -0,0 +1,5 @@ +Use the "Wizard" button on a Cubiscan device to open the screen and take +measurements. + +For developers: a script in the directory ``scripts/cubiscan_stub.py`` allows +to simulate a Cubiscan server and send random measurements. diff --git a/stock_cubiscan/scripts/cubiscan_stub.py b/stock_cubiscan/scripts/cubiscan_stub.py new file mode 100755 index 000000000..eb64be756 --- /dev/null +++ b/stock_cubiscan/scripts/cubiscan_stub.py @@ -0,0 +1,57 @@ +#!/usr/bin/python3 +# pylint: disable=print-used,attribute-deprecated +"""Stub a Cubiscan server + +Allow testing the connection to Cubiscan from Odoo +without real hardware. +""" + +import asyncio +import random + + +@asyncio.coroutine +def handle_cubiscan(reader, writer): + message = yield from reader.readline() + addr = writer.get_extra_info("peername") + + print("Received {!r} from {!r}".format(message, addr)) + # print("Expecting {!r} from {!r}".format(message, addr)) + print("{!r}".format(message == b"\x02M\x03\r\n")) + if message == b"\x02M\x03\r\n": + length = random.uniform(0, 1000) + width = random.uniform(0, 1000) + height = random.uniform(0, 1000) + weight = random.uniform(0, 10000) + answer = ( + b"\x02MAH123456,L%05.1f,W%05.1f,H%05.1f,M,K%06.1f,D%06.1f,M,F0000,I\x03\r\n" + % (length, width, height, weight, weight) + ) + else: + answer = b"\x02\x03\r\n" + print("Send: {!r}".format(answer)) + writer.write(answer) + yield from writer.drain() + + +def main(): + loop = asyncio.get_event_loop() + coro = asyncio.start_server(handle_cubiscan, "0.0.0.0", 9876, loop=loop) + server = loop.run_until_complete(coro) + + # Serve requests until Ctrl+C is pressed + addr = server.sockets[0].getsockname() + print("Serving on {}".format(addr)) + try: + loop.run_forever() + except KeyboardInterrupt: + pass + + # Close the server + server.close() + loop.run_until_complete(server.wait_closed()) + loop.close() + + +if __name__ == "__main__": + main() 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..feafda1b5 --- /dev/null +++ b/stock_cubiscan/tests/test_cubiscan.py @@ -0,0 +1,33 @@ +# 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) + + 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..321fbdbed --- /dev/null +++ b/stock_cubiscan/tests/test_cubiscan_wizard.py @@ -0,0 +1,122 @@ +# 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"] + PackType = cls.env["product.packaging.type"] + pack_type_data = [ + ("unit", 2, 0, 0), + ("internal", 3, 1, 0), + ("retail", 10, 1, 1), + ("transport", 20, 1, 1), + ("pallet", 30, 1, 1), + ] + for name, seq, gtin, req in pack_type_data: + PackType.create( + { + "name": name, + "code": name.upper(), + "sequence": seq, + "has_gtin": gtin, + "required": req, + } + ) + + 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") + cls.product_2 = cls.env.ref("product.product_product_7") + + cls.product_1.barcode = "424242" + PackType.cron_check_create_required_packaging() + + 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), 6) + + 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), 6) + + 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(["lngth", "width", "height", "max_weight", "volume"])[0], + { + "id": line.id, + "lngth": (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.sorted() + self.assertEqual(len(packagings), 6) + for idx, packaging in enumerate(packagings): + self.assertEqual( + packaging.read(["lngth", "width", "height", "max_weight", "volume"])[0], + { + "id": packaging.id, + "lngth": (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..e821fd123 --- /dev/null +++ b/stock_cubiscan/views/assets.xml @@ -0,0 +1,16 @@ + + + + diff --git a/stock_cubiscan/views/cubiscan_view.xml b/stock_cubiscan/views/cubiscan_view.xml new file mode 100644 index 000000000..689ddd592 --- /dev/null +++ b/stock_cubiscan/views/cubiscan_view.xml @@ -0,0 +1,67 @@ + + + + cubiscan.device.form + cubiscan.device + +
+
+
+ + + + + + + + + + + + + +
+
+
+ + cubiscan.device.tree + cubiscan.device + + + + + + + + + + + + + CubiScan Devices + cubiscan.device + tree,form + + +
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..df9a5779a --- /dev/null +++ b/stock_cubiscan/wizard/cubiscan_wizard.py @@ -0,0 +1,177 @@ +# 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): + """This wizard is used to show a screen showing Cubiscan information + + It is opened in a headless view (no breadcrumb, no menus, fullscreen). + """ + + _name = "cubiscan.wizard" + _inherit = "barcodes.barcode_events_mixin" + _description = "Cubiscan Wizard" + _rec_name = "device_id" + + device_id = fields.Many2one("cubiscan.device", readonly=True) + product_id = fields.Many2one("product.product", domain=[("type", "=", "product")]) + line_ids = fields.One2many("cubiscan.wizard.line", "wizard_id") + + @api.onchange("product_id") + def onchange_product_id(self): + if self.product_id: + to_create = [] + packaging_types = self.env["product.packaging.type"].search([]) + for seq, pack_type in enumerate(packaging_types): + pack = self.env["product.packaging"].search( + [ + ("product_id", "=", self.product_id.id), + ("packaging_type_id", "=", pack_type.id), + ], + limit=1, + ) + vals = { + "wizard_id": self.id, + "sequence": seq + 1, + "name": pack_type.name, + "qty": 0, + "max_weight": 0, + "lngth": 0, + "width": 0, + "height": 0, + "barcode": False, + "packaging_type_id": pack_type.id, + } + if pack: + vals.update( + { + "qty": pack.qty, + "max_weight": pack.max_weight, + "lngth": pack.lngth, + "width": pack.width, + "height": pack.height, + "barcode": pack.barcode, + "packaging_id": pack.id, + "packaging_type_id": pack_type.id, + } + ) + to_create.append(vals) + recs = self.env["cubiscan.wizard.line"].create(to_create) + self.line_ids = recs + else: + self.line_ids = [(5, 0, 0)] + + 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", + } + + def on_barcode_scanned(self, barcode): + self.ensure_one() + prod = self.env["product.product"].search([("barcode", "=", barcode)]) + self.product_id = prod + self.onchange_product_id() + + def action_save(self): + self.ensure_one() + actions = [] + for line in self.line_ids: + vals = { + "name": line.name, + "qty": line.qty, + "max_weight": line.max_weight, + "lngth": line.lngth, + "width": line.width, + "height": line.height, + "barcode": line.barcode, + "packaging_type_id": line.packaging_type_id.id, + } + pack = line.packaging_id + if pack: + actions.append((1, pack.id, vals)) + else: + actions.append((0, 0, vals)) + self.product_id.packaging_ids = actions + # reload lines + self.onchange_product_id() + + 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) + # this is not a typo: + # https://github.com/odoo/odoo/issues/41353#issuecomment-568037415 + lngth = fields.Integer("Length (mm)", readonly=True) + width = fields.Integer("Width (mm)", readonly=True) + height = fields.Integer("Height (mm)", readonly=True) + volume = fields.Float( + "Volume (m³)", + digits=(8, 4), + compute="_compute_volume", + readonly=True, + store=False, + ) + barcode = fields.Char("GTIN") + packaging_id = fields.Many2one( + "product.packaging", string="Packaging (rel)", readonly=True + ) + packaging_type_id = fields.Many2one( + "product.packaging.type", readonly=True, required=True + ) + required = fields.Boolean(related="packaging_type_id.required", readonly=True) + + @api.depends("lngth", "width", "height") + def _compute_volume(self): + for line in self: + line.volume = (line.lngth * line.width * line.height) / 1000.0 ** 3 + + def cubiscan_measure(self): + self.ensure_one() + measures = self.wizard_id.device_id.get_measure() + # measures are a tuple of 2 slots (measure, precision error), + # we only care about the measure for now + measures = { + "lngth": int(measures["length"][0] * 1000), + "width": int(measures["width"][0] * 1000), + "height": int(measures["height"][0] * 1000), + "max_weight": measures["weight"][0], + } + 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..f94f106ef --- /dev/null +++ b/stock_cubiscan/wizard/cubiscan_wizard.xml @@ -0,0 +1,92 @@ + + + + cubiscan.wizard.form + cubiscan.wizard + +
+
+
+ + + + + + + + + + + + + + + + + +