diff --git a/addons/delivery/models/stock_picking.py b/addons/delivery/models/stock_picking.py
index 7f6f1ae85bd..7d5257a5546 100644
--- a/addons/delivery/models/stock_picking.py
+++ b/addons/delivery/models/stock_picking.py
@@ -93,7 +93,11 @@ class StockPicking(models.Model):
@api.onchange('carrier_id')
def onchange_carrier(self):
if self.carrier_id.delivery_type in ['fixed', 'base_on_rule']:
- self.carrier_price = self.carrier_id.price
+ order = self.sale_id
+ if order:
+ self.carrier_price = self.carrier_id.get_price_available(order)
+ else:
+ self.carrier_price = self.carrier_id.price
@api.depends('product_id', 'move_lines')
def _cal_weight(self):
diff --git a/addons/hr_expense/views/hr_expense_views.xml b/addons/hr_expense/views/hr_expense_views.xml
index a9503f19df2..41ec2818b09 100644
--- a/addons/hr_expense/views/hr_expense_views.xml
+++ b/addons/hr_expense/views/hr_expense_views.xml
@@ -162,7 +162,7 @@
-
+
diff --git a/addons/hr_timesheet/hr_timesheet.py b/addons/hr_timesheet/hr_timesheet.py
index 32152b8f0e7..c8b7241e5c5 100644
--- a/addons/hr_timesheet/hr_timesheet.py
+++ b/addons/hr_timesheet/hr_timesheet.py
@@ -9,7 +9,7 @@ class AccountAnalyticLine(models.Model):
task_id = fields.Many2one('project.task', 'Task')
project_id = fields.Many2one('project.project', 'Project', domain=[('allow_timesheets', '=', True)])
- department_id = fields.Many2one('hr.department', "Department", related='user_id.employee_ids.department_id', store=True)
+ department_id = fields.Many2one('hr.department', "Department", related='user_id.employee_ids.department_id', store=True, readonly=True)
@api.onchange('project_id')
def onchange_project_id(self):
diff --git a/addons/hr_timesheet_sheet/models/hr_timesheet_sheet.py b/addons/hr_timesheet_sheet/models/hr_timesheet_sheet.py
index bcb6f6c3de3..8e02fab1604 100644
--- a/addons/hr_timesheet_sheet/models/hr_timesheet_sheet.py
+++ b/addons/hr_timesheet_sheet/models/hr_timesheet_sheet.py
@@ -87,14 +87,11 @@ class HrTimesheetSheet(models.Model):
if any(self.env.cr.fetchall()):
raise ValidationError('You cannot have 2 timesheets that overlap!\nPlease use the menu \'My Current Timesheet\' to avoid this problem.')
- @api.multi
@api.onchange('employee_id')
- def onchange_employee_id(self, employee_id):
- employee = self.env['hr.employee'].browse(employee_id)
- for sheet in self:
- if employee_id:
- sheet.department_id = employee.department_id
- sheet.user_id = employee.user_id
+ def onchange_employee_id(self):
+ if self.employee_id:
+ self.department_id = self.employee_id.department_id
+ self.user_id = self.employee_id.user_id
def copy(self, *args, **argv):
raise UserError(_('You cannot duplicate a timesheet.'))
diff --git a/addons/hw_blackbox_be/controllers/main.py b/addons/hw_blackbox_be/controllers/main.py
index 2abdcb5bff3..616f70346ef 100644
--- a/addons/hw_blackbox_be/controllers/main.py
+++ b/addons/hw_blackbox_be/controllers/main.py
@@ -14,6 +14,8 @@ import odoo.addons.hw_proxy.controllers.main as hw_proxy
_logger = logging.getLogger(__name__)
+DRIVER_NAME = 'fiscal_data_module'
+
class Blackbox(Thread):
def __init__(self):
Thread.__init__(self)
@@ -39,26 +41,31 @@ class Blackbox(Thread):
# request. The first device to give an answer that makes sense
# wins.
def _find_device_path_by_probing(self):
- path = "/dev/serial/by-path/"
- probe_message = self._wrap_low_level_message_around("S000")
+ with hw_proxy.rs232_lock:
+ path = "/dev/serial/by-path/"
+ probe_message = self._wrap_low_level_message_around("S000")
- try:
- devices = listdir(path)
- except OSError:
- _logger.warning(path + " doesn't exist")
- else:
- for device in listdir(path):
- path_to_device = path + device
- _logger.debug("Probing " + device)
+ try:
+ devices = listdir(path)
+ except OSError:
+ _logger.warning(path + " doesn't exist")
+ self.set_status("disconnected", ["No RS-232 device (or emulated ones) found"])
+ else:
+ for device in devices:
+ if device in hw_proxy.rs232_devices:
+ continue
+ path_to_device = path + device
+ _logger.debug("Probing " + device)
- if self._send_to_blackbox(probe_message, 21, path_to_device, just_wait_for_ack=True):
- _logger.info(device + " will be used as the blackbox")
- self.set_status("connected", [device])
- return path_to_device
+ if self._send_to_blackbox(probe_message, 21, path_to_device, just_wait_for_ack=True):
+ _logger.info(device + " will be used as the blackbox")
+ self.set_status("connected", [device])
+ hw_proxy.rs232_devices[device] = DRIVER_NAME
+ return path_to_device
- _logger.warning("Blackbox could not be found")
- self.set_status("error", ["Couldn't find the Fiscal Data Module"])
- return ""
+ _logger.warning("Blackbox could not be found")
+ self.set_status("disconnected", ["Couldn't find the Fiscal Data Module"])
+ return ""
def _lrc(self, msg):
lrc = 0
@@ -152,7 +159,7 @@ class Blackbox(Thread):
if isfile("/home/pi/registered_blackbox_be"):
blackbox_thread = Blackbox()
- hw_proxy.drivers['fiscal_data_module'] = blackbox_thread
+ hw_proxy.drivers[DRIVER_NAME] = blackbox_thread
class BlackboxDriver(hw_proxy.Proxy):
@http.route('/hw_proxy/request_blackbox/', type='json', auth='none', cors='*')
diff --git a/addons/hw_escpos/controllers/main.py b/addons/hw_escpos/controllers/main.py
index fbb9b58f876..812e8d74ce7 100644
--- a/addons/hw_escpos/controllers/main.py
+++ b/addons/hw_escpos/controllers/main.py
@@ -100,8 +100,12 @@ class EscposDriver(Thread):
printers = self.connected_usb_devices()
if len(printers) > 0:
- self.set_status('connected','Connected to '+printers[0]['name'])
- return Usb(printers[0]['vendor'], printers[0]['product'])
+ print_dev = Usb(printers[0]['vendor'], printers[0]['product'])
+ self.set_status(
+ 'connected',
+ "Connected to %s (in=0x%02x,out=0x%02x)" % (printers[0]['name'], print_dev.in_ep, print_dev.out_ep)
+ )
+ return print_dev
else:
self.set_status('disconnected','Printer Not Found')
return None
diff --git a/addons/hw_escpos/escpos/printer.py b/addons/hw_escpos/escpos/printer.py
index 11c48522a54..f994d946a02 100644
--- a/addons/hw_escpos/escpos/printer.py
+++ b/addons/hw_escpos/escpos/printer.py
@@ -13,7 +13,7 @@ from time import sleep
class Usb(Escpos):
""" Define USB printer """
- def __init__(self, idVendor, idProduct, interface=0, in_ep=0x82, out_ep=0x01):
+ def __init__(self, idVendor, idProduct, interface=0, in_ep=None, out_ep=None):
"""
@param idVendor : Vendor ID
@param idProduct : Product ID
@@ -42,6 +42,23 @@ class Usb(Escpos):
self.device.detach_kernel_driver(self.interface)
self.device.set_configuration()
usb.util.claim_interface(self.device, self.interface)
+
+ cfg = self.device.get_active_configuration()
+ intf = cfg[(0,0)] # first interface
+ if self.in_ep is None:
+ # Attempt to detect IN/OUT endpoint addresses
+ try:
+ is_IN = lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN
+ is_OUT = lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT
+ endpoint_in = usb.util.find_descriptor(intf, custom_match=is_IN)
+ endpoint_out = usb.util.find_descriptor(intf, custom_match=is_OUT)
+ self.in_ep = endpoint_in.bEndpointAddress
+ self.out_ep = endpoint_out.bEndpointAddress
+ except usb.core.USBError:
+ # default values for officially supported printers
+ self.in_ep = 0x82
+ self.out_ep = 0x01
+
except usb.core.USBError as e:
raise HandleDeviceError(e)
diff --git a/addons/hw_posbox_homepage/controllers/main.py b/addons/hw_posbox_homepage/controllers/main.py
index 38cc5212973..09ba008d430 100644
--- a/addons/hw_posbox_homepage/controllers/main.py
+++ b/addons/hw_posbox_homepage/controllers/main.py
@@ -35,10 +35,10 @@ index_template = """
Your PosBox is up and running
- 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
@@ -53,7 +53,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 6fab43f3ce4..91aee8708e8 100644
--- a/addons/hw_proxy/controllers/main.py
+++ b/addons/hw_proxy/controllers/main.py
@@ -4,6 +4,8 @@
import commands
import logging
import time
+from threading import Lock
+
from odoo import http
from odoo.http import request
@@ -24,6 +26,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):
@@ -41,7 +48,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 = """
@@ -89,6 +96,8 @@ class Proxy(http.Controller):
Connected Devices
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 += "
\n"
@@ -104,6 +113,17 @@ class Proxy(http.Controller):
resp += "
\n\n\n\n"
+ if debug is not None:
+ resp += """
+
+
Debug version
+
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 2befca2af24..75c1c4b06b4 100644
--- a/addons/hw_scale/controllers/main.py
+++ b/addons/hw_scale/controllers/main.py
@@ -3,7 +3,10 @@
import logging
import os
+import re
import time
+
+from collections import namedtuple
from os import listdir
from threading import Thread, Lock
@@ -13,6 +16,8 @@ import odoo.addons.hw_proxy.controllers.main as hw_proxy
_logger = logging.getLogger(__name__)
+DRIVER_NAME = 'scale'
+
try:
import serial
except ImportError:
@@ -20,6 +25,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)
@@ -30,8 +128,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:
@@ -39,15 +137,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:
@@ -56,61 +154,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):
@@ -120,108 +262,111 @@ 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):
- return {'weight': scale_thread.get_weight(), 'unit':'kg', 'info': scale_thread.get_weight_info()} if scale_thread else None
+ if scale_thread:
+ 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='*')
def scale_zero(self):
diff --git a/addons/im_livechat/controllers/main.py b/addons/im_livechat/controllers/main.py
index a58ff22ecc7..a60ecd26ff7 100644
--- a/addons/im_livechat/controllers/main.py
+++ b/addons/im_livechat/controllers/main.py
@@ -77,10 +77,6 @@ class LivechatController(http.Controller):
anonymous_name = request.env.user.name
return request.env["im_livechat.channel"].get_mail_channel(channel_id, anonymous_name)
- @http.route('/im_livechat/history', type="json", auth="public")
- def history(self, channel_id, limit):
- return request.env["mail.channel"].browse(channel_id).channel_fetch_message(limit=limit)
-
@http.route('/im_livechat/feedback', type='json', auth='public')
def feedback(self, uuid, rate, reason=None, **kwargs):
Channel = request.env['mail.channel']
diff --git a/addons/im_livechat/data/im_livechat_channel_data.xml b/addons/im_livechat/data/im_livechat_channel_data.xml
index f0e0de67550..e0cfea7ddba 100644
--- a/addons/im_livechat/data/im_livechat_channel_data.xml
+++ b/addons/im_livechat/data/im_livechat_channel_data.xml
@@ -5,6 +5,13 @@
:rating_0<img src='/rating/static/src/img/rating_0.png'/>
+ Rating Empty Smiley
+ image
+
+
+
+ :rating_1
+ <img src='/rating/static/src/img/rating_1.png'/>Rating Smiley Badimage
diff --git a/addons/im_livechat/static/src/js/im_livechat.js b/addons/im_livechat/static/src/js/im_livechat.js
index 84e123a18bd..446717b1b7f 100644
--- a/addons/im_livechat/static/src/js/im_livechat.js
+++ b/addons/im_livechat/static/src/js/im_livechat.js
@@ -47,7 +47,7 @@ var LivechatButton = Widget.extend({
});
} else {
var channel = JSON.parse(cookie);
- ready = session.rpc("/im_livechat/history", {channel_id: channel.id, limit: 100}).then(function (history) {
+ ready = session.rpc("/mail/chat_history", {uuid: channel.uuid, limit: 100}).then(function (history) {
self.history = history;
});
}
diff --git a/addons/im_livechat/static/src/xml/im_livechat.xml b/addons/im_livechat/static/src/xml/im_livechat.xml
index eb6758c6549..00fd4603eff 100644
--- a/addons/im_livechat/static/src/xml/im_livechat.xml
+++ b/addons/im_livechat/static/src/xml/im_livechat.xml
@@ -10,7 +10,7 @@
-
+
I don't want to rate this conversation
diff --git a/addons/l10n_nl/data/account.account.template.xml b/addons/l10n_nl/data/account.account.template.xml
index 93a32b3938a..5ec184e775f 100644
--- a/addons/l10n_nl/data/account.account.template.xml
+++ b/addons/l10n_nl/data/account.account.template.xml
@@ -540,7 +540,7 @@
- 150001
+ 1540Af te dragen BTW hoog tarief diensten
@@ -552,7 +552,7 @@
- 150101
+ 1541Af te dragen BTW laag tarief diensten
@@ -564,7 +564,7 @@
- 150201
+ 1542Af te dragen BTW overige tarieven diensten
@@ -582,7 +582,7 @@
- 150401
+ 1544Af te dragen BTW heffing verlegd diensten
@@ -601,7 +601,7 @@
- 151601
+ 1576Af te dragen BTW hoog tarief binnen de EU diensten
@@ -613,7 +613,7 @@
- 151701
+ 1577Af te dragen BTW laag tarief binnen de EU diensten
@@ -625,7 +625,7 @@
- 151801
+ 1578Af te dragen BTW overige tarieven binnen de EU diensten
@@ -638,7 +638,7 @@
- 151301
+ 1573Af te dragen BTW hoog tarief buiten de EU diensten
@@ -650,7 +650,7 @@
- 151401
+ 1574Af te dragen BTW laag tarief buiten de EU diensten
@@ -662,7 +662,7 @@
- 151501
+ 1575Af te dragen BTW overige tarieven buiten de EU diensten
@@ -692,7 +692,7 @@
- 151001
+ 1570Voorbelasting overige diensten
@@ -704,7 +704,7 @@
- 152001
+ 1580Voorbelasting hoog diensten
@@ -716,7 +716,7 @@
- 152101
+ 1581Voorbelasting laag diensten
@@ -728,7 +728,7 @@
- 152201
+ 1582Voorbelasting verlegd diensten
@@ -741,7 +741,7 @@
- 152301
+ 1583Voorbelasting overige binnen de EU diensten
@@ -753,7 +753,7 @@
- 152401
+ 1584Voorbelasting hoog binnen de EU diensten
@@ -765,7 +765,7 @@
- 152501
+ 1585Voorbelasting laag binnen de EU diensten
@@ -777,7 +777,7 @@
- 152601
+ 1586Voorbelasting verlegd binnen de EU diensten
@@ -790,7 +790,7 @@
- 152701
+ 1587Voorbelasting overige buiten EU diensten
@@ -802,7 +802,7 @@
- 152801
+ 1588Voorbelasting hoog buiten EU diensten
@@ -814,7 +814,7 @@
- 152901
+ 1589Voorbelasting laag buiten EU diensten
@@ -826,7 +826,7 @@
- 153001
+ 1535Voorbelasting verlegd buiten EU diensten
diff --git a/addons/mail/static/src/js/chat_manager.js b/addons/mail/static/src/js/chat_manager.js
index 0b7b37ec49f..2f63dd61b5a 100644
--- a/addons/mail/static/src/js/chat_manager.js
+++ b/addons/mail/static/src/js/chat_manager.js
@@ -661,9 +661,17 @@ var chat_manager = {
post_message: function (data, options) {
options = options || {};
+
+ // This message will be received from the mail composer as html content subtype
+ // but the urls will not be linkified. If the mail composer takes the responsibility
+ // to linkify the urls we end up with double linkification a bit everywhere.
+ // Ideally we want to keep the content as text internally and only make html
+ // enrichment at display time but the current design makes this quite hard to do.
+ var body = utils.linkify(_.str.trim(data.content));
+
var msg = {
partner_ids: data.partner_ids,
- body: _.str.trim(data.content),
+ body: body,
attachment_ids: data.attachment_ids,
};
if ('subject' in data) {
diff --git a/addons/mail/static/src/js/utils.js b/addons/mail/static/src/js/utils.js
index 7c64ba48df0..db82b976945 100644
--- a/addons/mail/static/src/js/utils.js
+++ b/addons/mail/static/src/js/utils.js
@@ -61,12 +61,23 @@ function _parse_and_transform(nodes, transform_function) {
// suggested regexp (gruber url matching regexp, adapted to js, see https://gist.github.com/gruber/8891611)
var url_regexp = /\b((?:https?:\/\/|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}\/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'".,<>?«»“”‘’]))/gi;
+function linkify(text, attrs) {
+ attrs = attrs || {};
+ if (attrs.target === undefined) {
+ attrs.target = '_blank';
+ }
+ attrs = _.map(attrs, function (value, key) {
+ return key + '="' + _.escape(value) + '"';
+ }).join(' ');
+ return text.replace(url_regexp, function (url) {
+ var href = (!/^(f|ht)tps?:\/\//i.test(url)) ? "http://" + url : url;
+ return '' + url + '';
+ });
+}
+
function add_link (node, transform_children) {
if (node.nodeType === 3) { // text node
- return node.data.replace(url_regexp, function (url) {
- var href = (!/^(f|ht)tps?:\/\//i.test(url)) ? "http://" + url : url;
- return '' + url + '';
- });
+ return linkify(node.data);
}
if (node.tagName === "A") return node.outerHTML;
node.innerHTML = transform_children();
@@ -132,6 +143,7 @@ return {
send_notification: send_notification,
parse_and_transform: parse_and_transform,
add_link: add_link,
+ linkify: linkify,
strip_html: strip_html,
inline: inline,
parse_email: parse_email,
diff --git a/addons/mass_mailing/static/src/js/mass_mailing_editor.js b/addons/mass_mailing/static/src/js/mass_mailing_editor.js
index 1ad2cf2c784..dfdafae3f10 100644
--- a/addons/mass_mailing/static/src/js/mass_mailing_editor.js
+++ b/addons/mass_mailing/static/src/js/mass_mailing_editor.js
@@ -259,6 +259,7 @@ snippets_editor.Class.include({
*/
check_selected_theme();
$body.addClass(selected_theme.className);
+ switch_images(selected_theme, $snippets);
$dropdown.insertAfter($snippets_menu);
@@ -292,6 +293,7 @@ snippets_editor.Class.include({
}
function switch_images(theme_params, $container) {
+ if (!theme_params) return;
$container.find("img").each(function () {
var $img = $(this);
var src = $img.attr("src");
diff --git a/addons/mass_mailing/wizard/test_mailing.py b/addons/mass_mailing/wizard/test_mailing.py
index 5d884bbdf10..17473464126 100644
--- a/addons/mass_mailing/wizard/test_mailing.py
+++ b/addons/mass_mailing/wizard/test_mailing.py
@@ -26,15 +26,12 @@ class TestMassMailing(models.TransientModel):
'reply_to': mailing.reply_to,
'email_to': test_mail,
'subject': mailing.name,
- 'body_html': '',
+ 'body_html': mailing.body_html,
'notification': True,
'mailing_id': mailing.id,
'attachment_ids': [(4, attachment.id) for attachment in mailing.attachment_ids],
}
mail = self.env['mail.mail'].create(mail_values)
- unsubscribe_url = mail._get_unsubscribe_url(test_mail)
- body = tools.append_content_to_html(mailing.body_html, unsubscribe_url, plaintext=False, container_tag='p')
- mail.write({'body_html': body})
mails |= mail
mails.send()
return True
diff --git a/addons/mrp/models/mrp_workorder.py b/addons/mrp/models/mrp_workorder.py
index 920d8b0bc4c..9d2062b62c9 100644
--- a/addons/mrp/models/mrp_workorder.py
+++ b/addons/mrp/models/mrp_workorder.py
@@ -301,9 +301,12 @@ class MrpWorkorder(models.Model):
if self.next_work_order_id and self.final_lot_id and not self.next_work_order_id.final_lot_id:
self.next_work_order_id.final_lot_id = self.final_lot_id.id
- # TODO: add filter for those that have not been done yet --> need to check as it can have different filters
- self.move_lot_ids.filtered(lambda x: not x.done_move and not x.lot_produced_id).write({'lot_produced_id': self.final_lot_id.id,
- 'lot_produced_qty': self.qty_producing,})
+ self.move_lot_ids.filtered(
+ lambda move_lot: not move_lot.done_move and not move_lot.lot_produced_id and move_lot.quantity_done > 0
+ ).write({
+ 'lot_produced_id': self.final_lot_id.id,
+ 'lot_produced_qty': self.qty_producing
+ })
# If last work order, then post lots used
# TODO: should be same as checking if for every workorder something has been done?
diff --git a/addons/point_of_sale/security/ir.model.access.csv b/addons/point_of_sale/security/ir.model.access.csv
index 75426a25c0f..1e6f9b4e619 100644
--- a/addons/point_of_sale/security/ir.model.access.csv
+++ b/addons/point_of_sale/security/ir.model.access.csv
@@ -34,3 +34,5 @@ access_barcode_nomenclature_pos_user,barcode.nomenclature.pos.user,barcodes.mode
access_barcode_nomenclature_pos_manager,barcode.nomenclature.pos.manager,barcodes.model_barcode_nomenclature,group_pos_manager,1,1,1,1
access_barcode_rule_pos_user,barcode.rule.pos.user,barcodes.model_barcode_rule,group_pos_user,1,0,0,0
access_barcode_rule_pos_manager,barcode.rule.pos.manager,barcodes.model_barcode_rule,group_pos_manager,1,1,1,1
+access_bank_statement_cashbox_user,account.bank.statement.cashbox user,account.model_account_bank_statement_cashbox,group_pos_user,1,1,1,0
+access_account_cashbox_line_user,account.cashbox.line user,account.model_account_cashbox_line,group_pos_user,1,1,1,0
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/product/models/product_template.py b/addons/product/models/product_template.py
index 4c5edbf08b2..6b5832ea57f 100644
--- a/addons/product/models/product_template.py
+++ b/addons/product/models/product_template.py
@@ -151,7 +151,7 @@ class ProductTemplate(models.Model):
except ValueError:
main_company = self.env['res.company'].sudo().search([], limit=1, order="id")
for template in self:
- template.currency_id = template.company_id.currency_id.id or main_company.currency_id.id
+ template.currency_id = template.company_id.sudo().currency_id.id or main_company.currency_id.id
@api.multi
def _compute_template_price(self):
diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py
index 2b5080ce1d1..1d3eb8bfa08 100644
--- a/addons/purchase/models/purchase.py
+++ b/addons/purchase/models/purchase.py
@@ -405,7 +405,7 @@ class PurchaseOrder(models.Model):
else:
picking = pickings[0]
moves = order.order_line._create_stock_moves(picking)
- moves = moves.action_confirm()
+ moves = moves.filtered(lambda x: x.state not in ('done', 'cancel')).action_confirm()
moves.force_assign()
picking.message_post_with_view('mail.message_origin_link',
values={'self': picking, 'origin': order},
diff --git a/addons/purchase/models/stock.py b/addons/purchase/models/stock.py
index 93b3f99a902..0b56586ccb7 100644
--- a/addons/purchase/models/stock.py
+++ b/addons/purchase/models/stock.py
@@ -128,7 +128,7 @@ class StockWarehouse(models.Model):
res = super(StockWarehouse, self)._update_name_and_code(name, code)
warehouse = self[0]
#change the buy procurement rule name
- if warehouse.buy_pull_id:
+ if warehouse.buy_pull_id and name:
warehouse.buy_pull_id.write({'name': warehouse.buy_pull_id.name.replace(warehouse.name, name, 1)})
return res
diff --git a/addons/rating/controllers/main.py b/addons/rating/controllers/main.py
index e4535131bf2..2782ffc3350 100644
--- a/addons/rating/controllers/main.py
+++ b/addons/rating/controllers/main.py
@@ -38,5 +38,6 @@ class Rating(http.Controller):
if request.session.uid:
return werkzeug.utils.redirect('/web#model=%s&id=%s&view_type=form' % (record_sudo._name, record_sudo.id))
return request.render('rating.rating_external_page_view', {
- 'web_base_url': request.env['ir.config_parameter'].sudo().get_param('web.base.url')
+ 'web_base_url': request.env['ir.config_parameter'].sudo().get_param('web.base.url'),
+ 'rating': rating,
})
diff --git a/addons/rating_project/models/project.py b/addons/rating_project/models/project.py
index d52fe7a5ab9..0ab47987a83 100644
--- a/addons/rating_project/models/project.py
+++ b/addons/rating_project/models/project.py
@@ -43,8 +43,8 @@ class Task(models.Model):
force_send = self.env.context.get('force_send', True)
task.rating_send_request(rating_template, reuse_rating=False, force_send=force_send)
- def _rating_get_partner_id(self):
- res = super(Task, self)._rating_get_partner_id()
+ def rating_get_partner_id(self):
+ res = super(Task, self).rating_get_partner_id()
if not res and self.project_id.partner_id:
return self.project_id.partner_id
return res
diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py
index 4720fb42a07..6667fc99619 100644
--- a/addons/sale/models/sale.py
+++ b/addons/sale/models/sale.py
@@ -49,7 +49,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/sale_order_dates/models/sale_order.py b/addons/sale_order_dates/models/sale_order.py
index 75176271e48..e2ea8b893bb 100644
--- a/addons/sale_order_dates/models/sale_order.py
+++ b/addons/sale_order_dates/models/sale_order.py
@@ -46,7 +46,7 @@ class SaleOrder(models.Model):
for pick in order.picking_ids:
dates_list.append(fields.Datetime.from_string(pick.date))
if dates_list:
- order.write({'effective_date': fields.Datetime.to_string(min(dates_list))})
+ order.effective_date = fields.Datetime.to_string(min(dates_list))
@api.onchange('requested_date')
def onchange_requested_date(self):
diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py
index 0ddd9de24d2..42c4e3b80bb 100644
--- a/addons/stock/models/stock_move.py
+++ b/addons/stock/models/stock_move.py
@@ -750,8 +750,8 @@ class StockMove(models.Model):
owner_id=pack_operation.owner_id.id, src_package_id=pack_operation.package_id.id,
dest_package_id=dest_package_id)
if redo_false_quants:
- false_quants_move = [x for x in move_rec_updateme.reserved_quant_ids if (not x.lot_id) and (x.owner_id.id == pack_operation.owner_id.id) \
- and (x.location_id.id == pack_operation.location_id.id) and (x.package_id.id != pack_operation.package_id.id)]
+ false_quants_move = [x for x in move_rec_updateme.reserved_quant_ids if (not x.lot_id) and (x.owner_id.id == pack_operation.owner_id.id) and
+ (x.location_id.id == pack_operation.location_id.id) and (x.package_id.id == pack_operation.package_id.id)]
return True
@api.multi
diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py
index b3cbaad92a3..377a4a14c6f 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)
@@ -924,7 +924,7 @@ class Picking(models.Model):
vals = self._prepare_values_extra_move(pack_operation, product, remaining_qty)
moves |= moves.create(vals)
if moves:
- moves.action_confirm()
+ moves.with_context(skip_check=True).action_confirm()
return moves
@api.model
@@ -967,6 +967,7 @@ class Picking(models.Model):
""" Move all non-done lines into a new backorder picking. If the key 'do_only_split' is given in the context, then move all lines not in context.get('split', []) instead of all non-done lines.
"""
# TDE note: o2o conversion, todo multi
+ backorders = self.env['stock.picking']
for picking in self:
backorder_moves = backorder_moves or picking.move_lines
if self._context.get('do_only_split'):
@@ -987,7 +988,8 @@ class Picking(models.Model):
picking.write({'date_done': time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)})
backorder_picking.action_confirm()
backorder_picking.action_assign()
- return True
+ backorders |= backorder_picking
+ return backorders
@api.multi
def put_in_pack(self):
diff --git a/addons/stock/tests/common.py b/addons/stock/tests/common.py
index 767470368f0..ec11da4eafc 100644
--- a/addons/stock/tests/common.py
+++ b/addons/stock/tests/common.py
@@ -27,15 +27,17 @@ class TestStockCommon(common.TransactionCase):
self.picking_type_out = self.ModelDataObj.xmlid_to_res_id('stock.picking_type_out')
self.supplier_location = self.ModelDataObj.xmlid_to_res_id('stock.stock_location_suppliers')
self.stock_location = self.ModelDataObj.xmlid_to_res_id('stock.stock_location_stock')
+ self.pack_location = self.ModelDataObj.xmlid_to_res_id('stock.location_pack_zone')
self.customer_location = self.ModelDataObj.xmlid_to_res_id('stock.stock_location_customers')
self.categ_unit = self.ModelDataObj.xmlid_to_res_id('product.product_uom_categ_unit')
self.categ_kgm = self.ModelDataObj.xmlid_to_res_id('product.product_uom_categ_kgm')
# Product Created A, B, C, D
- self.productA = self.ProductObj.create({'name': 'Product A'})
- self.productB = self.ProductObj.create({'name': 'Product B'})
- self.productC = self.ProductObj.create({'name': 'Product C'})
- self.productD = self.ProductObj.create({'name': 'Product D'})
+ self.productA = self.ProductObj.create({'name': 'Product A', 'type': 'product'})
+ 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({
@@ -90,9 +92,9 @@ class TestStockCommon(common.TransactionCase):
'rounding': 1.0})
# Product for different unit of measure.
- self.DozA = self.ProductObj.create({'name': 'Dozon-A', 'uom_id': self.uom_dozen.id, 'uom_po_id': self.uom_dozen.id})
- self.SDozA = self.ProductObj.create({'name': 'SuperDozon-A', 'uom_id': self.uom_sdozen.id, 'uom_po_id': self.uom_sdozen.id})
- self.SDozARound = self.ProductObj.create({'name': 'SuperDozenRound-A', 'uom_id': self.uom_sdozen_round.id, 'uom_po_id': self.uom_sdozen_round.id})
- self.UnitA = self.ProductObj.create({'name': 'Unit-A'})
- self.kgB = self.ProductObj.create({'name': 'kg-B', 'uom_id': self.uom_kg.id, 'uom_po_id': self.uom_kg.id})
- self.gB = self.ProductObj.create({'name': 'g-B', 'uom_id': self.uom_gm.id, 'uom_po_id': self.uom_gm.id})
+ self.DozA = self.ProductObj.create({'name': 'Dozon-A', 'type': 'product', 'uom_id': self.uom_dozen.id, 'uom_po_id': self.uom_dozen.id})
+ self.SDozA = self.ProductObj.create({'name': 'SuperDozon-A', 'type': 'product', 'uom_id': self.uom_sdozen.id, 'uom_po_id': self.uom_sdozen.id})
+ self.SDozARound = self.ProductObj.create({'name': 'SuperDozenRound-A', 'type': 'product', 'uom_id': self.uom_sdozen_round.id, 'uom_po_id': self.uom_sdozen_round.id})
+ self.UnitA = self.ProductObj.create({'name': 'Unit-A', 'type': 'product'})
+ self.kgB = self.ProductObj.create({'name': 'kg-B', 'type': 'product', 'uom_id': self.uom_kg.id, 'uom_po_id': self.uom_kg.id})
+ self.gB = self.ProductObj.create({'name': 'g-B', 'type': 'product', 'uom_id': self.uom_gm.id, 'uom_po_id': self.uom_gm.id})
diff --git a/addons/stock/tests/test_stock_flow.py b/addons/stock/tests/test_stock_flow.py
index 238bab1170e..65d2a862866 100644
--- a/addons/stock/tests/test_stock_flow.py
+++ b/addons/stock/tests/test_stock_flow.py
@@ -205,19 +205,21 @@ class TestStockFlow(TestStockCommon):
self.assertEqual(move.state, 'confirmed', 'Wrong state of move line.')
# Product assign to outgoing shipments
picking_out.action_assign()
- for move in picking_out.move_lines:
- self.assertEqual(move.state, 'assigned', 'Wrong state of move line.')
+ self.assertEqual(picking_out.move_lines[0].state, 'confirmed', 'Wrong state of move line.')
+ self.assertEqual(picking_out.move_lines[1].state, 'assigned', 'Wrong state of move line.')
+ self.assertEqual(picking_out.move_lines[2].state, 'assigned', 'Wrong state of move line.')
+ self.assertEqual(picking_out.move_lines[3].state, 'confirmed', 'Wrong state of move line.')
# Check availability for product A
- aval_a_qty = self.MoveObj.search([('product_id', '=', self.productA.id), ('picking_id', '=', picking_out.id)], limit=1).availability
+ aval_a_qty = self.MoveObj.search([('product_id', '=', self.productA.id), ('picking_id', '=', picking_out.id)], limit=1).reserved_availability
self.assertEqual(aval_a_qty, 4.0, 'Wrong move quantity availability of product A (%s found instead of 4)' % (aval_a_qty))
# Check availability for product B
- aval_b_qty = self.MoveObj.search([('product_id', '=', self.productB.id), ('picking_id', '=', picking_out.id)], limit=1).availability
+ aval_b_qty = self.MoveObj.search([('product_id', '=', self.productB.id), ('picking_id', '=', picking_out.id)], limit=1).reserved_availability
self.assertEqual(aval_b_qty, 5.0, 'Wrong move quantity availability of product B (%s found instead of 5)' % (aval_b_qty))
# Check availability for product C
- aval_c_qty = self.MoveObj.search([('product_id', '=', self.productC.id), ('picking_id', '=', picking_out.id)], limit=1).availability
+ aval_c_qty = self.MoveObj.search([('product_id', '=', self.productC.id), ('picking_id', '=', picking_out.id)], limit=1).reserved_availability
self.assertEqual(aval_c_qty, 3.0, 'Wrong move quantity availability of product C (%s found instead of 3)' % (aval_c_qty))
# Check availability for product D
- aval_d_qty = self.MoveObj.search([('product_id', '=', self.productD.id), ('picking_id', '=', picking_out.id)], limit=1).availability
+ aval_d_qty = self.MoveObj.search([('product_id', '=', self.productD.id), ('picking_id', '=', picking_out.id)], limit=1).reserved_availability
self.assertEqual(aval_d_qty, 7.0, 'Wrong move quantity availability of product D (%s found instead of 7)' % (aval_d_qty))
# ----------------------------------------------------------------------
@@ -815,23 +817,21 @@ class TestStockFlow(TestStockCommon):
for move in picking_out.move_lines:
self.assertEqual(move.state, 'assigned', 'Wrong state of move line.')
# Check product A available quantity
- DozA_qty = self.MoveObj.search([('product_id', '=', self.DozA.id), ('picking_id', '=', picking_out.id)], limit=1).availability
+ DozA_qty = self.MoveObj.search([('product_id', '=', self.DozA.id), ('picking_id', '=', picking_out.id)], limit=1).reserved_availability
self.assertEqual(DozA_qty, 4.5, 'Wrong move quantity availability (%s found instead of 4.5)' % (DozA_qty))
# Check product B available quantity
- SDozA_qty = self.MoveObj.search([('product_id', '=', self.SDozA.id), ('picking_id', '=', picking_out.id)], limit=1).availability
+ SDozA_qty = self.MoveObj.search([('product_id', '=', self.SDozA.id), ('picking_id', '=', picking_out.id)], limit=1).reserved_availability
self.assertEqual(SDozA_qty, 2, 'Wrong move quantity availability (%s found instead of 2)' % (SDozA_qty))
# Check product C available quantity
- SDozARound_qty = self.MoveObj.search([('product_id', '=', self.SDozARound.id), ('picking_id', '=', picking_out.id)], limit=1).availability
+ SDozARound_qty = self.MoveObj.search([('product_id', '=', self.SDozARound.id), ('picking_id', '=', picking_out.id)], limit=1).reserved_availability
self.assertEqual(SDozARound_qty, 3, 'Wrong move quantity availability (%s found instead of 3)' % (SDozARound_qty))
# Check product D available quantity
- gB_qty = self.MoveObj.search([('product_id', '=', self.gB.id), ('picking_id', '=', picking_out.id)], limit=1).availability
+ gB_qty = self.MoveObj.search([('product_id', '=', self.gB.id), ('picking_id', '=', picking_out.id)], limit=1).reserved_availability
self.assertEqual(gB_qty, 503, 'Wrong move quantity availability (%s found instead of 503)' % (gB_qty))
# Check product D available quantity
- kgB_qty = self.MoveObj.search([('product_id', '=', self.kgB.id), ('picking_id', '=', picking_out.id)], limit=1).availability
+ kgB_qty = self.MoveObj.search([('product_id', '=', self.kgB.id), ('picking_id', '=', picking_out.id)], limit=1).reserved_availability
self.assertEqual(kgB_qty, 0.020, 'Wrong move quantity availability (%s found instead of 0.020)' % (kgB_qty))
- picking_out.action_confirm()
- picking_out.action_assign()
picking_out.do_prepare_partial()
picking_out.do_transfer()
@@ -1276,4 +1276,263 @@ class TestStockFlow(TestStockCommon):
self.assertEqual(sum([x.qty for x in quants if not x.lot_id]), 0.0, 'Wrong sum of quants with no lot')
self.assertEqual(sum([x.qty for x in quants if x.lot_id.id == lot1.id]), 1.0, 'Wrong sum of quants with lot 1')
self.assertEqual(sum([x.qty for x in quants if x.lot_id.id == lot2.id]), 1.0, 'Wrong sum of quants with lot 2')
- self.assertEqual(sum([x.qty for x in quants if x.lot_id.id == lot3.id]), 2.0, 'Wrong sum of quants with lot 3')
\ No newline at end of file
+ self.assertEqual(sum([x.qty for x in quants if x.lot_id.id == lot3.id]), 2.0, 'Wrong sum of quants with lot 3')
+
+
+ def test_40_pack_in_pack(self):
+ """ Put a pack in pack"""
+ picking_out = self.PickingObj.create({
+ 'partner_id': self.partner_agrolite_id,
+ 'picking_type_id': self.picking_type_out,
+ 'location_id': self.pack_location,
+ 'location_dest_id': self.customer_location})
+ move_out = self.MoveObj.create({
+ 'name': self.productA.name,
+ 'product_id': self.productA.id,
+ 'product_uom_qty': 3,
+ 'product_uom': self.productA.uom_id.id,
+ 'picking_id': picking_out.id,
+ 'location_id': self.pack_location,
+ 'location_dest_id': self.customer_location})
+ picking_pack = 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.pack_location})
+ move_pack = self.MoveObj.create({
+ 'name': self.productA.name,
+ 'product_id': self.productA.id,
+ 'product_uom_qty': 3,
+ 'product_uom': self.productA.uom_id.id,
+ 'picking_id': picking_pack.id,
+ 'location_id': self.stock_location,
+ 'location_dest_id': self.pack_location,
+ 'move_dest_id': move_out.id})
+ 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})
+ move_in = self.MoveObj.create({
+ 'name': self.productA.name,
+ 'product_id': self.productA.id,
+ 'product_uom_qty': 3,
+ 'product_uom': self.productA.uom_id.id,
+ 'picking_id': picking_in.id,
+ 'location_id': self.supplier_location,
+ 'location_dest_id': self.stock_location,
+ 'move_dest_id': move_pack.id})
+
+ # Check incoming shipment move lines state.
+ for move in picking_in.move_lines:
+ self.assertEqual(move.state, 'draft', 'Wrong state of move line.')
+ # Confirm incoming shipment.
+ picking_in.action_confirm()
+ # Check incoming shipment move lines state.
+ for move in picking_in.move_lines:
+ self.assertEqual(move.state, 'assigned', 'Wrong state of move line.')
+
+ # Check incoming shipment move lines state.
+ for move in picking_pack.move_lines:
+ self.assertEqual(move.state, 'draft', 'Wrong state of move line.')
+ # Confirm incoming shipment.
+ picking_pack.action_confirm()
+ # Check incoming shipment move lines state.
+ for move in picking_pack.move_lines:
+ self.assertEqual(move.state, 'waiting', 'Wrong state of move line.')
+
+ # Check incoming shipment move lines state.
+ for move in picking_out.move_lines:
+ self.assertEqual(move.state, 'draft', 'Wrong state of move line.')
+ # Confirm incoming shipment.
+ picking_out.action_confirm()
+ # Check incoming shipment move lines state.
+ for move in picking_out.move_lines:
+ self.assertEqual(move.state, 'waiting', 'Wrong state of move line.')
+
+ # Set the quantity done on the pack operation
+ picking_in.pack_operation_product_ids.qty_done = 3.0
+ # Put in a pack
+ picking_in.put_in_pack()
+ # Get the new package
+ picking_in_package = picking_in.pack_operation_ids.result_package_id
+ # Validate picking
+ picking_in.do_new_transfer()
+
+ # Check first picking state changed to done
+ for move in picking_in.move_lines:
+ self.assertEqual(move.state, 'done', 'Wrong state of move line.')
+ # Check next picking state changed to 'assigned'
+ for move in picking_pack.move_lines:
+ self.assertEqual(move.state, 'assigned', 'Wrong state of move line.')
+
+ # set the pack in pack operation to 'done'
+ for pack in picking_pack.pack_operation_pack_ids:
+ pack.qty_done = 1.0
+
+ # Put in a pack
+ picking_pack.put_in_pack()
+ # Get the new package
+ picking_pack_package = picking_pack.pack_operation_ids.result_package_id
+ # Validate picking
+ picking_pack.do_new_transfer()
+
+ # Check second picking state changed to done
+ for move in picking_pack.move_lines:
+ self.assertEqual(move.state, 'done', 'Wrong state of move line.')
+ # Check next picking state changed to 'assigned'
+ for move in picking_out.move_lines:
+ self.assertEqual(move.state, 'assigned', 'Wrong state of move line.')
+
+ # set the pack in pack operation to 'done'
+ for pack in picking_out.pack_operation_pack_ids:
+ pack.qty_done = 1.0
+
+ # Validate picking
+ picking_out.do_new_transfer()
+
+ # check all pickings are done
+ for move in picking_in.move_lines:
+ self.assertEqual(move.state, 'done', 'Wrong state of move line.')
+ for move in picking_pack.move_lines:
+ self.assertEqual(move.state, 'done', 'Wrong state of move line.')
+ for move in picking_out.move_lines:
+ self.assertEqual(move.state, 'done', 'Wrong state of move line.')
+
+ # Check picking_in_package is in picking_pack_package
+ self.assertEqual(picking_in_package.parent_id.id, picking_pack_package.id, 'The package created in the picking in is not in the one created in picking pack')
+ # Check that both packages are in the customer location
+ self.assertEqual(picking_pack_package.location_id.id, self.customer_location, 'The package created in picking pack is not in the customer location')
+ self.assertEqual(picking_in_package.location_id.id, self.customer_location, 'The package created in picking in is not in the customer location')
+ # Check that we have a quant in customer location, for the productA with qty 3
+ quant = self.StockQuantObj.search([('location_id', '=', self.customer_location), ('product_id', '=', self.productA.id)])
+ self.assertTrue(quant.id, 'There is no quant in customer location for productA')
+ 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/stock/views/product_views.xml b/addons/stock/views/product_views.xml
index 68bfca2f4e1..8881820f59c 100644
--- a/addons/stock/views/product_views.xml
+++ b/addons/stock/views/product_views.xml
@@ -289,7 +289,7 @@