Merge PR #826 into 13.0

Signed-off-by simahawk
This commit is contained in:
OCA-git-bot
2020-03-30 08:58:51 +00:00
24 changed files with 785 additions and 0 deletions

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
cubiscan
mock

View File

@@ -0,0 +1 @@
../../../../stock_cubiscan

View File

@@ -0,0 +1,6 @@
import setuptools
setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)

View File

@@ -0,0 +1,2 @@
from . import models
from . import wizard

View File

@@ -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",
}

View File

@@ -0,0 +1,2 @@
from . import stock
from . import cubiscan

View File

@@ -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()

View File

@@ -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.",
)
]

View File

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

View File

@@ -0,0 +1 @@
* Patrick Tombez <patrick.tombez@camptocamp.com>

View File

@@ -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/

View File

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

View File

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

View File

@@ -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()

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_cubiscan_device_inventory_manager cubiscan.device.inventory.manager stock_cubiscan.model_cubiscan_device stock.group_stock_manager 1 1 1 1
3 access_cubiscan_device_inventory_user cubiscan.device.inventory.user stock_cubiscan.model_cubiscan_device stock.group_stock_user 1 0 0 0

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,2 @@
from . import test_cubiscan
from . import test_cubiscan_wizard

View File

@@ -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")

View File

@@ -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,
},
)

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" ?>
<odoo>
<template
id="cubiscan_assets"
name="cubiscan.assets"
inherit_id="web.assets_backend"
>
<xpath expr="." position="inside">
<link
rel="stylesheet"
type="text/scss"
href="/stock_cubiscan/static/src/scss/cubiscan_wizard.scss"
/>
</xpath>
</template>
</odoo>

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" ?>
<odoo>
<record model="ir.ui.view" id="view_cubiscan_device_form">
<field name="name">cubiscan.device.form</field>
<field name="model">cubiscan.device</field>
<field name="arch" type="xml">
<form>
<header>
<button
type="object"
name="open_wizard"
string="Wizard"
class="oe_highlight"
/>
<button
type="object"
name="test_device"
string="Test Device"
attrs="{'invisible': [('id','=',False)]}"
/>
<field name="state" widget="statusbar" />
</header>
<sheet>
<field name="id" invisible="1" />
<group>
<group name="cubiscan">
<field name="name" />
<field name="device_address" />
<field name="port" />
<field name="timeout" />
<field name="warehouse_id" />
</group>
<group />
</group>
</sheet>
</form>
</field>
</record>
<record model="ir.ui.view" id="view_cubiscan_device_tree">
<field name="name">cubiscan.device.tree</field>
<field name="model">cubiscan.device</field>
<field name="arch" type="xml">
<tree
decoration-warning="state == 'not_ready'"
decoration-success="state == 'ready'"
>
<field name="name" />
<field name="device_address" />
<field name="port" />
<field name="timeout" />
<field name="warehouse_id" />
<field name="state" invisible="1" />
</tree>
</field>
</record>
<record id="action_cubiscan_device_form" model="ir.actions.act_window">
<field name="name">CubiScan Devices</field>
<field name="res_model">cubiscan.device</field>
<field name="view_mode">tree,form</field>
</record>
<menuitem
action="action_cubiscan_device_form"
id="menu_action_cubiscan_device_form"
parent="stock.menu_warehouse_config"
sequence="2"
/>
</odoo>

View File

@@ -0,0 +1 @@
from . import cubiscan_wizard

View File

@@ -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)

View File

@@ -0,0 +1,92 @@
<?xml version="1.0" ?>
<odoo>
<record id="view_cubiscan_wizard" model="ir.ui.view">
<field name="name">cubiscan.wizard.form</field>
<field name="model">cubiscan.wizard</field>
<field name="arch" type="xml">
<form class="cubiscan_wizard">
<header>
<button
name="action_reopen_fullscreen"
string="Fullscreen"
type="object"
/>
</header>
<group>
<group>
<label for="device_id" />
<field
name="device_id"
nolabel="1"
options="{'no_open': True, 'no_create_edit': True}"
/>
<label for="product_id" />
<field
name="product_id"
nolabel="1"
options="{'no_open': True, 'no_create_edit': True}"
/>
<field
name="_barcode_scanned"
widget="barcode_handler"
invisible="1"
/>
</group>
<group />
</group>
<separator />
<field name="line_ids">
<tree editable="bottom" create="0" delete="0">
<field name="sequence" invisible="1" />
<field name="required" invisible="1" />
<field name="name" />
<field name="qty" />
<field
name="max_weight"
options="{'bg_color': 'lightcoral: max_weight == 0.0 and required'}"
/>
<field
name="lngth"
options="{'bg_color': 'lightcoral: lngth == 0.0 and required'}"
/>
<field
name="width"
options="{'bg_color': 'lightcoral: width == 0.0 and required'}"
/>
<field
name="height"
options="{'bg_color': 'lightcoral: height == 0.0 and required'}"
/>
<field
name="volume"
options="{'bg_color': 'lightcoral: volume == 0.0 and required'}"
/>
<button
name="cubiscan_measure"
type="object"
string="CubiScan"
class="btn btn-warning"
/>
<field name="barcode" />
</tree>
</field>
<footer>
<button
name="action_save"
type="object"
icon="fa-check"
class="btn btn-primary"
string="Save"
/>
<button
name="action_close"
type="object"
icon="fa-times"
class="btn btn-danger"
string="Close"
/>
</footer>
</form>
</field>
</record>
</odoo>