- The PosBox is an hardware adapter that allows you to use + The PosBox is a hardware adapter that allows you to use receipt printers and barcode scanners with Odoo's Point of Sale, version 8.0 or later. You can start an online free trial, - or download and install it yourself. + or download and install it yourself.
For more information on how to setup the Point of Sale with @@ -55,7 +55,10 @@ index_template = """ Wi-Fi can be configured by visiting the Wi-Fi configuration page.
- The PosBox software installed on this posbox is version 14, + If you need to grant remote debugging access to a developer, you can do it here. +
++ The PosBox software installed on this posbox is version 15, the posbox version number is independent from Odoo. You can upgrade the software on the upgrade page.
diff --git a/addons/hw_proxy/controllers/main.py b/addons/hw_proxy/controllers/main.py index 0a75d114edb..d7dada8c1e1 100644 --- a/addons/hw_proxy/controllers/main.py +++ b/addons/hw_proxy/controllers/main.py @@ -11,6 +11,8 @@ import subprocess import json import werkzeug import werkzeug.wrappers +from threading import Lock + _logger = logging.getLogger(__name__) @@ -30,6 +32,11 @@ BANNED_DEVICES = set([ # so that 'status' can return the status of all active drivers drivers = {} +# keep a list of RS-232 devices that have been recognized by a driver, +# so other drivers can skip them during probes +rs232_devices = {} # {'/path/to/device': 'driver'} +rs232_lock = Lock() # must be held to update `rs232_devices` + class Proxy(http.Controller): def get_status(self): @@ -47,7 +54,7 @@ class Proxy(http.Controller): return True @http.route('/hw_proxy/status', type='http', auth='none', cors='*') - def status_http(self): + def status_http(self, debug=None, **kwargs): resp = """ @@ -95,6 +102,8 @@ class Proxy(http.Controller):The list of connected USB devices as seen by the posbox
""" + if debug is None: + resp += """(debug version)""" devices = commands.getoutput("lsusb").split('\n') count = 0 resp += "lsusb -v output:
++ %s ++ + """ % subprocess.check_output('lsusb -v', shell=True) + return request.make_response(resp,{ 'Cache-Control': 'no-cache', 'Content-Type': 'text/html; charset=utf-8', diff --git a/addons/hw_scale/controllers/main.py b/addons/hw_scale/controllers/main.py index 2437b7829ea..33f423e33a3 100644 --- a/addons/hw_scale/controllers/main.py +++ b/addons/hw_scale/controllers/main.py @@ -1,21 +1,20 @@ # -*- coding: utf-8 -*- import logging import os +import re import time -from os import listdir -from os.path import join -from threading import Thread, Lock -from select import select -from Queue import Queue, Empty -import openerp +from collections import namedtuple +from os import listdir +from threading import Thread, Lock + import openerp.addons.hw_proxy.controllers.main as hw_proxy from openerp import http -from openerp.http import request -from openerp.tools.translate import _ _logger = logging.getLogger(__name__) +DRIVER_NAME = 'scale' + try: import serial except ImportError: @@ -23,6 +22,99 @@ except ImportError: serial = None +def _toledo8217StatusParse(status): + """ Parse a scale's status, returning a `(weight, weight_info)` pair. """ + weight, weight_info = None, None + stat = ord(status[status.index('?') + 1]) + if stat == 0: + weight_info = 'ok' + else: + weight_info = [] + if stat & 1 : + weight_info.append('moving') + if stat & 1 << 1: + weight_info.append('over_capacity') + if stat & 1 << 2: + weight_info.append('negative') + weight = 0.0 + if stat & 1 << 3: + weight_info.append('outside_zero_capture_range') + if stat & 1 << 4: + weight_info.append('center_of_zero') + if stat & 1 << 5: + weight_info.append('net_weight') + return weight, weight_info + +ScaleProtocol = namedtuple( + 'ScaleProtocol', + "name baudrate bytesize stopbits parity timeout writeTimeout weightRegexp statusRegexp " + "statusParse commandTerminator commandDelay weightDelay newWeightDelay " + "weightCommand zeroCommand tareCommand clearCommand emptyAnswerValid autoResetWeight") + +# 8217 Mettler-Toledo (Weight-only) Protocol, as described in the scale's Service Manual. +# e.g. here: https://www.manualslib.com/manual/861274/Mettler-Toledo-Viva.html?page=51#manual +# Our recommended scale, the Mettler-Toledo "Ariva-S", supports this protocol on +# both the USB and RS232 ports, it can be configured in the setup menu as protocol option 3. +# We use the default serial protocol settings, the scale's settings can be configured in the +# scale's menu anyway. +Toledo8217Protocol = ScaleProtocol( + name='Toledo 8217', + baudrate=9600, + bytesize=serial.SEVENBITS, + stopbits=serial.STOPBITS_ONE, + parity=serial.PARITY_EVEN, + timeout=1, + writeTimeout=1, + weightRegexp="\x02\\s*([0-9.]+)N?\\r", + statusRegexp="\x02\\s*(\\?.)\\r", + statusParse=_toledo8217StatusParse, + commandDelay=0.2, + weightDelay=0.5, + newWeightDelay=0.2, + commandTerminator='', + weightCommand='W', + zeroCommand='Z', + tareCommand='T', + clearCommand='C', + emptyAnswerValid=False, + autoResetWeight=False, +) + +# The ADAM scales have their own RS232 protocol, usually documented in the scale's manual +# e.g at https://www.adamequipment.com/media/docs/Print%20Publications/Manuals/PDF/AZEXTRA/AZEXTRA-UM.pdf +# https://www.manualslib.com/manual/879782/Adam-Equipment-Cbd-4.html?page=32#manual +# Only the baudrate and label format seem to be configurable in the AZExtra series. +ADAMEquipmentProtocol = ScaleProtocol( + name='Adam Equipment', + baudrate=4800, + bytesize=serial.EIGHTBITS, + stopbits=serial.STOPBITS_ONE, + parity=serial.PARITY_NONE, + timeout=0.2, + writeTimeout=0.2, + weightRegexp=r"\s*([0-9.]+)kg", # LABEL format 3 + KG in the scale settings, but Label 1/2 should work + statusRegexp=None, + statusParse=None, + commandTerminator="\r\n", + commandDelay=0.2, + weightDelay=0.5, + newWeightDelay=5, # AZExtra beeps every time you ask for a weight that was previously returned! + # Adding an extra delay gives the operator a chance to remove the products + # before the scale starts beeping. Could not find a way to disable the beeps. + weightCommand='P', + zeroCommand='Z', + tareCommand='T', + clearCommand=None, # No clear command -> Tare again + emptyAnswerValid=True, # AZExtra does not answer unless a new non-zero weight has been detected + autoResetWeight=True, # AZExtra will not return 0 after removing products +) + + +SCALE_PROTOCOLS = ( + Toledo8217Protocol, + ADAMEquipmentProtocol, # must be listed last, as it supports no probing! +) + class Scale(Thread): def __init__(self): Thread.__init__(self) @@ -33,8 +125,8 @@ class Scale(Thread): self.weight = 0 self.weight_info = 'ok' self.device = None - self.probed_device_paths = [] self.path_to_scale = '' + self.protocol = None def lockedstart(self): with self.lock: @@ -42,15 +134,15 @@ class Scale(Thread): self.daemon = True self.start() - def set_status(self, status, message = None): + def set_status(self, status, message=None): if status == self.status['status']: - if message != None and message != self.status['messages'][-1]: + if message is not None and message != self.status['messages'][-1]: self.status['messages'].append(message) if status == 'error' and message: - _logger.error('Scale Error: '+message) + _logger.error('Scale Error: '+ message) elif status == 'disconnected' and message: - _logger.warning('Disconnected Scale: '+message) + _logger.warning('Disconnected Scale: '+ message) else: self.status['status'] = status if message: @@ -59,61 +151,105 @@ class Scale(Thread): self.status['messages'] = [] if status == 'error' and message: - _logger.error('Scale Error: '+message) + _logger.error('Scale Error: '+ message) elif status == 'disconnected' and message: _logger.info('Disconnected Scale: %s', message) def _get_raw_response(self, connection): - response = "" + answer = [] while True: - byte = connection.read(1) - - if byte: - response += byte + char = connection.read(1) # may return `bytes` or `str` + if not char: + break else: - return response + answer.append(char) + return ''.join(answer) + + def _parse_weight_answer(self, protocol, answer): + """ Parse a scale's answer to a weighing request, returning + a `(weight, weight_info, status)` pair. + """ + weight, weight_info, status = None, None, None + try: + _logger.debug("Parsing weight [%r]", answer) + if not answer and protocol.emptyAnswerValid: + # Some scales do not return the same value again, but we + # should not clear the weight data, POS may still be reading it + return weight, weight_info, status + + if protocol.statusRegexp and re.search(protocol.statusRegexp, answer): + # parse status to set weight_info - we'll try weighing again later + weight, weight_info = protocol.statusParse(answer) + else: + match = re.search(protocol.weightRegexp, answer) + if match: + weight_text = match.group(1) + try: + weight = float(weight_text) + _logger.info('Weight: %s', weight) + except ValueError: + _logger.exception("Cannot parse weight [%r]", weight_text) + status = 'Invalid weight, please power-cycle the scale' + else: + _logger.error("Cannot parse scale answer [%r]", answer) + status = 'Invalid scale answer, please power-cycle the scale' + except Exception as e: + _logger.exception("Cannot parse scale answer [%r]", answer) + status = ("Could not weigh on scale %s with protocol %s: %s" % + (self.path_to_scale, protocol.name, e)) + return weight, weight_info, status def get_device(self): - try: - if not os.path.exists(self.input_dir): - self.set_status('disconnected','Scale Not Found') - return None - devices = [ device for device in listdir(self.input_dir)] + if self.device: + return self.device + + with hw_proxy.rs232_lock: + try: + if not os.path.exists(self.input_dir): + self.set_status('disconnected', 'No RS-232 device found') + return None + + devices = [device for device in listdir(self.input_dir)] - if len(devices) > 0: for device in devices: + driver = hw_proxy.rs232_devices.get(device) + if driver and driver != DRIVER_NAME: + # belongs to another driver + _logger.info('Ignoring %s, belongs to %s', device, driver) + continue path = self.input_dir + device - - # don't keep probing devices that are not a scale, - # only keep probing if in the past the device was - # confirmed to be a scale - if path not in self.probed_device_paths or path == self.path_to_scale: - _logger.debug('Probing: ' + path) + for protocol in SCALE_PROTOCOLS: + _logger.info('Probing %s with protocol %s', path, protocol) connection = serial.Serial(path, - baudrate = 9600, - bytesize = serial.SEVENBITS, - stopbits = serial.STOPBITS_ONE, - parity = serial.PARITY_EVEN, - timeout = 1, - writeTimeout = 1) - - connection.write("W") - self.probed_device_paths.append(path) - - if self._get_raw_response(connection): - _logger.debug(path + ' is scale') + baudrate=protocol.baudrate, + bytesize=protocol.bytesize, + stopbits=protocol.stopbits, + parity=protocol.parity, + timeout=1, # longer timeouts for probing + writeTimeout=1) # longer timeouts for probing + connection.write(protocol.weightCommand + protocol.commandTerminator) + time.sleep(protocol.commandDelay) + answer = self._get_raw_response(connection) + weight, weight_info, status = self._parse_weight_answer(protocol, answer) + if status: + _logger.info('Probing %s: no valid answer to protocol %s', path, protocol.name) + else: + _logger.info('Probing %s: answer looks ok for protocol %s', path, protocol.name) self.path_to_scale = path - self.set_status('connected','Connected to '+device) - connection.timeout = 0.02 - connection.writeTimeout = 0.02 + self.protocol = protocol + self.set_status( + 'connected', + 'Connected to %s with %s protocol' % (device, protocol.name) + ) + connection.timeout = protocol.timeout + connection.writeTimeout = protocol.writeTimeout + hw_proxy.rs232_devices[path] = DRIVER_NAME return connection - else: - _logger.debug('Already probed: ' + path) - self.set_status('disconnected','Scale Not Found') - return None - except Exception as e: - self.set_status('error',str(e)) + self.set_status('disconnected', 'No supported RS-232 scale found') + except Exception as e: + _logger.exception('Failed probing for scales') + self.set_status('error', 'Failed probing for scales: %s' % e) return None def get_weight(self): @@ -123,109 +259,110 @@ class Scale(Thread): def get_weight_info(self): self.lockedstart() return self.weight_info - + def get_status(self): self.lockedstart() return self.status def read_weight(self): with self.scalelock: - if self.device: - try: - self.device.write('W') - time.sleep(0.2) - answer = [] - - while True: - char = self.device.read(1) - if not char: - break - else: - answer.append(char) - - if '?' in answer: - stat = ord(answer[answer.index('?')+1]) - if stat == 0: - self.weight_info = 'ok' - else: - self.weight_info = [] - if stat & 1 : - self.weight_info.append('moving') - if stat & 1 << 1: - self.weight_info.append('over_capacity') - if stat & 1 << 2: - self.weight_info.append('negative') - self.weight = 0.0 - if stat & 1 << 3: - self.weight_info.append('outside_zero_capture_range') - if stat & 1 << 4: - self.weight_info.append('center_of_zero') - if stat & 1 << 5: - self.weight_info.append('net_weight') - else: - answer = answer[1:-1] - if 'N' in answer: - answer = answer[0:-1] - try: - self.weight = float(''.join(answer)) - except ValueError as v: - self.set_status('error','No data Received, please power-cycle the scale'); - self.device = None - - except Exception as e: - self.set_status('error',str(e)) + p = self.protocol + try: + self.device.write(p.weightCommand + p.commandTerminator) + time.sleep(p.commandDelay) + answer = self._get_raw_response(self.device) + weight, weight_info, status = self._parse_weight_answer(p, answer) + if status: + self.set_status('error', status) self.device = None + else: + if weight is not None: + self.weight = weight + if weight_info is not None: + self.weight_info = weight_info + except Exception as e: + self.set_status( + 'error', + "Could not weigh on scale %s with protocol %s: %s" % + (self.path_to_scale, p.name, e)) + self.device = None def set_zero(self): with self.scalelock: if self.device: - try: - self.device.write('Z') + try: + self.device.write(self.protocol.zeroCommand + self.protocol.commandTerminator) + time.sleep(self.protocol.commandDelay) except Exception as e: - self.set_status('error',str(e)) + self.set_status( + 'error', + "Could not zero scale %s with protocol %s: %s" % + (self.path_to_scale, self.protocol.name, e)) self.device = None def set_tare(self): with self.scalelock: if self.device: - try: - self.device.write('T') + try: + self.device.write(self.protocol.tareCommand + self.protocol.commandTerminator) + time.sleep(self.protocol.commandDelay) except Exception as e: - self.set_status('error',str(e)) + self.set_status( + 'error', + "Could not tare scale %s with protocol %s: %s" % + (self.path_to_scale, self.protocol.name, e)) self.device = None def clear_tare(self): with self.scalelock: if self.device: - try: - self.device.write('C') + p = self.protocol + try: + # if the protocol has no clear, we can just tare again + clearCommand = p.clearCommand or p.tareCommand + self.device.write(clearCommand + p.commandTerminator) + time.sleep(p.commandDelay) except Exception as e: - self.set_status('error',str(e)) + self.set_status( + 'error', + "Could not clear tare on scale %s with protocol %s: %s" % + (self.path_to_scale, p.name, e)) self.device = None def run(self): - self.device = None + self.device = None - while True: + while True: if self.device: + old_weight = self.weight self.read_weight() - time.sleep(0.15) + if self.weight != old_weight: + _logger.info('New Weight: %s, sleeping %ss', self.weight, self.protocol.newWeightDelay) + time.sleep(self.protocol.newWeightDelay) + if self.weight and self.protocol.autoResetWeight: + self.weight = 0 + else: + _logger.info('Weight: %s, sleeping %ss', self.weight, self.protocol.weightDelay) + time.sleep(self.protocol.weightDelay) else: with self.scalelock: self.device = self.get_device() if not self.device: - time.sleep(5) + # retry later to support "plug and play" + time.sleep(10) scale_thread = None if serial: scale_thread = Scale() - hw_proxy.drivers['scale'] = scale_thread + hw_proxy.drivers[DRIVER_NAME] = scale_thread class ScaleDriver(hw_proxy.Proxy): @http.route('/hw_proxy/scale_read/', type='json', auth='none', cors='*') def scale_read(self): if scale_thread: - return {'weight': scale_thread.get_weight(), 'unit':'kg', 'info': scale_thread.get_weight_info()} + return {'weight': scale_thread.get_weight(), + 'unit': 'kg', + 'info': scale_thread.get_weight_info()} return None @http.route('/hw_proxy/scale_zero/', type='json', auth='none', cors='*') @@ -245,5 +382,3 @@ class ScaleDriver(hw_proxy.Proxy): if scale_thread: scale_thread.clear_tare() return True - - diff --git a/addons/point_of_sale/tools/posbox/posbox_create_image.sh b/addons/point_of_sale/tools/posbox/posbox_create_image.sh index ba01ef09da5..1e4c9d1277c 100755 --- a/addons/point_of_sale/tools/posbox/posbox_create_image.sh +++ b/addons/point_of_sale/tools/posbox/posbox_create_image.sh @@ -13,6 +13,14 @@ file_exists() { [[ -f $1 ]]; } +require_command () { + type "$1" &> /dev/null || { echo "Command $1 is missing. Install it e.g. with 'apt-get install $1'. Aborting." >&2; exit 1; } +} + +require_command kpartx +require_command qemu-system-arm +require_command zerofree + __dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" __file="${__dir}/$(basename "${BASH_SOURCE[0]}")" __base="$(basename ${__file} .sh)" @@ -28,6 +36,7 @@ fi cp -a *raspbian*.img posbox.img CLONE_DIR="${OVERWRITE_FILES_BEFORE_INIT_DIR}/home/pi/odoo" +rm -rf "${CLONE_DIR}" mkdir "${CLONE_DIR}" git clone -b 8.0 --no-checkout --depth 1 https://github.com/odoo/odoo.git "${CLONE_DIR}" cd "${CLONE_DIR}" @@ -44,7 +53,7 @@ cd "${__dir}" USR_BIN="${OVERWRITE_FILES_BEFORE_INIT_DIR}/usr/bin/" mkdir -p "${USR_BIN}" cd "/tmp" -curl 'https://dl.ngrok.com/ngrok_2.0.19_linux_arm.zip' > ngrok.zip +curl 'https://bin.equinox.io/c/4VmDzA7iaHb/ngrok-stable-linux-arm.zip' > ngrok.zip unzip ngrok.zip rm ngrok.zip cd "${__dir}" @@ -68,6 +77,7 @@ START_OF_ROOT_PARTITION=$(fdisk -l posbox.img | tail -n 1 | awk '{print $2}') LOOP_MAPPER_PATH=$(kpartx -av posbox.img | tail -n 1 | cut -d ' ' -f 3) LOOP_MAPPER_PATH="/dev/mapper/${LOOP_MAPPER_PATH}" +sleep 5 # resize filesystem e2fsck -f "${LOOP_MAPPER_PATH}" # resize2fs requires clean fs diff --git a/addons/point_of_sale/tools/posbox/posbox_download_images.sh b/addons/point_of_sale/tools/posbox/posbox_download_images.sh index b500094058d..09bd79b2103 100755 --- a/addons/point_of_sale/tools/posbox/posbox_download_images.sh +++ b/addons/point_of_sale/tools/posbox/posbox_download_images.sh @@ -2,4 +2,4 @@ wget 'https://downloads.raspberrypi.org/raspbian_lite_latest' -O raspbian.img.zip unzip raspbian.img.zip -wget 'https://github.com/dhruvvyas90/qemu-rpi-kernel/raw/master/kernel-qemu-4.1.13-jessie' -O kernel-qemu +wget 'https://github.com/dhruvvyas90/qemu-rpi-kernel/raw/master/kernel-qemu-4.4.13-jessie' -O kernel-qemu diff --git a/addons/sale/sale.py b/addons/sale/sale.py index a96a42ed245..032e1381f91 100644 --- a/addons/sale/sale.py +++ b/addons/sale/sale.py @@ -54,7 +54,7 @@ class SaleOrder(models.Model): refund is not directly linked to the SO. """ for order in self: - invoice_ids = order.order_line.mapped('invoice_lines').mapped('invoice_id') + invoice_ids = order.order_line.mapped('invoice_lines').mapped('invoice_id').filtered(lambda r: r.type in ['out_invoice', 'out_refund']) # Search for invoices which have been 'cancelled' (filter_refund = 'modify' in # 'account.invoice.refund') # use like as origin may contains multiple references (e.g. 'SO01, SO02') diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index 67643c8754a..51b22be1e8e 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -725,7 +725,7 @@ class Picking(models.Model): # check if the quant is matching the operation details if ops.package_id: - flag = quant.package_id and bool(QuantPackage.search([('id', 'child_of', [ops.package_id.id])])) or False + flag = quant.package_id == ops.package_id else: flag = not quant.package_id.id flag = flag and (ops.owner_id.id == quant.owner_id.id) diff --git a/addons/stock/tests/common.py b/addons/stock/tests/common.py index e836c93a0d6..ec11da4eafc 100644 --- a/addons/stock/tests/common.py +++ b/addons/stock/tests/common.py @@ -37,6 +37,7 @@ class TestStockCommon(common.TransactionCase): self.productB = self.ProductObj.create({'name': 'Product B', 'type': 'product'}) self.productC = self.ProductObj.create({'name': 'Product C', 'type': 'product'}) self.productD = self.ProductObj.create({'name': 'Product D', 'type': 'product'}) + self.productE = self.ProductObj.create({'name': 'Product E', 'type': 'product'}) # Configure unit of measure. self.uom_kg = self.UomObj.create({ diff --git a/addons/stock/tests/test_stock_flow.py b/addons/stock/tests/test_stock_flow.py index b7bcad76539..7c701372f31 100644 --- a/addons/stock/tests/test_stock_flow.py +++ b/addons/stock/tests/test_stock_flow.py @@ -1410,3 +1410,129 @@ class TestStockFlow(TestStockCommon): self.assertEqual(quant.qty, 3.0, 'The quant in customer location for productA has not a quantity of 3.0') # Check that the parent package of the quant is the picking_in_package self.assertEqual(quant.package_id.id, picking_in_package.id, 'The quant in customer location is not in its package created in picking in') + + def test_50_create_in_out_with_product_pack_lines(self): + picking_in = self.PickingObj.create({ + 'partner_id': self.partner_delta_id, + 'picking_type_id': self.picking_type_in, + 'location_id': self.supplier_location, + 'location_dest_id': self.stock_location}) + self.MoveObj.create({ + 'name': self.productE.name, + 'product_id': self.productE.id, + 'product_uom_qty': 10, + 'product_uom': self.productE.uom_id.id, + 'picking_id': picking_in.id, + 'location_id': self.supplier_location, + 'location_dest_id': self.stock_location}) + + picking_in.action_confirm() + picking_in.action_assign() + pack_obj = self.env['stock.quant.package'] + pack1 = pack_obj.create({'name': 'PACKINOUTTEST1'}) + pack2 = pack_obj.create({'name': 'PACKINOUTTEST2'}) + picking_in.pack_operation_ids[0].result_package_id = pack1 + picking_in.pack_operation_ids[0].product_qty = 4 + packop2 = picking_in.pack_operation_ids[0].copy() + packop2.product_qty = 6 + packop2.result_package_id = pack2 + picking_in.do_transfer() + self.assertEqual(sum([x.qty for x in picking_in.move_lines[0].quant_ids]), 10.0, 'Expecting 10 pieces in stock') + #check the quants are in the package + self.assertEqual(sum(x.qty for x in pack1.quant_ids), 4.0, 'Pack 1 should have 4 pieces') + self.assertEqual(sum(x.qty for x in pack2.quant_ids), 6.0, 'Pack 2 should have 6 pieces') + picking_out = self.PickingObj.create({ + 'partner_id': self.partner_agrolite_id, + 'picking_type_id': self.picking_type_out, + 'location_id': self.stock_location, + 'location_dest_id': self.customer_location}) + self.MoveObj.create({ + 'name': self.productE.name, + 'product_id': self.productE.id, + 'product_uom_qty': 3, + 'product_uom': self.productE.uom_id.id, + 'picking_id': picking_out.id, + 'location_id': self.stock_location, + 'location_dest_id': self.customer_location}) + picking_out.action_confirm() + picking_out.action_assign() + packout1 = picking_out.pack_operation_ids[0] + packout2 = picking_out.pack_operation_ids[0].copy() + packout1.product_qty = 2 + packout1.package_id = pack1 + packout2.package_id = pack2 + packout2.product_qty = 1 + picking_out.do_transfer() + #Check there are no negative quants + neg_quants = self.env['stock.quant'].search([('product_id', '=', self.productE.id), ('qty', '<', 0.0)]) + self.assertEqual(len(neg_quants), 0, 'There are negative quants!') + self.assertEqual(len(picking_out.move_lines[0].linked_move_operation_ids), 2, 'We should have 2 links in the matching between the move and the operations') + self.assertEqual(len(picking_out.move_lines[0].quant_ids), 2, 'We should have exactly 2 quants in the end') + + def test_60_create_in_out_with_product_pack_lines(self): + picking_in = self.PickingObj.create({ + 'partner_id': self.partner_delta_id, + 'picking_type_id': self.picking_type_in, + 'location_id': self.supplier_location, + 'location_dest_id': self.stock_location}) + self.MoveObj.create({ + 'name': self.productE.name, + 'product_id': self.productE.id, + 'product_uom_qty': 200, + 'product_uom': self.productE.uom_id.id, + 'picking_id': picking_in.id, + 'location_id': self.supplier_location, + 'location_dest_id': self.stock_location}) + + picking_in.action_confirm() + picking_in.action_assign() + pack_obj = self.env['stock.quant.package'] + pack1 = pack_obj.create({'name': 'PACKINOUTTEST1'}) + pack2 = pack_obj.create({'name': 'PACKINOUTTEST2'}) + picking_in.pack_operation_ids[0].result_package_id = pack1 + picking_in.pack_operation_ids[0].product_qty = 120 + packop2 = picking_in.pack_operation_ids[0].copy() + packop2.product_qty = 80 + packop2.result_package_id = pack2 + picking_in.do_transfer() + self.assertEqual(sum([x.qty for x in picking_in.move_lines[0].quant_ids]), 200.0, 'Expecting 200 pieces in stock') + #check the quants are in the package + self.assertEqual(sum(x.qty for x in pack1.quant_ids), 120, 'Pack 1 should have 120 pieces') + self.assertEqual(sum(x.qty for x in pack2.quant_ids), 80, 'Pack 2 should have 80 pieces') + picking_out = self.PickingObj.create({ + 'partner_id': self.partner_agrolite_id, + 'picking_type_id': self.picking_type_out, + 'location_id': self.stock_location, + 'location_dest_id': self.customer_location}) + self.MoveObj.create({ + 'name': self.productE.name, + 'product_id': self.productE.id, + 'product_uom_qty': 200, + 'product_uom': self.productE.uom_id.id, + 'picking_id': picking_out.id, + 'location_id': self.stock_location, + 'location_dest_id': self.customer_location}) + picking_out.action_confirm() + picking_out.action_assign() + #Convert entire packs into taking out of packs + packout0 = picking_out.pack_operation_ids[0] + packout1 = picking_out.pack_operation_ids[1] + packout0.write({ + 'package_id': pack1.id, + 'product_id': self.productE.id, + 'product_qty': 120.0, + 'product_uom_id': self.productE.uom_id.id, + }) + packout1.write({ + 'package_id': pack2.id, + 'product_id': self.productE.id, + 'product_qty': 80.0, + 'product_uom_id': self.productE.uom_id.id, + }) + picking_out.do_transfer() + #Check there are no negative quants + neg_quants = self.env['stock.quant'].search([('product_id', '=', self.productE.id), ('qty', '<', 0.0)]) + self.assertEqual(len(neg_quants), 0, 'There are negative quants!') + # We should also make sure that when matching stock moves with pack operations, it takes the correct + self.assertEqual(len(picking_out.move_lines[0].linked_move_operation_ids), 2, 'We should only have 2 links beween the move and the 2 operations') + self.assertEqual(len(picking_out.move_lines[0].quant_ids), 2, 'We should have exactly 2 quants in the end') diff --git a/addons/web/static/src/js/views/graph_widget.js b/addons/web/static/src/js/views/graph_widget.js index 80a575d0505..b2cd726c95c 100644 --- a/addons/web/static/src/js/views/graph_widget.js +++ b/addons/web/static/src/js/views/graph_widget.js @@ -98,7 +98,8 @@ return Widget.extend({ "there is no active filter in the search bar."), })); } else { - this['display_' + this.mode](); + var chart = this['display_' + this.mode](); + chart.tooltip.chartContainer(this.$el[0]); } }, display_bar: function () { @@ -182,6 +183,8 @@ return Widget.extend({ chart(svg); this.to_remove = chart.update; nv.utils.onWindowResize(chart.update); + + return chart; }, display_pie: function () { var data = [], @@ -232,6 +235,8 @@ return Widget.extend({ chart(svg); this.to_remove = chart.update; nv.utils.onWindowResize(chart.update); + + return chart; }, display_line: function () { if (this.data.length < 2) { @@ -309,7 +314,9 @@ return Widget.extend({ chart(svg); this.to_remove = chart.update; - nv.utils.onWindowResize(chart.update); + nv.utils.onWindowResize(chart.update); + + return chart; }, destroy: function () { nv.utils.offWindowResize(this.to_remove); diff --git a/addons/web/static/src/js/views/list_view.js b/addons/web/static/src/js/views/list_view.js index e97b4eac93f..e43e8acdfd6 100644 --- a/addons/web/static/src/js/views/list_view.js +++ b/addons/web/static/src/js/views/list_view.js @@ -554,8 +554,11 @@ var ListView = View.extend({ if (self.display_nocontent_helper()) { self.no_result(); } else { - // Load previous page if the current one is empty - if (self.records.length === 0 && self.dataset.size() > 0) { + if (self.records.length && self.current_min === 1) { + // Reload the list view if we delete all the records of the first page + self.reload(); + } else if (self.records.length && self.dataset.size() > 0) { + // Load previous page if the current one is empty self.pager.previous(); } // Reload the list view if we are not on the last page diff --git a/addons/web/static/src/less/graph_view.less b/addons/web/static/src/less/graph_view.less index c0d460ab3f1..f77b4faa9d0 100644 --- a/addons/web/static/src/less/graph_view.less +++ b/addons/web/static/src/less/graph_view.less @@ -10,9 +10,13 @@ } } +.nvtooltip thead { + background-color: transparent; +} + // ------------------------------------------------------------------ // Control panel buttons // ------------------------------------------------------------------ .o_graph_measures_list { .o-selected-li; -} \ No newline at end of file +} diff --git a/addons/web_calendar/static/src/js/web_calendar.js b/addons/web_calendar/static/src/js/web_calendar.js index 6f1be2bd5c4..f8f7317e93c 100644 --- a/addons/web_calendar/static/src/js/web_calendar.js +++ b/addons/web_calendar/static/src/js/web_calendar.js @@ -911,9 +911,9 @@ var CalendarView = View.extend({ var index = this.dataset.get_id_index(id); this.dataset.index = index; if (this.write_right) { - this.do_switch_view('form', null, { mode: "edit" }); + this.do_switch_view('form', { mode: "edit" }); } else { - this.do_switch_view('form', null, { mode: "view" }); + this.do_switch_view('form', { mode: "view" }); } } else { @@ -927,7 +927,7 @@ var CalendarView = View.extend({ buttons: [ {text: _t("Edit"), classes: 'btn-primary', close: true, click: function() { self.dataset.index = self.dataset.get_id_index(id); - self.do_switch_view('form', null, { mode: "edit" }); + self.do_switch_view('form', { mode: "edit" }); }}, {text: _t("Delete"), close: true, click: function() { diff --git a/addons/web_editor/static/src/js/backend.js b/addons/web_editor/static/src/js/backend.js index 17ce8c3c1a1..3b414d45cb5 100644 --- a/addons/web_editor/static/src/js/backend.js +++ b/addons/web_editor/static/src/js/backend.js @@ -161,6 +161,11 @@ var FieldTextHtmlSimple = widget.extend({ return !this.get('value') || this.get('value') === "