From 17d44d6617baf302bf01e5d0f5c3c32a2c8d5007 Mon Sep 17 00:00:00 2001 From: "Ricardo Gomes Rodrigues (rigr)" Date: Wed, 15 Jun 2022 09:44:53 +0000 Subject: [PATCH] [IMP] account{,fleet,lock}: add quick invoice encoding This commit: - adds a fiduciary mode which allows fast encoding of invoices. This is an Accounting setting which must be activated. This fiduciary mode allows one to edit directly the invoice sequence and enter the total amount of the invoice (by creating a single line which contains the base price, the tax amount, and the most frequent account) such that we match this 'quick total amount'. - adds a new test for this feature - adds a 'quick edit total amount' in the tax computation which verifies that the 'quick edit total amount' correctly matches sum of the base and the tax (in case of fiduciary mode) - sorts and filters the accounts suggestions in an invoice line. The most frequent accounts for a particular partner rank higher while filtering on the type of account suggested (this is disabled if the user searches itself for the account) - selects by default the most frequent account for a particular partner when creating a new invoice line - adds the option to hide some columns (label & quantity) - moves the car column after the account column in case the Fleet app is installed Task-id: 2857253 Community PR: odoo#91938 Enterprise PR: odoo/enterprise#27605 Part-of: odoo/odoo#91938 --- addons/account/models/account_account.py | 32 ++++ addons/account/models/account_move.py | 175 +++++++++++++++++- addons/account/models/company.py | 8 + addons/account/models/res_config_settings.py | 3 + .../tests/test_account_move_out_invoice.py | 45 +++++ addons/account/views/account_move_views.xml | 45 +++-- .../views/res_config_settings_views.xml | 17 ++ .../views/account_move_views.xml | 4 +- addons/account_lock/__manifest__.py | 2 +- addons/account_lock/models/res_company.py | 14 +- 10 files changed, 309 insertions(+), 36 deletions(-) diff --git a/addons/account/models/account_account.py b/addons/account/models/account_account.py index d9262817a8d..1b781bc373f 100644 --- a/addons/account/models/account_account.py +++ b/addons/account/models/account_account.py @@ -442,8 +442,40 @@ class AccountAccount(models.Model): contextual_self = self.with_context(default_name=default_name, default_code=default_code) return super(AccountAccount, contextual_self).default_get(default_fields) + @api.model + def _order_by_frequency_per_partner(self, company_id, partner_id, move_type=None, limit=None): + """ + Returns the accounts ordered from most used to least used for a given partner + and filtered according to the move type. + """ + limit = "LIMIT 1" if limit is not None else "" + where_internal_group = "" + if move_type in self.env['account.move'].get_inbound_types(include_receipts=True): + where_internal_group = "AND account.internal_group = 'income'" + elif move_type in self.env['account.move'].get_outbound_types(include_receipts=True): + where_internal_group = "AND account.internal_group = 'expense'" + self._cr.execute(f""" + SELECT account.id + FROM account_account account + LEFT JOIN account_move_line aml + ON aml.account_id = account.id + AND aml.partner_id = %s + AND account.deprecated = FALSE + AND account.company_id = aml.company_id + AND aml.date >= now() - interval '2 years' + WHERE account.company_id = %s + {where_internal_group} + GROUP BY account.id + ORDER BY COUNT(aml.id) DESC, account.code + {limit} + """, [partner_id, company_id]) + return [r[0] for r in self._cr.fetchall()] + @api.model def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None): + if not name and self._context.get('partner_id') and self._context.get('move_type'): + return self._order_by_frequency_per_partner( + self.env.company.id, self._context.get('partner_id'), self._context.get('move_type')) args = args or [] domain = [] if name: diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 9c771a0eba1..541ddcfe6c8 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -385,6 +385,12 @@ class AccountMove(models.Model): readonly=True, states={'draft': [('readonly', False)]}, help='Defines the smallest coinage of the currency that can be used to pay by cash.') + # Fiduciary mode fields + quick_edit_mode = fields.Boolean(compute='_compute_quick_edit_mode') + quick_edit_total_amount = fields.Monetary(string='Total (Tax inc.)', + help='Use this field to encode the total amount of the invoice. ' + 'Odoo will automatically create one invoice line with default values to match it.') + # ==== Display purpose fields ==== invoice_filter_type_domain = fields.Char(compute='_compute_invoice_filter_type_domain', help="Technical field used to have a dynamic domain on journal / taxes in the form view.") @@ -540,7 +546,7 @@ class AccountMove(models.Model): if new_currency != self.currency_id: self.currency_id = new_currency self._onchange_currency() - if self.state == 'draft' and self._get_last_sequence() and self.name and self.name != '/': + if self.state == 'draft' and self.name and self.name != '/' and not self.quick_edit_mode and self._get_last_sequence(): self.name = '/' @api.onchange('partner_id') @@ -637,6 +643,121 @@ class AccountMove(models.Model): self.invoice_vendor_bill_id = False self._recompute_dynamic_lines() + @api.model + def _get_frequent_account_and_taxes(self, company_id, partner_id, move_type): + """ + Returns the most used accounts and taxes for a given partner and company, + eventually filtered according to the move type. + """ + where_internal_group = "" + if move_type in self.env['account.move'].get_inbound_types(include_receipts=True): + where_internal_group = "AND account.internal_group = 'income'" + elif move_type in self.env['account.move'].get_outbound_types(include_receipts=True): + where_internal_group = "AND account.internal_group = 'expense'" + self._cr.execute(f""" + SELECT + COUNT(foo.id), foo.account_id, foo.taxes + FROM + ( + SELECT + account.id AS account_id, + account.code, + aml.id, + ARRAY_AGG(tax_rel.account_tax_id) AS taxes + FROM account_account account + LEFT JOIN account_move_line aml + ON (account.id = aml.account_id + AND aml.partner_id = %s + AND aml.date >= now() - interval '2 years') + LEFT JOIN account_move_line_account_tax_rel tax_rel ON (aml.id = tax_rel.account_move_line_id) + WHERE + account.company_id = %s + AND account.deprecated = FALSE + {where_internal_group} + GROUP BY account.id, account.code, aml.id + ) AS foo + GROUP BY foo.account_id, foo.code, foo.taxes + ORDER BY COUNT(foo.id) DESC, foo.code + LIMIT 1 + """, [partner_id, company_id]) + return self._cr.fetchone() + + @api.model + def _get_quick_edit_suggestions(self, amount_total, quick_edit_total_amount, move_type, journal_id, partner_id, fiscal_position_id): + """ + Returns a dictionnary containing the suggested values when creating a new + line with the quick_edit_total_amount set. We will compute the price_unit + that has to be set with the correct that in order to match this total amount. + If the vendor/customer is set, we will suggest the most frequently used account + for that partner as the default one, otherwise the default of the journal. + """ + if not quick_edit_total_amount: + return False + journal = self.env['account.journal'].browse(journal_id) + count, account_id, tax_ids = self._get_frequent_account_and_taxes(journal.company_id.id, partner_id, move_type) + if count: + taxes = self.env['account.tax'].browse(tax_ids) + else: + account_id = journal.default_account_id.id + if journal.default_account_id.tax_ids: + taxes = journal.default_account_id.tax_ids + else: + taxes = journal.company_id.account_sale_tax_id if journal.type == 'sale' else journal.company_id.account_purchase_tax_id + taxes = self.env['account.fiscal.position'].browse(fiscal_position_id).map_tax(taxes) + price_untaxed = taxes.with_context(force_price_include=True).compute_all(quick_edit_total_amount - amount_total)['total_excluded'] + return {'account_id': account_id, 'tax_ids': taxes.ids, 'price_unit': price_untaxed} + + @api.onchange('quick_edit_total_amount') + def _onchange_quick_edit_total_amount(self): + """ + Creates a new line with the suggested values (for the account, the price_unit, + and the tax) such that the total amount matches the quick total amount. + """ + if not self.quick_edit_total_amount or not self.quick_edit_mode or len(self.invoice_line_ids) > 0: + return + self.line_ids = [Command.clear()] + suggestions = self._get_quick_edit_suggestions( + self.amount_total, + self.quick_edit_total_amount, + self.move_type, + self.journal_id.id, + self.partner_id.id, + self.fiscal_position_id.id, + ) + self.env['account.move.line'].new({ + 'move_id': self.id, + 'partner_id': self.partner_id, + 'account_id': suggestions['account_id'], + 'currency_id': self.currency_id.id, + 'price_unit': suggestions['price_unit'], + 'tax_ids': [Command.set(suggestions['tax_ids'])], + }) + self.line_ids._onchange_price_subtotal() + self._onchange_invoice_line_ids() + + def _check_quick_edit_total_amount(self): + """ + Verifies that the total amount corresponds to the quick total amount chosen as some + rounding errors may appear. In such a case, we round up the tax such that the total + is equal to the quick total amount set + E.g.: 100€ including 21% tax: base = 82.64, tax = 17.35, total = 99.99 + The tax will be set to 17.36 in order to have a total of 100.00 + """ + self.ensure_one() + if not self.quick_edit_total_amount or not self.quick_edit_mode: + return + totals = json.loads(self.tax_totals_json) + tax_amount_rounding_error = self.quick_edit_total_amount - self.amount_total + if not float_compare(abs(tax_amount_rounding_error), self.currency_id.rounding, precision_rounding=self.currency_id.rounding) > 0: + try: + totals['groups_by_subtotal']['Untaxed Amount'][0]['tax_group_amount'] += tax_amount_rounding_error + totals['groups_by_subtotal']['Untaxed Amount'][0]['formatted_tax_group_amount'] = formatLang(self.env, totals['groups_by_subtotal']['Untaxed Amount'][0]['tax_group_amount'], currency_obj=self.currency_id) + totals['amount_total'] = self.quick_edit_total_amount + totals['formatted_amount_total'] = formatLang(self.env, self.quick_edit_total_amount, currency_obj=self.currency_id) + self.tax_totals_json = json.dumps(totals) + self._onchange_tax_totals_json() + except KeyError: + pass @api.onchange('invoice_line_ids') def _onchange_invoice_line_ids(self): @@ -646,6 +767,7 @@ class AccountMove(models.Model): others_lines[0].recompute_tax_line = True self.line_ids = others_lines + self.invoice_line_ids self._onchange_recompute_dynamic_lines() + self._check_quick_edit_total_amount() @api.onchange('line_ids', 'invoice_payment_term_id', 'invoice_date_due', 'invoice_cash_rounding_id', 'invoice_vendor_bill_id') def _onchange_recompute_dynamic_lines(self): @@ -1183,6 +1305,9 @@ class AccountMove(models.Model): # In the form view, we need to compute a default sequence so that the user can edit # it. We only check the first move as an approximation (enough for new in form view) pass + elif (not move.name or move.name == '/') and move.quick_edit_mode: + # We suggest the next sequence as the default name of the new move + pass elif (move.name and move.name != '/') or move.state != 'posted': try: if not move.posted_before: @@ -1237,6 +1362,17 @@ class AccountMove(models.Model): for record in self: record.highest_name = record._get_last_sequence() + @api.depends('journal_id.type', 'company_id.quick_edit_mode', 'quick_edit_total_amount') + def _compute_quick_edit_mode(self): + for move in self: + quick_edit_mode = move.company_id.quick_edit_mode + if move.journal_id.type == 'sale': + move.quick_edit_mode = quick_edit_mode in ('out_invoices', 'out_and_in_invoices') + elif move.journal_id.type == 'purchase': + move.quick_edit_mode = quick_edit_mode in ('in_invoices', 'out_and_in_invoices') + else: + move.quick_edit_mode = False + @api.depends('name', 'journal_id') def _compute_made_sequence_hole(self): self.env.cr.execute(""" @@ -2177,7 +2313,7 @@ class AccountMove(models.Model): if move.journal_id.sequence_override_regex and vals.get('name') and vals['name'] != '/' and not re.match(move.journal_id.sequence_override_regex, vals['name']): if not self.env.user.has_group('account.group_account_manager'): - raise UserError(_('The Journal Entry sequence is not conform to the current format. Only the Advisor can change it.')) + raise UserError(_('The Journal Entry sequence is not conform to the current format. Only the Accountant can change it.')) move.journal_id.sequence_override_regex = False if self._move_autocomplete_invoice_lines_write(vals): @@ -2833,6 +2969,10 @@ class AccountMove(models.Model): if not self.env.su and not self.env.user.has_group('account.group_account_invoice'): raise AccessError(_("You don't have the access rights to post an invoice.")) for move in to_post: + if move.quick_edit_mode and move.quick_edit_total_amount and move.quick_edit_total_amount != move.amount_total: + computed_total = formatLang(self.env, move.amount_total, currency_obj=move.currency_id) + encoded_total = formatLang(self.env, move.quick_edit_total_amount, currency_obj=move.currency_id) + raise UserError(_("The current total is %s but the expected total is %s. In order to post the invoice/bill, you can adjust its lines or the expected Total (tax inc.).", computed_total, encoded_total)) if move.partner_bank_id and not move.partner_bank_id.active: raise UserError(_("The recipient bank account link to this invoice is archived.\nSo you cannot confirm the invoice.")) if move.state == 'posted': @@ -4391,6 +4531,28 @@ class AccountMoveLine(models.Model): for record in self: record.is_same_currency = record.currency_id == record.company_currency_id + @api.onchange('tax_ids') + def _onchange_quick_suggest_price_unit(self): + """When a new line is created, we suggest a price_unit according to the quick suggestions""" + # TODO Using an onchange on tax_ids to do this is a huge hack, we should instead give default values in default_get + # but the current implementation of the invoice lines is sooo messy that it's overly complicated to success doing so + for line in self: + if line.price_unit != 0 or not line.move_id.quick_edit_mode: + continue + suggestions = line.move_id._get_quick_edit_suggestions( + line.move_id.amount_total, + line.move_id.quick_edit_total_amount, + line.move_id.move_type, + line.move_id.journal_id.id, + line.partner_id.id, + line.move_id.fiscal_position_id.id) + if suggestions: + line.price_unit = suggestions['price_unit'] + line.tax_ids = suggestions['tax_ids'] + line.exclude_from_invoice_tab = False + line._onchange_price_subtotal() + line.move_id._onchange_invoice_line_ids() + # ------------------------------------------------------------------------- # CONSTRAINT METHODS # ------------------------------------------------------------------------- @@ -4765,8 +4927,15 @@ class AccountMoveLine(models.Model): and self._context.get('default_move_type') in ('out_invoice', 'out_refund', 'in_invoice', 'in_refund', 'out_receipt', 'in_receipt'): # Fill missing 'account_id'. journal = self.env['account.journal'].browse(self._context.get('default_journal_id') or self._context['journal_id']) - values['account_id'] = journal.default_account_id.id + if values['partner_id']: + # Get most used account of the partner as the default. + account_id = self.env['account.account']._order_by_frequency_per_partner( + journal.company_id.id, values['partner_id'], self._context.get("default_move_type"), limit=1)[0] + else: + account_id = journal.default_account_id + values['account_id'] = account_id elif self._context.get('line_ids') and any(field_name in default_fields for field_name in ('debit', 'credit', 'account_id', 'partner_id')): + # MISC JOURNAL ENTRY ENCODING move = self.env['account.move'].new({'line_ids': self._context['line_ids']}) # Suggest default value for debit / credit to balance the journal entry. diff --git a/addons/account/models/company.py b/addons/account/models/company.py index 4b122e1ef97..d351cf168d1 100644 --- a/addons/account/models/company.py +++ b/addons/account/models/company.py @@ -172,6 +172,14 @@ class ResCompany(models.Model): # Storno Accounting account_storno = fields.Boolean(string="Storno accounting", readonly=False) + # Fiduciary mode + quick_edit_mode = fields.Selection( + selection=[ + ('out_invoices', 'Customer Invoices'), + ('in_invoices', 'Vendor Bills'), + ('out_and_in_invoices', 'Customer Invoices and Vendor Bills')], + string="Quick encoding") + @api.constrains('account_opening_move_id', 'fiscalyear_last_day', 'fiscalyear_last_month') def _check_fiscalyear_last_day(self): # if the user explicitly chooses the 29th of February we allow it: diff --git a/addons/account/models/res_config_settings.py b/addons/account/models/res_config_settings.py index a546b19e8ac..07ed0db5ca0 100644 --- a/addons/account/models/res_config_settings.py +++ b/addons/account/models/res_config_settings.py @@ -159,6 +159,9 @@ class ResConfigSettings(models.TransientModel): # Allows for the use of a different delivery address group_sale_delivery_address = fields.Boolean("Customer Addresses", implied_group='account.group_delivery_invoice_address') + # Quick encoding (fiduciary mode) + quick_edit_mode = fields.Selection(string="Quick encoding", readonly=False, related='company_id.quick_edit_mode') + def set_values(self): super().set_values() # install a chart of accounts for the given company (if required) diff --git a/addons/account/tests/test_account_move_out_invoice.py b/addons/account/tests/test_account_move_out_invoice.py index 94ded3675d8..7cb6bb9b560 100644 --- a/addons/account/tests/test_account_move_out_invoice.py +++ b/addons/account/tests/test_account_move_out_invoice.py @@ -3452,3 +3452,48 @@ class TestAccountMoveOutInvoiceOnchanges(AccountTestInvoicingCommon): # Tags should be empty since the tax has been removed from the invoice line self.assertRecordValues(invoice.line_ids, [{'tax_tag_ids': []}, {'tax_tag_ids': []}]) + + def test_quick_edit_total_amount(self): + move_form = Form(self.env['account.move'].with_context(default_move_type='out_invoice')) + move_form.invoice_date = fields.Date.from_string('2022-01-01') + move_form.partner_id = self.partner_a + + # Quick edit total amount not activated yet + move_form.quick_edit_total_amount = 100.0 + invoice = move_form.save() + self.assertEqual(invoice.amount_total, 0.0) + self.assertEqual(len(invoice.invoice_line_ids), 0) + + # Quick edit total amount activated + self.env.company.quick_edit_mode = "out_and_in_invoices" + self.env.company.account_sale_tax_id = self.env['account.tax'].create({ + 'name': '21%', + 'amount': 21, + 'type_tax_use': 'sale', + }) # 21% tax of a total amount may create a rounding error (99.99 or 100.01) + + # Let's make sure it does not (the rounding cent should be on the tax) + move_form.quick_edit_total_amount = 100.0 + invoice = move_form.save() + self.assertEqual(invoice.amount_total, 100) + self.assertEqual(invoice.amount_untaxed, 82.64) + self.assertEqual(invoice.amount_tax, 17.36) + self.assertEqual(len(invoice.invoice_line_ids), 1) + + # Modify one invoice line + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.price_unit = 50 + invoice = move_form.save() + self.assertEqual(invoice.amount_total, 60.5) + self.assertEqual(invoice.amount_untaxed, 50) + self.assertEqual(invoice.amount_tax, 10.5) + self.assertEqual(len(invoice.invoice_line_ids), 1) + + # Suggest the new amount such that the total is equal to the quick amount + with move_form.invoice_line_ids.new() as line_form: + self.assertEqual(line_form.price_unit, 32.64) + invoice = move_form.save() + self.assertEqual(invoice.amount_total, 100) + self.assertEqual(invoice.amount_untaxed, 82.64) + self.assertEqual(invoice.amount_tax, 17.36) + self.assertEqual(len(invoice.invoice_line_ids), 2) diff --git a/addons/account/views/account_move_views.xml b/addons/account/views/account_move_views.xml index f2c6088a1c1..9f5d3b96050 100644 --- a/addons/account/views/account_move_views.xml +++ b/addons/account/views/account_move_views.xml @@ -739,21 +739,20 @@ +
- -

- Draft -

- The current highest number is . You might want to put a higher number here. -

- -

+

+ + + Draft +

@@ -772,17 +771,13 @@ + - + diff --git a/addons/account/views/res_config_settings_views.xml b/addons/account/views/res_config_settings_views.xml index 7fb95e28e6b..2bbd7ee59d5 100644 --- a/addons/account/views/res_config_settings_views.xml +++ b/addons/account/views/res_config_settings_views.xml @@ -691,6 +691,23 @@ +

Accounting Firms mode

+
+
+
+

Accounting firm mode will change invoice/bill encoding:

+

- The sequence’s document becomes manageable for each document.

+

- A new field « Total (tax inc.) » to speed up and control the encoding by automating line creation with the right account & tax.

+
+
+
+
+
diff --git a/addons/account_fleet/views/account_move_views.xml b/addons/account_fleet/views/account_move_views.xml index 1d089b2b540..5e8e198daa8 100644 --- a/addons/account_fleet/views/account_move_views.xml +++ b/addons/account_fleet/views/account_move_views.xml @@ -6,11 +6,11 @@ account.move - + - + diff --git a/addons/account_lock/__manifest__.py b/addons/account_lock/__manifest__.py index 155ccf34a3a..70a377a50d7 100644 --- a/addons/account_lock/__manifest__.py +++ b/addons/account_lock/__manifest__.py @@ -7,7 +7,7 @@ 'description': """ Make the lock date irreversible: - * You cannot set stricter restrictions on advisors than on users. Therefore, the All Users Lock Date must be anterior (or equal) to the Invoice/Bills Lock Date. + * You cannot set stricter restrictions on accountants than on users. Therefore, the All Users Lock Date must be anterior (or equal) to the Invoice/Bills Lock Date. * You cannot lock a period that has not yet ended. Therefore, the All Users Lock Date must be anterior (or equal) to the last day of the previous month. * Any new All Users Lock Date must be posterior (or equal) to the previous one. """, diff --git a/addons/account_lock/models/res_company.py b/addons/account_lock/models/res_company.py index 9aeab2287d1..d126ff11ba1 100644 --- a/addons/account_lock/models/res_company.py +++ b/addons/account_lock/models/res_company.py @@ -13,7 +13,7 @@ class ResCompany(models.Model): def _autorise_lock_date_changes(self, vals): '''Check the lock dates for the current companies. This can't be done in a api.constrains because we need to perform some comparison between new/old values. This method forces the lock dates to be irreversible. - * You cannot set stricter restrictions on advisors than on users. + * You cannot set stricter restrictions on accountants than on users. Therefore, the All Users Lock Date must be anterior (or equal) to the Invoice/Bills Lock Date. * You cannot lock a period that has not yet ended. Therefore, the All Users Lock Date must be anterior (or equal) to the last day of the previous month. @@ -48,11 +48,11 @@ class ResCompany(models.Model): if tax_lock_date and tax_lock_date > previous_month: raise UserError(_('You cannot lock a period that has not yet ended. Therefore, the tax lock date must be anterior (or equal) to the last day of the previous month.')) - # The user attempts to remove the lock date for advisors + # The user attempts to remove the lock date for accountants if old_fiscalyear_lock_date and not fiscalyear_lock_date and 'fiscalyear_lock_date' in vals: - raise UserError(_('The lock date for advisors is irreversible and can\'t be removed.')) + raise UserError(_('The lock date for accountants is irreversible and can\'t be removed.')) - # The user attempts to set a lock date for advisors prior to the previous one + # The user attempts to set a lock date for accountants prior to the previous one if old_fiscalyear_lock_date and fiscalyear_lock_date and fiscalyear_lock_date < old_fiscalyear_lock_date: raise UserError(_('Any new All Users Lock Date must be posterior (or equal) to the previous one.')) @@ -61,7 +61,7 @@ class ResCompany(models.Model): if not fiscalyear_lock_date: continue - # The user attempts to set a lock date for advisors prior to the last day of previous month + # The user attempts to set a lock date for accountants prior to the last day of previous month if fiscalyear_lock_date > previous_month: raise UserError(_('You cannot lock a period that has not yet ended. Therefore, the All Users Lock Date must be anterior (or equal) to the last day of the previous month.')) @@ -70,9 +70,9 @@ class ResCompany(models.Model): if not period_lock_date: continue - # The user attempts to set a lock date for advisors prior to the lock date for users + # The user attempts to set a lock date for accountants prior to the lock date for users if period_lock_date < fiscalyear_lock_date: - raise UserError(_('You cannot set stricter restrictions on advisors than on users. Therefore, the All Users Lock Date must be anterior (or equal) to the Invoice/Bills Lock Date.')) + raise UserError(_('You cannot set stricter restrictions on accountants than on users. Therefore, the All Users Lock Date must be anterior (or equal) to the Invoice/Bills Lock Date.')) def write(self, vals): # fiscalyear_lock_date can't be set to a prior date