diff --git a/addons/account/models/account.py b/addons/account/models/account.py index d7613478ce6..872bd8f13d0 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -67,12 +67,11 @@ class AccountAccount(models.Model): @api.constrains('user_type_id') def _check_user_type_id(self): data_unaffected_earnings = self.env.ref('account.data_unaffected_earnings') - for company in self.mapped('company_id'): - account_unaffected_earnings = self.search([ - ('company_id', '=', company.id), - ('user_type_id', '=', data_unaffected_earnings.id), - ]) - if len(account_unaffected_earnings) >= 2: + result = self.read_group([('user_type_id', '=', data_unaffected_earnings.id)], ['company_id'], ['company_id']) + for res in result: + if res.get('company_id_count', 0) >= 2: + account_unaffected_earnings = self.search([('company_id', '=', res['company_id'][0]), + ('user_type_id', '=', data_unaffected_earnings.id)]) raise ValidationError(_('You cannot have more than one account with "Current Year Earnings" as type. (accounts: %s)') % [a.code for a in account_unaffected_earnings]) name = fields.Char(required=True, index=True) @@ -228,7 +227,6 @@ class AccountAccount(models.Model): self.group_id = group @api.multi - @api.depends('name', 'code') def name_get(self): result = [] for account in self: @@ -801,7 +799,6 @@ class AccountJournal(models.Model): }).id @api.multi - @api.depends('name', 'currency_id', 'company_id', 'company_id.currency_id') def name_get(self): res = [] for journal in self: @@ -946,7 +943,6 @@ class AccountTax(models.Model): default = dict(default or {}, name=_("%s (Copy)") % self.name) return super(AccountTax, self).copy(default=default) - @api.depends('name', 'type_tax_use') def name_get(self): if not self._context.get('append_type_to_tax_name'): return super(AccountTax, self).name_get() diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index b8a478cfef8..d0632970bed 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -550,6 +550,8 @@ class AccountBankStatementLine(models.Model): :returns: The journal entries with which the transaction was matched. If there was at least an entry in counterpart_aml_dicts or new_aml_dicts, this list contains the move created by the reconciliation, containing entries for the statement.line (1), the counterpart move lines (0..*) and the new move lines (0..*). """ + payable_account_type = self.env.ref('account.data_account_type_payable') + receivable_account_type = self.env.ref('account.data_account_type_receivable') counterpart_aml_dicts = counterpart_aml_dicts or [] payment_aml_rec = payment_aml_rec or self.env['account.move.line'] new_aml_dicts = new_aml_dicts or [] @@ -570,10 +572,16 @@ class AccountBankStatementLine(models.Model): raise UserError(_('A selected move line was already reconciled.')) if isinstance(aml_dict['move_line'], int): aml_dict['move_line'] = aml_obj.browse(aml_dict['move_line']) + + account_types = self.env['account.account.type'] for aml_dict in (counterpart_aml_dicts + new_aml_dicts): if aml_dict.get('tax_ids') and isinstance(aml_dict['tax_ids'][0], int): # Transform the value in the format required for One2many and Many2many fields aml_dict['tax_ids'] = [(4, id, None) for id in aml_dict['tax_ids']] + + user_type_id = self.env['account.account'].browse(aml_dict.get('account_id')).user_type_id + if user_type_id in [payable_account_type, receivable_account_type] and user_type_id not in account_types: + account_types |= user_type_id if any(line.journal_entry_ids for line in self): raise UserError(_('A selected statement line was already reconciled with an account move.')) @@ -609,6 +617,8 @@ class AccountBankStatementLine(models.Model): payment = self.env['account.payment'] if abs(total)>0.00001: payment_vals = self._prepare_payment_vals(total) + if self.partner_id and len(account_types) == 1: + payment_vals['partner_type'] = 'customer' if account_types == receivable_account_type else 'supplier' payment = payment.create(payment_vals) # Complete dicts to create both counterpart move lines and write-offs diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index 8db6ced6104..91f06252680 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -147,8 +147,13 @@ class AccountInvoice(models.Model): amount_to_show = currency._convert(abs(line.amount_residual), self.currency_id, self.company_id, line.date or fields.Date.today()) if float_is_zero(amount_to_show, precision_rounding=self.currency_id.rounding): continue + if line.ref : + title = '%s : %s' % (line.move_id.name, line.ref) + else: + title = line.move_id.name info['content'].append({ 'journal_name': line.ref or line.move_id.name, + 'title': title, 'amount': amount_to_show, 'currency': currency_id.symbol, 'id': line.id, @@ -1402,6 +1407,20 @@ class AccountInvoice(models.Model): return result @api.model + def _refund_tax_lines_account_change(self, lines, taxes_to_change): + # Let's change the account on tax lines when + # @param {list} lines: a list of orm commands + # @param {dict} taxes_to_change + # key: tax ID, value: refund account + + if not taxes_to_change: + return lines + + for line in lines: + if isinstance(line[2], dict) and line[2]['tax_id'] in taxes_to_change: + line[2]['account_id'] = taxes_to_change[line[2]['tax_id']] + return lines + def _get_refund_common_fields(self): return ['partner_id', 'payment_term_id', 'account_id', 'currency_id', 'journal_id'] @@ -1447,7 +1466,12 @@ class AccountInvoice(models.Model): values['invoice_line_ids'] = self._refund_cleanup_lines(invoice.invoice_line_ids) tax_lines = invoice.tax_line_ids - values['tax_line_ids'] = self._refund_cleanup_lines(tax_lines) + taxes_to_change = { + line.tax_id.id: line.tax_id.refund_account_id.id + for line in tax_lines.filtered(lambda l: l.tax_id.refund_account_id != l.tax_id.account_id) + } + cleaned_tax_lines = self._refund_cleanup_lines(tax_lines) + values['tax_line_ids'] = self._refund_tax_lines_account_change(cleaned_tax_lines, taxes_to_change) if journal_id: journal = self.env['account.journal'].browse(journal_id) diff --git a/addons/account/models/account_journal_dashboard.py b/addons/account/models/account_journal_dashboard.py index f4753ef4796..ee8087a33b0 100644 --- a/addons/account/models/account_journal_dashboard.py +++ b/addons/account/models/account_journal_dashboard.py @@ -185,13 +185,14 @@ class account_journal(models.Model): self.env.cr.execute(query, query_args) query_results_drafts = self.env.cr.dictfetchall() - today = datetime.today() - query = """SELECT amount_total, currency_id AS currency, type, date_invoice, company_id FROM account_invoice WHERE journal_id = %s AND date <= %s AND state = 'open';""" + today = fields.Date.today() + query = """SELECT residual_signed as amount_total, currency_id AS currency, type, date_invoice, company_id FROM account_invoice WHERE journal_id = %s AND date <= %s AND state = 'open';""" self.env.cr.execute(query, (self.id, today)) late_query_results = self.env.cr.dictfetchall() - (number_waiting, sum_waiting) = self._count_results_and_sum_amounts(query_results_to_pay, currency) - (number_draft, sum_draft) = self._count_results_and_sum_amounts(query_results_drafts, currency) - (number_late, sum_late) = self._count_results_and_sum_amounts(late_query_results, currency) + curr_cache = {} + (number_waiting, sum_waiting) = self._count_results_and_sum_amounts(query_results_to_pay, currency, curr_cache=curr_cache) + (number_draft, sum_draft) = self._count_results_and_sum_amounts(query_results_drafts, currency, curr_cache=curr_cache) + (number_late, sum_late) = self._count_results_and_sum_amounts(late_query_results, currency, curr_cache=curr_cache) difference = currency.round(last_balance-account_sum) + 0.0 return { @@ -226,23 +227,45 @@ class account_journal(models.Model): gather the bills in draft state data, and the arguments dictionary to use to run it as its second. """ - return ("""SELECT state, amount_total, currency_id AS currency, type, date_invoice, company_id - FROM account_invoice + # there is no account_move_lines for draft invoices, so no relevant residual_signed value + return ("""SELECT state, + (CASE WHEN inv.type in ('out_invoice', 'in_invoice') + THEN inv.amount_total + ELSE (-1 * inv.amount_total) + END) AS amount_total, + inv.currency_id AS currency, + inv.type, + inv.date_invoice, + inv.company_id + FROM account_invoice inv WHERE journal_id = %(journal_id)s AND state = 'draft';""", {'journal_id':self.id}) - def _count_results_and_sum_amounts(self, results_dict, target_currency): + def _count_results_and_sum_amounts(self, results_dict, target_currency, curr_cache=None): """ Loops on a query result to count the total number of invoices and sum their amount_total field (expressed in the given target currency). + amount_total must be signed ! """ rslt_count = 0 rslt_sum = 0.0 + # Create a cache with currency rates to avoid unnecessary SQL requests. Do not copy + # curr_cache on purpose, so the dictionary is modified and can be re-used for subsequent + # calls of the method. + curr_cache = {} if curr_cache is None else curr_cache for result in results_dict: cur = self.env['res.currency'].browse(result.get('currency')) company = self.env['res.company'].browse(result.get('company_id')) or self.env.user.company_id rslt_count += 1 - type_factor = result.get('type') in ('in_refund', 'out_refund') and -1 or 1 - rslt_sum += type_factor * cur._convert( - result.get('amount_total'), target_currency, company, result.get('date_invoice') or fields.Date.today()) + date = result.get('date_invoice') or fields.Date.today() + + amount = result.get('amount_total', 0) + if cur != target_currency: + key = (cur, target_currency, company, date) + # Using setdefault will call _get_conversion_rate, so we explicitly check the + # existence of the key in the cache instead. + if key not in curr_cache: + curr_cache[key] = self.env['res.currency']._get_conversion_rate(*key) + amount *= curr_cache[key] + rslt_sum += target_currency.round(amount) return (rslt_count, rslt_sum) @api.multi @@ -360,7 +383,7 @@ class account_journal(models.Model): action['view_id'] = False if self.type == 'purchase': new_help = self.env['account.invoice'].with_context(ctx).complete_empty_list_help() - action.update({'help': action.get('help', '') + new_help}) + action.update({'help': (action.get('help') or '') + new_help}) return action @api.multi diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index b19485674a4..baf69b2e646 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -410,9 +410,6 @@ class AccountMove(models.Model): journal_id=journal_id, auto=auto) reversed_moves |= reversed_move - #unreconcile all lines reversed - aml = ac_move.line_ids.filtered(lambda x: x.account_id.reconcile or x.account_id.internal_type == 'liquidity') - aml.remove_move_reconcile() #reconcile together the reconcilable (or the liquidity aml) and their newly created counterpart for account in set([x.account_id for x in aml]): to_rec = aml.filtered(lambda y: y.account_id == account) @@ -429,6 +426,18 @@ class AccountMove(models.Model): def open_reconcile_view(self): return self.line_ids.open_reconcile_view() + # FIXME: Clarify me and change me in master + @api.multi + def action_duplicate(self): + self.ensure_one() + action = self.env.ref('account.action_move_journal_line').read()[0] + action['target'] = 'inline' + action['context'] = dict(self.env.context) + action['context']['view_no_maturity'] = False + action['views'] = [(self.env.ref('account.view_move_form').id, 'form')] + action['res_id'] = self.copy().id + return action + @api.model def _run_reverses_entries(self): ''' This method is called from a cron job. ''' @@ -618,7 +627,7 @@ class AccountMoveLine(models.Model): index=True, store=True, copy=False) # related is required blocked = fields.Boolean(string='No Follow-up', default=False, help="You can check this box to mark this journal item as a litigation with the associated partner") - date_maturity = fields.Date(string='Due date', index=True, required=True, + date_maturity = fields.Date(string='Due date', index=True, required=True, copy=False, help="This field is used for payable and receivable journal entries. You can put the limit date for the payment of this line.") date = fields.Date(related='move_id.date', string='Date', index=True, store=True, copy=False, readonly=False) # related is required analytic_line_ids = fields.One2many('account.analytic.line', 'move_id', string='Analytic lines', oldname="analytic_lines") @@ -877,22 +886,13 @@ class AccountMoveLine(models.Model): ret = self._reconcile_lines(debit_moves, credit_moves, field) return ret - @api.multi - def reconcile(self, writeoff_acc_id=False, writeoff_journal_id=False): - # Empty self can happen if the user tries to reconcile entries which are already reconciled. - # The calling method might have filtered out reconciled lines. - if not self: - return True - + def _check_reconcile_validity(self): #Perform all checks on lines company_ids = set() all_accounts = [] - partners = set() for line in self: company_ids.add(line.company_id.id) all_accounts.append(line.account_id) - if (line.account_id.internal_type in ('receivable', 'payable')): - partners.add(line.partner_id.id) if line.reconciled: raise UserError(_('You are trying to reconcile some entries that are already reconciled.')) if len(company_ids) > 1: @@ -902,6 +902,14 @@ class AccountMoveLine(models.Model): if not (all_accounts[0].reconcile or all_accounts[0].internal_type == 'liquidity'): raise UserError(_('Account %s (%s) does not allow reconciliation. First change the configuration of this account to allow it.') % (all_accounts[0].name, all_accounts[0].code)) + @api.multi + def reconcile(self, writeoff_acc_id=False, writeoff_journal_id=False): + # Empty self can happen if the user tries to reconcile entries which are already reconciled. + # The calling method might have filtered out reconciled lines. + if not self: + return True + + self._check_reconcile_validity() #reconcile everything that can be remaining_moves = self.auto_reconcile_lines() @@ -919,7 +927,7 @@ class AccountMoveLine(models.Model): #add writeoff line to reconcile algorithm and finish the reconciliation remaining_moves = (remaining_moves + writeoff_to_reconcile).auto_reconcile_lines() # Check if reconciliation is total or needs an exchange rate entry to be created - (self+writeoff_to_reconcile).check_full_reconcile() + (self + writeoff_to_reconcile).check_full_reconcile() return True def _create_writeoff(self, writeoff_vals): @@ -963,8 +971,9 @@ class AccountMoveLine(models.Model): raise UserError(_("Either pass both debit and credit or none.")) if 'date' not in vals: vals['date'] = self._context.get('date_p') or fields.Date.today() - if vals['date'] < date: - date = vals['date'] + vals['date'] = fields.Date.to_date(vals['date']) + if vals['date'] and vals['date'] < date: + date = vals['date'] if 'name' not in vals: vals['name'] = self._context.get('comment') or _('Write-Off') if 'analytic_account_id' not in vals: diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py index c83d2b31ed3..bb2e7ff4f03 100644 --- a/addons/account/models/account_payment.py +++ b/addons/account/models/account_payment.py @@ -324,7 +324,7 @@ class account_register_payments(models.TransientModel): pmt_communication = self.show_communication_field and self.communication \ or self.group_invoices and ' '.join([inv.reference or inv.number for inv in invoices]) \ or invoices[0].reference # in this case, invoices contains only one element, since group_invoices is False - return { + values = { 'journal_id': self.journal_id.id, 'payment_method_id': self.payment_method_id.id, 'payment_date': self.payment_date, @@ -337,8 +337,13 @@ class account_register_payments(models.TransientModel): 'partner_type': MAP_INVOICE_TYPE_PARTNER_TYPE[invoices[0].type], 'partner_bank_account_id': bank_account.id, 'multi': False, + 'payment_difference_handling': self.payment_difference_handling, + 'writeoff_account_id': self.writeoff_account_id.id, + 'writeoff_label': self.writeoff_label, } + return values + @api.multi def get_payments_vals(self): '''Compute the values for payments. @@ -429,6 +434,8 @@ class account_payment(models.Model): if move_line.account_id.reconcile: move_line_id = move_line.id break; + if not self.partner_id: + raise UserError(_("Payments without a customer can't be matched")) action_context = {'company_ids': [self.company_id.id], 'partner_ids': [self.partner_id.commercial_partner_id.id]} if self.partner_type == 'customer': action_context.update({'mode': 'customers'}) diff --git a/addons/account/models/chart_template.py b/addons/account/models/chart_template.py index 8d7a48424fe..717b99ad26d 100644 --- a/addons/account/models/chart_template.py +++ b/addons/account/models/chart_template.py @@ -173,7 +173,7 @@ class AccountChartTemplate(models.Model): # (won't work well for multi-company) company = self.env.user.company_id # If we don't have any chart of account on this company, install this chart of account - if not company.chart_template_id: + if not self.existing_accounting(company): self.load_for_current_company(15.0, 15.0) def load_for_current_company(self, sale_tax_rate, purchase_tax_rate): diff --git a/addons/account/models/company.py b/addons/account/models/company.py index 5ea0db1cd95..f2efe950361 100644 --- a/addons/account/models/company.py +++ b/addons/account/models/company.py @@ -381,8 +381,13 @@ Best Regards,''')) ('user_type_id', '=', unaffected_earnings_type.id)]) if account: return account[0] + # Do not assume '999999' doesn't exist since the user might have created such an account + # manually. + code = 999999 + while self.env['account.account'].search([('code', '=', str(code)), ('company_id', '=', self.id)]): + code -= 1 return self.env['account.account'].create({ - 'code': '999999', + 'code': str(code), 'name': _('Undistributed Profits/Losses'), 'user_type_id': unaffected_earnings_type.id, 'company_id': self.id, diff --git a/addons/account/models/partner.py b/addons/account/models/partner.py index f2485b379e6..85ec5f9bb66 100644 --- a/addons/account/models/partner.py +++ b/addons/account/models/partner.py @@ -204,7 +204,7 @@ class ResPartner(models.Model): @api.multi def _credit_debit_get(self): - tables, where_clause, where_params = self.env['account.move.line']._query_get() + tables, where_clause, where_params = self.env['account.move.line'].with_context(company_id=self.env.user.company_id.id)._query_get() where_params = [tuple(self.ids)] + where_params if where_clause: where_clause = 'AND ' + where_clause diff --git a/addons/account/models/reconciliation_widget.py b/addons/account/models/reconciliation_widget.py index 2fb6aad94b8..df92a5b45f1 100644 --- a/addons/account/models/reconciliation_widget.py +++ b/addons/account/models/reconciliation_widget.py @@ -224,6 +224,27 @@ class AccountReconciliation(models.AbstractModel): """ Returns the data required for the invoices & payments matching of partners/accounts. If an argument is None, fetch all related reconciliations. Use [] to fetch nothing. """ + MoveLine = self.env['account.move.line'] + aml_ids = self._context.get('active_ids') and self._context.get('active_model') == 'account.move.line' and tuple(self._context.get('active_ids')) + if aml_ids: + aml = MoveLine.browse(aml_ids) + aml._check_reconcile_validity() + account = aml[0].account_id + currency = account.currency_id or account.company_id.currency_id + return { + 'accounts': [{ + 'reconciliation_proposition': self._prepare_move_lines(aml, target_currency=currency), + 'company_id': account.company_id.id, + 'currency_id': currency.id, + 'mode': 'accounts', + 'account_id': account.id, + 'account_name': account.name, + 'account_code': account.code, + }], + 'customers': [], + 'suppliers': [], + } + return { 'customers': self.get_data_for_manual_reconciliation('partner', partner_ids, 'receivable'), 'suppliers': self.get_data_for_manual_reconciliation('partner', partner_ids, 'payable'), @@ -344,7 +365,6 @@ class AccountReconciliation(models.AbstractModel): # be reconciled. return [r for r in rows if r['reconciliation_proposition']] + [r for r in rows if not r['reconciliation_proposition']] - @api.model def process_move_lines(self, data): """ Used to validate a batch of reconciliations in a single call diff --git a/addons/account/report/account_aged_partner_balance.py b/addons/account/report/account_aged_partner_balance.py index bc660b494b3..08d0b976263 100644 --- a/addons/account/report/account_aged_partner_balance.py +++ b/addons/account/report/account_aged_partner_balance.py @@ -142,6 +142,7 @@ class ReportAgedPartnerBalance(models.AbstractModel): if not self.env.user.company_id.currency_id.is_zero(line_amount): partners_amount[partner_id] += line_amount + lines.setdefault(partner_id, []) lines[partner_id].append({ 'line': line, 'amount': line_amount, @@ -179,6 +180,7 @@ class ReportAgedPartnerBalance(models.AbstractModel): line_amount -= partial_line.company_id.currency_id._convert(partial_line.amount, user_currency, user_company, date_from) if not self.env.user.company_id.currency_id.is_zero(line_amount): undue_amounts[partner_id] += line_amount + lines.setdefault(partner_id, []) lines[partner_id].append({ 'line': line, 'amount': line_amount, diff --git a/addons/account/static/src/js/account_payment_field.js b/addons/account/static/src/js/account_payment_field.js index a20a681b0e6..c4dec36dda0 100644 --- a/addons/account/static/src/js/account_payment_field.js +++ b/addons/account/static/src/js/account_payment_field.js @@ -162,4 +162,8 @@ var ShowPaymentLineWidget = AbstractField.extend({ field_registry.add('payment', ShowPaymentLineWidget); +return { + ShowPaymentLineWidget: ShowPaymentLineWidget +}; + }); diff --git a/addons/account/static/src/js/reconciliation/reconciliation_model.js b/addons/account/static/src/js/reconciliation/reconciliation_model.js index 5e756f28247..06387df0579 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_model.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_model.js @@ -858,6 +858,7 @@ var StatementModel = BasicModel.extend({ 'tax_id': [tax.id, null], 'amount': tax.amount, 'label': tax.name, + 'date': prop.date, 'account_id': tax.account_id ? [tax.account_id, null] : prop.account_id, 'analytic': tax.analytic, 'is_tax': true, @@ -1072,6 +1073,7 @@ var StatementModel = BasicModel.extend({ */ _formatQuickCreate: function (line, values) { values = values || {}; + var today = new moment().utc().format(); var account = this._formatNameGet(values.account_id); var formatOptions = { currency_id: line.st_line.currency_id, @@ -1088,6 +1090,7 @@ var StatementModel = BasicModel.extend({ 'tax_id': this._formatNameGet(values.tax_id), 'debit': 0, 'credit': 0, + 'date': values.date ? values.date : field_utils.parse.date(today, {}, {isUTC: true}), 'base_amount': values.amount_type !== "percentage" ? (amount) : line.balance.amount * values.amount / 100, 'percent': values.amount_type === "percentage" ? values.amount : null, @@ -1238,7 +1241,7 @@ var StatementModel = BasicModel.extend({ * datas allowing manual reconciliation */ var ManualModel = StatementModel.extend({ - quickCreateFields: ['account_id', 'journal_id', 'amount', 'analytic_account_id', 'label', 'tax_id', 'force_tax_included', 'analytic_tag_ids'], + quickCreateFields: ['account_id', 'journal_id', 'amount', 'analytic_account_id', 'label', 'tax_id', 'force_tax_included', 'analytic_tag_ids', 'date'], //-------------------------------------------------------------------------- // Public @@ -1636,6 +1639,12 @@ var ManualModel = StatementModel.extend({ }) .then(this._formatMoveLine.bind(this, handle)); }, + + _formatToProcessReconciliation: function (line, prop) { + var result = this._super(line, prop); + result['date'] = prop.date; + return result; + }, }); return { diff --git a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js index f0641eae409..23c680ddcda 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js @@ -542,10 +542,13 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { }, { type: 'float', name: 'amount', + }, { + type: 'char', //TODO is it a bug or a feature when type date exists ? + name: 'date', }], { account_id: {string: _t("Account")}, label: {string: _t("Label")}, - amount: {string: _t("Account")} + amount: {string: _t("Account")}, }).then(function (recordID) { self.handleCreateRecord = recordID; var record = self.model.get(self.handleCreateRecord); @@ -573,6 +576,9 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { self.fields.amount = new basic_fields.FieldFloat(self, 'amount', record, {mode: 'edit'}); + + self.fields.date = new basic_fields.FieldDate(self, + 'date', record, {mode: 'edit'}); var $create = $(qweb.render("reconciliation.line.create", {'state': state})); self.fields.account_id.appendTo($create.find('.create_account_id .o_td_field')) @@ -586,6 +592,7 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { .then(addRequiredStyle.bind(self, self.fields.label)); self.fields.amount.appendTo($create.find('.create_amount .o_td_field')) .then(addRequiredStyle.bind(self, self.fields.amount)); + self.fields.date.appendTo($create.find('.create_date .o_td_field')) self.$('.create').append($create); function addRequiredStyle(widget) { @@ -893,6 +900,7 @@ var ManualLineRenderer = LineRenderer.extend({ _renderCreate: function (state) { this._super(state); this.$('.create .create_journal_id').show(); + this.$('.create .create_date').removeClass('d-none') this.$('.create .create_journal_id .o_input').addClass('o_required_modifier'); }, diff --git a/addons/account/static/src/xml/account_payment.xml b/addons/account/static/src/xml/account_payment.xml index 4e72bd67d06..55b327183fc 100644 --- a/addons/account/static/src/xml/account_payment.xml +++ b/addons/account/static/src/xml/account_payment.xml @@ -16,8 +16,8 @@ Add - - + +
diff --git a/addons/account/static/src/xml/account_reconciliation.xml b/addons/account/static/src/xml/account_reconciliation.xml index 3caa61eb633..33c778a9b7c 100644 --- a/addons/account/static/src/xml/account_reconciliation.xml +++ b/addons/account/static/src/xml/account_reconciliation.xml @@ -212,6 +212,10 @@ Tax Included in Price + + Writeoff Date + +
diff --git a/addons/account/static/tests/reconciliation_tests.js b/addons/account/static/tests/reconciliation_tests.js index 01645d5c97e..62fa37e1199 100644 --- a/addons/account/static/tests/reconciliation_tests.js +++ b/addons/account/static/tests/reconciliation_tests.js @@ -649,8 +649,8 @@ QUnit.module('account', { assert.strictEqual(widget.$('.o_input_dropdown input').val(), "Agrolait", "the partner many2one should display agrolait"); assert.strictEqual(clientAction.widgets[2].$('.o_input_dropdown input').val(), "Camptocamp", "the partner many2one should display Camptocamp"); widget.$('.accounting_view tfoot td:first').trigger('click'); - assert.containsN(widget, '.create input.o_input', 7, - "create panel should contain 7 fields (account_id, tax_id, journal_id, analytic_account_id, analytic_tag_ids, label, amount)"); + assert.containsN(widget, '.create input.o_input', 8, + "create panel should contain 8 fields (account_id, tax_id, journal_id, analytic_account_id, analytic_tag_ids, label, amount, date)"); assert.containsN(widget, '.create .create_account_id .o_required_modifier, .create .create_label .o_required_modifier, .create .create_amount .o_required_modifier', 3, "account_id, label and amount should be required fields"); assert.strictEqual(widget.$('.create .create_label input').val(), 'SAJ/2014/002 and SAJ/2014/003', diff --git a/addons/account/tests/test_account_customer_invoice.py b/addons/account/tests/test_account_customer_invoice.py index 0906eae595b..03ca61aecf5 100644 --- a/addons/account/tests/test_account_customer_invoice.py +++ b/addons/account/tests/test_account_customer_invoice.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from odoo.addons.account.tests.account_test_users import AccountTestUsers import datetime from odoo.tests import tagged @@ -165,3 +167,174 @@ class TestAccountCustomerInvoice(AccountTestUsers): )) self.assertEquals(invoice.amount_untaxed, sum([x.base for x in invoice.tax_line_ids])) + + def test_customer_invoice_tax_refund(self): + company = self.env.user.company_id + tax_account = self.env['account.account'].create({ + 'name': 'TAX', + 'code': 'TAX', + 'user_type_id': self.env.ref('account.data_account_type_current_assets').id, + 'company_id': company.id, + }) + + tax_refund_account = self.env['account.account'].create({ + 'name': 'TAX_REFUND', + 'code': 'TAX_R', + 'user_type_id': self.env.ref('account.data_account_type_current_assets').id, + 'company_id': company.id, + }) + + journalrec = self.env['account.journal'].search([('type', '=', 'sale')])[0] + partner3 = self.env.ref('base.res_partner_3') + account_id = self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_revenue').id)], limit=1).id + + tax = self.env['account.tax'].create({ + 'name': 'Tax 15.0', + 'amount': 15.0, + 'amount_type': 'percent', + 'type_tax_use': 'sale', + 'account_id': tax_account.id, + 'refund_account_id': tax_refund_account.id + }) + + invoice_line_data = [ + (0, 0, + { + 'product_id': self.env.ref('product.product_product_1').id, + 'quantity': 40.0, + 'account_id': account_id, + 'name': 'product test 1', + 'discount': 10.00, + 'price_unit': 2.27, + 'invoice_line_tax_ids': [(6, 0, [tax.id])], + } + )] + + invoice = self.env['account.invoice'].create(dict( + name="Test Customer Invoice", + journal_id=journalrec.id, + partner_id=partner3.id, + invoice_line_ids=invoice_line_data + )) + + invoice.action_invoice_open() + + refund = invoice.refund() + self.assertEqual(invoice.tax_line_ids.mapped('account_id'), tax_account) + self.assertEqual(refund.tax_line_ids.mapped('account_id'), tax_refund_account) + + def test_customer_invoice_dashboard(self): + def patched_today(*args, **kwargs): + return '2019-01-22' + + date_invoice = '2019-01-21' + partner3 = self.env.ref('base.res_partner_3') + account_id = self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_revenue').id)], limit=1).id + + journal = self.env['account.journal'].create({ + 'name': 'sale_0', + 'code': 'SALE0', + 'type': 'sale', + }) + + invoice_line_data = [ + (0, 0, + { + 'product_id': self.env.ref('product.product_product_1').id, + 'quantity': 40.0, + 'account_id': account_id, + 'name': 'product test 1', + 'discount': 10.00, + 'price_unit': 2.27, + } + ) + ] + + invoice = self.env['account.invoice'].create(dict( + name="Test Customer Invoice", + journal_id=journal.id, + partner_id=partner3.id, + invoice_line_ids=invoice_line_data, + date_invoice=date_invoice, + )) + + refund_line_data = [ + (0, 0, + { + 'product_id': self.env.ref('product.product_product_1').id, + 'quantity': 1.0, + 'account_id': account_id, + 'name': 'product test 1', + 'price_unit': 13.3, + } + )] + + refund = self.env['account.invoice'].create(dict( + name="Test Customer Refund", + type='out_refund', + journal_id=journal.id, + partner_id=partner3.id, + invoice_line_ids=refund_line_data, + date_invoice=date_invoice, + )) + + # Check Draft + dashboard_data = journal.get_journal_dashboard_datas() + + self.assertEquals(dashboard_data['number_draft'], 2) + self.assertEquals(dashboard_data['sum_draft'], '$ 68.42') + + self.assertEquals(dashboard_data['number_waiting'], 0) + self.assertEquals(dashboard_data['sum_waiting'], '$ 0.00') + + # Check Both + invoice.action_invoice_open() + + dashboard_data = journal.get_journal_dashboard_datas() + self.assertEquals(dashboard_data['number_draft'], 1) + self.assertEquals(dashboard_data['sum_draft'], '$ -13.30') + + self.assertEquals(dashboard_data['number_waiting'], 1) + self.assertEquals(dashboard_data['sum_waiting'], '$ 81.72') + + # Check waiting payment + refund.action_invoice_open() + + dashboard_data = journal.get_journal_dashboard_datas() + self.assertEquals(dashboard_data['number_draft'], 0) + self.assertEquals(dashboard_data['sum_draft'], '$ 0.00') + + self.assertEquals(dashboard_data['number_waiting'], 2) + self.assertEquals(dashboard_data['sum_waiting'], '$ 68.42') + + # Check partial + receivable_account = refund.move_id.line_ids.mapped('account_id').filtered(lambda a: a.internal_type == 'receivable') + payment_move = self.env['account.move'].create({ + 'journal_id': journal.id, + }) + payment_move_line = self.env['account.move.line'].with_context(check_move_validity=False).create({ + 'move_id': payment_move.id, + 'account_id': receivable_account.id, + 'debit': 10.00, + }) + self.env['account.move.line'].with_context(check_move_validity=False).create({ + 'move_id': payment_move.id, + 'account_id': self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_liquidity').id)], limit=1).id, + 'credit': 10.00, + }) + + payment_move.post() + + refund.register_payment(payment_move_line) + + dashboard_data = journal.get_journal_dashboard_datas() + self.assertEquals(dashboard_data['number_draft'], 0) + self.assertEquals(dashboard_data['sum_draft'], '$ 0.00') + + self.assertEquals(dashboard_data['number_waiting'], 2) + self.assertEquals(dashboard_data['sum_waiting'], '$ 78.42') + + with patch('odoo.fields.Date.today', patched_today): + dashboard_data = journal.get_journal_dashboard_datas() + self.assertEquals(dashboard_data['number_late'], 2) + self.assertEquals(dashboard_data['sum_late'], '$ 78.42') diff --git a/addons/account/tests/test_payment.py b/addons/account/tests/test_payment.py index a061f0c7964..c010d0084aa 100644 --- a/addons/account/tests/test_payment.py +++ b/addons/account/tests/test_payment.py @@ -595,3 +595,37 @@ class TestPayment(AccountingTestCase): # The invoice should now be paid self.assertEqual(invoice.state, 'paid', "Invoice should be in 'paid' state after having reconciled the two payments with a bank statement") + + def test_register_payments_multi_invoices(self): + inv1 = self.create_invoice(amount=40) + inv2 = inv1.copy() + inv2.action_invoice_open() + + batch_payment = self.env['account.register.payments'].with_context(active_ids=(inv1 + inv2).ids).create({ + 'amount': 70, + 'partner_id': inv1.partner_id.id, + 'journal_id': self.bank_journal_usd.id, + 'invoice_ids': [(6, False, (inv1 + inv2).ids)], + 'partner_type': 'customer', + 'payment_difference_handling': 'reconcile', + 'payment_type': 'inbound', + 'payment_method_id': self.payment_method_manual_in.id, + 'writeoff_account_id': self.account_revenue.id, + 'writeoff_label': "why can't we live together"}) + + payment_id = batch_payment.create_payments()['res_id'] + payment_id = self.env['account.payment'].browse(payment_id) + + self.assertEqual(inv1.state, 'paid') + self.assertEqual(inv2.state, 'paid') + + receivable_inv1 = inv1.move_id.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + receivable_inv2 = inv2.move_id.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + receivable_payment = payment_id.move_line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + self.assertEqual( + receivable_inv1.full_reconcile_id.reconciled_line_ids, + receivable_inv1 + receivable_inv2 + receivable_payment + ) + write_off_line = payment_id.move_line_ids.filtered(lambda l: l.account_id == payment_id.writeoff_account_id) + self.assertEqual(write_off_line.debit, 10) diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml index 15219345394..197c6a1e1cf 100644 --- a/addons/account/views/account_view.xml +++ b/addons/account/views/account_view.xml @@ -1556,10 +1556,11 @@ account.move.form account.move -
+
diff --git a/addons/account/wizard/account_invoice_send.py b/addons/account/wizard/account_invoice_send.py index ba5da81b889..54fbd7ff4be 100644 --- a/addons/account/wizard/account_invoice_send.py +++ b/addons/account/wizard/account_invoice_send.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models -from odoo.exceptions import UserError +from odoo import api, fields, models, _ +from odoo.addons.mail.wizard.mail_compose_message import _reopen class AccountInvoiceSend(models.TransientModel): @@ -67,3 +67,11 @@ class AccountInvoiceSend(models.TransientModel): if self.is_print: return self._print_document() return {'type': 'ir.actions.act_window_close'} + + @api.multi + def save_as_template(self): + self.ensure_one() + self.composer_id.save_as_template() + action = _reopen(self, self.id, self.model, context=self._context) + action.update({'name': _('Send Invoice')}) + return action diff --git a/addons/attachment_indexation/models/ir_attachment.py b/addons/attachment_indexation/models/ir_attachment.py index 2bc6aee234d..bdc4559e893 100644 --- a/addons/attachment_indexation/models/ir_attachment.py +++ b/addons/attachment_indexation/models/ir_attachment.py @@ -9,7 +9,7 @@ import zipfile from odoo import api, models _logger = logging.getLogger(__name__) -FTYPES = ['docx', 'pptx', 'xlsx', 'opendoc', 'pdf'] +FTYPES = ['docx', 'pptx', 'xlsx', 'opendoc'] def textToString(element): buff = u"" @@ -92,6 +92,9 @@ class IrAttachment(models.Model): def _index_pdf(self, bin_data): '''Index PDF documents''' + # extractText gives very bad results for indexing, hence we don't index PDF anymore. A + # better alternative is probably PDFMiner.six, but not for stable. + # See POC at https://github.com/odoo/odoo/pull/27568. buf = u"" if bin_data.startswith(b'%PDF-'): f = io.BytesIO(bin_data) diff --git a/addons/base_address_city/models/res_partner.py b/addons/base_address_city/models/res_partner.py index 7591299f3e2..f8f01119e7d 100644 --- a/addons/base_address_city/models/res_partner.py +++ b/addons/base_address_city/models/res_partner.py @@ -15,9 +15,10 @@ class Partner(models.Model): @api.onchange('city_id') def _onchange_city_id(self): - self.city = self.city_id.name - self.zip = self.city_id.zipcode - self.state_id = self.city_id.state_id + if self.city_id: + self.city = self.city_id.name + self.zip = self.city_id.zipcode + self.state_id = self.city_id.state_id @api.model def _fields_view_get_address(self, arch): @@ -37,7 +38,10 @@ class Partner(models.Model): }" /> interval %s THEN 'offline' - WHEN age(now() AT TIME ZONE 'UTC', B.last_presence) > interval %s THEN 'away' + CASE WHEN age(now() AT TIME ZONE 'UTC', max(B.last_poll)) > interval %s THEN 'offline' + WHEN age(now() AT TIME ZONE 'UTC', max(B.last_presence)) > interval %s THEN 'away' ELSE 'online' END as status FROM bus_presence B JOIN res_users U ON B.user_id = U.id WHERE U.partner_id IN %s AND U.active = 't' + GROUP BY U.partner_id """, ("%s seconds" % DISCONNECTION_TIMER, "%s seconds" % AWAY_TIMER, tuple(self.ids))) res = dict(((status['id'], status['status']) for status in self.env.cr.dictfetchall())) for partner in self: diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml index acaf0f5ea72..794e37ddb66 100644 --- a/addons/crm/views/crm_lead_views.xml +++ b/addons/crm/views/crm_lead_views.xml @@ -888,6 +888,7 @@ for rec in records: name="Team Pipelines" parent="crm_menu_sales" action="sales_team.crm_team_salesteams_pipelines_act" + groups="sales_team.group_sale_manager" sequence="4"/> self.delivery_packaging_id.max_weight: diff --git a/addons/delivery/wizard/choose_delivery_package_views.xml b/addons/delivery/wizard/choose_delivery_package_views.xml index c37231ed38d..72e5074def3 100644 --- a/addons/delivery/wizard/choose_delivery_package_views.xml +++ b/addons/delivery/wizard/choose_delivery_package_views.xml @@ -11,7 +11,7 @@
&nbsp;
-
-
+
+
diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index 77469f2fe94..51e87a86982 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -534,6 +534,16 @@ class Message(models.Model): failures_infos = [] # for each channel, build the information header and include the logged partner information for message in self: + # Check if user has access to the record before displaying a notification about it. + # In case the user switches from one company to another, it might happen that he doesn't + # have access to the record related to the notification. In this case, we skip it. + if message.model and message.res_id: + record = self.env[message.model].browse(message.res_id) + try: + record.check_access_rights('read') + record.check_access_rule('read') + except AccessError: + continue info = { 'message_id': message.id, 'record_name': message.record_name, diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index a27a8464c9c..82d45e8dce3 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -1335,7 +1335,8 @@ class MailThread(models.AbstractModel): subtype = 'mail.mt_note' if message_dict.get('parent_id'): parent_message = self.env['mail.message'].sudo().browse(message_dict['parent_id']) - partner_ids = [(4, parent_message.author_id.id)] + if parent_message.author_id: + partner_ids = [(4, parent_message.author_id.id)] else: subtype = 'mail.mt_comment' @@ -2062,7 +2063,7 @@ class MailThread(models.AbstractModel): prioritary_attachments = all_attachments.filtered(lambda x: x.mimetype.endswith('pdf')) \ or all_attachments.filtered(lambda x: x.mimetype.startswith('image')) \ or all_attachments - self.write({'message_main_attachment_id': prioritary_attachments[0].id}) + self.sudo().write({'message_main_attachment_id': prioritary_attachments[0].id}) # Notify recipients of the newly-created message (Inbox / Email + channels) if msg_vals.get('moderation_status') != 'pending_moderation': message._notify( diff --git a/addons/mail/static/src/js/activity.js b/addons/mail/static/src/js/activity.js index b1f43e495c7..d99abb11e7d 100644 --- a/addons/mail/static/src/js/activity.js +++ b/addons/mail/static/src/js/activity.js @@ -273,7 +273,7 @@ var BasicActivity = AbstractField.extend({ var self = this; var $markDoneBtn = $(ev.currentTarget); var activityID = $markDoneBtn.data('activity-id'); - var previousActivityTypeID = $markDoneBtn.data('previous-activity-type-id'); + var previousActivityTypeID = $markDoneBtn.data('previous-activity-type-id') || false; var forceNextActivity = $markDoneBtn.data('force-next-activity'); if ($markDoneBtn.data('toggle') == 'collapse') { diff --git a/addons/mail/static/src/js/chatter.js b/addons/mail/static/src/js/chatter.js index da23521f65f..330e56b77b4 100644 --- a/addons/mail/static/src/js/chatter.js +++ b/addons/mail/static/src/js/chatter.js @@ -300,6 +300,7 @@ var Chatter = Widget.extend({ recordName: this.recordName, defaultBody: oldComposer && oldComposer.$input && oldComposer.$input.val(), defaultMentionSelections: oldComposer && oldComposer.getMentionListenerSelections(), + attachmentIds: (oldComposer && oldComposer.get('attachment_ids')) || [], }); this._composer.on('input_focused', this, function () { this._composer.mentionSetPrefetchedPartners(this._mentionSuggestions || []); @@ -309,9 +310,7 @@ var Chatter = Widget.extend({ if (oldComposer) { oldComposer.destroy(); } - if (!config.device.isMobile) { - self._composer.focus(); - } + self._composer.focus(); self._composer.on('post_message', self, function (messageData) { self._discardOnReload(messageData).then(function () { self._disableComposer(); @@ -321,7 +320,7 @@ var Chatter = Widget.extend({ self.trigger_up('reload'); } else if (messageData.attachment_ids.length) { self._reloadAttachmentBox(); - self.trigger_up('reload', {fieldNames: ['message_attachment_count']}); + self.trigger_up('reload', {fieldNames: ['message_attachment_count'], keepChanges: true}); } }).fail(function () { self._enableComposer(); diff --git a/addons/mail/static/src/js/composers/basic_composer.js b/addons/mail/static/src/js/composers/basic_composer.js index af503ff2711..eef11bbab33 100644 --- a/addons/mail/static/src/js/composers/basic_composer.js +++ b/addons/mail/static/src/js/composers/basic_composer.js @@ -58,7 +58,7 @@ var BasicComposer = Widget.extend({ // Attachments this._attachmentDataSet = new data.DataSetSearch(this, 'ir.attachment', this.context); this.fileuploadID = _.uniqueId('o_chat_fileupload'); - this.set('attachment_ids', []); + this.set('attachment_ids', options.attachmentIds || []); // Mention this._mentionManager = new MentionManager(this); @@ -120,6 +120,7 @@ var BasicComposer = Widget.extend({ }); // Attachments + this._renderAttachments(); $(window).on(this.fileuploadID, this._onAttachmentLoaded.bind(this)); this.on('change:attachment_ids', this, this._renderAttachments); diff --git a/addons/mail/static/src/js/services/mail_window_manager.js b/addons/mail/static/src/js/services/mail_window_manager.js index c8e05957afb..4971e7f3f4b 100644 --- a/addons/mail/static/src/js/services/mail_window_manager.js +++ b/addons/mail/static/src/js/services/mail_window_manager.js @@ -101,31 +101,37 @@ MailManager.include({ // valid threadID, therefore no check var thread = this.getThread(threadID); var threadWindow = this._getThreadWindow(threadID); + var def = $.when(); if (!threadWindow) { - threadWindow = this._makeNewThreadWindow(thread, options); - this._placeNewThreadWindow(threadWindow, options.passively); - - threadWindow.appendTo($(this.THREAD_WINDOW_APPENDTO)) - .then(function () { - self._repositionThreadWindows(); - return thread.fetchMessages(); - }).then(function () { - threadWindow.render(); - threadWindow.scrollToBottom(); - if ( - !self._areAllThreadWindowsHidden() && - !thread.isFolded() && - !threadWindow.isPassive() - ) { - thread.markAsRead(); - } - }); + def = thread.fetchMessages().then(function () { + threadWindow = self._makeNewThreadWindow(thread, options); + self._placeNewThreadWindow(threadWindow, options.passively); + return threadWindow.appendTo($(self.THREAD_WINDOW_APPENDTO)); + }).then(function () { + self._repositionThreadWindows(); + threadWindow.render(); + threadWindow.scrollToBottom(); + if ( + !self._areAllThreadWindowsHidden() && + !thread.isFolded() && + !threadWindow.isPassive() + ) { + thread.markAsRead(); + } + }).fail(function () { + // thread window could not be open, which may happen due to + // access error while fetching messages to the document. + // abort opening the thread window in this case. + thread.close(); + }); } else if (!options.passively) { if (threadWindow.isHidden()) { this._makeThreadWindowVisible(threadWindow); } } - threadWindow.updateVisualFoldState(); + def.then(function () { + threadWindow.updateVisualFoldState(); + }); }, /** * Called when a thread has its window state that has been changed, so its diff --git a/addons/mail/static/src/js/systray/systray_activity_menu.js b/addons/mail/static/src/js/systray/systray_activity_menu.js index 2b42167861c..593cac91506 100644 --- a/addons/mail/static/src/js/systray/systray_activity_menu.js +++ b/addons/mail/static/src/js/systray/systray_activity_menu.js @@ -46,7 +46,7 @@ var ActivityMenu = Widget.extend({ kwargs: {context: session.user_context}, }).then(function (data) { self._activities = data; - self.activityCounter = _.reduce(data, function (total_count, p_data) { return total_count + p_data.total_count; }, 0); + self.activityCounter = _.reduce(data, function (total_count, p_data) { return total_count + p_data.total_count || 0; }, 0); self.$('.o_notification_counter').text(self.activityCounter); self.$el.toggleClass('o_no_notification', !self.activityCounter); }); diff --git a/addons/mail/static/src/js/thread_windows/thread_window.js b/addons/mail/static/src/js/thread_windows/thread_window.js index 57907a55943..331228c91c8 100644 --- a/addons/mail/static/src/js/thread_windows/thread_window.js +++ b/addons/mail/static/src/js/thread_windows/thread_window.js @@ -280,8 +280,10 @@ var ThreadWindow = AbstractThreadWindow.extend({ clear_breadcrumbs: false, active_id: this.hasThread() ? this._getThreadID() : undefined, on_reverse_breadcrumb: function () { - self.call('mail_service', 'getMailBus') - .trigger('discuss_open', false); + var mailBus = self.call('mail_service', 'getMailBus'); + if (mailBus) { + mailBus.trigger('discuss_open', false); + } }, }); } diff --git a/addons/mail/static/src/scss/followers.scss b/addons/mail/static/src/scss/followers.scss index b2bbc77f7f2..934df9b7acc 100644 --- a/addons/mail/static/src/scss/followers.scss +++ b/addons/mail/static/src/scss/followers.scss @@ -84,6 +84,8 @@ .o_followers_list { font-size: small; + max-height: 50vh; // ancestor has size 33px + overflow: auto; .o_partner { position: relative; // required for old design compatibility diff --git a/addons/mail/static/tests/document_thread_window_tests.js b/addons/mail/static/tests/document_thread_window_tests.js index 811b01f62e4..93095362306 100644 --- a/addons/mail/static/tests/document_thread_window_tests.js +++ b/addons/mail/static/tests/document_thread_window_tests.js @@ -271,6 +271,37 @@ QUnit.test('open, fold, unfold and close a document thread window', function (as messagingMenu.destroy(); }); +QUnit.test('do not open thread window on fetch message failure', function (assert) { + // this may happen when the user receives a notification from a document + // that he does not have access rights at the moment. + assert.expect(4); + + var messagingMenu = new MessagingMenu(); + testUtils.addMockEnvironment(messagingMenu, { + services: this.services, + data: this.data, + session: this.session, + mockRPC: function (route, args) { + if (args.method === 'read' && args.model === 'some.res.model' && args.args[0][0] === 1) { + assert.step('some.res.model/1/read'); + return $.Deferred().reject(); // simulate failure + } + return this._super.apply(this, arguments); + }, + }); + messagingMenu.appendTo($('#qunit-fixture')); + + testUtils.dom.click(messagingMenu.$('.dropdown-toggle')); + assert.containsOnce(messagingMenu, '.o_mail_preview'); + + testUtils.dom.click(messagingMenu.$('.o_mail_preview')); + assert.verifySteps(['some.res.model/1/read']); + assert.strictEqual($('.o_thread_window').length, 0, + "should not have open the DocumentThread in a thread window on fetch messages failure"); + + messagingMenu.destroy(); +}); + }); }); }); diff --git a/addons/mass_mailing/models/__init__.py b/addons/mass_mailing/models/__init__.py index 6afa7a98d09..c542abccfba 100644 --- a/addons/mass_mailing/models/__init__.py +++ b/addons/mass_mailing/models/__init__.py @@ -4,6 +4,7 @@ from . import mass_mailing from . import mass_mailing_stats from . import mail_mail +from . import mail_template from . import mail_thread from . import res_config_settings from . import mass_mailing_report diff --git a/addons/mass_mailing/models/mail_template.py b/addons/mass_mailing/models/mail_template.py new file mode 100644 index 00000000000..68005ee0d10 --- /dev/null +++ b/addons/mass_mailing/models/mail_template.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class MailTemplate(models.Model): + _inherit = "mail.template" + + @api.model + def render_post_process(self, html): + # super will transform relative url to absolute + html = super(MailTemplate, self).render_post_process(html) + + # apply shortener after + if self.env.context.get('post_convert_links'): + html = self.env['link.tracker'].convert_links( + html, + self.env.context['post_convert_links'], + blacklist=['/unsubscribe_from_list'] + ) + return html diff --git a/addons/mass_mailing/models/mass_mailing.py b/addons/mass_mailing/models/mass_mailing.py index a4187e0ec6e..943059eb6d9 100644 --- a/addons/mass_mailing/models/mass_mailing.py +++ b/addons/mass_mailing/models/mass_mailing.py @@ -779,19 +779,47 @@ class MassMailing(models.Model): _logger.info("Mass-mailing %s targets %s, no opt out list available", self, target._name) return opt_out + def _get_convert_links(self): + self.ensure_one() + utm_mixin = self.mass_mailing_campaign_id if self.mass_mailing_campaign_id else self + vals = {'mass_mailing_id': self.id} + + if self.mass_mailing_campaign_id: + vals['mass_mailing_campaign_id'] = self.mass_mailing_campaign_id.id + if utm_mixin.campaign_id: + vals['campaign_id'] = utm_mixin.campaign_id.id + if utm_mixin.source_id: + vals['source_id'] = utm_mixin.source_id.id + if utm_mixin.medium_id: + vals['medium_id'] = utm_mixin.medium_id.id + return vals + def _get_seen_list(self): """Returns a set of emails already targeted by current mailing/campaign (no duplicates)""" self.ensure_one() target = self.env[self.mailing_model_real] - mail_field = 'email' if 'email' in target._fields else 'email_from' - # avoid loading a large number of records in memory - # + use a basic heuristic for extracting emails - query = """ - SELECT lower(substring(t.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)')) - FROM mail_mail_statistics s - JOIN %(target)s t ON (s.res_id = t.id) - WHERE substring(t.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)') IS NOT NULL - """ + if set(['email', 'email_from']) & set(target._fields): + mail_field = 'email' if 'email' in target._fields else 'email_from' + # avoid loading a large number of records in memory + # + use a basic heuristic for extracting emails + query = """ + SELECT lower(substring(t.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)')) + FROM mail_mail_statistics s + JOIN %(target)s t ON (s.res_id = t.id) + WHERE substring(t.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)') IS NOT NULL + """ + elif 'partner_id' in target._fields: + mail_field = 'email' + query = """ + SELECT lower(substring(p.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)')) + FROM mail_mail_statistics s + JOIN %(target)s t ON (s.res_id = t.id) + JOIN res_partner p ON (t.partner_id = p.id) + WHERE substring(p.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)') IS NOT NULL + """ + else: + raise UserError(_("Unsupported mass mailing model %s") % self.mailing_model_id.name) + if self.mass_mailing_campaign_id.unique_ab_testing: query +=""" AND s.mass_mailing_campaign_id = %%(mailing_campaign_id)s; @@ -814,6 +842,7 @@ class MassMailing(models.Model): return { 'mass_mailing_opt_out_list': self._get_opt_out_list(), 'mass_mailing_seen_list': self._get_seen_list(), + 'post_convert_links': self._get_convert_links(), } def get_recipients(self): @@ -855,13 +884,10 @@ class MassMailing(models.Model): if not res_ids: raise UserError(_('There is no recipients selected.')) - # Convert links in absolute URLs before the application of the shortener - mailing.body_html = self.env['mail.thread']._replace_local_links(mailing.body_html) - composer_values = { 'author_id': author_id, 'attachment_ids': [(4, attachment.id) for attachment in mailing.attachment_ids], - 'body': mailing.convert_links()[mailing.id], + 'body': mailing.body_html, 'subject': mailing.name, 'model': mailing.mailing_model_real, 'email_from': mailing.email_from, diff --git a/addons/mass_mailing/tests/__init__.py b/addons/mass_mailing/tests/__init__.py index b02648f85eb..36745d1525a 100644 --- a/addons/mass_mailing/tests/__init__.py +++ b/addons/mass_mailing/tests/__init__.py @@ -2,3 +2,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import test_mass_mailing_list_merge +from . import test_mass_mailing_shortener diff --git a/addons/mass_mailing/tests/test_mass_mailing_shortener.py b/addons/mass_mailing/tests/test_mass_mailing_shortener.py new file mode 100644 index 00000000000..99e3de1e864 --- /dev/null +++ b/addons/mass_mailing/tests/test_mass_mailing_shortener.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import common +from lxml import etree + +try: + from unittest.mock import patch +except ImportError: + from mock import patch + + +class TestMassMailingShortener(common.TransactionCase): + def getHrefFor(self, html, id): + return html.xpath("*[@id='%s']" % id)[0].attrib.get('href') + + def shorturl_to_link(self, short_url): + return self.env['link.tracker.code'].search([('code', '=', short_url.split('/r/')[-1])]).link_id + + def setUp(self): + super(TestMassMailingShortener, self).setUp() + + def _get_title_from_url(u): + return "Hello" + + def _compute_favicon(): + # 1px to avoid real request + return 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==' + + patcher = patch('odoo.addons.link_tracker.models.link_tracker.link_tracker._compute_favicon', wraps=_compute_favicon) + patcher2 = patch('odoo.addons.link_tracker.models.link_tracker.link_tracker._get_title_from_url', wraps=_get_title_from_url) + patcher.start() + patcher2.start() + self.addCleanup(patcher.stop) + self.addCleanup(patcher2.stop) + + def test_00_test_mass_mailing_shortener(self): + mailing_list_A = self.env['mail.mass_mailing.list'].create({ + 'name': 'A', + }) + self.env['mail.mass_mailing.contact'].create({ + 'name': 'User 1', 'email': 'user1@example.com', 'list_ids': [(4, mailing_list_A.id)] + }) + self.env['mail.mass_mailing.contact'].create({ + 'name': 'User 2', 'email': 'user2@example.com', 'list_ids': [(4, mailing_list_A.id)] + }) + self.env['mail.mass_mailing.contact'].create({ + 'name': 'User 3', 'email': 'user3@example.com', 'list_ids': [(4, mailing_list_A.id)] + }) + + mass_mailing = self.env['mail.mass_mailing'].create({ + "reply_to_mode": "email", + "reply_to": "Administrator ", + "mailing_model_id": self.env.ref('mass_mailing.model_mail_mass_mailing_list').id, + "mailing_domain": "[('list_ids', 'in', [%d])]" % mailing_list_A.id, + "contact_list_ids": [[6, False, [mailing_list_A.id]]], + "mass_mailing_campaign_id": False, + "name": "sdf", + "body_html": """ +Hi, +% set url = "www.odoo.com" +% set httpurl = "https://www.odoo.eu" +Website0: https://www.odoo.tz/my/${object.name} +Website1: https://www.odoo.be +Website2: https://${url} +Website3: ${httpurl} +Email: test@odoo.com + """, + "schedule_date": False, + "state": "draft", + "keep_archives": True, + }) + + mass_mailing.put_in_queue() + mass_mailing._process_mass_mailing_queue() + + sent_mails = self.env['mail.mail'].search([('mailing_id', '=', mass_mailing.id)]) + sent_messages = sent_mails.mapped('mail_message_id') + + self.assertEqual(mailing_list_A.contact_nbr, len(sent_messages), + 'Some message has not been sent') + + xbody = etree.fromstring(sent_messages[0].body) + after_url0 = self.getHrefFor(xbody, 'url0') + after_url1 = self.getHrefFor(xbody, 'url1') + after_url2 = self.getHrefFor(xbody, 'url2') + after_url3 = self.getHrefFor(xbody, 'url3') + after_url4 = self.getHrefFor(xbody, 'url4') + + self.assertTrue('/r/' in after_url0, 'URL0 should be shortened: %s' % after_url0) + self.assertTrue('/r/' in after_url1, 'URL1 should be shortened: %s' % after_url1) + self.assertTrue('/r/' in after_url2, 'URL2 should be shortened: %s' % after_url2) + self.assertTrue('/r/' in after_url3, 'URL3 should be shortened: %s' % after_url3) + self.assertEqual(after_url4, "mailto:test@odoo.com", 'mailto: has been converted') + + short0 = self.shorturl_to_link(after_url0) + short1 = self.shorturl_to_link(after_url1) + short2 = self.shorturl_to_link(after_url2) + short3 = self.shorturl_to_link(after_url3) + + self.assertTrue("https://www.odoo.tz/my/User" in short0.url, 'URL mismatch') + self.assertEqual(short1.url, "https://www.odoo.be", 'URL mismatch') + self.assertEqual(short2.url, "https://www.odoo.com", 'URL mismatch') + self.assertEqual(short3.url, "https://www.odoo.eu", 'URL mismatch') + + _xbody = etree.fromstring(sent_messages[1].body) + _after_url0 = self.getHrefFor(_xbody, 'url0') + _after_url1 = self.getHrefFor(_xbody, 'url1') + _after_url2 = self.getHrefFor(_xbody, 'url2') + _after_url3 = self.getHrefFor(_xbody, 'url3') + _after_url4 = self.getHrefFor(_xbody, 'url4') + + self.assertTrue('/r/' in _after_url0, 'URL0 should be shortened: %s' % _after_url0) + self.assertTrue('/r/' in _after_url1, 'URL1 should be shortened: %s' % _after_url1) + self.assertTrue('/r/' in _after_url2, 'URL2 should be shortened: %s' % _after_url2) + self.assertTrue('/r/' in _after_url3, 'URL3 should be shortened: %s' % _after_url3) + self.assertEqual(_after_url4, "mailto:test@odoo.com", 'mailto: has been converted') + + _short0 = self.shorturl_to_link(_after_url0) + _short1 = self.shorturl_to_link(_after_url1) + _short2 = self.shorturl_to_link(_after_url2) + _short3 = self.shorturl_to_link(_after_url3) + + self.assertTrue("https://www.odoo.tz/my/User" in _short0.url, 'URL mismatch') + self.assertEqual(_short1.url, "https://www.odoo.be", 'URL mismatch') + self.assertEqual(_short2.url, "https://www.odoo.com", 'URL mismatch') + self.assertEqual(_short3.url, "https://www.odoo.eu", 'URL mismatch') + + self.assertNotEqual(short0.url, _short0.url) + self.assertEqual(short1.url, _short1.url) + self.assertEqual(short2.url, _short2.url) + self.assertEqual(short3.url, _short3.url) diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 6310ffd8b82..c7942656e4d 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -5,7 +5,7 @@ from collections import defaultdict from odoo import api, fields, models, _ from odoo.addons import decimal_precision as dp -from odoo.exceptions import UserError +from odoo.exceptions import AccessError, UserError from odoo.tools import float_compare, float_round class MrpProduction(models.Model): @@ -30,6 +30,10 @@ class MrpProduction(models.Model): location = self.env['stock.picking.type'].browse(self.env.context['default_picking_type_id']).default_location_src_id if not location: location = self.env.ref('stock.stock_location_stock', raise_if_not_found=False) + try: + location.check_access_rule('read') + except (AttributeError, AccessError): + location = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.company_id.id)], limit=1).lot_stock_id return location and location.id or False @api.model @@ -39,6 +43,10 @@ class MrpProduction(models.Model): location = self.env['stock.picking.type'].browse(self.env.context['default_picking_type_id']).default_location_dest_id if not location: location = self.env.ref('stock.stock_location_stock', raise_if_not_found=False) + try: + location.check_access_rule('read') + except (AttributeError, AccessError): + location = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.company_id.id)], limit=1).lot_stock_id return location and location.id or False name = fields.Char( @@ -397,6 +405,10 @@ class MrpProduction(models.Model): @api.onchange('picking_type_id') def onchange_picking_type(self): location = self.env.ref('stock.stock_location_stock') + try: + location.check_access_rule('read') + except (AttributeError, AccessError): + location = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.company_id.id)], limit=1).lot_stock_id self.move_raw_ids.update({'picking_type_id': self.picking_type_id}) self.location_src_id = self.picking_type_id.default_location_src_id.id or location.id self.location_dest_id = self.picking_type_id.default_location_dest_id.id or location.id diff --git a/addons/mrp/models/mrp_unbuild.py b/addons/mrp/models/mrp_unbuild.py index e33e0dfbea5..2bac0c1d0f7 100644 --- a/addons/mrp/models/mrp_unbuild.py +++ b/addons/mrp/models/mrp_unbuild.py @@ -2,7 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models, _ -from odoo.exceptions import UserError +from odoo.exceptions import AccessError, UserError from odoo.tools import float_compare @@ -13,10 +13,20 @@ class MrpUnbuild(models.Model): _order = 'id desc' def _get_default_location_id(self): - return self.env.ref('stock.stock_location_stock', raise_if_not_found=False) + stock_location = self.env.ref('stock.stock_location_stock', raise_if_not_found=False) + try: + stock_location.check_access_rule('read') + return stock_location.id + except (AttributeError, AccessError): + return self.env['stock.warehouse'].search([('company_id', '=', self.env.user.company_id.id)], limit=1).lot_stock_id.id def _get_default_location_dest_id(self): - return self.env.ref('stock.stock_location_stock', raise_if_not_found=False) + stock_location = self.env.ref('stock.stock_location_stock', raise_if_not_found=False) + try: + stock_location.check_access_rule('read') + return stock_location.id + except (AttributeError, AccessError): + return self.env['stock.warehouse'].search([('company_id', '=', self.env.user.company_id.id)], limit=1).lot_stock_id.id name = fields.Char('Reference', copy=False, readonly=True, default=lambda x: _('New')) product_id = fields.Many2one( diff --git a/addons/mrp/views/mrp_unbuild_views.xml b/addons/mrp/views/mrp_unbuild_views.xml index 58c1fb6014b..1a05dbc0b4a 100644 --- a/addons/mrp/views/mrp_unbuild_views.xml +++ b/addons/mrp/views/mrp_unbuild_views.xml @@ -128,8 +128,8 @@ - - + + diff --git a/addons/mrp/views/mrp_workcenter_views.xml b/addons/mrp/views/mrp_workcenter_views.xml index 8172349a27b..906cbc0070b 100644 --- a/addons/mrp/views/mrp_workcenter_views.xml +++ b/addons/mrp/views/mrp_workcenter_views.xml @@ -171,7 +171,7 @@
- diff --git a/addons/pad/models/pad.py b/addons/pad/models/pad.py index 9cdcbec12af..6e3476099fd 100644 --- a/addons/pad/models/pad.py +++ b/addons/pad/models/pad.py @@ -18,6 +18,7 @@ _logger = logging.getLogger(__name__) class PadCommon(models.AbstractModel): _name = 'pad.common' + _description = 'Pad Common' @api.model def pad_is_configured(self): diff --git a/addons/payment/controllers/portal.py b/addons/payment/controllers/portal.py index ba2a2551892..516eb8712a9 100644 --- a/addons/payment/controllers/portal.py +++ b/addons/payment/controllers/portal.py @@ -100,7 +100,10 @@ class PaymentProcessing(http.Controller): class WebsitePayment(http.Controller): @http.route(['/my/payment_method'], type='http', auth="user", website=True) def payment_method(self, **kwargs): - acquirers = list(request.env['payment.acquirer'].search([('website_published', '=', True), ('registration_view_template_id', '!=', False), ('payment_flow', '=', 's2s')])) + acquirers = list(request.env['payment.acquirer'].search([ + ('website_published', '=', True), ('registration_view_template_id', '!=', False), + ('payment_flow', '=', 's2s'), ('company_id', '=', request.env.user.company_id.id) + ])) partner = request.env.user.partner_id payment_tokens = partner.payment_token_ids payment_tokens |= partner.commercial_partner_id.sudo().payment_token_ids diff --git a/addons/payment/views/payment_portal_templates.xml b/addons/payment/views/payment_portal_templates.xml index bac8783fcea..8328fac20c8 100644 --- a/addons/payment/views/payment_portal_templates.xml +++ b/addons/payment/views/payment_portal_templates.xml @@ -34,7 +34,7 @@ diff --git a/addons/pos_cache/models/pos_cache.py b/addons/pos_cache/models/pos_cache.py index 86dbfdfb39f..e7b779581d9 100644 --- a/addons/pos_cache/models/pos_cache.py +++ b/addons/pos_cache/models/pos_cache.py @@ -9,6 +9,7 @@ from odoo import models, fields, api class pos_cache(models.Model): _name = 'pos.cache' + _description = 'Point of Sale Cache' cache = fields.Binary(attachment=True) product_domain = fields.Text(required=True) diff --git a/addons/product/report/product_product_templates.xml b/addons/product/report/product_product_templates.xml index 2a612f556bc..f8213d924b0 100644 --- a/addons/product/report/product_product_templates.xml +++ b/addons/product/report/product_product_templates.xml @@ -1,75 +1,80 @@ - - - - - - - - - + + + + + + + + + + + + diff --git a/addons/rating/models/mail_message.py b/addons/rating/models/mail_message.py index f6b3553b7f4..aade3b9ade7 100644 --- a/addons/rating/models/mail_message.py +++ b/addons/rating/models/mail_message.py @@ -8,7 +8,7 @@ class MailMessage(models.Model): _inherit = 'mail.message' rating_ids = fields.One2many('rating.rating', 'message_id', string='Related ratings') - rating_value = fields.Float("Rating Value", compute='_compute_rating_value', store=False) + rating_value = fields.Float("Rating Value", compute='_compute_rating_value', store=False, search='_search_rating_value') @api.multi @api.depends('rating_ids', 'rating_ids.rating') @@ -17,3 +17,7 @@ class MailMessage(models.Model): mapping = dict((r.message_id.id, r.rating) for r in ratings) for message in self: message.rating_value = mapping.get(message.id, 0.0) + + @api.multi + def _search_rating_value(self, operator, operand): + return [('rating_ids.rating', operator, operand)] diff --git a/addons/resource/models/resource.py b/addons/resource/models/resource.py index b16c121799b..fd0e8260fc5 100644 --- a/addons/resource/models/resource.py +++ b/addons/resource/models/resource.py @@ -196,7 +196,8 @@ class ResourceCalendar(models.Model): hour_count = 0.0 for attendance in attendances: hour_count += attendance.hour_to - attendance.hour_from - self.hours_per_day = float_round(hour_count / float(len(set(attendances.mapped('dayofweek')))), precision_digits=2) + if attendances: + self.hours_per_day = float_round(hour_count / float(len(set(attendances.mapped('dayofweek')))), precision_digits=2) # -------------------------------------------------- # Computation API diff --git a/addons/sale/data/mail_data.xml b/addons/sale/data/mail_data.xml index 5edabafb06b..a2abc42ff23 100644 --- a/addons/sale/data/mail_data.xml +++ b/addons/sale/data/mail_data.xml @@ -103,7 +103,7 @@
- ${(object.name or '').replace('/','_')}${object.state == 'draft' and '_draft' or ''} + ${(object.name or '').replace('/','_')} ${object.partner_id.lang} diff --git a/addons/sale/models/analytic.py b/addons/sale/models/analytic.py index 8fb938582a0..a60dd11b023 100644 --- a/addons/sale/models/analytic.py +++ b/addons/sale/models/analytic.py @@ -19,7 +19,7 @@ class AccountAnalyticLine(models.Model): @api.model def create(self, values): result = super(AccountAnalyticLine, self).create(values) - if 'so_line' not in values and not result.so_line and result.product_id and result.product_id.expense_policy != 'no' and result.amount <= 0: # allow to force a False value for so_line + if 'so_line' not in values and not result.so_line and result.product_id and result.product_id.expense_policy not in [False, 'no'] and result.amount <= 0: # allow to force a False value for so_line result.sudo()._sale_determine_order_line() return result @@ -28,7 +28,8 @@ class AccountAnalyticLine(models.Model): result = super(AccountAnalyticLine, self).write(values) if 'so_line' not in values: # allow to force a False value for so_line # only take the AAL from expense or vendor bill, meaning having a negative amount - self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy != 'no' and aal.amount <= 0)._sale_determine_order_line() + self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy not in [False, 'no'] and aal.amount <= 0)._sale_determine_order_line() + return result # ---------------------------------------------------------- # Vendor Bill / Expense : determine the Sale Order to reinvoice diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index f4b4ceb6826..da8ee7fa25c 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -47,8 +47,8 @@ class SaleOrder(models.Model): amount_untaxed += line.price_subtotal amount_tax += line.price_tax order.update({ - 'amount_untaxed': order.pricelist_id.currency_id.round(amount_untaxed), - 'amount_tax': order.pricelist_id.currency_id.round(amount_tax), + 'amount_untaxed': amount_untaxed, + 'amount_tax': amount_tax, 'amount_total': amount_untaxed + amount_tax, }) @@ -66,12 +66,14 @@ class SaleOrder(models.Model): for possible refunds created directly from existing invoices. This is necessary since such a refund is not directly linked to the SO. """ + # Ignore the status of the deposit product + deposit_product_id = self.env['sale.advance.payment.inv']._default_product_id() + line_invoice_status_all = [(d['order_id'][0], d['invoice_status']) for d in self.env['sale.order.line'].read_group([('order_id', 'in', self.ids), ('product_id', '!=', deposit_product_id.id)], ['order_id', 'invoice_status'], ['order_id', 'invoice_status'], lazy=False)] for order in self: invoice_ids = order.order_line.mapped('invoice_lines.invoice_id').filtered(lambda r: r.type in ['out_invoice', 'out_refund']) - # Ignore the status of the deposit product - deposit_product_id = self.env['sale.advance.payment.inv']._default_product_id() - line_invoice_status = [line.invoice_status for line in order.order_line if line.product_id != deposit_product_id] + + line_invoice_status = [d[1] for d in line_invoice_status_all if d[0] == order.id] if order.state not in ('sale', 'done'): invoice_status = 'no' @@ -652,7 +654,7 @@ class SaleOrder(models.Model): # create an analytic account if at least an expense product for order in self: - if any([expense_policy != 'no' for expense_policy in order.order_line.mapped('product_id.expense_policy')]): + if any([expense_policy not in [False, 'no'] for expense_policy in order.order_line.mapped('product_id.expense_policy')]): if not order.analytic_account_id: order._create_analytic_account() @@ -1016,7 +1018,7 @@ class SaleOrderLine(models.Model): msg = _("Extra line with %s ") % (line.product_id.display_name,) line.order_id.message_post(body=msg) # create an analytic account if at least an expense product - if line.product_id.expense_policy != 'no' and not self.order_id.analytic_account_id: + if line.product_id.expense_policy not in [False, 'no'] and not self.order_id.analytic_account_id: self.order_id._create_analytic_account() return line diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml index ade6a2e3f52..d0f81c38a8a 100644 --- a/addons/sale/views/sale_views.xml +++ b/addons/sale/views/sale_views.xml @@ -6,7 +6,6 @@ name="Sales" web_icon="sale,static/description/icon.png" active="False" - groups="sales_team.group_sale_salesman,sales_team.group_sale_salesman_all_leads,sales_team.group_sale_manager" sequence="7"/> @@ -314,6 +314,7 @@ +
@@ -322,6 +323,7 @@ name="product_uom_qty" class="oe_inline"/> + @@ -480,6 +484,7 @@ + diff --git a/addons/sale_expense/models/analytic.py b/addons/sale_expense/models/analytic.py index 9f037bfcae1..d387091adc1 100644 --- a/addons/sale_expense/models/analytic.py +++ b/addons/sale_expense/models/analytic.py @@ -10,7 +10,7 @@ class AccountAnalyticLine(models.Model): @api.multi def _sale_determine_order(self): mapping = super(AccountAnalyticLine, self)._sale_determine_order() - for analytic_line in self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy != 'no'): + for analytic_line in self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy not in [False, 'no']): if analytic_line.move_id.expense_id.sale_order_id: mapping[analytic_line.id] = analytic_line.move_id.expense_id.sale_order_id return mapping diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index 9f7613ccf51..75e7953e903 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -51,7 +51,7 @@ class SaleOrder(models.Model): def _action_confirm(self): """ On SO confirmation, some lines should generate a task or a project. """ result = super(SaleOrder, self)._action_confirm() - self.mapped('order_line').sudo()._timesheet_service_generation() + self.mapped('order_line').sudo().with_context(default_company_id=self.company_id.id)._timesheet_service_generation() return result @api.multi diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index b92954e3199..25215366e04 100644 --- a/addons/stock/models/stock_move_line.py +++ b/addons/stock/models/stock_move_line.py @@ -143,7 +143,7 @@ class StockMoveLine(models.Model): """ res = {} if self.qty_done and self.product_id.tracking == 'serial': - if float_compare(self.qty_done, 1.0, precision_rounding=self.move_id.product_id.uom_id.rounding) != 0: + if float_compare(self.qty_done, 1.0, precision_rounding=self.product_id.uom_id.rounding) != 0: message = _('You can only process 1.0 %s of products with unique serial number.') % self.product_id.uom_id.name res['warning'] = {'title': _('Warning'), 'message': message} return res diff --git a/addons/stock/models/stock_scrap.py b/addons/stock/models/stock_scrap.py index 16f6b505acb..7c8eeefb57a 100644 --- a/addons/stock/models/stock_scrap.py +++ b/addons/stock/models/stock_scrap.py @@ -131,7 +131,8 @@ class StockScrap(models.Model): self.package_id, self.owner_id, strict=True).mapped('quantity')) - if float_compare(available_qty, self.scrap_qty, precision_digits=precision) >= 0: + scrap_qty = self.product_uom_id._compute_quantity(self.scrap_qty, self.product_id.uom_id) + if float_compare(available_qty, scrap_qty, precision_digits=precision) >= 0: return self.do_scrap() else: return { diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py index 2e2d171eddf..8e9ebbd980c 100644 --- a/addons/stock/models/stock_warehouse.py +++ b/addons/stock/models/stock_warehouse.py @@ -971,7 +971,7 @@ class Orderpoint(models.Model): # These days will be substracted when creating the PO days += self.product_id._select_seller( quantity=product_qty, - date=start_date, + date=fields.Date.context_today(self,start_date), uom_id=self.product_uom).delay or 0.0 date_planned = start_date + relativedelta.relativedelta(days=days) return date_planned.strftime(DEFAULT_SERVER_DATETIME_FORMAT) diff --git a/addons/stock/report/report_stockpicking_operations.xml b/addons/stock/report/report_stockpicking_operations.xml index 63bfbd8cc14..09715938ca3 100644 --- a/addons/stock/report/report_stockpicking_operations.xml +++ b/addons/stock/report/report_stockpicking_operations.xml @@ -155,7 +155,7 @@ -

+

All products could not be reserved. Click on the "Check Availability" button to try to reserve products

diff --git a/addons/stock/tests/test_move.py b/addons/stock/tests/test_move.py index 2037ec4f34e..0939389dda9 100644 --- a/addons/stock/tests/test_move.py +++ b/addons/stock/tests/test_move.py @@ -3569,6 +3569,24 @@ class StockMove(TransactionCase): self.assertEqual(scrap.state, 'done') self.assertEqual(move1.reserved_availability, 0.25) + def test_scrap_6(self): + """ Check that scrap correctly handle UoM. """ + self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1) + scrap = self.env['stock.scrap'].create({ + 'product_id': self.product1.id, + 'product_uom_id': self.uom_dozen.id, + 'scrap_qty': 1, + }) + warning_message = scrap.action_validate() + self.assertEqual(warning_message.get('res_model', 'Wrong Model'), 'stock.warn.insufficient.qty.scrap') + insufficient_qty_wizard = self.env['stock.warn.insufficient.qty.scrap'].create({ + 'product_id': self.product1.id, + 'location_id': self.stock_location.id, + 'scrap_id': scrap.id + }) + insufficient_qty_wizard.action_done() + self.assertEqual(self.env['stock.quant']._gather(self.product1, self.stock_location).quantity, -11) + def test_in_date_1(self): """ Check that moving a tracked quant keeps the incoming date. """ diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml index 3a75e9744a0..8629dbff92c 100644 --- a/addons/stock/views/stock_picking_views.xml +++ b/addons/stock/views/stock_picking_views.xml @@ -220,7 +220,7 @@