From 5d17749ff47c02294d5ff2ae56bbcef9d082562e Mon Sep 17 00:00:00 2001 From: Nicolas Lempereur Date: Fri, 21 Oct 2016 17:10:57 +0200 Subject: [PATCH 01/23] [FIX] web: repair length field in view list Underscore method _.each expects an array like object when a "length" property is present. This was an issue with a record having a numeric "length" field set to a non-negative value. When changing a line the change would not appear on blur. This issue was fixed with 0e664c9e9 but introduced back with f0e331e00. opw-691070 --- addons/web/static/src/js/view_list.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/addons/web/static/src/js/view_list.js b/addons/web/static/src/js/view_list.js index a16dddcbf91..ae1a7fa328f 100644 --- a/addons/web/static/src/js/view_list.js +++ b/addons/web/static/src/js/view_list.js @@ -554,11 +554,12 @@ instance.web.ListView = instance.web.View.extend( /** @lends instance.web.ListVi self.records.remove(record); return; } - _.each(values, function (value, key) { + // _.each is broken if a field "length" is present + for (var key in values) { if (fields[key] && fields[key].type === 'many2many') record.set(key + '__display', false, {silent: true}); - record.set(key, value, {silent: true}); - }); + record.set(key, values[key], {silent: true}); + } record.trigger('change', record); }); }, From f82ab99c1aa110aa6a28a2787402da09b7ea311e Mon Sep 17 00:00:00 2001 From: Nicolas Lempereur Date: Fri, 21 Oct 2016 17:30:59 +0200 Subject: [PATCH 02/23] [FIX] web: BufferedDataSet write to length field Underscore method _.each expects an array like object when a "length" property is present. This was an issue with a record having a numeric "length" field set to a non-negative value. opw-691070 --- addons/web/static/src/js/framework/data.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/addons/web/static/src/js/framework/data.js b/addons/web/static/src/js/framework/data.js index 7287070013a..005ceb1fb1e 100644 --- a/addons/web/static/src/js/framework/data.js +++ b/addons/web/static/src/js/framework/data.js @@ -799,18 +799,19 @@ var BufferedDataSet = DataSetStatic.extend({ var def = $.Deferred(); this.mutex.exec(function () { var dirty = false; - _.each(data, function (v, k) { - if (!_.isEqual(v, cached.values[k])) { + // _.each is broken if a field "length" is present + for (var k in data) { + if (!_.isEqual(data[k], cached.values[k])) { dirty = true; - if (_.isEqual(v, cached.from_read[k])) { // clean changes + if (_.isEqual(data[k], cached.from_read[k])) { // clean changes delete cached.changes[k]; } else { - cached.changes[k] = v; + cached.changes[k] = data[k]; } } else { delete data[k]; } - }); + } self._update_cache(id, options); if (dirty) { From 097f46e09a40fe2a5e7c50a8e9c27c897566c7ff Mon Sep 17 00:00:00 2001 From: Simon Lejeune Date: Mon, 24 Oct 2016 15:55:58 +0200 Subject: [PATCH 03/23] [REF] packaging: debian: depend on python-babel Before this patch, the debian package depends on `python-pybabel`. According to the documentation, this is a dummy package for transition from `python-pybabel` to `python-babel`[1]. This dummy package has thus been removed in debian stretch in favor of `python-babel`, and the odoo package is thus not installable in debian stretch. To fix this, we depend directly on `python-babel`, which is available in all debian releases[2]. Closes #13905 [1] https://packages.debian.org/jessie/python-pybabel [2] https://packages.debian.org/jessie/python-babel --- debian/control | 2 +- setup/package.dfdebian | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/debian/control b/debian/control index f6982ecc396..af0f90208c5 100644 --- a/debian/control +++ b/debian/control @@ -16,6 +16,7 @@ Depends: adduser, postgresql-client, python, + python-babel, python-dateutil, python-decorator, python-docutils, @@ -31,7 +32,6 @@ Depends: python-passlib, python-psutil, python-psycopg2, - python-pybabel, python-pychart, python-pydot, python-pyparsing, diff --git a/setup/package.dfdebian b/setup/package.dfdebian index af5c49956c5..1b9c255fa94 100644 --- a/setup/package.dfdebian +++ b/setup/package.dfdebian @@ -20,6 +20,7 @@ RUN apt-get update -qq && \ postgresql \ postgresql-client \ python \ + python-babel \ python-dateutil \ python-decorator \ python-docutils \ @@ -35,7 +36,6 @@ RUN apt-get update -qq && \ python-passlib \ python-psutil \ python-psycopg2 \ - python-pybabel \ python-pychart \ python-pydot \ python-pyparsing \ From 88d48818c259f44ef1e2c821972cd32b691a5d27 Mon Sep 17 00:00:00 2001 From: qsm-odoo Date: Mon, 24 Oct 2016 16:43:59 +0200 Subject: [PATCH 04/23] [FIX] website: correctly define website.error function * The Dialog dependency was missing * The function arguments were wrong --- addons/website/static/src/js/website.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/addons/website/static/src/js/website.js b/addons/website/static/src/js/website.js index 7a7e8809b31..6606adc4978 100644 --- a/addons/website/static/src/js/website.js +++ b/addons/website/static/src/js/website.js @@ -2,6 +2,7 @@ odoo.define('website.website', function (require) { "use strict"; var ajax = require('web.ajax'); + var Dialog = require("web.Dialog"); var core = require('web.core'); var Widget = require('web.Widget'); var session = require('web.session'); @@ -135,9 +136,9 @@ odoo.define('website.website', function (require) { var error = function (title, message, url) { return new Dialog(null, { - title: data.data ? data.data.arguments[0] : "", + title: title || "", $content: $(qweb.render('website.error_dialog', { - message: data.data ? data.data.arguments[1] : data.statusText, + message: message || "", backend_url: url, })), }).open(); From 389574e550e58a7ac8a7acdb277a2908d223e13e Mon Sep 17 00:00:00 2001 From: qsm-odoo Date: Mon, 24 Oct 2016 17:19:19 +0200 Subject: [PATCH 05/23] [FIX] website: make the 'include assets bundle' option of ace editor work Commit which added summernote broke the ace editor with a strange change: https://github.com/odoo/odoo/commit/9069d0127c176317436b67b23ae5677dd9d53de7#diff-e9d66a1b466f404be082fa00519eafa5L140. With this change instead of not including the assets bundles in the ace editor and allowing to see them by checking the option, the assets bundles were included when NOT in debug mode and not included when in debug mode. --- addons/website/static/src/js/website.ace.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/website/static/src/js/website.ace.js b/addons/website/static/src/js/website.ace.js index ad39ac92025..2a3f149fbad 100644 --- a/addons/website/static/src/js/website.ace.js +++ b/addons/website/static/src/js/website.ace.js @@ -137,7 +137,7 @@ var args = { key: $(document.documentElement).data('view-xmlid'), full: true, - bundles: !!$('script[src*=".assets_common"]').length + bundles: this.$('.js_include_bundles')[0].checked }; return openerp .jsonRpc('/website/customize_template_get', 'call', args) From eb7226d8d061ecafa523910bf48b1b2649090dd1 Mon Sep 17 00:00:00 2001 From: Damien Bouvy Date: Mon, 24 Oct 2016 17:33:38 +0200 Subject: [PATCH 06/23] [FIX] website: prevent faulty 'no access' message for analytics There are multiple scopes giving access to analytics api, like https://www.googleapis.com/auth/analytics.readonly Having a space at the end of the queried string prevents correct scope detection; so a user who has, in fact, access will receive a faulty error message. --- addons/website/static/src/js/website.backendDashboard.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/website/static/src/js/website.backendDashboard.js b/addons/website/static/src/js/website.backendDashboard.js index ed241fd2215..35b27340c5f 100644 --- a/addons/website/static/src/js/website.backendDashboard.js +++ b/addons/website/static/src/js/website.backendDashboard.js @@ -257,7 +257,7 @@ var Dashboard = Widget.extend(ControlPanelMixin, { // Check if the user is authenticated and has the right to make API calls if (!gapi.analytics.auth.getAuthResponse()) { this.display_unauthorized_message($analytics_components, 'not_connected'); - } else if (gapi.analytics.auth.getAuthResponse() && gapi.analytics.auth.getAuthResponse().scope.indexOf('https://www.googleapis.com/auth/analytics ') === -1) { + } else if (gapi.analytics.auth.getAuthResponse() && gapi.analytics.auth.getAuthResponse().scope.indexOf('https://www.googleapis.com/auth/analytics') === -1) { this.display_unauthorized_message($analytics_components, 'no_right'); } else { this.make_analytics_calls($analytics_components); From 19cca50e7c8d7e6b86f3851398725ce64694b063 Mon Sep 17 00:00:00 2001 From: Wolfgang Taferner Date: Fri, 7 Oct 2016 07:52:54 +0200 Subject: [PATCH 07/23] [FIX] point_of_sale: create two pickings for one POS order when required We support 'returns' in the POS frontend by allowing the user to specify a negative quantity. Before this patch however, the backend would always generate a single picking per pos.order. This is problematic when a pos.order contains both lines with a positive and negative quantity. The generated move lines would not all have the same source and destination location and so could not be added to the same picking. This commit creates an extra 'return' picking when required and assigns the generated moves to the correct picking. Closes #13699 Closes #13762 opw-690812 --- addons/point_of_sale/point_of_sale.py | 76 ++++++++++++++++++--------- 1 file changed, 50 insertions(+), 26 deletions(-) diff --git a/addons/point_of_sale/point_of_sale.py b/addons/point_of_sale/point_of_sale.py index 531960faf4b..b815673878b 100644 --- a/addons/point_of_sale/point_of_sale.py +++ b/addons/point_of_sale/point_of_sale.py @@ -948,6 +948,17 @@ class pos_order(osv.osv): return False return True + def _force_picking_done(self, cr, uid, picking_id, context=None): + context = context or {} + picking_obj = self.pool.get('stock.picking') + picking_obj.action_confirm(cr, uid, [picking_id], context=context) + picking_obj.force_assign(cr, uid, [picking_id], context=context) + # Mark pack operations as done + pick = picking_obj.browse(cr, uid, picking_id, context=context) + for pack in pick.pack_operation_ids: + self.pool['stock.pack.operation'].write(cr, uid, [pack.id], {'qty_done': pack.product_qty}, context=context) + picking_obj.action_done(cr, uid, [picking_id], context=context) + def create_picking(self, cr, uid, ids, context=None): """Create a picking for each order and validate it.""" picking_obj = self.pool.get('stock.picking') @@ -959,7 +970,9 @@ class pos_order(osv.osv): continue addr = order.partner_id and partner_obj.address_get(cr, uid, [order.partner_id.id], ['delivery']) or {} picking_type = order.picking_type_id - picking_id = False + return_pick_type = order.picking_type_id.return_picking_type_id or order.picking_type_id + order_picking_id = False + return_picking_id = False location_id = order.location_id.id if order.partner_id: destination_id = order.partner_id.property_stock_customer.id @@ -970,52 +983,63 @@ class pos_order(osv.osv): else: destination_id = picking_type.default_location_dest_id.id - #All qties negative => Create negative + # Create the normal use case picking (Stock -> Customer) if picking_type: - pos_qty = all([x.qty >= 0 for x in order.lines]) - #Check negative quantities - picking_id = picking_obj.create(cr, uid, { + picking_vals = { 'origin': order.name, - 'partner_id': addr.get('delivery',False), - 'date_done' : order.date_order, + 'partner_id': addr.get('delivery', False), + 'date_done': order.date_order, 'picking_type_id': picking_type.id, 'company_id': order.company_id.id, 'move_type': 'direct', 'note': order.note or "", - 'location_id': location_id if pos_qty else destination_id, - 'location_dest_id': destination_id if pos_qty else location_id, - }, context=context) - self.write(cr, uid, [order.id], {'picking_id': picking_id}, context=context) + 'location_id': location_id, + 'location_dest_id': destination_id, + } + pos_qty = any([x.qty >= 0 for x in order.lines]) + if pos_qty: + order_picking_id = picking_obj.create(cr, uid, picking_vals.copy(), context=context) + neg_qty = any([x.qty < 0 for x in order.lines]) + if neg_qty: + return_vals = picking_vals.copy() + return_vals.update({ + 'location_id': destination_id, + 'location_dest_id': location_id, + 'picking_type_id': return_pick_type.id + }) + return_picking_id = picking_obj.create(cr, uid, return_vals, context=context) move_list = [] for line in order.lines: if line.product_id and line.product_id.type not in ['product', 'consu']: continue - - move_list.append(move_obj.create(cr, uid, { + move_id = move_obj.create(cr, uid, { 'name': line.name, 'product_uom': line.product_id.uom_id.id, - 'picking_id': picking_id, - 'picking_type_id': picking_type.id, + 'picking_id': order_picking_id if line.qty >= 0 else return_picking_id, + 'picking_type_id': picking_type.id if line.qty >= 0 else return_pick_type.id, 'product_id': line.product_id.id, 'product_uom_qty': abs(line.qty), 'state': 'draft', 'location_id': location_id if line.qty >= 0 else destination_id, 'location_dest_id': destination_id if line.qty >= 0 else location_id, - }, context=context)) - - if picking_id: - picking_obj.action_confirm(cr, uid, [picking_id], context=context) - picking_obj.force_assign(cr, uid, [picking_id], context=context) - # Mark pack operations as done - pick = picking_obj.browse(cr, uid, picking_id, context=context) - for pack in pick.pack_operation_ids: - self.pool['stock.pack.operation'].write(cr, uid, [pack.id], {'qty_done': pack.product_qty}, context=context) - picking_obj.action_done(cr, uid, [picking_id], context=context) - elif move_list: + }, context=context) + move_list.append(move_id) + + # prefer associating the regular order picking, not the return + self.write(cr, uid, [order.id], {'picking_id': order_picking_id or return_picking_id}, context=context) + + if return_picking_id: + self._force_picking_done(cr, uid, return_picking_id, context=context) + if order_picking_id: + self._force_picking_done(cr, uid, order_picking_id, context=context) + + # when the pos.config has no picking_type_id set only the moves will be created + if move_list and not return_picking_id and not order_picking_id: move_obj.action_confirm(cr, uid, move_list, context=context) move_obj.force_assign(cr, uid, move_list, context=context) move_obj.action_done(cr, uid, move_list, context=context) + return True def cancel_order(self, cr, uid, ids, context=None): From d183de7aae00fb1613977ee40483047fefa8fb45 Mon Sep 17 00:00:00 2001 From: Martin Trigaux Date: Tue, 25 Oct 2016 11:10:14 +0200 Subject: [PATCH 08/23] [FIX] point_of_sale: do not strip name on backend report The max size of 20 is for the pos ticket which has a limited size. Strip on client side to keep the full name for the pdf report. opw-691483 --- addons/point_of_sale/models/pos_order.py | 2 +- addons/point_of_sale/static/src/xml/pos.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index b0492d28d43..20e2e40cc71 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -819,7 +819,7 @@ class ReportSaleDetails(models.AbstractModel): 'taxes': taxes.values(), 'products': sorted([{ 'product_id': product.id, - 'product_name': product.name[:20], + 'product_name': product.name, 'code': product.default_code, 'quantity': qty, 'price_unit': price_unit, diff --git a/addons/point_of_sale/static/src/xml/pos.xml b/addons/point_of_sale/static/src/xml/pos.xml index b593e8b72c1..a40e0c50fae 100644 --- a/addons/point_of_sale/static/src/xml/pos.xml +++ b/addons/point_of_sale/static/src/xml/pos.xml @@ -864,7 +864,7 @@
- + From 5ee4344efd0074fac4006984c1eb1ccc4f335518 Mon Sep 17 00:00:00 2001 From: Leonardo Rochael Almeida Date: Wed, 5 Oct 2016 20:45:51 -0300 Subject: [PATCH 09/23] [IMP] Allow configuration of server_wide_modules from file Allow reading and saving the value of `server_wide_modules` from the configuration file, instead of just the command line. --- openerp/tools/config.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/openerp/tools/config.py b/openerp/tools/config.py index d6affc6a407..70b70900b60 100644 --- a/openerp/tools/config.py +++ b/openerp/tools/config.py @@ -118,7 +118,7 @@ class configmanager(object): self.has_ssl = _check_ssl() self._LOGLEVELS = dict([ - (getattr(loglevels, 'LOG_%s' % x), getattr(logging, x)) + (getattr(loglevels, 'LOG_%s' % x), getattr(logging, x)) for x in ('CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG', 'NOTSET') ]) @@ -142,7 +142,7 @@ class configmanager(object): group.add_option("--addons-path", dest="addons_path", help="specify additional addons paths (separated by commas).", action="callback", callback=self._check_addons_path, nargs=1, type="string") - group.add_option("--load", dest="server_wide_modules", help="Comma-separated list of server-wide modules default=web") + group.add_option("--load", dest="server_wide_modules", help="Comma-separated list of server-wide modules. Default is 'web,web_kanban'") group.add_option("-D", "--data-dir", dest="data_dir", my_default=_get_default_datadir(), help="Directory where to store Odoo data") @@ -443,6 +443,7 @@ class configmanager(object): 'test_file', 'test_enable', 'test_commit', 'test_report_directory', 'osv_memory_count_limit', 'osv_memory_age_limit', 'max_cron_threads', 'unaccent', 'data_dir', + 'server_wide_modules', ] posix_keys = [ @@ -529,14 +530,21 @@ class configmanager(object): #if self.options['db_host']: # self._generate_pgpassfile() + # server_wide_modules defaults to web,web_kanban if empty or unset + server_wide_modules = self.options['server_wide_modules'] = ( + self.options['server_wide_modules'] + if self.options['server_wide_modules'] + else 'web,web_kanban' + ) + if opt.save: self.save() openerp.conf.addons_paths = self.options['addons_path'].split(',') - if opt.server_wide_modules: - openerp.conf.server_wide_modules = map(lambda m: m.strip(), opt.server_wide_modules.split(',')) - else: - openerp.conf.server_wide_modules = ['web','web_kanban'] + + openerp.conf.server_wide_modules = [ + m.strip() for m in server_wide_modules.split(',') + ] def _generate_pgpassfile(self): """ From e99e0d6922742500ecb222b7667a85fe9220863f Mon Sep 17 00:00:00 2001 From: Nicolas Martinelli Date: Wed, 26 Oct 2016 10:46:00 +0200 Subject: [PATCH 10/23] [FIX] point_of_sale: journal of closing entries - Invoice a POS order => the invoice number is for example 0001 - Close the POS session - Invoice another POS order => the invoice number is 0003 This is because the journal used for the POS closing entries is the same than the journal used for the customer invoices. Therefore, the closing entry consumes a sequence number of the invoices. This affects POS orders, but potentially SO as well if they use the same journal. This is fixed in v10 from commit b5b0d36b31. In v9, we use a workaround by looking first if a `ir.config_parameter` named `pos.closing.journal_id` exists. We use this journal for the session closing. opw-691771 --- addons/point_of_sale/point_of_sale.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/addons/point_of_sale/point_of_sale.py b/addons/point_of_sale/point_of_sale.py index b815673878b..1d35dc8437c 100644 --- a/addons/point_of_sale/point_of_sale.py +++ b/addons/point_of_sale/point_of_sale.py @@ -610,7 +610,9 @@ class pos_session(osv.osv): local_context = dict(context or {}, force_company=company_id) order_ids = [order.id for order in session.order_ids if order.state == 'paid'] - move_id = pos_order_obj._create_account_move(cr, uid, session.start_at, session.name, session.config_id.journal_id.id, company_id, context=context) + # FORWARD-PORT UP TO SAAS-12 + journal_id = self.pool['ir.config_parameter'].get_param(cr, SUPERUSER_ID, 'pos.closing.journal_id', default=session.config_id.journal_id.id, context=context) + move_id = pos_order_obj._create_account_move(cr, uid, session.start_at, session.name, int(journal_id), company_id, context=context) pos_order_obj._create_account_move_line(cr, uid, order_ids, session, move_id, context=local_context) @@ -1287,7 +1289,9 @@ class pos_order(osv.osv): if move_id is None: # Create an entry for the sale - move_id = self._create_account_move(cr, uid, order.session_id.start_at, order.name, order.sale_journal.id, order.company_id.id, context=context) + # FORWARD-PORT UP TO SAAS-12 + journal_id = self.pool['ir.config_parameter'].get_param(cr, SUPERUSER_ID, 'pos.closing.journal_id', default=order.sale_journal.id, context=context) + move_id = self._create_account_move(cr, uid, order.session_id.start_at, order.name, int(journal_id), order.company_id.id, context=context) move = account_move_obj.browse(cr, SUPERUSER_ID, move_id, context=context) From cc59da1fc88a0b50ac8d405af4fcca5b11a20fb9 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Thu, 13 Oct 2016 09:56:46 +0200 Subject: [PATCH 11/23] [FIX] web_diagram: wait for dialog's form view to be ready Since rev. 002660a, form views are instantiated with their fields_view. This has an impact in FormViewDialog as the inner form view instantiation is now asynchronous. The diagram view instantiates several FormViewDialogs but doesn't wait for the form view to be instantiated before accessing it, which produces a traceback. Closes #13775 --- addons/web_diagram/static/src/js/diagram.js | 40 ++++++++++----------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/addons/web_diagram/static/src/js/diagram.js b/addons/web_diagram/static/src/js/diagram.js index 4192e30a185..56664176ebe 100644 --- a/addons/web_diagram/static/src/js/diagram.js +++ b/addons/web_diagram/static/src/js/diagram.js @@ -246,16 +246,13 @@ var DiagramView = View.extend({ self.dataset.read_index(_.keys(self.fields_view.fields)).then(self.on_diagram_loaded); }); - var form_fields = [self.parent_field]; - var form_controller = pop.view_form; - - form_controller.on("load_record", self, function(){ - _.each(form_fields, function(fld) { - if (!(fld in form_controller.fields)) { return; } - var field = form_controller.fields[fld]; + pop.opened().then(function(){ + var form_controller = pop.view_form; + var field = form_controller.fields[self.parent_field]; + if (field) { field.$input.prop('disabled', true); field.$dropdown.unbind(); - }); + } }); }, @@ -274,16 +271,13 @@ var DiagramView = View.extend({ } }).open(); - var form_controller = pop.view_form; - var form_fields = [this.parent_field]; - - form_controller.on("load_record", self, function(){ - _.each(form_fields, function(fld) { - if (!(fld in form_controller.fields)) { return; } - var field = form_controller.fields[fld]; + pop.opened().then(function(){ + var form_controller = pop.view_form; + var field = form_controller.fields[self.parent_field]; + if (field) { field.set_value(self.id); field.dirty = true; - }); + } }); }, @@ -326,12 +320,14 @@ var DiagramView = View.extend({ } }); - var form_controller = pop.view_form; - form_controller.on("load_record", self, function(){ - form_controller.fields[self.connectors.attrs.source].set_value(node_source_id); - form_controller.fields[self.connectors.attrs.source].dirty = true; - form_controller.fields[self.connectors.attrs.destination].set_value(node_dest_id); - form_controller.fields[self.connectors.attrs.destination].dirty = true; + pop.opened().then(function(){ + var form_controller = pop.view_form; + var source_field = form_controller.fields[self.connectors.attrs.source]; + var destination_field = form_controller.fields[self.connectors.attrs.destination]; + source_field.set_value(node_source_id); + source_field.dirty = true; + destination_field.set_value(node_dest_id); + destination_field.dirty = true; }); }, From 5591a44e0fe061075cf989835ac3f1b9e63cade7 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Mon, 24 Oct 2016 16:08:57 +0200 Subject: [PATCH 12/23] [FIX] board: traceback when removing calendar from dashboard The dashboard instantiates an action manager for each view it contains. When removing a view from the dashboard, the $el of its action manager is simply removed from the DOM, unbinding all DOM event handlers attached on it and its children, but the action manager isn't destroyed. It means that Odoo event handlers (like core.bus.on(...)) aren't unbound. This causes a traceback when removing a calendar view from the dashboard, because such an Odoo handler is defined, and tries to access some autocomplete stuff that doesn't exist anymore since the element has been removed. This commit ensures to destroy the action_manager. Closes #13858 --- addons/board/static/src/js/dashboard.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/addons/board/static/src/js/dashboard.js b/addons/board/static/src/js/dashboard.js index 356b0906ff9..1d543f1216c 100644 --- a/addons/board/static/src/js/dashboard.js +++ b/addons/board/static/src/js/dashboard.js @@ -137,7 +137,11 @@ var DashBoard = form_common.FormWidget.extend({ }, on_close_action: function(e) { if (confirm(_t("Are you sure you want to remove this item ?"))) { - $(e.currentTarget).parents('.oe_action:first').remove(); + var $container = $(e.currentTarget).parents('.oe_action:first'); + var am = _.findWhere(this.action_managers, { am_id: $container.data('am_id') }); + am.destroy(); + this.action_managers.splice(_.indexOf(this.action_managers, am), 1); + $container.remove(); this.do_save_dashboard(); } }, @@ -221,7 +225,13 @@ var DashBoard = form_common.FormWidget.extend({ var am = new ActionManager(this), // FIXME: ideally the dashboard view shall be refactored like kanban. $action = $('#' + this.view.element_id + '_action_' + index); - $action.parent().data('action_attrs', action_attrs); + var $action_container = $action.closest('.oe_action'); + var am_id = _.uniqueId('action_manager_'); + am.am_id = am_id; + $action_container.data({ + action_attrs: action_attrs, + am_id: am_id, + }); this.action_managers.push(am); am.appendTo($action); am.do_action(action); From 1f06cc41684af15bc777da0024d66911e0c92990 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Mon, 24 Oct 2016 12:15:18 +0200 Subject: [PATCH 13/23] [FIX] web: list editable: traceback when removing a row in edition When clicking on the trash to remove a line (e.g. a sale order line in a quotation form view) while editing another line, it produces a traceback (in most cases), or behaves randomly (less frequently) like removing both lines, or one of them, or maybe none of them. Anyway, this feature doesn't work at all, so this commit simply disables it when we are in edition mode. In a perfect world, we would fix the feature instead of disabling it, but the code of the list editable is so instable that it would most certainly break something else. Closes #13778 --- addons/web/static/src/css/base.css | 4 ++++ addons/web/static/src/css/base.sass | 3 +++ 2 files changed, 7 insertions(+) diff --git a/addons/web/static/src/css/base.css b/addons/web/static/src/css/base.css index dfc127f7c65..4a2d5f81bb2 100644 --- a/addons/web/static/src/css/base.css +++ b/addons/web/static/src/css/base.css @@ -2224,6 +2224,10 @@ .openerp .oe_list.oe_list_editable.oe_editing .oe_form_field.oe_form_field_reference { display: table; } +.openerp .oe_list.oe_list_editable.oe_editing .oe_list_record_delete { + opacity: 0.5; + pointer-events: none; +} .openerp .oe_list_group_name { white-space: nowrap; } diff --git a/addons/web/static/src/css/base.sass b/addons/web/static/src/css/base.sass index f11fb0a76df..7ba9e6bece3 100644 --- a/addons/web/static/src/css/base.sass +++ b/addons/web/static/src/css/base.sass @@ -1909,6 +1909,9 @@ $sheet-padding: 16px width: calc(100% - 20px) &.oe_form_field_reference display: table + .oe_list_record_delete + opacity: 0.5 + pointer-events: none .oe_list_group_name white-space: nowrap // }}} From 69c41c7e3c2dd46080c259613b28e1f5eeb9e22f Mon Sep 17 00:00:00 2001 From: Goffin Simon Date: Wed, 26 Oct 2016 11:11:06 +0200 Subject: [PATCH 14/23] [FIX] account: invoices on unmatched payment When unreconciling a payment from an invoice, the link between the payment and the invoice (in many2many invoice_ids)was kept and then the invoice still appeared when clicking on the button invoices in the payment form view. The invoice must be removed from invoice_ids. opw:691692 --- addons/account/models/account_move.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 9bd24f3c94f..df2cc859baa 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -972,6 +972,9 @@ class AccountMoveLine(models.Model): return True rec_move_ids = self.env['account.partial.reconcile'] for account_move_line in self: + for invoice in account_move_line.payment_id.invoice_ids: + if account_move_line in invoice.payment_move_line_ids: + account_move_line.payment_id.write({'invoice_ids': [(3, invoice.id, None)]}) rec_move_ids += account_move_line.matched_debit_ids rec_move_ids += account_move_line.matched_credit_ids return rec_move_ids.unlink() From ea9ef7c39d3113b42de626e943425f7d63312c2f Mon Sep 17 00:00:00 2001 From: Raphael Collet Date: Wed, 26 Oct 2016 14:25:17 +0200 Subject: [PATCH 15/23] [FIX] base: prevent deletion of custom fields when other fields depend on them (#13978) Consider that you have a custom one2many field based on a custom many2one field, and that you delete the many2one field. From that point on, it is impossible to load the registry of the corresponding database. To prevent this from happening, we add a check before modifying or deleting custom fields. --- openerp/addons/base/ir/ir_model.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/openerp/addons/base/ir/ir_model.py b/openerp/addons/base/ir/ir_model.py index 1777982c75e..0b571bfcc06 100644 --- a/openerp/addons/base/ir/ir_model.py +++ b/openerp/addons/base/ir/ir_model.py @@ -142,6 +142,10 @@ class ir_model(osv.osv): if model.state != 'manual': raise UserError(_("Model '%s' contains module data and cannot be removed!") % (model.name,)) + # prevent screwing up fields that depend on these models' fields + for model in self.browse(cr, user, ids, context=context): + model.field_id._prepare_update() + self._drop_table(cr, user, ids, context) res = super(ir_model, self).unlink(cr, user, ids, context) if not context.get(MODULE_UNINSTALL_FLAG): @@ -431,6 +435,19 @@ class ir_model_fields(osv.osv): return True + @api.multi + def _prepare_update(self): + """ Check whether the fields in ``self`` may be modified or removed. + This method prevents the modification/deletion of many2one fields + that have an inverse one2many, for instance. + """ + for record in self: + model = self.env[record.model] + field = model._fields[record.name] + if field.type == 'many2one' and model._field_inverses.get(field): + msg = _("The field '%s' cannot be removed because the field '%s' depends on it.") + raise UserError(msg % (field, model._field_inverses[field][0])) + def unlink(self, cr, user, ids, context=None): # Prevent manual deletion of module columns if context is None: context = {} @@ -440,6 +457,9 @@ class ir_model_fields(osv.osv): any(field.state != 'manual' for field in self.browse(cr, user, ids, context)): raise UserError(_("This column contains module data and cannot be removed!")) + # prevent screwing up fields that depend on these fields + self.browse(cr, user, ids, context=context)._prepare_update() + self._drop_column(cr, user, ids, context) res = super(ir_model_fields, self).unlink(cr, user, ids, context) if not context.get(MODULE_UNINSTALL_FLAG): @@ -528,6 +548,7 @@ class ir_model_fields(osv.osv): if vals.get('name', item.name) != item.name: # We need to rename the column + item._prepare_update() if column_rename: raise UserError(_('Can only rename one field at a time!')) if vals['name'] in obj._fields: From 83eaaab80188d9b31f3903a31f793d08a20dded7 Mon Sep 17 00:00:00 2001 From: Christophe Simonis Date: Wed, 26 Oct 2016 19:09:26 +0200 Subject: [PATCH 16/23] [FIX] web: correct tests to mock `fields_get` method --- addons/web/static/test/list-editable.js | 21 +++++++++++++++++++++ addons/web/static/test/list.js | 6 ++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/addons/web/static/test/list-editable.js b/addons/web/static/test/list-editable.js index be2acdb1365..4b88d9f06e5 100644 --- a/addons/web/static/test/list-editable.js +++ b/addons/web/static/test/list-editable.js @@ -196,6 +196,13 @@ odoo.define_section('list.edition', ['web.data', 'web.ListView', 'web.data_manag mock.add('demo:onchange', function () { return {}; }); + mock.add('demo:fields_get', function () { + return { + a: {type: 'char', string: "A"}, + b: {type: 'char', string: "B"}, + c: {type: 'char', string: "C"} + }; + }); } test('newrecord', function (assert, data, ListView, data_manager) { @@ -263,6 +270,13 @@ odoo.define_section('list.edition.events', ['web.data', 'web.ListView', 'web.dat mock.add('demo:read', function () { return [{ id: 1, a: 'foo', b: 'bar', c: 'baz' }]; }); + mock.add('demo:fields_get', function () { + return { + a: {type: 'char', string: "A"}, + b: {type: 'char', string: "B"}, + c: {type: 'char', string: "C"} + }; + }); } test('edition events',function (assert, data, ListView, data_manager) { @@ -345,6 +359,13 @@ odoo.define_section('list.edition.onwrite', ['web.data', 'web.ListView', 'web.da mock.add('demo:default_get', function () { return {}; }); mock.add('demo:create', function () { return 1; }); mock.add('demo:on_write', function () { return [42]; }); + mock.add('demo:fields_get', function () { + return { + a: {type: 'char', string: "A"}, + b: {type: 'char', string: "B"}, + c: {type: 'char', string: "C"} + }; + }); var ds = new data.DataSetStatic(null, 'demo', null, []); var fields_view = data_manager._postprocess_fvg({ diff --git a/addons/web/static/test/list.js b/addons/web/static/test/list.js index 9d1d1b9ec7d..49adabba13d 100644 --- a/addons/web/static/test/list.js +++ b/addons/web/static/test/list.js @@ -2,7 +2,7 @@ odoo.define_section('list.buttons', ['web.ListView', 'web.data', 'web.data_manag test('record-deletion', function (assert, ListView, data, data_manager) { assert.expect(2); - + mock.add('demo:read', function (args, kwargs) { if (_.isEqual(args[0], [1, 2, 3])) { return [ @@ -18,7 +18,9 @@ odoo.define_section('list.buttons', ['web.ListView', 'web.data', 'web.data_manag throw new Error(JSON.stringify(_.toArray(arguments))); }); mock.add('/web/dataset/call_button', function () { return false; }); - + mock.add('demo:fields_get', function() { + return {a: {type: 'char', string: "A"}}; + }); var ds = new data.DataSetStatic(null, 'demo', null, [1, 2, 3]); var fields_view = data_manager._postprocess_fvg({ From b4d5246545bb13baf6ebf16b28d5fadf5b270aaf Mon Sep 17 00:00:00 2001 From: Nicolas Lempereur Date: Wed, 26 Oct 2016 11:26:51 +0200 Subject: [PATCH 17/23] [IMP] mail: no jump for chatter show older messages When clicking on "Show older messages" in the bottom of the chatter, the page would jump to the top when the additional messages are loaded. There is already a functionality in a discuss channel to stay at the previous scrolling position (related to the content) : https://github.com/odoo/odoo/blob/ffe0db0d/addons/mail/static/src/js/client_action.js#L506-L521 This fix does the same steps for the chatter. opw-692010 --- addons/mail/static/src/js/chatter.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/addons/mail/static/src/js/chatter.js b/addons/mail/static/src/js/chatter.js index 5a1f57b3a0f..d2a5cfdb432 100644 --- a/addons/mail/static/src/js/chatter.js +++ b/addons/mail/static/src/js/chatter.js @@ -11,6 +11,7 @@ var core = require('web.core'); var data = require('web.data'); var Dialog = require('web.Dialog'); var form_common = require('web.form_common'); +var framework = require('web.framework'); var session = require('web.session'); var _t = core._t; @@ -841,7 +842,14 @@ var Chatter = form_common.AbstractField.extend({ }, load_more_messages: function () { - this.fetch_and_render_thread(this.msg_ids, {force_fetch: true}); + var self = this; + var top_msg_id = this.$('.o_thread_message').first().data('messageId'); + var top_msg_selector = '.o_thread_message[data-message-id="' + top_msg_id + '"]'; + var offset = -framework.getPosition(document.querySelector(top_msg_selector)).top; + this.fetch_and_render_thread(this.msg_ids, {force_fetch: true}).then(function(){ + offset += framework.getPosition(document.querySelector(top_msg_selector)).top; + self.thread.scroll_to({offset: offset}); + }); }, /** From 10e9aed584736a50ec279834116caf182f40e5bf Mon Sep 17 00:00:00 2001 From: Goffin Simon Date: Wed, 26 Oct 2016 13:11:34 +0200 Subject: [PATCH 18/23] [FIX] account: reconciliation_widget_auto_reconcile The ids returned in notifications must be ids of account move. opw:690621 --- addons/account/models/account_bank_statement.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index 056cf27c445..b23b7a82d80 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -471,7 +471,7 @@ class AccountBankStatementLine(models.Model): 'details': { 'name': _("Automatically reconciled items"), 'model': 'account.move', - 'ids': automatic_reconciliation_entries.ids + 'ids': automatic_reconciliation_entries.mapped('journal_entry_ids').ids } }] return { From 147db011c733136d429ba0b705da13b92c18a629 Mon Sep 17 00:00:00 2001 From: Christophe Simonis Date: Thu, 27 Oct 2016 13:44:55 +0200 Subject: [PATCH 19/23] [FIX] web: deactivate unreliable test --- addons/web/tests/test_js.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/addons/web/tests/test_js.py b/addons/web/tests/test_js.py index 811f3aac4d0..841c14fd40e 100644 --- a/addons/web/tests/test_js.py +++ b/addons/web/tests/test_js.py @@ -1,5 +1,8 @@ +import unittest import openerp.tests class WebSuite(openerp.tests.HttpCase): + + @unittest.skip('Memory leak in this test lead to phantomjs crash, making it unreliable') def test_01_js(self): self.phantom_js('/web/tests?mod=web',"","", login='admin') From d3de63ec3fe7d98d0f29ec86218ee1102472d3c6 Mon Sep 17 00:00:00 2001 From: David Arnold Date: Thu, 27 Oct 2016 13:13:18 +0200 Subject: [PATCH 20/23] [FIX] account: nested group of taxes When a group of tax is nested inside another group of taxes, the tax amount and base are wrongly calculated. It is also the case when a tax is applied after a group of taxes. This is because the recursive call doesn't reuse the previously calculated tax amounts and base, but always use the same entry amounts. The fix introduces this behavior, and moreover makes sure to include the tax group in the base amount only if it should. Closes #13995 --- addons/account/models/account.py | 12 ++++++++--- addons/account/tests/test_tax.py | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/addons/account/models/account.py b/addons/account/models/account.py index 7b8f7beafd1..3dfae4aeda0 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -689,7 +689,12 @@ class AccountTax(models.Model): if not round_tax: prec += 5 - total_excluded = total_included = base = round(price_unit * quantity, prec) + + base_values = self.env.context.get('base_values') + if not base_values: + total_excluded = total_included = base = round(price_unit * quantity, prec) + else: + total_excluded, total_included, base = base_values # Sorting key is mandatory in this case. When no key is provided, sorted() will perform a # search. However, the search method is overridden in account.tax in order to add a domain @@ -697,9 +702,10 @@ class AccountTax(models.Model): # case of group taxes. for tax in self.sorted(key=lambda r: r.sequence): if tax.amount_type == 'group': - ret = tax.children_tax_ids.compute_all(price_unit, currency, quantity, product, partner) + children = tax.children_tax_ids.with_context(base_values=(total_excluded, total_included, base)) + ret = children.compute_all(price_unit, currency, quantity, product, partner) total_excluded = ret['total_excluded'] - base = ret['base'] + base = ret['base'] if tax.include_base_amount else base total_included = ret['total_included'] tax_amount = total_included - total_excluded taxes += ret['taxes'] diff --git a/addons/account/tests/test_tax.py b/addons/account/tests/test_tax.py index d221e705412..02308fa6498 100644 --- a/addons/account/tests/test_tax.py +++ b/addons/account/tests/test_tax.py @@ -43,10 +43,46 @@ class TestTax(AccountTestUsers): (4, self.percent_tax.id, 0) ] }) + self.group_tax_bis = self.tax_model.create({ + 'name': "Group tax bis", + 'amount_type': 'group', + 'amount': 0, + 'sequence': 6, + 'children_tax_ids': [ + (4, self.fixed_tax.id, 0), + (4, self.percent_tax.id, 0) + ] + }) + self.group_of_group_tax = self.tax_model.create({ + 'name': "Group of group tax", + 'amount_type': 'group', + 'amount': 0, + 'sequence': 7, + 'children_tax_ids': [ + (4, self.group_tax.id, 0), + (4, self.group_tax_bis.id, 0) + ] + }) self.bank_journal = self.env['account.journal'].search([('type', '=', 'bank'), ('company_id', '=', self.account_manager.company_id.id)])[0] self.bank_account = self.bank_journal.default_debit_account_id self.expense_account = self.env['account.account'].search([('user_type_id.type', '=', 'payable')], limit=1) #Should be done by onchange later + def test_tax_group_of_group_tax(self): + self.fixed_tax.include_base_amount = True + self.group_tax.include_base_amount = True + self.group_of_group_tax.include_base_amount = True + res = self.group_of_group_tax.compute_all(200.0) + self.assertEquals(res['total_excluded'], 200.0) + # After calculation of first group + # base = 210 + # total_included = 231 + # Base of the first grouped is passed + # Base after the second group (220) is dropped. + # Base of the group of groups is passed out, + # so we obtain base as after first group + self.assertEquals(res['base'], 210.0) + self.assertEquals(res['total_included'], 263.0) + def test_tax_group(self): res = self.group_tax.compute_all(200.0) self.assertEquals(res['total_excluded'], 200.0) From 179f7c0767feb8d26f1369afe9eccda3df98b079 Mon Sep 17 00:00:00 2001 From: David Arnold Date: Thu, 27 Oct 2016 13:46:25 +0200 Subject: [PATCH 21/23] [FIX] account: missing data in hook function Oversight of commit 7e58821a9f842c95a88d41a8146f597a50020153 Closes #13998 --- addons/account/models/account_invoice.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index c2c836110f8..8a2b74b0083 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -560,6 +560,14 @@ class AccountInvoice(models.Model): 'account_analytic_id': tax['analytic'] and line.account_analytic_id.id or False, 'account_id': self.type in ('out_invoice', 'in_invoice') and (tax['account_id'] or line.account_id.id) or (tax['refund_account_id'] or line.account_id.id), } + + # If the taxes generate moves on the same financial account as the invoice line, + # propagate the analytic account from the invoice line to the tax line. + # This is necessary in situations were (part of) the taxes cannot be reclaimed, + # to ensure the tax move is allocated to the proper analytic account. + if not vals.get('account_analytic_id') and line.account_analytic_id and vals['account_id'] == line.account_id.id: + vals['account_analytic_id'] = line.account_analytic_id.id + return vals @api.multi @@ -570,14 +578,6 @@ class AccountInvoice(models.Model): taxes = line.invoice_line_tax_ids.compute_all(price_unit, self.currency_id, line.quantity, line.product_id, self.partner_id)['taxes'] for tax in taxes: val = self._prepare_tax_line_vals(line, tax) - - # If the taxes generate moves on the same financial account as the invoice line, - # propagate the analytic account from the invoice line to the tax line. - # This is necessary in situations were (part of) the taxes cannot be reclaimed, - # to ensure the tax move is allocated to the proper analytic account. - if not val.get('account_analytic_id') and line.account_analytic_id and val['account_id'] == line.account_id.id: - val['account_analytic_id'] = line.account_analytic_id.id - key = self.env['account.tax'].browse(tax['id']).get_grouping_key(val) if key not in tax_grouped: From 25616b9f2bbca3f03dbde1dc81a57986241c1aab Mon Sep 17 00:00:00 2001 From: Nicolas Lempereur Date: Fri, 21 Oct 2016 17:10:57 +0200 Subject: [PATCH 22/23] [FIX] web: repair length field in view list Underscore method _.each expects an array like object when a "length" property is present. This was an issue with a record having a numeric "length" field set to a non-negative value. When changing a line the change would not appear on blur. This issue was fixed with 0e664c9e9 but introduced back with f0e331e00. opw-691070 Forward-port of 5d17749ff47c02294d5ff2ae56bbcef9d082562e which has been forgotten during previous forward-port. --- addons/web/static/src/js/views/list_view.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/addons/web/static/src/js/views/list_view.js b/addons/web/static/src/js/views/list_view.js index 301b3936d02..3e01ea0c4f6 100644 --- a/addons/web/static/src/js/views/list_view.js +++ b/addons/web/static/src/js/views/list_view.js @@ -560,11 +560,12 @@ var ListView = View.extend( /** @lends instance.web.ListView# */ { self.records.remove(record); return; } - _.each(values, function (value, key) { + // _.each is broken if a field "length" is present + for (var key in values) { if (fields[key] && fields[key].type === 'many2many') record.set(key + '__display', false, {silent: true}); - record.set(key, value, {silent: true}); - }); + record.set(key, values[key], {silent: true}); + } record.trigger('change', record); /* When a record is reloaded, there is a rendering lag because of the addition/suppression of From cb5fd87e7fdab3e3aa559adfd6fbe5392e3ee301 Mon Sep 17 00:00:00 2001 From: Wolfgang Taferner Date: Fri, 21 Oct 2016 08:19:31 +0200 Subject: [PATCH 23/23] [FIX] point_of_sale: handle returns always correctly We support 'returns' in the POS frontend by allowing the user to specify a negative quantity. Before this patch however, the backend would always generate a single picking per pos.order. This is problematic when a pos.order contains both lines with a positive and negative quantity. The generated move lines would not all have the same source and destination location and so could not be added to the same picking. This commit creates an extra 'return' picking when required and assigns the generated moves to the correct picking. opw-690812 Closes #13929 Forward-port of 19cca50e7c8d7e6b86f3851398725ce64694b063 --- addons/point_of_sale/models/pos_order.py | 74 +++++-- .../tests/test_point_of_sale_flow.py | 201 +++++++++++++++++- 2 files changed, 251 insertions(+), 24 deletions(-) diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index 20e2e40cc71..28973248676 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -532,7 +532,10 @@ class PosOrder(models.Model): for order in self: address = order.partner_id.address_get(['delivery']) or {} picking_type = order.picking_type_id - picking_id = False + return_pick_type = order.picking_type_id.return_picking_type_id or order.picking_type_id + order_picking = Picking + return_picking = Picking + moves = Move location_id = order.location_id.id if order.partner_id: destination_id = order.partner_id.property_stock_customer.id @@ -542,9 +545,9 @@ class PosOrder(models.Model): destination_id = customerloc.id else: destination_id = picking_type.default_location_dest_id.id + if picking_type: - pos_qty = all([x.qty >= 0 for x in order.lines]) - picking_id = Picking.create({ + picking_vals = { 'origin': order.name, 'partner_id': address.get('delivery', False), 'date_done': order.date_order, @@ -552,36 +555,61 @@ class PosOrder(models.Model): 'company_id': order.company_id.id, 'move_type': 'direct', 'note': order.note or "", - 'location_id': location_id if pos_qty else destination_id, - 'location_dest_id': destination_id if pos_qty else location_id, - }) - order.write({'picking_id': picking_id.id}) + 'location_id': location_id, + 'location_dest_id': destination_id, + } + pos_qty = any([x.qty >= 0 for x in order.lines]) + if pos_qty: + order_picking = Picking.create(picking_vals.copy()) + neg_qty = any([x.qty < 0 for x in order.lines]) + if neg_qty: + return_vals = picking_vals.copy() + return_vals.update({ + 'location_id': destination_id, + 'location_dest_id': return_pick_type != picking_type and return_pick_type.default_location_dest_id.id or location_id, + 'picking_type_id': return_pick_type.id + }) + return_picking = Picking.create(return_vals) for line in order.lines.filtered(lambda l: l.product_id.type in ['product', 'consu']): - Move += Move.create({ + moves |= Move.create({ 'name': line.name, 'product_uom': line.product_id.uom_id.id, - 'picking_id': picking_id and picking_id.id or False, - 'picking_type_id': picking_type.id, + 'picking_id': order_picking.id if line.qty >= 0 else return_picking.id, + 'picking_type_id': picking_type.id if line.qty >= 0 else return_pick_type.id, 'product_id': line.product_id.id, 'product_uom_qty': abs(line.qty), 'state': 'draft', 'location_id': location_id if line.qty >= 0 else destination_id, - 'location_dest_id': destination_id if line.qty >= 0 else location_id, + 'location_dest_id': destination_id if line.qty >= 0 else return_pick_type != picking_type and return_pick_type.default_location_dest_id.id or location_id, }) - if picking_id: - picking_id.action_confirm() - picking_id.force_assign() - # Mark pack operations as done - for pack in picking_id.pack_operation_ids: - pack.write({'qty_done': pack.product_qty}) - picking_id.action_done() - elif Move: - Move.action_confirm() - Move.force_assign() - Move.action_done() + + # prefer associating the regular order picking, not the return + order.write({'picking_id': order_picking.id or return_picking.id}) + + if return_picking: + order._force_picking_done(return_picking) + if order_picking: + order._force_picking_done(order_picking) + + # when the pos.config has no picking_type_id set only the moves will be created + if moves and not return_picking and not order_picking: + moves.action_confirm() + moves.force_assign() + moves.action_done() + return True + def _force_picking_done(self, picking): + """Force picking in order to be set as done.""" + for order in self: + picking.action_confirm() + picking.force_assign() + # Mark pack operations as done + for pack in picking.pack_operation_ids: + pack.write({'qty_done': pack.product_qty}) + picking.action_done() + def add_payment(self, data): """Create a new payment for the order""" args = { @@ -835,4 +863,4 @@ class ReportSaleDetails(models.AbstractModel): date_stop = self.env.context.get('date_stop', False) data = dict(data or {}, date_start=date_start, date_stop=date_stop) data.update(self.get_sale_details(date_start, date_stop, company)) - return self.env['report'].render('point_of_sale.report_saledetails', data) \ No newline at end of file + return self.env['report'].render('point_of_sale.report_saledetails', data) diff --git a/addons/point_of_sale/tests/test_point_of_sale_flow.py b/addons/point_of_sale/tests/test_point_of_sale_flow.py index 023eff033a0..4f029df9439 100644 --- a/addons/point_of_sale/tests/test_point_of_sale_flow.py +++ b/addons/point_of_sale/tests/test_point_of_sale_flow.py @@ -119,9 +119,208 @@ class TestPointOfSaleFlow(TestPointOfSaleCommon): # I test that the generated journal entry is attached to the PoS order self.assertTrue(self.pos_order_pos0.account_move, "Journal entry has not been attached to Pos order.") + def test_order_to_picking(self): + """ + In order to test the Point of Sale in module, I will do three orders from the sale to the payment, + invoicing + picking, but will only check the picking consistency in the end. + + TODO: Check the negative picking after changing the picking relation to One2many (also for a mixed use case), + check the quantity, the locations and return picking logic + """ + # I click on create a new session button + self.pos_config.open_session_cb() + + # I create a PoS order with 2 units of PCSC234 at 450 EUR + # and 3 units of PCSC349 at 300 EUR. + self.pos_order_pos1 = self.PosOrder.create({ + 'company_id': self.company_id, + 'pricelist_id': self.partner1.property_product_pricelist.id, + 'partner_id': self.partner1.id, + 'lines': [(0, 0, { + 'name': "OL/0001", + 'product_id': self.product3.id, + 'price_unit': 450, + 'discount': 0.0, + 'qty': 2.0, + }), (0, 0, { + 'name': "OL/0002", + 'product_id': self.product4.id, + 'price_unit': 300, + 'discount': 0.0, + 'qty': 3.0, + })] + }) + + # I click on the "Make Payment" wizard to pay the PoS order with the total amount (2*450 + 3*300 = 1800) + context_make_payment = { + "active_ids": [self.pos_order_pos1.id], + "active_id": self.pos_order_pos1.id + } + self.pos_make_payment_2 = self.PosMakePayment.with_context(context_make_payment).create({ + 'amount': 1800 + }) + + # I click on the validate button to register the payment. + context_payment = {'active_id': self.pos_order_pos1.id} + self.pos_make_payment_2.with_context(context_payment).check() + + # I check that the order is marked as paid + self.assertEqual( + self.pos_order_pos1.state, + 'paid', + 'Order should be in paid state.' + ) + + # I generate the journal entries + self.pos_order_pos1._create_account_move_line() + + # I test that the generated journal entry is attached to the PoS order + self.assertTrue( + self.pos_order_pos1.account_move, + "Journal entry has not been attached to Pos order." + ) + + # I test that the pickings are created as expected + # One picking attached and having all the positive move lines in the correct state + self.pos_order_pos1.create_picking() + self.assertEqual( + self.pos_order_pos1.picking_id.state, + 'done', + 'Picking should be in done state.' + ) + self.assertEqual( + self.pos_order_pos1.picking_id.move_lines.mapped('state'), + ['done', 'done'], + 'Move Lines should be in done state.' + ) + + self.pos_order_pos2 = self.PosOrder.create({ + 'company_id': self.company_id, + 'pricelist_id': self.partner1.property_product_pricelist.id, + 'partner_id': self.partner1.id, + 'lines': [(0, 0, { + 'name': "OL/0003", + 'product_id': self.product3.id, + 'price_unit': 450, + 'discount': 0.0, + 'qty': (-2.0), + }), (0, 0, { + 'name': "OL/0004", + 'product_id': self.product4.id, + 'price_unit': 300, + 'discount': 0.0, + 'qty': (-3.0), + })] + }) + + # I click on the "Make Payment" wizard to pay the PoS order with the total amount (-2*450 + -3*300 = -1800) + context_make_payment = { + "active_ids": [self.pos_order_pos2.id], + "active_id": self.pos_order_pos2.id + } + self.pos_make_payment_3 = self.PosMakePayment.with_context(context_make_payment).create({ + 'amount': (-1800) + }) + + # I click on the validate button to register the payment. + context_payment = {'active_id': self.pos_order_pos2.id} + self.pos_make_payment_3.with_context(context_payment).check() + + # I check that the order is marked as paid + self.assertEqual( + self.pos_order_pos2.state, + 'paid', + 'Order should be in paid state.' + ) + + # I generate the journal entries + self.pos_order_pos2._create_account_move_line() + + # I test that the generated journal entry is attached to the PoS order + self.assertTrue( + self.pos_order_pos2.account_move, + "Journal entry has not been attached to PoS order." + ) + + # I test that the pickings are created as expected + # One picking attached and having all the positive move lines in the correct state + self.pos_order_pos2.create_picking() + self.assertEqual( + self.pos_order_pos2.picking_id.state, + 'done', + 'Picking should be in done state.' + ) + self.assertEqual( + self.pos_order_pos2.picking_id.move_lines.mapped('state'), + ['done', 'done'], + 'Move Lines should be in done state.' + ) + + self.pos_order_pos3 = self.PosOrder.create({ + 'company_id': self.company_id, + 'pricelist_id': self.partner1.property_product_pricelist.id, + 'partner_id': self.partner1.id, + 'lines': [(0, 0, { + 'name': "OL/0005", + 'product_id': self.product3.id, + 'price_unit': 450, + 'discount': 0.0, + 'qty': (-2.0), + }), (0, 0, { + 'name': "OL/0006", + 'product_id': self.product4.id, + 'price_unit': 300, + 'discount': 0.0, + 'qty': 3.0, + })] + }) + + # I click on the "Make Payment" wizard to pay the PoS order with the total amount (-2*450 + 3*300 = 0) + context_make_payment = { + "active_ids": [self.pos_order_pos3.id], + "active_id": self.pos_order_pos3.id + } + self.pos_make_payment_4 = self.PosMakePayment.with_context(context_make_payment).create({ + 'amount': 0 + }) + + # I click on the validate button to register the payment. + context_payment = {'active_id': self.pos_order_pos3.id} + self.pos_make_payment_4.with_context(context_payment).check() + + # I check that the order is marked as paid + self.assertEqual( + self.pos_order_pos3.state, + 'paid', + 'Order should be in paid state.' + ) + + # I generate the journal entries + self.pos_order_pos3._create_account_move_line() + + # I test that the generated journal entry is attached to the PoS order + self.assertTrue( + self.pos_order_pos3.account_move, + "Journal entry has not been attached to PoS order." + ) + + # I test that the pickings are created as expected + # One picking attached and having all the positive move lines in the correct state + self.pos_order_pos3.create_picking() + self.assertEqual( + self.pos_order_pos3.picking_id.state, + 'done', + 'Picking should be in done state.' + ) + self.assertEqual( + self.pos_order_pos3.picking_id.move_lines.mapped('state'), + ['done'], + 'Move Lines should be in done state.' + ) + def test_order_to_invoice(self): - #I create a new PoS order with 2 units of PC1 at 450 EUR (Tax Incl) and 3 units of PCSC349 at 300 EUR. (Tax Excl) + # I create a new PoS order with 2 units of PC1 at 450 EUR (Tax Incl) and 3 units of PCSC349 at 300 EUR. (Tax Excl) self.pos_order_pos1 = self.PosOrder.create({ 'company_id': self.company_id, 'partner_id': self.partner1.id,