diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py index 1dc107794f8..d2e5a5bfc5d 100644 --- a/addons/account/models/account_payment.py +++ b/addons/account/models/account_payment.py @@ -428,12 +428,17 @@ class account_payment(models.Model): @api.multi def button_invoices(self): + if self.partner_type == 'supplier': + views = [(self.env.ref('account.invoice_supplier_tree').id, 'tree'), (self.env.ref('account.invoice_supplier_form').id, 'form')] + else: + views = [(self.env.ref('account.invoice_tree').id, 'tree'), (self.env.ref('account.invoice_form').id, 'form')] return { 'name': _('Paid Invoices'), 'view_type': 'form', 'view_mode': 'tree,form', 'res_model': 'account.invoice', 'view_id': False, + 'views': views, 'type': 'ir.actions.act_window', 'domain': [('id', 'in', [x.id for x in self.invoice_ids])], } diff --git a/addons/analytic/models/analytic_account.py b/addons/analytic/models/analytic_account.py index fbf7951aa4e..9b3a4a1eec0 100644 --- a/addons/analytic/models/analytic_account.py +++ b/addons/analytic/models/analytic_account.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from collections import defaultdict from odoo import api, fields, models, _ from odoo.osv import expression from odoo.exceptions import ValidationError @@ -61,9 +62,9 @@ class AccountAnalyticAccount(models.Model): @api.multi def _compute_debit_credit_balance(self): - res_currency_obj = self.env['res.currency'] + Curr = self.env['res.currency'] analytic_line_obj = self.env['account.analytic.line'] - domain = [('account_id', 'in', self.mapped('id'))] + domain = [('account_id', 'in', self.ids)] if self._context.get('from_date', False): domain.append(('date', '>=', self._context['from_date'])) if self._context.get('to_date', False): @@ -75,17 +76,23 @@ class AccountAnalyticAccount(models.Model): domain.append(('company_id', 'in', self._context['company_ids'])) user_currency = self.env.user.company_id.currency_id - account_amounts = analytic_line_obj.search_read(domain, ['account_id', 'amount', 'currency_id']) - account_ids = set([line['account_id'][0] for line in account_amounts]) - data_debit = {account_id: 0.0 for account_id in account_ids} - data_credit = {account_id: 0.0 for account_id in account_ids} - for account_amount in account_amounts: - currency_id = account_amount['currency_id'][0] - amount = res_currency_obj.browse(currency_id).compute(account_amount['amount'], user_currency) - if amount < 0.0: - data_debit[account_amount['account_id'][0]] += amount - else: - data_credit[account_amount['account_id'][0]] += amount + credit_groups = analytic_line_obj.read_group( + domain=domain + [('amount', '>=', 0.0)], + fields=['account_id', 'currency_id', 'amount'], + groupby=['account_id', 'currency_id'], + ) + data_credit = defaultdict(float) + for l in credit_groups: + data_credit[l['account_id'][0]] += Curr.browse(l['currency_id'][0]).compute(l['amount'], user_currency) + + debit_groups = analytic_line_obj.read_group( + domain=domain + [('amount', '<', 0.0)], + fields=['account_id', 'currency_id', 'amount'], + groupby=['account_id', 'currency_id'], + ) + data_debit = defaultdict(float) + for l in debit_groups: + data_debit[l['account_id'][0]] += Curr.browse(l['currency_id'][0]).compute(l['amount'], user_currency) for account in self: account.debit = abs(data_debit.get(account.id, 0.0)) diff --git a/addons/calendar/models/calendar.py b/addons/calendar/models/calendar.py index 3b36e1872d7..fa890e27495 100644 --- a/addons/calendar/models/calendar.py +++ b/addons/calendar/models/calendar.py @@ -655,7 +655,13 @@ class Meeting(models.Model): }[data['rrule_type']] deadline = fields.Datetime.from_string(data['stop']) - return deadline + relativedelta(**{delay: count * mult}) + computed_final_date = False + while not computed_final_date and count > 0: + try: # may crash if year > 9999 (in case of recurring events) + computed_final_date = deadline + relativedelta(**{delay: count * mult}) + except ValueError: + count -= data['interval'] + return computed_final_date or deadline return final_date @api.multi diff --git a/addons/l10n_co/__manifest__.py b/addons/l10n_co/__manifest__.py index 72a39483284..88f5fb464f9 100644 --- a/addons/l10n_co/__manifest__.py +++ b/addons/l10n_co/__manifest__.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -# Copyright (C) David Arnold (devCO). -# Author David Arnold (devCO), dar@devco.co +# Copyright (C) David Arnold (XOE Solutions). +# Author David Arnold (XOE Solutions), dar@xoe.solutions # Co-Authors Juan Pablo Aries (devCO), jpa@devco.co # Hector Ivan Valencia Muñoz (TIX SAS) # Nhomar Hernandez (Vauxoo) @@ -13,7 +13,8 @@ 'version': '0.8', 'category': 'Localization', 'description': 'Colombian Accounting and Tax Preconfiguration', - 'author': 'David Arnold BA HSG (devCO)', + 'author': 'David Arnold (XOE Solutions)', + 'website': 'https://xoe.solutions', 'depends': [ 'account', ], diff --git a/addons/l10n_es/__manifest__.py b/addons/l10n_es/__manifest__.py index c5d7599074e..44b23a5e9c4 100644 --- a/addons/l10n_es/__manifest__.py +++ b/addons/l10n_es/__manifest__.py @@ -3,6 +3,7 @@ # List of contributors: # Jordi Esteve +# Ignacio Ibeas # Dpto. Consultoría Grupo Opentia # Pedro M. Baeza # Carlos Liébana diff --git a/addons/l10n_es/data/account_fiscal_position_template_data.xml b/addons/l10n_es/data/account_fiscal_position_template_data.xml index 49beed9d022..eef1bd1ac03 100644 --- a/addons/l10n_es/data/account_fiscal_position_template_data.xml +++ b/addons/l10n_es/data/account_fiscal_position_template_data.xml @@ -113,6 +113,16 @@ + + REAGYP - Agricultura + + + + + REAGYP - Ganadería y pesca + + + @@ -2889,6 +2899,28 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/l10n_es/data/account_tax_data.xml b/addons/l10n_es/data/account_tax_data.xml index d07503b0b88..327144f6fe1 100644 --- a/addons/l10n_es/data/account_tax_data.xml +++ b/addons/l10n_es/data/account_tax_data.xml @@ -1044,6 +1044,18 @@ + + P_IVA105_GAN + purchase + + 10,5% IVA Soportado régimen ganadero o pesca + + + + percent + + + S_IVA0_E sale @@ -1330,6 +1342,7 @@ + percent diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index c3936238f2f..71dce759f8e 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -124,7 +124,8 @@ class MailThread(models.AbstractModel): followers = self.env['mail.followers'].sudo().search([ ('res_model', '=', self._name), ('partner_id', operator, operand)]) - return [('id', 'in', followers.mapped('res_id'))] + # using read() below is much faster than followers.mapped('res_id') + return [('id', 'in', [res['res_id'] for res in followers.read(['res_id'])])] @api.model def _search_follower_channels(self, operator, operand): @@ -137,7 +138,8 @@ class MailThread(models.AbstractModel): followers = self.env['mail.followers'].sudo().search([ ('res_model', '=', self._name), ('channel_id', operator, operand)]) - return [('id', 'in', followers.mapped('res_id'))] + # using read() below is much faster than followers.mapped('res_id') + return [('id', 'in', [res['res_id'] for res in followers.read(['res_id'])])] @api.multi @api.depends('message_follower_ids') @@ -147,7 +149,8 @@ class MailThread(models.AbstractModel): ('res_id', 'in', self.ids), ('partner_id', '=', self.env.user.partner_id.id), ]) - following_ids = followers.mapped('res_id') + # using read() below is much faster than followers.mapped('res_id') + following_ids = [res['res_id'] for res in followers.read(['res_id'])] for record in self: record.message_is_follower = record.id in following_ids @@ -159,9 +162,11 @@ class MailThread(models.AbstractModel): ]) # Cases ('message_is_follower', '=', True) or ('message_is_follower', '!=', False) if (operator == '=' and operand) or (operator == '!=' and not operand): - return [('id', 'in', followers.mapped('res_id'))] + # using read() below is much faster than followers.mapped('res_id') + return [('id', 'in', [res['res_id'] for res in followers.read(['res_id'])])] else: - return [('id', 'not in', followers.mapped('res_id'))] + # using read() below is much faster than followers.mapped('res_id') + return [('id', 'not in', [res['res_id'] for res in followers.read(['res_id'])])] @api.multi def _get_message_unread(self): diff --git a/addons/mail/static/src/js/activity.js b/addons/mail/static/src/js/activity.js index 4bb81489789..fce7eb29f6f 100644 --- a/addons/mail/static/src/js/activity.js +++ b/addons/mail/static/src/js/activity.js @@ -32,6 +32,7 @@ function _readActivities(self, ids) { model: 'mail.activity', method: 'read', args: [ids], + context: (self.record && self.record.getContext()) || self.getSession().user_context, }).then(function (activities) { // convert create_date and date_deadline to moments _.each(activities, function (activity) { @@ -108,6 +109,7 @@ var AbstractActivityField = AbstractField.extend({ method: 'action_feedback', args: [[id]], kwargs: {feedback: feedback}, + context: this.record.getContext(), }); }, _scheduleActivity: function (id, previous_activity_type_id, callback) { diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 8b39d73d3ae..80e2cfe56e4 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -474,10 +474,16 @@ class MrpProduction(models.Model): @api.multi def _generate_workorders(self, exploded_boms): workorders = self.env['mrp.workorder'] + original_one = False for bom, bom_data in exploded_boms: # If the routing of the parent BoM and phantom BoM are the same, don't recreate work orders, but use one master routing if bom.routing_id.id and (not bom_data['parent_line'] or bom_data['parent_line'].bom_id.routing_id.id != bom.routing_id.id): - workorders += self._workorders_create(bom, bom_data) + temp_workorders = self._workorders_create(bom, bom_data) + workorders += temp_workorders + if temp_workorders: # In order to avoid two "ending work orders" + if original_one: + temp_workorders[-1].next_work_order_id = original_one + original_one = temp_workorders[0] return workorders def _workorders_create(self, bom, bom_data): diff --git a/addons/mrp/models/mrp_workorder.py b/addons/mrp/models/mrp_workorder.py index eef3b9a7de9..945b2b7436b 100644 --- a/addons/mrp/models/mrp_workorder.py +++ b/addons/mrp/models/mrp_workorder.py @@ -225,6 +225,7 @@ class MrpWorkorder(models.Model): 'done_wo': False, 'location_id': move.location_id.id, 'location_dest_id': move.location_dest_id.id, + 'date': move.date, }) qty_todo -= 1 elif float_compare(qty_todo, 0.0, precision_rounding=rounding) < 0: @@ -416,7 +417,12 @@ class MrpWorkorder(models.Model): @api.multi def button_start(self): - # TDE CLEANME + self.ensure_one() + # As button_start is automatically called in the new view + if self.state in ('done', 'cancel'): + return True + + # Need a loss in case of the real time exceeding the expected timeline = self.env['mrp.workcenter.productivity'] if self.duration < self.duration_expected: loss_id = self.env['mrp.workcenter.productivity.loss'].search([('loss_type','=','productive')], limit=1) diff --git a/addons/pos_mercury/static/src/js/pos_mercury.js b/addons/pos_mercury/static/src/js/pos_mercury.js index e403b9bcf12..7f896e5661f 100644 --- a/addons/pos_mercury/static/src/js/pos_mercury.js +++ b/addons/pos_mercury/static/src/js/pos_mercury.js @@ -449,6 +449,7 @@ PaymentScreenWidget.include({ order.selected_paymentline.paid = true; order.selected_paymentline.mercury_swipe_pending = false; order.selected_paymentline.mercury_amount = response.authorize; + order.selected_paymentline.set_amount(response.authorize); order.selected_paymentline.mercury_card_number = decodedMagtek['number']; order.selected_paymentline.mercury_card_brand = response.card_type; order.selected_paymentline.mercury_card_owner_name = decodedMagtek['name']; diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index 1d3db0a3d97..35ff379f620 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -347,6 +347,7 @@ class PurchaseOrder(models.Model): @api.multi def print_quotation(self): + self.write({'state': "sent"}) return self.env.ref('purchase.report_purchase_quotation').report_action(self) @api.multi diff --git a/addons/stock/data/stock_data.xml b/addons/stock/data/stock_data.xml index bc37a9bb572..53e4639be74 100644 --- a/addons/stock/data/stock_data.xml +++ b/addons/stock/data/stock_data.xml @@ -142,11 +142,6 @@ 1 - - - - stock.location diff --git a/addons/stock/models/res_company.py b/addons/stock/models/res_company.py index a310991cc17..58c61be714f 100644 --- a/addons/stock/models/res_company.py +++ b/addons/stock/models/res_company.py @@ -28,12 +28,18 @@ class Company(models.Model): location.sudo().write({'company_id': company.id}) company.write({'internal_transit_location_id': location.id}) + warehouses = self.env['stock.warehouse'].search([('partner_id', '=', self.partner_id.id)]) + warehouses.mapped('partner_id').with_context(force_company=self.id).write({ + 'property_stock_customer': location.id, + 'property_stock_supplier': location.id, + }) + @api.model def create(self, vals): company = super(Company, self).create(vals) - # multi-company rules prevents creating warehouse and sub-locations + company.create_transit_location() + # mutli-company rules prevents creating warehouse and sub-locations self.env['stock.warehouse'].check_access_rights('create') self.env['stock.warehouse'].sudo().create({'name': company.name, 'code': company.name[:5], 'company_id': company.id, 'partner_id': company.partner_id.id}) - company.create_transit_location() return company diff --git a/addons/stock/models/stock_quant.py b/addons/stock/models/stock_quant.py index c1765c3f083..0b73b7815e6 100644 --- a/addons/stock/models/stock_quant.py +++ b/addons/stock/models/stock_quant.py @@ -79,12 +79,6 @@ class StockQuant(models.Model): if float_compare(quant.quantity, 1, precision_rounding=quant.product_uom_id.rounding) > 0 and quant.lot_id and quant.product_id.tracking == 'serial': raise ValidationError(_('A serial number should only be linked to a single product.')) - @api.constrains('in_date', 'lot_id') - def check_in_date(self): - for quant in self: - if quant.in_date and not quant.lot_id: - raise ValidationError(_('An incoming date cannot be set to an untracked product.')) - @api.constrains('location_id') def check_location_id(self): for quant in self: @@ -109,9 +103,9 @@ class StockQuant(models.Model): @api.model def _get_removal_strategy_order(self, removal_strategy): if removal_strategy == 'fifo': - return 'in_date, id' + return 'in_date ASC NULLS FIRST, id' elif removal_strategy == 'lifo': - return 'in_date desc, id desc' + return 'in_date DESC NULLS LAST, id desc' raise UserError(_('Removal strategy %s not implemented.') % (removal_strategy,)) def _gather(self, product_id, location_id, lot_id=None, package_id=None, owner_id=None, strict=False): @@ -134,7 +128,17 @@ class StockQuant(models.Model): domain = expression.AND([[('owner_id', '=', owner_id and owner_id.id or False)], domain]) domain = expression.AND([[('location_id', '=', location_id.id)], domain]) - return self.search(domain, order=removal_strategy_order) + # Copy code of _search for special NULLS FIRST/LAST order + self.sudo(self._uid).check_access_rights('read') + query = self._where_calc(domain) + self._apply_ir_rules(query, 'read') + from_clause, where_clause, where_clause_params = query.get_sql() + where_str = where_clause and (" WHERE %s" % where_clause) or '' + query_str = 'SELECT "%s".id FROM ' % self._table + from_clause + where_str + " ORDER BY "+ removal_strategy_order + self._cr.execute(query_str, where_clause_params) + res = self._cr.fetchall() + # No uniquify list necessary as auto_join is not applied anyways... + return self.browse([x[0] for x in res]) @api.model def _get_available_quantity(self, product_id, location_id, lot_id=None, package_id=None, owner_id=None, strict=False, allow_negative=False): @@ -196,17 +200,16 @@ class StockQuant(models.Model): quants = self._gather(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=True) rounding = product_id.uom_id.rounding - if lot_id: - incoming_dates = quants.mapped('in_date') # `mapped` already filtered out falsy items - incoming_dates = [fields.Datetime.from_string(incoming_date) for incoming_date in incoming_dates] - if in_date: - incoming_dates += [in_date] - # If multiple incoming dates are available for a given lot_id/package_id/owner_id, we - # consider only the oldest one as being relevant. - if incoming_dates: - in_date = fields.Datetime.to_string(min(incoming_dates)) - else: - in_date = fields.Datetime.now() + incoming_dates = [d for d in quants.mapped('in_date') if d] + incoming_dates = [fields.Datetime.from_string(incoming_date) for incoming_date in incoming_dates] + if in_date: + incoming_dates += [in_date] + # If multiple incoming dates are available for a given lot_id/package_id/owner_id, we + # consider only the oldest one as being relevant. + if incoming_dates: + in_date = fields.Datetime.to_string(min(incoming_dates)) + else: + in_date = fields.Datetime.now() for quant in quants: try: diff --git a/addons/stock/models/stock_traceability.py b/addons/stock/models/stock_traceability.py index 633fb1b785e..7869c732d3c 100644 --- a/addons/stock/models/stock_traceability.py +++ b/addons/stock/models/stock_traceability.py @@ -21,24 +21,31 @@ class MrpStockReport(models.TransientModel): @api.model def _get_move_lines(self, move_lines): - res = self.env['stock.move.line'] - for move_line in move_lines: + lines_seen = move_lines + lines_todo = list(move_lines) + while lines_todo: + move_line = lines_todo.pop(0) # if MTO if move_line.move_id.move_orig_ids: - res |= move_line.move_id.move_orig_ids.mapped('move_line_ids').filtered( - lambda m: m.lot_id.id == move_line.lot_id.id) + lines = move_line.move_id.move_orig_ids.mapped('move_line_ids').filtered( + lambda m: m.lot_id == move_line.lot_id + ) - lines_seen # if MTS + elif move_line: + lines = self.env['stock.move.line'].search([ + ('product_id', '=', move_line.product_id.id), + ('lot_id', '=', move_line.lot_id.id), + ('id', 'not in', lines_seen.ids), + ('date', '<', move_line.date), + ]) else: - if move_line: - res |= self.env['stock.move.line'].search([ - ('product_id', '=', move_line.product_id.id), - ('lot_id', '=', move_line.lot_id.id), - ('id', '!=', move_line.id), - ('date', '<', move_line.date), - ]) - if res: - res |= self._get_move_lines(res) - return res + continue + lines_todo += list(lines) + lines_seen |= lines + return lines_seen - move_lines + lines_todo += list(lines) + lines_seen |= lines + return lines_seen - move_lines @api.model def get_lines(self, line_id=None, **kw): diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py index dd8d2a09d07..661628b2132 100644 --- a/addons/stock/models/stock_warehouse.py +++ b/addons/stock/models/stock_warehouse.py @@ -178,9 +178,10 @@ class Warehouse(models.Model): ResCompany = self.env['res.company'] if company_id: transit_loc = ResCompany.browse(company_id).internal_transit_location_id.id + self.env['res.partner'].browse(partner_id).with_context(force_company=company_id).write({'property_stock_customer': transit_loc, 'property_stock_supplier': transit_loc}) else: transit_loc = ResCompany._company_default_get('stock.warehouse').internal_transit_location_id.id - self.env['res.partner'].browse(partner_id).write({'property_stock_customer': transit_loc, 'property_stock_supplier': transit_loc}) + self.env['res.partner'].browse(partner_id).write({'property_stock_customer': transit_loc, 'property_stock_supplier': transit_loc}) def create_sequences_and_picking_types(self): IrSequenceSudo = self.env['ir.sequence'].sudo() diff --git a/addons/stock/report/report_stockpicking_operations.xml b/addons/stock/report/report_stockpicking_operations.xml index cbb472a946f..836e0d0e843 100644 --- a/addons/stock/report/report_stockpicking_operations.xml +++ b/addons/stock/report/report_stockpicking_operations.xml @@ -98,7 +98,7 @@ - + diff --git a/addons/stock/tests/test_move2.py b/addons/stock/tests/test_move2.py index 4e22335cad2..dc385165b61 100644 --- a/addons/stock/tests/test_move2.py +++ b/addons/stock/tests/test_move2.py @@ -185,6 +185,73 @@ class TestPickShip(TestStockCommon): # the client picking should not be assigned anymore, as we returned partially what we took self.assertEqual(picking_client.state, 'confirmed') + def test_mto_moves_return_return(self): + picking_pick, picking_client = self.create_pick_ship() + stock_location = self.env['stock.location'].browse(self.stock_location) + lot = self.env['stock.production.lot'].create({ + 'product_id': self.productA.id, + 'name': '123456789' + }) + self.env['stock.quant']._update_available_quantity(self.productA, stock_location, 10.0, lot_id=lot) + + picking_pick.action_assign() + picking_pick.move_lines[0].move_line_ids[0].qty_done = 10.0 + picking_pick.button_validate() + self.assertEqual(picking_pick.state, 'done') + self.assertEqual(picking_client.state, 'assigned') + + # return this picking + stock_return_picking = self.env['stock.return.picking']\ + .with_context(active_ids=picking_pick.ids, active_id=picking_pick.ids[0])\ + .create({}) + stock_return_picking.product_return_moves.quantity = 10.0 + stock_return_picking_action = stock_return_picking.create_returns() + return_pick = self.env['stock.picking'].browse(stock_return_picking_action['res_id']) + return_pick.move_lines[0].move_line_ids[0].write({ + 'qty_done': 10.0, + 'lot_id': lot.id, + }) + return_pick.button_validate() + # return this return of this picking + stock_return_picking = self.env['stock.return.picking']\ + .with_context(active_id=return_pick.id)\ + .create({}) + stock_return_picking.product_return_moves.quantity = 10.0 + stock_return_picking_action = stock_return_picking.create_returns() + return_return_pick = self.env['stock.picking'].browse(stock_return_picking_action['res_id']) + return_return_pick.move_lines[0].move_line_ids[0].write({ + 'qty_done': 10.0, + 'lot_id': lot.id, + }) + return_return_pick.button_validate() + # test computation of traceability + vals = { + 'line_id': 1, + 'model_name': 'stock.move.line', + 'level': 11, + 'parent_quant': False, + } + lines = self.env['stock.traceability.report'].get_lines( + model_id=return_return_pick.move_line_ids[0].id, + stream='upstream', + **vals + ) + self.assertEqual( + [l.get('res_id') for l in lines], + [return_return_pick.id, return_pick.id, picking_pick.id], + "Upstream computation from return of return worked" + ) + lines = self.env['stock.traceability.report'].get_lines( + model_id=picking_pick.move_line_ids[0].id, + stream='downstream', + **vals + ) + self.assertEqual( + [l.get('res_id') for l in lines], + [picking_pick.id, return_pick.id, return_return_pick.id], + "Downstream computation from original picking worked" + ) + def test_mto_resupply_cancel_ship(self): """ This test simulates a pick pack ship with a resupply route set. Pick and pack are validated, ship is cancelled. This test diff --git a/addons/stock/tests/test_quant.py b/addons/stock/tests/test_quant.py index bff1f902ba5..4a41b7474a4 100644 --- a/addons/stock/tests/test_quant.py +++ b/addons/stock/tests/test_quant.py @@ -629,7 +629,24 @@ class StockQuant(TransactionCase): }) quantity, in_date = self.env['stock.quant']._update_available_quantity(product1, stock_location, 1.0) self.assertEqual(quantity, 1) - self.assertEqual(in_date, None) + self.assertNotEqual(in_date, None) + + + def test_in_date_1b(self): + stock_location = self.env.ref('stock.stock_location_stock') + product1 = self.env['product.product'].create({ + 'name': 'Product A', + 'type': 'product', + }) + self.env['stock.quant'].create({ + 'product_id': product1.id, + 'location_id': stock_location.id, + 'quantity': 1.0, + }) + quantity, in_date = self.env['stock.quant']._update_available_quantity(product1, stock_location, 2.0) + self.assertEqual(quantity, 3) + self.assertNotEqual(in_date, None) + def test_in_date_2(self): """ Check that an incoming date is correctly set when updating the quantity of a tracked @@ -707,6 +724,34 @@ class StockQuant(TransactionCase): # Removal strategy is LIFO, so lot1 should be received as it was received later. self.assertEqual(quants[0][0].lot_id.id, lot1.id) + def test_in_date_4b(self): + """ Check for LIFO and max with/without in_date that it handles the LIFO NULLS LAST well + """ + stock_location = self.env.ref('stock.stock_location_stock') + stock_location1 = self.env.ref('stock.stock_location_components') + stock_location2 = self.env.ref('stock.stock_location_14') + lifo_strategy = self.env['product.removal'].search([('method', '=', 'lifo')]) + stock_location.removal_strategy_id = lifo_strategy + product1 = self.env['product.product'].create({ + 'name': 'Product A', + 'type': 'product', + 'tracking': 'serial', + }) + + self.env['stock.quant'].create({ + 'product_id': product1.id, + 'location_id': stock_location1.id, + 'quantity': 1.0, + }) + + in_date_location2 = datetime.now() + self.env['stock.quant']._update_available_quantity(product1, stock_location2, 1.0, in_date=in_date_location2) + + quants = self.env['stock.quant']._update_reserved_quantity(product1, stock_location, 1) + + # Removal strategy is LIFO, so the one with date is the most recent one and should be selected + self.assertEqual(quants[0][0].location_id.id, stock_location2.id) + def test_in_date_5(self): """ Receive the same lot at different times, once they're in the same location, the quants are merged and only the earliest incoming date is kept. diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index 77d7304df12..f05a3855ab8 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -1047,8 +1047,12 @@ class Binary(http.Controller): elif status != 200 and download: return request.not_found() - height = int(height or 0) - width = int(width or 0) + if headers and dict(headers).get('Content-Type', '') == 'image/svg+xml': # we shan't resize svg images + height = 0 + width = 0 + else: + height = int(height or 0) + width = int(width or 0) if crop and (width or height): content = crop_image(content, type='center', size=(width, height), ratio=(1, 1)) diff --git a/addons/web/static/src/js/core/ajax.js b/addons/web/static/src/js/core/ajax.js index 2912b319df2..d6c67eacaec 100644 --- a/addons/web/static/src/js/core/ajax.js +++ b/addons/web/static/src/js/core/ajax.js @@ -260,12 +260,20 @@ function get_file(options) { if (options.error) { var body = this.contentDocument.body; var nodes = body.children.length === 0 ? body.childNodes : body.children; - options.error({ - message: nodes.length > 1 ? nodes[1].textContent : '', - data: { - title: nodes.length > 0 ? nodes[0].textContent : '', - }, - }); + var errorParams = {}; + + try { // Case of a serialized Odoo Exception: It is Json Parsable + var node = nodes[1] || nodes[0]; + errorParams = JSON.parse(node.textContent); + } catch (e) { // Arbitrary uncaught python side exception + errorParams = { + message: nodes.length > 1 ? nodes[1].textContent : '', + data: { + title: nodes.length > 0 ? nodes[0].textContent : '', + } + } + } + options.error(errorParams); } } finally { complete(); diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js index 8b266d8ffef..e4d2248d7c3 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -1542,8 +1542,10 @@ var BasicModel = AbstractModel.extend({ rec = self._makeDataPoint(params); list._cache[rec.res_id] = rec.id; } - - rec._noAbandon = true; + // Do not abandon the record if it has been created + // from `default_get`. The list has a savepoint only + // after having fully executed `default_get`. + rec._noAbandon = !list._savePoint; list._changes.push({operation: 'ADD', id: rec.id}); if (command[0] === 1) { list._changes.push({operation: 'UPDATE', id: rec.id}); @@ -1962,6 +1964,9 @@ var BasicModel = AbstractModel.extend({ var records = []; var ids = []; list = this._applyX2ManyOperations(list); + if (_.isEmpty(list.data)) { + return $.when(); + } _.each(list.data, function (localId) { var record = self.localData[localId]; var data = record._changes || record.data; @@ -1975,13 +1980,16 @@ var BasicModel = AbstractModel.extend({ return this._rpc({ model: model, method: 'name_get', - args: [ids], + args: [_.uniq(ids)], context: list.context, }) .then(function (name_gets) { - for (var i = 0; i < name_gets.length; i++) { - records[i].data.display_name = name_gets[i][1]; - } + _.each(records, function (record) { + var nameGet = _.find(name_gets, function (nameGet) { + return nameGet[0] === record.data.id; + }); + record.data.display_name = nameGet[1]; + }); }); }, /** @@ -2467,7 +2475,16 @@ var BasicModel = AbstractModel.extend({ var self = this; var def; if (list.static) { - def = this._readUngroupedList(list); + def = this._readUngroupedList(list).then(function () { + if (list.parentID && self.isNew(list.parentID)) { + // list from a default_get, so fetch display_name for many2one fields + var many2ones = self._getMany2OneFieldNames(list); + var defs = _.map(many2ones, function (name) { + return self._fetchNameGets(list, name); + }); + return $.when.apply($, defs); + } + }); } else { def = this._searchReadUngroupedList(list); } @@ -3035,6 +3052,23 @@ var BasicModel = AbstractModel.extend({ var viewType = options && options.viewType || element.viewType; return Object.keys(fieldsInfo && fieldsInfo[viewType] || {}); }, + /** + * Get many2one fields names in a datapoint. This is useful in order to + * fetch their names in the case of a default_get. + * + * @private + * @param {Object} datapoint a valid resource object + * @returns {string[]} list of field names that are many2one + */ + _getMany2OneFieldNames: function (datapoint) { + var many2ones = []; + _.each(datapoint.fields, function (field, name) { + if (field.type === 'many2one') { + many2ones.push(name); + } + }); + return many2ones; + }, /** * Evaluate the record evaluation context. This method is supposed to be * called by _getEvalContext. It basically only generates a dictionary of diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 23e7aedb0eb..99de84c1654 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -1237,8 +1237,167 @@ QUnit.module('relational_fields', { form.destroy(); }); - QUnit.test('list in form: discard newly added element with empty required field (onchange)', function (assert) { - assert.expect(8); + QUnit.test('list in form: name_get with unique ids (default_get)', function (assert) { + assert.expect(2); + + this.data.partner.records[0].display_name = "MyTrululu"; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + mockRPC: function (route, args) { + if (args.method === 'default_get') { + return $.when({ + p: [ + [0, 0, { trululu: 1 }], + [0, 0, { trululu: 1 }] + ] + }); + } + if (args.method === 'name_get') { + assert.deepEqual(args.args[0], _.uniq(args.args[0]), + "should not have duplicates in name_get rpc"); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(form.$('td.o_data_cell').text(), "MyTrululuMyTrululu", + "both records should have the correct display_name for trululu field"); + + form.destroy(); + }); + + QUnit.test('list in form: show name of many2one fields in multi-page (default_get)', function (assert) { + assert.expect(4); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
', + mockRPC: function (route, args) { + if (args.method === 'default_get') { + return $.when({ + p: [ + [0, 0, { display_name: 'record1', trululu: 1 }], + [0, 0, { display_name: 'record2', trululu: 2 }] + ] + }); + } + return this._super.apply(this, arguments); + }, + }); + + assert.strictEqual(form.$('td.o_data_cell').first().text(), + "record1", "should show display_name of 1st record"); + assert.strictEqual(form.$('td.o_data_cell').first().next().text(), + "first record", "should show display_name of trululu of 1st record"); + + form.$('button.o_pager_next').click(); + + assert.strictEqual(form.$('td.o_data_cell').first().text(), + "record2", "should show display_name of 2nd record"); + assert.strictEqual(form.$('td.o_data_cell').first().next().text(), + "second record", "should show display_name of trululu of 2nd record"); + + form.destroy(); + }); + + QUnit.test('list in form: discard newly added element with empty required field (onchange in default_get)', function (assert) { + // variant of the test "list in form: discard newly added element with + // empty required field (default_get)", in which the `default_get` + // performs an `onchange` at the same time. This `onchange` may create + // some records, which should not be abandoned on discard, similarly + // to records created directly by `default_get` + assert.expect(7); + + var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY; + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0; + + this.data.partner.onchanges = { + product_id: function (obj) { + if (obj.product_id === 37) { + obj.p = [[0, 0, { display_name: "entry", trululu: false }]]; + } + }, + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + mockRPC: function (route, args) { + if (args.method === 'default_get') { + return $.when({ + product_id: 37, + }); + } + return this._super.apply(this, arguments); + }, + }); + + // check that there is a record in the editable list with empty string as required field + assert.strictEqual(form.$('.o_data_row').length, 1, + "should have a row in the editable list"); + assert.strictEqual($('td.o_data_cell').first().text(), "entry", + "should have the correct displayed name"); + var requiredField = $('td.o_data_cell.o_required_modifier'); + assert.strictEqual(requiredField.length, 1, + "should have a required field on this record"); + assert.strictEqual(requiredField.text(), "", + "should have empty string in the required field on this record"); + + // click on empty required field in editable list record + requiredField.click(); + // click off so that the required field still stay empty + $('body').click(); + + // record should not be dropped + assert.strictEqual(form.$('.o_data_row').length, 1, + "should not have dropped record in the editable list"); + assert.strictEqual($('td.o_data_cell').first().text(), "entry", + "should still have the correct displayed name"); + assert.strictEqual($('td.o_data_cell.o_required_modifier').text(), "", + "should still have empty string in the required field"); + + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY; + form.destroy(); + }); + + QUnit.test('list in form: discard newly added element with empty required field (onchange after default_get)', function (assert) { + // discarding a record from an `onchange` in a `default_get` should not + // abandon the record. However, any `onchange` after `default_get` + // should be abandoned on discard + assert.expect(6); var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY; relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0; @@ -1249,7 +1408,6 @@ QUnit.module('relational_fields', { obj.p = [[0, 0, { display_name: "entry", trululu: false }]]; } }, - trululu: function () {}, }; var form = createView({ @@ -1291,13 +1449,9 @@ QUnit.module('relational_fields', { // click off so that the required field still stay empty $('body').click(); - // record should not be dropped - assert.strictEqual(form.$('.o_data_row').length, 1, - "should not have droppped record in the editable list"); - assert.strictEqual($('td.o_data_cell').first().text(), "entry", - "should still have the correct displayed name"); - assert.strictEqual($('td.o_data_cell.o_required_modifier').text(), "", - "should still have empty string in the required field"); + // record should be dropped + assert.strictEqual(form.$('.o_data_row').length, 0, + "should have dropped record in the editable list"); relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY; form.destroy(); diff --git a/addons/website_form/controllers/main.py b/addons/website_form/controllers/main.py index b37ca97ea79..41a120008b4 100644 --- a/addons/website_form/controllers/main.py +++ b/addons/website_form/controllers/main.py @@ -112,6 +112,7 @@ class WebsiteForm(http.Controller): 'record': {}, # Values to create record 'attachments': [], # Attached files 'custom': '', # Custom fields values + 'meta': '', # Add metadata if enabled } authorized_fields = model.sudo()._get_form_writable_fields() diff --git a/addons/website_sale/static/src/js/website_sale_tour_buy.js b/addons/website_sale/static/src/js/website_sale_tour_buy.js index 47921e71547..a0659e43925 100644 --- a/addons/website_sale/static/src/js/website_sale_tour_buy.js +++ b/addons/website_sale/static/src/js/website_sale_tour_buy.js @@ -77,7 +77,7 @@ tour.register('shop_buy_product', { content: "Pay Now", //Either there are multiple payment methods, and one is checked, either there is only one, and therefore there are no radio inputs extra_trigger: '#payment_method label:contains("Wire Transfer") input:checked,#payment_method:not(:has("input:radio:visible"))', - trigger: 'button[id="o_payment_form_pay"]:visible', + trigger: 'button[id="o_payment_form_pay"]:visible:not(:disabled)', }, { content: "finish", diff --git a/addons/website_sale_delivery/controllers/main.py b/addons/website_sale_delivery/controllers/main.py index 56ef9c451f2..70b3b446303 100644 --- a/addons/website_sale_delivery/controllers/main.py +++ b/addons/website_sale_delivery/controllers/main.py @@ -4,7 +4,6 @@ from odoo import http, _ from odoo.http import request from odoo.addons.website_sale.controllers.main import WebsiteSale -from odoo.tools import float_repr class WebsiteSaleDelivery(WebsiteSale): @@ -46,9 +45,6 @@ class WebsiteSaleDelivery(WebsiteSale): has_stockable_products = any(line.product_id.type in ['consu', 'product'] for line in order.order_line) if has_stockable_products: if order.carrier_id and not order.delivery_rating_success: - values['errors'].append( - (_("Ouch, you cannot choose this carrier!"), - _("%s does not ship to your address, please choose another one.\n(Error: %s)" % (order.carrier_id.name, order.delivery_message)))) order._remove_delivery_line() delivery_carriers = order._get_delivery_methods() @@ -67,8 +63,15 @@ class WebsiteSaleDelivery(WebsiteSale): return {'status': order.delivery_rating_success, 'error_message': order.delivery_message, 'carrier_id': carrier_id, - 'new_amount_delivery': float_repr(currency.round(order.delivery_price), currency.decimal_places), - 'new_amount_untaxed': order.amount_untaxed, - 'new_amount_tax': order.amount_tax, - 'new_amount_total': order.amount_total, + 'new_amount_delivery': self._format_amount(order.delivery_price, currency), + 'new_amount_untaxed': self._format_amount(order.amount_untaxed, currency), + 'new_amount_tax': self._format_amount(order.amount_tax, currency), + 'new_amount_total': self._format_amount(order.amount_total, currency), } + + def _format_amount(self, amount, currency): + fmt = "%.{0}f".format(currency.decimal_places) + lang = request.env['res.lang']._lang_get(request.env.context.get('lang') or 'en_US') + + return lang.format(fmt, currency.round(amount), grouping=True, monetary=True)\ + .replace(r' ', u'\N{NO-BREAK SPACE}').replace(r'-', u'\u2011') diff --git a/addons/website_sale_delivery/models/sale_order.py b/addons/website_sale_delivery/models/sale_order.py index 027d3db904d..461a70f5496 100644 --- a/addons/website_sale_delivery/models/sale_order.py +++ b/addons/website_sale_delivery/models/sale_order.py @@ -65,12 +65,11 @@ class SaleOrder(models.Model): carrier = delivery break self.write({'carrier_id': carrier.id}) + self._remove_delivery_line() if carrier: self.get_delivery_price() if self.delivery_rating_success: self.set_delivery_line() - else: - self._remove_delivery_line() return bool(carrier) diff --git a/addons/website_sale_delivery/static/src/js/website_sale_delivery.js b/addons/website_sale_delivery/static/src/js/website_sale_delivery.js index 3cb0df152c1..971e604d1d1 100644 --- a/addons/website_sale_delivery/static/src/js/website_sale_delivery.js +++ b/addons/website_sale_delivery/static/src/js/website_sale_delivery.js @@ -27,6 +27,10 @@ odoo.define('website_sale_delivery.checkout', function (require) { else { console.error(result.error_message); $compute_badge.text(result.error_message); + $amount_delivery.text(result.new_amount_delivery); + $amount_untaxed.text(result.new_amount_untaxed); + $amount_tax.text(result.new_amount_tax); + $amount_total.text(result.new_amount_total); } }; @@ -40,6 +44,12 @@ odoo.define('website_sale_delivery.checkout', function (require) { var $carriers = $("#delivery_carrier input[name='delivery_type']"); $carriers.click(_onCarrierClick); + // Workaround to: + // - update the amount/error on the label at first rendering + // - prevent clicking on 'Pay Now' if the shipper rating fails + if ($carriers.length > 0) { + $carriers.filter(':checked').click(); + } /* Handle stuff */ $(".oe_website_sale select[name='shipping_id']").on('change', function () { diff --git a/addons/website_sale_delivery/views/website_sale_delivery_templates.xml b/addons/website_sale_delivery/views/website_sale_delivery_templates.xml index 3a39f64c27f..70279df3681 100644 --- a/addons/website_sale_delivery/views/website_sale_delivery_templates.xml +++ b/addons/website_sale_delivery/views/website_sale_delivery_templates.xml @@ -3,7 +3,7 @@