From e1769cbea9dc9b13453f507321b4de2e86de2d36 Mon Sep 17 00:00:00 2001 From: amoyaux Date: Tue, 10 Oct 2017 14:01:41 +0200 Subject: [PATCH 01/24] [FIX] base_import: parse date and float for relational fields Use case to reproduce: -> Import a file with a float using a coma related to the model (for example the product.supplierinfo/price) It happens because the method that parse the date and float field only compare them to the main imported model. This commit complete the method by using a recursion on relational model in order to also parse the relational subfields. --- addons/base_import/models/base_import.py | 17 +++++++++++-- addons/base_import/tests/test_base_import.py | 26 ++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/addons/base_import/models/base_import.py b/addons/base_import/models/base_import.py index 9f1804395ce..2727663b68c 100644 --- a/addons/base_import/models/base_import.py +++ b/addons/base_import/models/base_import.py @@ -605,9 +605,18 @@ class Import(models.TransientModel): @api.multi def _parse_import_data(self, data, import_fields, options): + """ Lauch first call to _parse_import_data_recursive with an + empty prefix. _parse_import_data_recursive will be run + recursively for each relational field. + """ + return self._parse_import_data_recursive(self.res_model, '', data, import_fields, options) + + @api.multi + def _parse_import_data_recursive(self, model, prefix, data, import_fields, options): # Get fields of type date/datetime - all_fields = self.env[self.res_model].fields_get() + all_fields = self.env[model].fields_get() for name, field in all_fields.iteritems(): + name = prefix + name if field['type'] in ('date', 'datetime') and name in import_fields: # Parse date index = import_fields.index(name) @@ -624,7 +633,11 @@ class Import(models.TransientModel): raise ValueError(_("Column %s contains incorrect values. Error in line %d: %s") % (name, num + 1, ustr(e.message))) except Exception, e: raise ValueError(_("Error Parsing Date [%s:L%d]: %s") % (name, num + 1, ustr(e.message))) - + # Check if the field is in import_field and is a relational (followed by /) + # Also verify that the field name exactly match the import_field at the correct level. + elif any(name + '/' in import_field and name == import_field.split('/')[prefix.count('/')] for import_field in import_fields): + # Recursive call with the relational as new model and add the field name to the prefix + self._parse_import_data_recursive(field['relation'], name + '/', data, import_fields, options) elif field['type'] in ('float', 'monetary') and name in import_fields: # Parse float, sometimes float values from file have currency symbol or () to denote a negative value # We should be able to manage both case diff --git a/addons/base_import/tests/test_base_import.py b/addons/base_import/tests/test_base_import.py index 891fc3e7671..f858101fef3 100644 --- a/addons/base_import/tests/test_base_import.py +++ b/addons/base_import/tests/test_base_import.py @@ -402,7 +402,33 @@ class test_convert_import_data(TransactionCase): # if results empty, no errors self.assertItemsEqual(results, []) + def test_parse_relational_fields(self): + """ Ensure that relational fields float and date are correctly + parsed during the import call. + """ + import_wizard = self.env['base_import.import'].create({ + 'res_model': 'res.partner', + 'file': 'name,parent_id/id,parent_id/date,parent_id/credit_limit\n' + '"foo","__export__.res_partner_1","2017年10月12日","5,69"\n', + 'file_type': 'text/csv' + }) + options = { + 'date_format': '%Y年%m月%d日', + 'quoting': '"', + 'separator': ',', + 'float_decimal_separator': ',', + 'float_thousand_separator': '.', + 'headers': True + } + data, import_fields = import_wizard._convert_import_data( + ['name', 'parent_id/.id', 'parent_id/date', 'parent_id/credit_limit'], + options + ) + result = import_wizard._parse_import_data(data, import_fields, options) + # Check if the data 5,69 as been correctly parsed. + self.assertEqual(float(result[0][-1]), 5.69) + self.assertEqual(str(result[0][-2]), '2017-10-12') def test_filtered(self): """ If ``False`` is provided as field mapping for a column, From 8f1d889e50e18d81db7d4e8bb0555513a2b1019c Mon Sep 17 00:00:00 2001 From: Martin Trigaux Date: Fri, 13 Oct 2017 10:28:04 +0200 Subject: [PATCH 02/24] [FIX] tools: correctly export the non-module translations 8ac39287 introdcude a way to export the source terms not located inside an addons module (e.g. error messages in openerp/service/models.py) 0529a7f9 fixed a bug in the get_module_from_path with addons path with similar names. The above commit introduced a regression making files located outside of an addons path to be wrongly considered as a module e.g. openerp/service/models.py used not to match any module and was considered correctly as belonging to base module After, 0529a7f9, '~/openerp/service' being different than '~/openerp/service/' the module was considered as 'models.py' (which is incorrect). Compare correctly both parent path to have a correct match Fixes # 19907 --- openerp/tools/translate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openerp/tools/translate.py b/openerp/tools/translate.py index 6929b31f954..886a87860b3 100644 --- a/openerp/tools/translate.py +++ b/openerp/tools/translate.py @@ -958,7 +958,8 @@ def trans_generate(lang, modules, cr): def get_module_from_path(path): for (mp, rec) in path_list: mp = os.path.join(mp, '') - if rec and path.startswith(mp) and os.path.dirname(path) != mp: + dirname = os.path.join(os.path.dirname(path), '') + if rec and path.startswith(mp) and dirname != mp: path = path[len(mp):] return path.split(os.path.sep)[0] return 'base' # files that are not in a module are considered as being in 'base' module From c68ad2e0ccac6654e5ac0ddfc294b81007b2d915 Mon Sep 17 00:00:00 2001 From: fda-odoo Date: Fri, 13 Oct 2017 09:27:07 +0200 Subject: [PATCH 03/24] [FIX] crm: remove duplicate filter in seach bar of pipeline report The search bar of the pipeline report page had duplicate filters. For each, there was one with "selection" widget and one without the widget. opw-771298 --- addons/crm/report/crm_opportunity_report_views.xml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/addons/crm/report/crm_opportunity_report_views.xml b/addons/crm/report/crm_opportunity_report_views.xml index 8f2c96bfb2a..6eb888b2662 100644 --- a/addons/crm/report/crm_opportunity_report_views.xml +++ b/addons/crm/report/crm_opportunity_report_views.xml @@ -113,17 +113,10 @@ - - - - - - - From c0a47980fc2458b2ef5f1e0c689842c8eafdde06 Mon Sep 17 00:00:00 2001 From: Jeremy Kersten Date: Thu, 12 Oct 2017 14:28:36 +0200 Subject: [PATCH 04/24] [FIX] base: change width by default for apps description --- addons/crm/static/description/index.html | 2 +- .../base/static/src/css/description.css | 39 +++++++++---------- .../base/static/src/css/description.sass | 36 ++++++++--------- 3 files changed, 38 insertions(+), 39 deletions(-) diff --git a/addons/crm/static/description/index.html b/addons/crm/static/description/index.html index df8b9a447f8..6f4ab397d69 100644 --- a/addons/crm/static/description/index.html +++ b/addons/crm/static/description/index.html @@ -43,7 +43,7 @@

Automate activities based on your sales scripts: calls, meetings, mails and quotations. Get all information you need, right on the opportunity: page viewed, mails received, etc.

- + diff --git a/odoo/addons/base/static/src/css/description.css b/odoo/addons/base/static/src/css/description.css index cbfa3d5e968..b1f436b872f 100644 --- a/odoo/addons/base/static/src/css/description.css +++ b/odoo/addons/base/static/src/css/description.css @@ -1,5 +1,4 @@ -@charset "utf-8"; -/* +/** * This CSS is for the html description of modules * TODO clean */ @@ -78,11 +77,11 @@ /* ------ BASE GRID CONSTRUCTS ----- */ .oe_page { margin: 0px auto 64px auto; - max-width: 992px; + max-width: 100%; } .oe_row { - width: 928px; + width: 100%; margin-top: 16px; margin-bottom: 16px; margin-left: auto; @@ -111,35 +110,35 @@ } .oe_span12 { - width: 928px; + width: 100%; } .oe_span10 { - width: 773px; + width: 83.33333%; } .oe_span9 { - width: 696px; + width: 75%; } .oe_span8 { - width: 618px; + width: 66.66667%; } .oe_span6 { - width: 464px; + width: 50%; } .oe_span4 { - width: 309px; + width: 33.33333%; } .oe_span3 { - width: 232px; + width: 25%; } .oe_span2 { - width: 154px; + width: 16.66667%; } [class*='oe_span'].oe_fit { @@ -162,28 +161,28 @@ width: auto; } .oe_row.oe_flex .oe_span12 { - max-width: 928px; + max-width: 100%; } .oe_row.oe_flex .oe_span10 { - max-width: 769px; + max-width: 83.33333%; } .oe_row.oe_flex .oe_span9 { - max-width: 692px; + max-width: 75%; } .oe_row.oe_flex .oe_span8 { - max-width: 614px; + max-width: 66.66667%; } .oe_row.oe_flex .oe_span6 { - max-width: 460px; + max-width: 50%; } .oe_row.oe_flex .oe_span4 { - max-width: 305px; + max-width: 33.33333%; } .oe_row.oe_flex .oe_span3 { - max-width: 228px; + max-width: 25%; } .oe_row.oe_flex .oe_span2 { - max-width: 150px; + max-width: 16.66667%; } .oe_mb0 { diff --git a/odoo/addons/base/static/src/css/description.sass b/odoo/addons/base/static/src/css/description.sass index 4fa2ed100d8..49be6054652 100644 --- a/odoo/addons/base/static/src/css/description.sass +++ b/odoo/addons/base/static/src/css/description.sass @@ -86,11 +86,11 @@ $v8_anchor_visited_color: rgb(91, 40, 79) .oe_page margin: 0px auto 64px auto - max-width: 992px + max-width: 100% .oe_row - width: 928px + width: 100% margin-top: 16px margin-bottom: 16px margin-left: auto @@ -107,14 +107,14 @@ $v8_anchor_visited_color: rgb(91, 40, 79) line-height: 0 height: 0 -$oe_span12_width: 928px -$oe_span10_width: 773px -$oe_span9_width: 696px -$oe_span8_width: 618px -$oe_span6_width: 464px -$oe_span4_width: 309px -$oe_span3_width: 232px -$oe_span2_width: 154px +$oe_span12_width: 100% +$oe_span10_width: 83.33333333% +$oe_span9_width: 75% +$oe_span8_width: 66.66666667% +$oe_span6_width: 50% +$oe_span4_width: 33.33333333% +$oe_span3_width: 25% +$oe_span2_width: 16.66666667% [class*='oe_span'] float: left @@ -155,19 +155,19 @@ $oe_span2_width: 154px .oe_span12 max-width: $oe_span12_width .oe_span10 - max-width: ($oe_span10_width +-4px) + max-width: $oe_span10_width .oe_span9 - max-width: ($oe_span9_width +-4px) - .oe_span8 - max-width: ($oe_span8_width +-4px) + max-width: $oe_span9_width + .oe_span8 + max-width: $oe_span8_width .oe_span6 - max-width: ($oe_span6_width +-4px) + max-width: $oe_span6_width .oe_span4 - max-width: ($oe_span4_width +-4px) + max-width: $oe_span4_width .oe_span3 - max-width: ($oe_span3_width +-4px) + max-width: $oe_span3_width .oe_span2 - max-width: ($oe_span2_width +-4px) + max-width: $oe_span2_width .oe_mb0 margin-bottom: 0px !important From dc5bb0d6b2806e81d1dd32839e6a600c8782bad3 Mon Sep 17 00:00:00 2001 From: Jeremy Kersten Date: Fri, 13 Oct 2017 10:50:24 +0200 Subject: [PATCH 05/24] [FIX] website_forum: add missing karma_answer field in form view This commit closes #19672 --- addons/website_forum/views/forum.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/addons/website_forum/views/forum.xml b/addons/website_forum/views/forum.xml index a8b45e64c04..03dd07cda85 100644 --- a/addons/website_forum/views/forum.xml +++ b/addons/website_forum/views/forum.xml @@ -63,6 +63,7 @@ + From 7fc6205419427ecefb250de6abc361e0422821b7 Mon Sep 17 00:00:00 2001 From: Raphael Collet Date: Fri, 13 Oct 2017 12:13:50 +0200 Subject: [PATCH 06/24] [FIX] base_import_module: retrieve message from exception (#20101) --- odoo/loglevels.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odoo/loglevels.py b/odoo/loglevels.py index 400f08e1a8f..0972dc5d2b4 100644 --- a/odoo/loglevels.py +++ b/odoo/loglevels.py @@ -94,7 +94,7 @@ def ustr(value, hint_encoding='utf-8', errors='strict'): def exception_to_unicode(e): if (sys.version_info[:2] < (2,6)) and hasattr(e, 'message'): return ustr(e.message) - if hasattr(e, 'args'): + if getattr(e, 'args', ()): return "\n".join((ustr(a) for a in e.args)) try: return unicode(e) From 51861e889ab7c8795cccc3eaca27b90b62ceb89c Mon Sep 17 00:00:00 2001 From: Josse Colpaert Date: Thu, 12 Oct 2017 13:55:12 +0200 Subject: [PATCH 07/24] [FIX] mrp: no propagation of stock moves when destination is a component of an mo When you cancel or split a move, it will check the propagation setting to see if it needs to cancel or split the next move too. If this next move is a raw material in a manufacturing order however, it does not make sense to do this as a lot of logic is also on these moves related to bill of material lines, workorders, ... opw-770062 --- addons/mrp/models/stock_move.py | 12 ++++++++++++ addons/stock/models/stock_move.py | 26 ++++++++++++++++---------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py index da140337817..a87d6623dcc 100644 --- a/addons/mrp/models/stock_move.py +++ b/addons/mrp/models/stock_move.py @@ -138,6 +138,14 @@ class StockMove(models.Model): self.check_move_lots() return res + def _propagate_cancel(self): + self.ensure_one() + if not self.move_dest_id.raw_material_production_id: + super(StockMove, self)._propagate_cancel() + elif self.move_dest_id.state == 'waiting': + # If waiting, the chain will be broken and we are not sure if we can still wait for it (=> could take from stock instead) + self.move_dest_id.write({'state': 'confirmed'}) + @api.multi def action_cancel(self): if any(move.quantity_done for move in self): @@ -373,6 +381,10 @@ class StockMove(models.Model): self.sudo().unlink() return processed_moves + def _propagate_split(self, new_move, qty): + if not self.move_dest_id.raw_material_production_id: + super(StockMove, self)._propagate_split(new_move, qty) + def _generate_move_phantom(self, bom_line, quantity): if bom_line.product_id.type in ['product', 'consu']: return self.copy(default={ diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 0455c5b95e3..30c988954d7 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -658,6 +658,15 @@ class StockMove(models.Model): if not no_prepare: self.check_recompute_pack_op() + def _propagate_cancel(self): + self.ensure_one() + if self.move_dest_id: + if self.propagate: + self.move_dest_id.action_cancel() + elif self.move_dest_id.state == 'waiting': + # If waiting, the chain will be broken and we are not sure if we can still wait for it (=> could take from stock instead) + self.move_dest_id.write({'state': 'confirmed'}) + @api.multi def action_cancel(self): """ Cancels the moves and if all moves are cancelled it cancels the picking. """ @@ -674,12 +683,7 @@ class StockMove(models.Model): pass # procurements.search([('move_dest_id', '=', move.id)]).cancel() else: - if move.move_dest_id: - if move.propagate: - move.move_dest_id.action_cancel() - elif move.move_dest_id.state == 'waiting': - # If waiting, the chain will be broken and we are not sure if we can still wait for it (=> could take from stock instead) - move.move_dest_id.write({'state': 'confirmed'}) + move._propagate_cancel() if move.procurement_id: procurements |= move.procurement_id @@ -919,6 +923,11 @@ class StockMove(models.Model): raise UserError(_('You can only delete draft moves.')) return super(StockMove, self).unlink() + def _propagate_split(self, new_move, qty): + if self.move_dest_id and self.propagate and self.move_dest_id.state not in ('done', 'cancel'): + new_move_prop = self.move_dest_id.split(qty) + new_move.write({'move_dest_id': new_move_prop}) + @api.multi def split(self, qty, restrict_lot_id=False, restrict_partner_id=False): """ Splits qty from move move into a new move @@ -959,10 +968,7 @@ class StockMove(models.Model): # TDE CLEANME: used only in write in this file, to clean # ctx['do_not_propagate'] = True self.with_context(do_not_propagate=True, rounding_method='HALF-UP').write({'product_uom_qty': self.product_uom_qty - uom_qty}) - - if self.move_dest_id and self.propagate and self.move_dest_id.state not in ('done', 'cancel'): - new_move_prop = self.move_dest_id.split(qty) - new_move.write({'move_dest_id': new_move_prop}) + self._propagate_split(new_move, qty) # returning the first element of list returned by action_confirm is ok because we checked it wouldn't be exploded (and # thus the result of action_confirm should always be a list of 1 element length) new_move.action_confirm() From 26517172510bfff003e7f555aa63ab463334d05a Mon Sep 17 00:00:00 2001 From: Yannick Tivisse Date: Mon, 14 Aug 2017 10:15:53 +0200 Subject: [PATCH 08/24] [IMP] base_import: Defer parent computation after record creation/update Currently when importing files, the parent left and right are recomputed on each record creation/update, which could lead to a lot a request. This commit will defer the parent field recomputation to the end on the `load` function by recomputing the values from the MPTT and inserting directly the new values on the records. Tested on a customer instance, this will reduce the execution time when importing a thousand stock.location from 15 minutes to several seconds. --- addons/base_import/models/base_import.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/addons/base_import/models/base_import.py b/addons/base_import/models/base_import.py index 2727663b68c..0be10bf6da8 100644 --- a/addons/base_import/models/base_import.py +++ b/addons/base_import/models/base_import.py @@ -681,7 +681,13 @@ class Import(models.TransientModel): }] _logger.info('importing %d rows...', len(data)) - import_result = self.env[self.res_model].with_context(import_file=True).load(import_fields, data) + + model = self.env[self.res_model].with_context(import_file=True) + defer_parent_store = self.env.context.get('defer_parent_store_computation', True) + if defer_parent_store and model._parent_store: + model = model.with_context(defer_parent_store_computation=True) + + import_result = model.load(import_fields, data) _logger.info('done') # If transaction aborted, RELEASE SAVEPOINT is going to raise From cd77219cbd14929c8035de0a795ffd75c625cb4c Mon Sep 17 00:00:00 2001 From: Goffin Simon Date: Thu, 12 Oct 2017 11:22:03 +0200 Subject: [PATCH 09/24] [FIX] account: Duplicating journal entries When duplictating a Journal Entry that has been created from a new payment (accounting/purchases/payments/create), the new copy of the Journal Entry also duplicated the already existent relationship (link) to the payment, causing possible future conflicts in functionalities like reports, payments, reconciliations, among others. opw:775151 --- addons/account/models/account_move.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 4458152f940..2c04fcd283f 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -385,7 +385,7 @@ class AccountMoveLine(models.Model): help="The move of this entry line.", index=True, required=True, auto_join=True) narration = fields.Text(related='move_id.narration', string='Narration') ref = fields.Char(related='move_id.ref', string='Reference', store=True, copy=False, index=True) - payment_id = fields.Many2one('account.payment', string="Originator Payment", help="Payment that created this entry") + payment_id = fields.Many2one('account.payment', string="Originator Payment", help="Payment that created this entry", copy=False) statement_id = fields.Many2one('account.bank.statement', string='Statement', help="The bank statement used for bank reconciliation", index=True, copy=False) reconciled = fields.Boolean(compute='_amount_residual', store=True) From 5afb5ecd04cf49be6bedb655b187b19a65e1e02d Mon Sep 17 00:00:00 2001 From: Laurent Smet Date: Mon, 16 Oct 2017 11:35:08 +0200 Subject: [PATCH 10/24] [FIX] account: fix complex tax computations (multiple price included taxes, etc.) Suppose the correct computation: seq | amount | incl | incl_base | base | amount ---------------------------------------------------- 1 | 10% | t | t | 100.0 | 10.0 2 | 10% | | | 110.0 | 11.0 3 | 10% | t | | 110.0 | 11.0 4 | 10% | | | 110.0 | 11.0 5 | 10% | t | | 110.0 | 11.0 ---------------------------------------------------- with an initial amount of 132. Without this commit, the base amount was computed by: 132 / 1.1 / 1.1 = 109.09 instead of: 132 / 1.2 = 110.0 see task: https://www.odoo.com/web#id=34471&view_type=form&model=project.task&action=333&active_id=967&menu_id=4720 Was PR #18275 --- addons/account/models/account.py | 165 ++++++++++---- addons/account/tests/test_tax.py | 213 ++++++++++++++++--- addons/point_of_sale/static/src/js/models.js | 156 ++++++++++---- 3 files changed, 421 insertions(+), 113 deletions(-) diff --git a/addons/account/models/account.py b/addons/account/models/account.py index c64473778ec..4630ece0eeb 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -619,6 +619,7 @@ class AccountTax(models.Model): price_unit * quantity eventually affected by previous taxes (if tax is include_base_amount XOR price_include) """ self.ensure_one() + price_include = self._context.get('force_price_include', self.price_include) if self.amount_type == 'fixed': # Use copysign to take into account the sign of the base amount which includes the sign # of the quantity and the sign of the price_unit @@ -632,11 +633,11 @@ class AccountTax(models.Model): return math.copysign(quantity, base_amount) * self.amount else: return quantity * self.amount - if (self.amount_type == 'percent' and not self.price_include) or (self.amount_type == 'division' and self.price_include): + if (self.amount_type == 'percent' and not price_include) or (self.amount_type == 'division' and self.price_include): return base_amount * self.amount / 100 - if self.amount_type == 'percent' and self.price_include: + if self.amount_type == 'percent' and price_include: return base_amount - (base_amount / (1 + self.amount / 100)) - if self.amount_type == 'division' and not self.price_include: + if self.amount_type == 'division' and not price_include: return base_amount / (1 - self.amount / 100) - base_amount @api.v8 @@ -659,13 +660,58 @@ class AccountTax(models.Model): 'analytic': boolean, }] } """ + + # 1) Flatten the taxes. + + def collect_taxes(self, all_taxes=None): + # Collect all the taxes recursively ordered by the sequence. + # Example: + # group | seq | sub-group | + # ------------|-----------| + # | 1 | | + # ------------|-----------| + # t | 2 | | seq | | + # | | | 4 | | + # | | | 5 | | + # | | | 6 | | + # | | | + # ------------|-----------| + # | 3 | | + # ------------|-----------| + # Result: 1-4-5-6-3 + if not all_taxes: + all_taxes = self.env['account.tax'] + for tax in self.sorted(key=lambda r: r.sequence): + if tax.amount_type == 'group': + all_taxes = collect_taxes(tax.children_tax_ids, all_taxes) + else: + all_taxes += tax + return all_taxes + + taxes = collect_taxes(self) + + # 2) Avoid dealing with taxes mixing price_include=False && include_base_amount=True + # with price_include=True + + base_excluded_flag = False # price_include=False && include_base_amount=True + included_flag = False # price_include=True + for tax in taxes: + if tax.price_include: + included_flag = True + elif tax.include_base_amount: + base_excluded_flag = True + if base_excluded_flag and included_flag: + raise UserError(_('Unable to mix any taxes being price included with taxes affecting the base amount but not included in price.')) + + # 3) Deal with the rounding methods + if len(self) == 0: company_id = self.env.user.company_id else: company_id = self[0].company_id if not currency: currency = company_id.currency_id - taxes = [] + # By default, for each tax, tax amount will first be computed # and rounded at the 'Account' decimal precision for each # PO/SO/invoice line and then these rounded amounts will be @@ -690,50 +736,93 @@ class AccountTax(models.Model): if not round_tax: prec += 5 - base_values = self.env.context.get('base_values') - if not base_values: - total_excluded = total_included = base = round(price_unit * quantity, prec) + # 4) Iterate the taxes in the reversed sequence order to retrieve the initial base of the computation. + # tax | base | amount | + # /\ ---------------------------- + # || tax_1 | XXXX | | <- we are looking for that, it's the total_excluded + # || tax_2 | | | + # || tax_3 | | | + # || ... | .. | .. | + # ---------------------------- + + def recompute_base(base_amount, fixed_amount, percent_amount): + # Recompute the new base amount based on included fixed/percent amount and the current base amount. + # Example: + # tax | amount | + # ------------------ + # tax_1 | 10% | + # tax_2 | 15 | + # tax_3 | 20% | + # ------------------ + # if base_amount = 145, the new base is computed as: + # (145 - 15) / (1.0 + ((10 + 20) / 100.0)) = 130 / 1.3 = 100 + if fixed_amount == 0.0 and percent_amount == 0.0: + return base_amount + return round((base_amount - fixed_amount) / (1.0 + percent_amount / 100.0), prec) + + base = round(price_unit * quantity, prec) + + # For the computation of move lines, we could have a negative base value. + # In this case, compute all with positive values and negative them at the end. + if base < 0: + base = -base + sign = -1 else: - total_excluded, total_included, base = base_values + sign = 1 - # 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 - # depending on the context. This domain might filter out some taxes from self, e.g. in the - # case of group taxes. - for tax in self.sorted(key=lambda r: r.sequence): - if tax.amount_type == 'group': - 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'] if tax.include_base_amount else base - total_included = ret['total_included'] - tax_amount = total_included - total_excluded - taxes += ret['taxes'] - continue + # Keep track of the accumulated included fixed/percent amount. + incl_fixed_amount = incl_percent_amount = 0 + for tax in reversed(taxes): + if tax.include_base_amount: + base = recompute_base(base, incl_fixed_amount, incl_percent_amount) + incl_fixed_amount = incl_percent_amount = 0 + if tax.price_include: + if tax.amount_type == 'fixed': + incl_fixed_amount += tax.amount + elif tax.amount_type == 'percent': + incl_percent_amount += tax.amount + # Start the computation of accumulated amounts at the total_excluded value. + total_excluded = total_included = base = recompute_base(base, incl_fixed_amount, incl_percent_amount) - tax_amount = tax._compute_amount(base, price_unit, quantity, product, partner) + # 5) Iterate the taxes in the sequence order to fill missing base/amount values. + # tax | base | amount | + # || ---------------------------- + # || tax_1 | OK | XXXX | + # || tax_2 | XXXX | XXXX | + # || tax_3 | XXXX | XXXX | + # \/ ... | .. | .. | + # ---------------------------- + taxes_vals = [] + for tax in taxes: + # Compute the amount of the tax but don't deal with the price_include because it's already + # took into account on the base amount except for 'division' tax: + # (tax.amount_type == 'percent' && not tax.price_include) + # == (tax.amount_type == 'division' && tax.price_include) + tax_amount = tax.with_context(force_price_include=False)._compute_amount(base, price_unit, quantity, product, partner) if not round_tax: tax_amount = round(tax_amount, prec) else: tax_amount = currency.round(tax_amount) - if tax.price_include: - total_excluded -= tax_amount - base -= tax_amount - else: - total_included += tax_amount - - # Keep base amount used for the current tax + # Suppose: + # seq | amount | incl | incl_base | base | amount + # ----------------------------------------------- + # 1 | 10 % | t | t | 100.0 | 10.0 + # ----------------------------------------------- + # ... the next computation must be done using 100.0 + 10.0 = 110.0 as base but + # the tax base of this tax will be 100.0. tax_base = base - if tax.include_base_amount: base += tax_amount - taxes.append({ + # The total_included amount is computed as the sum of total_excluded with all tax_amount + total_included += tax_amount + + taxes_vals.append({ 'id': tax.id, 'name': tax.with_context(**{'lang': partner.lang} if partner else {}).name, - 'amount': tax_amount, - 'base': tax_base, + 'amount': sign * tax_amount, + 'base': sign * tax_base, 'sequence': tax.sequence, 'account_id': tax.account_id.id, 'refund_account_id': tax.refund_account_id.id, @@ -741,10 +830,10 @@ class AccountTax(models.Model): }) return { - 'taxes': sorted(taxes, key=lambda k: k['sequence']), - 'total_excluded': currency.round(total_excluded) if round_total else total_excluded, - 'total_included': currency.round(total_included) if round_total else total_included, - 'base': base, + 'taxes': taxes_vals, + 'total_excluded': sign * (currency.round(total_excluded) if round_total else total_excluded), + 'total_included': sign * (currency.round(total_included) if round_total else total_included), + 'base': sign * base, } @api.v7 diff --git a/addons/account/tests/test_tax.py b/addons/account/tests/test_tax.py index 02308fa6498..5b6361bf69c 100644 --- a/addons/account/tests/test_tax.py +++ b/addons/account/tests/test_tax.py @@ -1,6 +1,7 @@ +# -*- coding: utf-8 -*- + from openerp.addons.account.tests.account_test_users import AccountTestUsers -from openerp.tests.common import TransactionCase -from openerp.tools import float_compare + import time @@ -67,47 +68,110 @@ class TestTax(AccountTestUsers): 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 _check_compute_all_results(self, base, total_included, total_excluded, taxes, res): + self.assertAlmostEqual(res['base'], base) + self.assertAlmostEqual(res['total_included'], total_included) + self.assertAlmostEqual(res['total_excluded'], total_excluded) + for i in range(0, len(taxes)): + self.assertAlmostEqual(res['taxes'][i]['base'], taxes[i][0]) + self.assertAlmostEqual(res['taxes'][i]['amount'], taxes[i][1]) + 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) + self._check_compute_all_results( + 220, # 'base' + 263, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 10.0), # | 1 | 10 | | t + (210.0, 21.0), # | 3 | 10% | | + (210.0, 10.0), # | 1 | 10 | | t + (220.0, 22.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res + ) def test_tax_group(self): res = self.group_tax.compute_all(200.0) - self.assertEquals(res['total_excluded'], 200.0) - self.assertEquals(res['total_included'], 230.0) - self.assertEquals(len(res['taxes']), 2) - self.assertEquals(res['taxes'][0]['amount'], 10.0) - self.assertEquals(res['taxes'][1]['amount'], 20.0) + self._check_compute_all_results( + 200, # 'base' + 230, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 10.0), # | 1 | 10 | | + (200.0, 20.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res + ) def test_tax_percent_division(self): self.division_tax.price_include = True self.division_tax.include_base_amount = True + res_division = self.division_tax.compute_all(200.0) + self._check_compute_all_results( + 220, # 'base' + 220, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 20.0), # | 4 | 10/ | t | t + # --------------------------------------------------- + ], + res_division + ) self.percent_tax.price_include = False self.percent_tax.include_base_amount = False - res_division = self.division_tax.compute_all(200.0) res_percent = self.percent_tax.compute_all(200.0) - self.assertEquals(res_division['taxes'][0]['amount'], 20.0) - self.assertEquals(res_percent['taxes'][0]['amount'], 20.0) + self._check_compute_all_results( + 200, # 'base' + 220, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 20.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res_percent + ) self.division_tax.price_include = False self.division_tax.include_base_amount = False + res_division = self.division_tax.compute_all(200.0) + self._check_compute_all_results( + 200, # 'base' + 222.22, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 22.22), # | 4 | 10/ | | + # --------------------------------------------------- + ], + res_division + ) self.percent_tax.price_include = True self.percent_tax.include_base_amount = True - res_division = self.division_tax.compute_all(200.0) res_percent = self.percent_tax.compute_all(200.0) - self.assertEquals(res_division['taxes'][0]['amount'], 22.22) - self.assertEquals(res_percent['taxes'][0]['amount'], 18.18) + self._check_compute_all_results( + 200, # 'base' + 200, # 'total_included' + 181.82, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (181.82, 18.18), # | 3 | 10% | t | t + # --------------------------------------------------- + ], + res_percent + ) def test_tax_sequence_normalized_set(self): self.division_tax.sequence = 1 @@ -115,19 +179,53 @@ class TestTax(AccountTestUsers): self.percent_tax.sequence = 3 taxes_set = (self.group_tax | self.division_tax) res = taxes_set.compute_all(200.0) - self.assertEquals(res['taxes'][0]['amount'], 22.22) - self.assertEquals(res['taxes'][1]['amount'], 10.0) - self.assertEquals(res['taxes'][2]['amount'], 20.0) + self._check_compute_all_results( + 200, # 'base' + 252.22, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 22.22), # | 1 | 10/ | | + (200.0, 10.0), # | 2 | 10 | | + (200.0, 20.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res + ) def test_tax_include_base_amount(self): self.fixed_tax.include_base_amount = True res = self.group_tax.compute_all(200.0) - self.assertEquals(res['total_included'], 231.0) + self._check_compute_all_results( + 210, # 'base' + 231, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 10.0), # | 1 | 10 | | t + (210.0, 21.0), # | 3 | 10% | | + # --------------------------------------------------- + ], + res + ) def test_tax_currency(self): self.division_tax.amount = 15.0 res = self.division_tax.compute_all(200.0, currency=self.env.ref('base.VEF')) - self.assertAlmostEqual(res['total_included'], 235.2941) + self._check_compute_all_results( + 200, # 'base' + 235.2941, # 'total_included' + 200, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (200.0, 35.2941), # | 4 | 15/ | | + # --------------------------------------------------- + ], + res + ) def test_tax_move_lines_creation(self): """ Test that creating a move.line with tax_ids generates the tax move lines and adjust line amount when a tax is price_include """ @@ -172,3 +270,58 @@ class TestTax(AccountTestUsers): aml_with_taxes = move.line_ids.filtered(lambda l: set(l.tax_ids.ids) == set([self.group_tax.id, self.fixed_tax_bis.id])) self.assertEquals(len(aml_with_taxes), 1) self.assertEquals(aml_with_taxes.credit, 190) + + def test_advanced_taxes_computation_0(self): + '''Test more advanced taxes computation (see issue 34471).''' + tax_1 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_1', + 'amount_type': 'percent', + 'amount': 10, + 'price_include': True, + 'include_base_amount': True, + 'sequence': 1, + }) + tax_2 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_2', + 'amount_type': 'percent', + 'amount': 10, + 'sequence': 2, + }) + tax_3 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_3', + 'amount_type': 'percent', + 'amount': 10, + 'price_include': True, + 'sequence': 3, + }) + tax_4 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_4', + 'amount_type': 'percent', + 'amount': 10, + 'sequence': 4, + }) + tax_5 = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_computation_0_5', + 'amount_type': 'percent', + 'amount': 10, + 'price_include': True, + 'sequence': 5, + }) + taxes = tax_1 + tax_2 + tax_3 + tax_4 + tax_5 + res = taxes.compute_all(132.0) + self._check_compute_all_results( + 110, # 'base' + 154, # 'total_included' + 100, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (100.0, 10.0), # | 1 | 10% | t | t + (110.0, 11.0), # | 3 | 10% | | + (110.0, 11.0), # | 3 | 10% | t | + (110.0, 11.0), # | 3 | 10% | | + (110.0, 11.0), # | 3 | 10% | t | + # --------------------------------------------------- + ], + res + ) diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index 814856f4e5a..a18bd707da6 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -202,7 +202,7 @@ exports.PosModel = Backbone.Model.extend({ }, },{ model: 'account.tax', - fields: ['name','amount', 'price_include', 'include_base_amount', 'amount_type', 'children_tax_ids'], + fields: ['name','amount', 'price_include', 'include_base_amount', 'amount_type', 'children_tax_ids', 'sequence'], domain: null, loaded: function(self, taxes){ self.taxes = taxes; @@ -1332,72 +1332,138 @@ exports.Orderline = Backbone.Model.extend({ return tax; }, - _compute_all: function(tax, base_amount, quantity) { + /** + * Mirror JS method of: + * _compute_amount in addons/account/models/account.py + */ + _compute_all: function(tax, base_amount, quantity, price_include) { + if(price_include === undefined) + price_include = tax.price_include; if (tax.amount_type === 'fixed') { var sign_base_amount = base_amount >= 0 ? 1 : -1; return (Math.abs(tax.amount) * sign_base_amount) * quantity; } - if ((tax.amount_type === 'percent' && !tax.price_include) || (tax.amount_type === 'division' && tax.price_include)){ + if ((tax.amount_type === 'percent' && !price_include) || (tax.amount_type === 'division' && tax.price_include)){ return base_amount * tax.amount / 100; } - if (tax.amount_type === 'percent' && tax.price_include){ + if (tax.amount_type === 'percent' && price_include){ return base_amount - (base_amount / (1 + tax.amount / 100)); } - if (tax.amount_type === 'division' && !tax.price_include) { + if (tax.amount_type === 'division' && !price_include) { return base_amount / (1 - tax.amount / 100) - base_amount; } return false; }, + /** + * Mirror JS method of: + * compute_all in addons/account/models/account.py + * + * Read comments in the python side method for more details about each sub-methods. + */ compute_all: function(taxes, price_unit, quantity, currency_rounding, no_map_tax) { var self = this; - var list_taxes = []; + + // 1) Flatten the taxes. + + var _collect_taxes = function(taxes, all_taxes){ + taxes.sort(function (tax1, tax2) { + return tax1.sequence - tax2.sequence; + }); + _(taxes).each(function(tax){ + if(tax.amount_type === 'group') + all_taxes = _collect_taxes(tax.children_tax_ids, all_taxes); + else + all_taxes.push(tax); + }); + return all_taxes; + } + var collect_taxes = function(taxes){ + return _collect_taxes(taxes, []); + } + + taxes = collect_taxes(taxes); + + // 2) Avoid dealing with taxes mixing price_include=False && include_base_amount=True + // with price_include=True + + var base_excluded_flag = false; // price_include=False && include_base_amount=True + var included_flag = false; // price_include=True + _(taxes).each(function(tax){ + if(tax.price_include) + included_flag = true; + else if(tax.include_base_amount) + base_excluded_flag = true + if(base_excluded_flag && included_flag) + throw new Error('Unable to mix any taxes being price included with taxes affecting the base amount but not included in price.'); + }); + + // 3) Deal with the rounding methods + var currency_rounding_bak = currency_rounding; if (this.pos.company.tax_calculation_rounding_method == "round_globally"){ currency_rounding = currency_rounding * 0.00001; } - var total_excluded = round_pr(price_unit * quantity, currency_rounding); - var total_included = total_excluded; - var base = total_excluded; - _(taxes).each(function(tax) { - if (!no_map_tax){ - tax = self._map_tax_fiscal_position(tax); - } - if (!tax){ - return; - } - if (tax.amount_type === 'group'){ - var ret = self.compute_all(tax.children_tax_ids, price_unit, quantity, currency_rounding); - total_excluded = ret.total_excluded; - base = ret.total_excluded; - total_included = ret.total_included; - list_taxes = list_taxes.concat(ret.taxes); - } - else { - var tax_amount = self._compute_all(tax, base, quantity); - tax_amount = round_pr(tax_amount, currency_rounding); - if (tax_amount){ - if (tax.price_include) { - total_excluded -= tax_amount; - base -= tax_amount; - } - else { - total_included += tax_amount; - } - if (tax.include_base_amount) { - base += tax_amount; - } - var data = { - id: tax.id, - amount: tax_amount, - name: tax.name, - }; - list_taxes.push(data); - } + var recompute_base = function(base_amount, fixed_amount, percent_amount){ + if(fixed_amount === 0.0 && percent_amount === 0.0) + return base_amount; + return round_pr((base_amount - fixed_amount) / (1.0 + percent_amount / 100.0), currency_rounding); + } + + // 4) Iterate the taxes in the reversed sequence order to retrieve the initial base of the computation. + + var base = round_pr(price_unit * quantity, currency_rounding); + var sign = 1; + if(base < 0){ + base = -base; + var sign = -1; + } + + var incl_fixed_amount = 0.0; + var incl_percent_amount = 0.0; + _(taxes.reverse()).each(function(tax){ + if(tax.include_base_amount){ + base = recompute_base(base, incl_fixed_amount, incl_percent_amount); + incl_fixed_amount = 0.0; + incl_percent_amount = 0.0; + } + if(tax.price_include){ + if(tax.amount_type === 'fixed') + incl_fixed_amount += tax.amount; + else if(tax.amount_type === 'percent') + incl_percent_amount += tax.amount; } }); + + base = recompute_base(base, incl_fixed_amount, incl_percent_amount); + var total_excluded = base; + var total_included = base; + + // 5) Iterate the taxes in the sequence order to fill missing base/amount values. + + var taxes_vals = []; + _(taxes.reverse()).each(function(tax){ + var tax_amount = self._compute_all(tax, base, quantity, false); + tax_amount = round_pr(tax_amount, currency_rounding); + + var tax_base = base; + + if(tax.include_base_amount) + base += tax_amount; + + total_included += tax_amount; + + var tax_vals = { + id: tax.id, + amount: sign * tax_amount, + name: tax.name, + }; + + taxes_vals.push(tax_vals); + }); + return { - taxes: list_taxes, + taxes: taxes_vals, total_excluded: round_pr(total_excluded, currency_rounding_bak), total_included: round_pr(total_included, currency_rounding_bak) }; From fea667a2433dfc2387daa7051989e840d30e18e0 Mon Sep 17 00:00:00 2001 From: amoyaux Date: Wed, 11 Oct 2017 14:59:47 +0200 Subject: [PATCH 11/24] [FIX] account_invoice: Wrong default uom The UOM by default should be the purchase UOM opw-775105 --- addons/account/models/account_invoice.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index 8d73a1f5c85..06fba95630c 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -1310,6 +1310,9 @@ class AccountInvoiceLine(models.Model): self.price_unit = 0.0 domain['uom_id'] = [] else: + # Use the purchase uom by default + self.uom_id = self.product_id.uom_po_id + if part.lang: product = self.product_id.with_context(lang=part.lang) else: From 0aefcdbad77ffe560f24eda0dd5239f0ec9bc645 Mon Sep 17 00:00:00 2001 From: amoyaux Date: Mon, 16 Oct 2017 11:05:24 +0200 Subject: [PATCH 12/24] [FIX] stock: procurement generated by scheduler truncate datetime When purchase order are created from a scheduler they always have the same hours even if they were created at different time. Also the procurement order that generate the purchase order line have the same problem. It happens because the transformation from datetime to string use a date format and thus truncate the DEFAULT_SERVER_TIME_FORMAT This commit use a datetime format instead the dateformat. opw-775833 --- addons/stock/models/stock_warehouse.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py index 4dd80595c53..b112d11567b 100644 --- a/addons/stock/models/stock_warehouse.py +++ b/addons/stock/models/stock_warehouse.py @@ -8,7 +8,7 @@ from dateutil import relativedelta from odoo import api, fields, models, _ from odoo.addons import decimal_precision as dp from odoo.exceptions import UserError, ValidationError -from odoo.tools import DEFAULT_SERVER_DATE_FORMAT +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT import logging @@ -843,7 +843,7 @@ class Orderpoint(models.Model): # These days will be substracted when creating the PO days += self.product_id._select_seller().delay or 0.0 date_planned = start_date + relativedelta.relativedelta(days=days) - return date_planned.strftime(DEFAULT_SERVER_DATE_FORMAT) + return date_planned.strftime(DEFAULT_SERVER_DATETIME_FORMAT) @api.multi def _prepare_procurement_values(self, product_qty, date=False, group=False): From 671c20555e97fa882dfc42cbd9e429141a87e7c5 Mon Sep 17 00:00:00 2001 From: amoyaux Date: Fri, 13 Oct 2017 11:59:16 +0200 Subject: [PATCH 13/24] [FIX] mrp: Unbuild multi serial number components Use case to reproduce: - Create a product with a BOM that use at least twice the same component tracked by SN - Build this product with SN 'A' and SN 'B' - Unbuild it -> Constraint error on duplicate SN It happens because the unbuild order will create only one stock.move.lots for SN A with a quantity of 2. This commit create a stock.move.lots by quant used, for example if the MO used 2 quants with a qty of 1 and one with lot 'A' and the other with lot 'B'. It will create 2 stock.move.lots with lots 'A' and 'B'. Still a know issue: If the user try to unbuild twice the same MO it will reuse the same unbuilded componenets than the first unbuild order. --- addons/mrp/models/mrp_unbuild.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/addons/mrp/models/mrp_unbuild.py b/addons/mrp/models/mrp_unbuild.py index 4fc4af387c4..90ab1cdeed4 100644 --- a/addons/mrp/models/mrp_unbuild.py +++ b/addons/mrp/models/mrp_unbuild.py @@ -121,14 +121,20 @@ class MrpUnbuild(models.Model): for produce_move in produce_moves: if produce_move.has_tracking != 'none': original = original_quants.filtered(lambda quant: quant.product_id == produce_move.product_id) - if original and not original[0].lot_id: + if not original: raise UserError(_("You don't have in the stock the required lot/serial number for %s .") % (produce_move.product_id.name,)) - self.env['stock.move.lots'].create({ - 'move_id': produce_move.id, - 'lot_id': original[0].lot_id.id, - 'quantity_done': produce_move.product_uom_qty, - 'quantity': produce_move.product_uom_qty - }) + quantity_todo = produce_move.product_qty + for quant in original: + if quantity_todo <= 0: + break + move_quantity = min(quantity_todo, quant.qty) + self.env['stock.move.lots'].create({ + 'move_id': produce_move.id, + 'lot_id': quant.lot_id.id, + 'quantity_done': produce_move.product_id.uom_id._compute_quantity(move_quantity, produce_move.product_uom), + 'quantity': produce_move.product_id.uom_id._compute_quantity(move_quantity, produce_move.product_uom), + }) + quantity_todo -= move_quantity else: produce_move.quantity_done = produce_move.product_uom_qty produce_moves.move_validate() From 296498fb588614b8ce64da47954faac71eef1751 Mon Sep 17 00:00:00 2001 From: qsm-odoo Date: Mon, 16 Oct 2017 11:11:18 +0200 Subject: [PATCH 14/24] [FIX] web_editor: do not break

when pasting a

element This fixes a bug when pasting text in a paragraph, it was always creating a new paragraph for no reason. --- .../static/lib/summernote/src/js/module/Clipboard.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/addons/web_editor/static/lib/summernote/src/js/module/Clipboard.js b/addons/web_editor/static/lib/summernote/src/js/module/Clipboard.js index 8b42726ff7a..955b49437a8 100644 --- a/addons/web_editor/static/lib/summernote/src/js/module/Clipboard.js +++ b/addons/web_editor/static/lib/summernote/src/js/module/Clipboard.js @@ -206,6 +206,18 @@ define([ r.select(); } + // If only pasting a

element in an unique

element, only paste + // the

element text + var $p = $arch.children('p'); + var onlyAP = ($p.length === 1 && $arch.children().length === 1); + if (onlyAP) { + var $p1 = $(r.sc).closest('p'); + var $p2 = $(r.ec).closest('p'); + if ($p1.length && $p2.length && $p1[0] === $p2[0]) { + $arch.html($p.text()); + } + } + /* insert content */ From eda976e63dcad01f4b65755f1e20fe695ba16a8c Mon Sep 17 00:00:00 2001 From: qsm-odoo Date: Mon, 16 Oct 2017 15:15:08 +0200 Subject: [PATCH 15/24] [FIX] web_editor: do not break labels' texts into

When editing the content of a

element was added inside. This behavior is there to automatically add

elements in empty editable

/
elements on edition. This should however not apply on