import calendar from contextlib import contextmanager from itertools import chain from dateutil.relativedelta import relativedelta import logging import markupsafe import re from odoo import fields, models, api, _ from odoo.exceptions import UserError from odoo.fields import Command, Domain from odoo.tools import SQL, float_compare from odoo.tools.misc import formatLang _logger = logging.getLogger(__name__) DEFERRED_DATE_MIN = '1900-01-01' DEFERRED_DATE_MAX = '9999-12-31' class AccountMove(models.Model): _inherit = "account.move" # Technical field to keep the value of payment_state when switching from invoicing to accounting # (using invoicing_switch_threshold setting field). It allows keeping the former payment state, so that # we can restore it if the user misconfigured the switch date and wants to change it. payment_state_before_switch = fields.Char(string="Payment State Before Switch", copy=False) # Deferred management fields deferred_move_ids = fields.Many2many( string="Deferred Entries", comodel_name='account.move', relation='account_move_deferred_rel', column1='original_move_id', column2='deferred_move_id', help="The deferred entries created by this invoice", copy=False, ) deferred_original_move_ids = fields.Many2many( string="Original Invoices", comodel_name='account.move', relation='account_move_deferred_rel', column1='deferred_move_id', column2='original_move_id', help="The original invoices that created the deferred entries", copy=False, ) deferred_entry_type = fields.Selection( string="Deferred Entry Type", selection=[ ('expense', 'Deferred Expense'), ('revenue', 'Deferred Revenue'), ('misc', 'Deferred Miscellaneous'), ], compute='_compute_deferred_entry_type', copy=False, ) signing_user = fields.Many2one( string='Signer', comodel_name='res.users', compute='_compute_signing_user', store=True, copy=False, ) show_signature_area = fields.Boolean(compute='_compute_signature') signature = fields.Binary(compute='_compute_signature') # can't be `related`: the sign module might not be there @api.depends('state', 'move_type', 'invoice_user_id') def _compute_signing_user(self): other_moves = self.filtered(lambda move: not move.is_sale_document()) other_moves.signing_user = False is_odoobot_user = self.env.user == self.env.ref('base.user_root') is_backend_user = self.env.user.has_group('base.group_user') for invoice in (self - other_moves).filtered(lambda inv: inv.state == 'posted'): # signer priority: # - res.user set in res.settings # - real backend user posting the invoice # - if odoobot: the person that initiated the invoice ie: The salesman # - if invoice initiated by a portal user -> No signature representative = invoice.company_id.signing_user # checking `has_group('base.group_user')` ensure we never keep a portal user to sign if is_odoobot_user: user_can_sign = invoice.invoice_user_id and invoice.invoice_user_id.has_group('base.group_user') invoice.signing_user = representative or invoice.invoice_user_id if user_can_sign else False else: invoice.signing_user = representative or self.env.user if is_backend_user else False @api.depends('state') def _compute_signature(self): is_portal_user = self.env.user.has_group('base.group_portal') # Checking `company_id.sign_invoice` removes the needs to check if the sign module is installed # Setting it to True through `res.settings` auto install the sign module moves_not_to_sign = self.filtered( lambda inv: not inv.company_id.sign_invoice or inv.state in {'draft', 'cancel'} or not inv.is_sale_document() # Allow signature for portal user only if the invoice already went through the send&print workflow or (is_portal_user and not inv.invoice_pdf_report_id) ) moves_not_to_sign.show_signature_area = False moves_not_to_sign.signature = None invoice_with_signature = self - moves_not_to_sign invoice_with_signature.show_signature_area = True for invoice in invoice_with_signature: invoice.signature = invoice.signing_user.sudo().sign_signature def _post(self, soft=True): # Deferred management posted = super()._post(soft) for move in self: if move._get_deferred_entries_method() == 'on_validation' and any(move.line_ids.mapped('deferred_start_date')): move._generate_deferred_entries() return posted def button_draft(self): if any(len(deferral_move.deferred_original_move_ids) > 1 for deferral_move in self.deferred_move_ids): raise UserError(_("You cannot reset to draft an invoice that is grouped in deferral entry. You can create a credit note instead.")) reversed_moves = self.deferred_move_ids._unlink_or_reverse() if reversed_moves: for move in reversed_moves: move.with_context(skip_readonly_check=True).write({ 'date': move._get_accounting_date(move.date, move._affect_tax_report()), }) self.deferred_move_ids |= reversed_moves return super().button_draft() def unlink(self): # Prevent deferred moves under audit trail restriction from being unlinked deferral_moves = self.filtered(lambda move: move._is_protected_by_audit_trail() and move.deferred_original_move_ids) deferral_moves.deferred_original_move_ids.deferred_move_ids = False deferral_moves._reverse_moves() return super(AccountMove, self - deferral_moves).unlink() # ============================= START - Deferred Management ==================================== def _get_deferred_entries_method(self): self.ensure_one() if self.is_entry(): move_types = set(self.line_ids.filtered('deferred_start_date').account_id.mapped("internal_group")) if ( "expense" in move_types and "income" in move_types and self.company_id.generate_deferred_expense_entries_method != self.company_id.generate_deferred_revenue_entries_method ): raise UserError(self.env._( "Having different deferred entries generation methods for expenses and revenues is not supported on " "journal entries involving both expense and revenue accounts. You can split this entry into two entries instead." )) elif "expense" in move_types: return self.company_id.generate_deferred_expense_entries_method else: return self.company_id.generate_deferred_revenue_entries_method if self.is_purchase_document(): return self.company_id.generate_deferred_expense_entries_method return self.company_id.generate_deferred_revenue_entries_method @api.depends('deferred_original_move_ids') def _compute_deferred_entry_type(self): for move in self: if move.deferred_original_move_ids: move_types = set(move.deferred_original_move_ids.mapped('move_type')) if len(move_types) > 1 or move.deferred_original_move_ids[0].is_entry(): move.deferred_entry_type = 'misc' elif move.deferred_original_move_ids[0].is_purchase_document(): move.deferred_entry_type = 'expense' else: move.deferred_entry_type = 'revenue' else: move.deferred_entry_type = False @api.model def _get_deferred_diff_dates(self, start, end): """ Returns the number of months between two dates [start, end[ The computation is done by using months of 30 days so that the deferred amount for february (28-29 days), march (31 days) and april (30 days) are all the same (in case of monthly computation). See test_deferred_management_get_diff_dates for examples. """ if start > end: start, end = end, start nb_months = end.month - start.month + 12 * (end.year - start.year) start_day, end_day = start.day, end.day if start_day == calendar.monthrange(start.year, start.month)[1]: start_day = 30 if end_day == calendar.monthrange(end.year, end.month)[1]: end_day = 30 nb_days = end_day - start_day return (nb_months * 30 + nb_days) / 30 @api.model def _get_deferred_period_amount(self, method, period_start, period_end, line_start, line_end, balance): """ Returns the amount to defer for the given period taking into account the deferred method (day/month/full_months). """ if period_end <= line_start or period_end <= period_start: return 0 # invalid period if method == 'day': amount_per_day = balance / (line_end - line_start).days return (period_end - period_start).days * amount_per_day elif method in ('month', 'full_months'): if method == 'full_months': reset_day_1 = relativedelta(day=1) line_start, line_end = line_start + reset_day_1, line_end + reset_day_1 period_start, period_end = period_start + reset_day_1, period_end + reset_day_1 line_diff = self._get_deferred_diff_dates(line_end, line_start) period_diff = self._get_deferred_diff_dates(period_end, period_start) return period_diff / line_diff * balance if line_diff else balance @api.model def _get_deferred_amounts_by_line(self, lines, periods, deferred_type): """ :return: a list of dictionaries containing the deferred amounts for each line and each period E.g. (where period1 = (date1, date2, label1), period2 = (date2, date3, label2), ...) [ {'account_id': 1, period_1: 100, period_2: 200}, {'account_id': 1, period_1: 100, period_2: 200}, {'account_id': 2, period_1: 300, period_2: 400}, ] """ values = [] for line in lines: line_start = fields.Date.to_date(line['deferred_start_date']) line_end = fields.Date.to_date(line['deferred_end_date']) if line_end < line_start: # This normally shouldn't happen, but if it does, would cause calculation errors later on. # To not make the reports crash, we just set both dates to the same day. # The user should fix the dates manually. line_end = line_start columns = {} for period in periods: if period[2] == 'not_started' and line_start <= period[0]: # The 'Not Started' column only considers lines starting the deferral after the report end date columns[period] = 0.0 continue # periods = [Total, Not Started, Before, ..., Current, ..., Later] # The dates to calculate the amount for the current period period_start = max(period[0], line_start) period_end = min(period[1], line_end) + relativedelta(days=1) # +1 to include end date of report columns[period] = self._get_deferred_period_amount( self.env.company.deferred_expense_amount_computation_method if deferred_type == "expense" else self.env.company.deferred_revenue_amount_computation_method, period_start, period_end, line_start, line_end + relativedelta(days=1), # +1 to include the end date of the line line['balance'] ) values.append({ **self.env['account.move.line']._get_deferred_amounts_by_line_values(line), **columns, }) return values @api.model def _get_deferred_lines(self, line, deferred_account, deferred_type, period, ref, force_balance=None, grouping_field='account_id'): """ :return: a list of Command objects to create the deferred lines of a single given period """ deferred_amounts = self._get_deferred_amounts_by_line(line, [period], deferred_type)[0] balance = deferred_amounts[period] if force_balance is None else force_balance return [ Command.create({ **self.env['account.move.line']._get_deferred_lines_values(account.id, coeff * balance, ref, line.analytic_distribution, line), 'partner_id': line.partner_id.id, 'product_id': line.product_id.id, }) for (account, coeff) in [(deferred_amounts[grouping_field], 1), (deferred_account, -1)] ] def _generate_deferred_entries(self): """ Generates the deferred entries for the invoice. """ self.ensure_one() if self.state != 'posted': return for deferred_type in ('expense', 'revenue'): moves_vals_to_create = [] lines_vals_to_create = [] lines_periods = [] lines = self.line_ids.filtered(lambda l: ( l.account_id.internal_group == ('expense' if deferred_type == 'expense' else 'income') and l.deferred_start_date and l.deferred_end_date )) if not lines: continue deferred_account = self.company_id.deferred_expense_account_id if deferred_type == "expense" else self.company_id.deferred_revenue_account_id deferred_journal = self.company_id.deferred_expense_journal_id if deferred_type == "expense" else self.company_id.deferred_revenue_journal_id deferred_method = self.company_id.deferred_expense_amount_computation_method if deferred_type == "expense" else self.company_id.deferred_revenue_amount_computation_method if not deferred_journal: raise UserError(_("Please set the deferred journal in the accounting settings.")) if not deferred_account: raise UserError(_("Please set the deferred accounts in the accounting settings.")) for line in lines: periods = line._get_deferred_periods() if not periods: continue start_date = line.deferred_start_date end_date = line.deferred_end_date accounting_date = line.date # When using the 'full_months' computation method, every consumed month counts as a full month. # We therefore need to subtract one month from the end date for the following check on dates. if deferred_method == 'full_months': # We need to add one day to the end date since it's excluded by _get_deferred_diff_dates(). if self._get_deferred_diff_dates(start_date.replace(day=1), end_date + relativedelta(days=1)) < 2: end_date += relativedelta(months=-1) # When all move line dates (start, end, accounting) are within the same month, we skip the line. # It would otherwise lead to the creation of both a reversal and a deferral move that would cancel each other out. if start_date.replace(day=1) == end_date.replace(day=1) == accounting_date.replace(day=1): continue ref = _("Deferral of %s", line.move_id.name or '') moves_vals_to_create.append({ 'move_type': 'entry', 'deferred_original_move_ids': [Command.set(line.move_id.ids)], 'journal_id': deferred_journal.id, 'company_id': self.company_id.id, 'partner_id': line.partner_id.id, 'auto_post': 'at_date', 'ref': ref, 'name': False, 'date': line.move_id.date, }) lines_vals_to_create.append([ { **self.env['account.move.line']._get_deferred_lines_values(account.id, coeff * line.balance, ref, line.analytic_distribution, line), 'partner_id': line.partner_id.id, } for (account, coeff) in [(line.account_id, -1), (deferred_account, 1)] ]) lines_periods.append((line, periods)) # create the deferred moves moves_fully_deferred = self.create(moves_vals_to_create) # We write the lines after creation, to make sure the `deferred_original_move_ids` is set. # This way we can avoid adding taxes for deferred moves. for move_fully_deferred, lines_vals in zip(moves_fully_deferred, lines_vals_to_create): for line_vals in lines_vals: # This will link the moves to the lines. Instead of move.write('line_ids': lines_ids) line_vals['move_id'] = move_fully_deferred.id self.env['account.move.line'].create(list(chain(*lines_vals_to_create))) deferral_moves_vals = [] deferral_moves_line_vals = [] # Create the deferred entries for the periods [deferred_start_date, deferred_end_date] for (line, periods), move_vals in zip(lines_periods, moves_vals_to_create): remaining_balance = line.balance for period_index, period in enumerate(periods): # For the last deferral move the balance is forced to remaining balance to avoid rounding errors force_balance = remaining_balance if period_index == len(periods) - 1 else None deferred_amounts = self._get_deferred_amounts_by_line(line, [period], deferred_type)[0] balance = deferred_amounts[period] if force_balance is None else force_balance remaining_balance -= line.currency_id.round(balance) deferral_moves_vals.append({**move_vals, 'date': period[1]}) deferral_moves_line_vals.append([ { **self.env['account.move.line']._get_deferred_lines_values(account.id, coeff * balance, move_vals['ref'], line.analytic_distribution, line), 'partner_id': line.partner_id.id, 'product_id': line.product_id.id, } for (account, coeff) in [(deferred_amounts['account_id'], 1), (deferred_account, -1)] ]) deferral_moves = self.create(deferral_moves_vals) for deferral_move, lines_vals in zip(deferral_moves, deferral_moves_line_vals): for line_vals in lines_vals: # This will link the moves to the lines. Instead of move.write('line_ids': lines_ids) line_vals['move_id'] = deferral_move.id self.env['account.move.line'].create(list(chain(*deferral_moves_line_vals))) # Avoid having deferral moves with a total amount of 0. to_unlink = deferral_moves.filtered(lambda move: move.currency_id.is_zero(move.amount_total)) to_unlink.unlink() (moves_fully_deferred + deferral_moves - to_unlink)._post(soft=True) def open_deferred_entries(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': _("Deferred Entries"), 'res_model': 'account.move.line', 'domain': [('id', 'in', self.deferred_move_ids.line_ids.ids)], 'views': [(self.env.ref('account_accountant.view_deferred_entries_tree').id, 'list')], 'context': { 'search_default_group_by_move': True, 'expand': True, } } def open_deferred_original_entry(self): self.ensure_one() action = { 'type': 'ir.actions.act_window', 'name': _("Original Deferred Entries"), 'res_model': 'account.move.line', 'domain': [('id', 'in', self.deferred_original_move_ids.line_ids.ids)], 'views': [(False, 'list'), (False, 'form')], 'context': { 'search_default_group_by_move': True, 'expand': True, } } if len(self.deferred_original_move_ids) == 1: action.update({ 'res_model': 'account.move', 'res_id': self.deferred_original_move_ids[0].id, 'views': [(False, 'form')], }) return action # ============================= END - Deferred management ====================================== def action_open_bank_reconciliation_widget(self): return self.statement_line_id._action_open_bank_reconciliation_widget( default_context={ 'search_default_journal_id': self.statement_line_id.journal_id.id, 'search_default_statement_line_id': self.statement_line_id.id, 'default_st_line_id': self.statement_line_id.id, } ) def action_open_bank_reconciliation_widget_statement(self): return self.statement_line_id._action_open_bank_reconciliation_widget( extra_domain=[('statement_id', 'in', self.statement_id.ids)], ) def action_open_business_doc(self): if self.statement_line_id: return self.action_open_bank_reconciliation_widget() else: action = super().action_open_business_doc() # prevent propagation of the following keys action['context'] = action.get('context', {}) | { 'preferred_aml_value': None, 'preferred_aml_currency_id': None, } return action def _get_mail_thread_data_attachments(self): res = super()._get_mail_thread_data_attachments() res += self.statement_line_id.statement_id.attachment_ids if self.env.context.get('from_bank_reco'): res += self.line_ids.reconciled_lines_ids.move_attachment_ids return res @contextmanager def _get_edi_creation(self): with super()._get_edi_creation() as move: previous_lines = move.invoice_line_ids yield move.with_context(disable_onchange_name_predictive=True) for line in move.invoice_line_ids - previous_lines: line._onchange_name_predictive() def _get_outstanding_bank_statement_lines_domain(self): """ Returns a search domain for move lines for unreconciled bank payments linked to this move's partner. """ self.ensure_one() return [ ('parent_state', '=', 'posted'), ('partner_id', '=', self.commercial_partner_id.id), ('partner_id', '!=', False), ('account_id.account_type', '=', 'asset_cash'), ('journal_id', 'in', self.env['account.journal']._search([ *self.env['account.journal']._check_company_domain(self.company_id.id), ('type', '=', 'bank') ])), ('balance', '>' if self.is_inbound() else '<', 0.0), ('statement_line_id', '!=', False), ('move_id.line_ids', 'any', [ ('account_id', '=', self.company_id.account_journal_suspense_account_id.id), ('reconciled', '=', False), ]), ] def _compute_payments_widget_to_reconcile_info(self): # EXTENDS 'account' super()._compute_payments_widget_to_reconcile_info() for move in self: if move.state not in {'draft', 'posted'} \ or move.payment_state not in ('not_paid', 'partial') \ or not move.is_invoice(include_receipts=True) \ or not move.partner_id: continue bank_domain = [ ('parent_state', '=', 'posted'), ('partner_id', '=', move.commercial_partner_id.id), ('partner_id', '!=', False), ('account_id.account_type', '=', 'asset_cash'), ('journal_id', 'in', self.env['account.journal']._search([ *self.env['account.journal']._check_company_domain(move.company_id.id), ('type', '=', 'bank') ])), ('balance', '>' if move.is_inbound() else '<', 0.0), ('statement_line_id', '!=', False), ('move_id.line_ids', 'any', [ ('account_id', '=', move.company_id.account_journal_suspense_account_id.id), ('reconciled', '=', False), ]), ] payments_widget_vals = { 'outstanding': True, 'content': [], 'move_id': move.id, 'title': _('Outstanding credits') if move.is_inbound() else _('Outstanding debits') } for line in self.env['account.move.line'].search(bank_domain): st_line = line.statement_line_id currency = st_line.foreign_currency_id or st_line.currency_id amount = currency._convert( from_amount=abs(st_line.amount_residual), to_currency=move.currency_id, company=move.company_id, date=line.date, ) if move.currency_id.is_zero(amount): continue payments_widget_vals['content'].append({ 'bank_label': line.name if line.journal_id.type == 'bank' else False, 'journal_name': line.ref or line.move_id.name, 'amount': amount, 'currency_id': move.currency_id.id, 'id': line.id, 'move_id': line.move_id.id, 'date': fields.Date.to_string(line.date), 'account_payment_id': line.payment_id.id, }) if payments_widget_vals['content']: if move.invoice_outstanding_credits_debits_widget: move.invoice_outstanding_credits_debits_widget['content'].extend(payments_widget_vals['content']) move.invoice_outstanding_credits_debits_widget['content'].sort(key=lambda x: (x['date'], x['id']), reverse=True) else: move.invoice_outstanding_credits_debits_widget = payments_widget_vals def _get_partner_credit_warning_exclude_amount(self): # EXTENDS 'account' exclude_amount = super()._get_partner_credit_warning_exclude_amount() bank_domain = self._get_outstanding_bank_statement_lines_domain() for line in self.env['account.move.line'].search(bank_domain): currency = line.statement_line_id.foreign_currency_id or line.statement_line_id.currency_id exclude_amount += currency._convert( from_amount=abs(line.statement_line_id.amount_residual), to_currency=self.company_id.currency_id, company=self.company_id, date=line.date, ) matched_credits = self.line_ids.filtered(lambda line: line.account_id.account_type == 'asset_receivable').matched_credit_ids if matched_credits: bank_line_amount = sum(matched_credits.mapped('amount')) payment_date = max(matched_credits.mapped('max_date')) amount_company = matched_credits[0].credit_currency_id._convert( from_amount=bank_line_amount, to_currency=self.company_id.currency_id, company=self.company_id, date=payment_date, ) exclude_amount += amount_company return exclude_amount def js_assign_outstanding_line(self, line_id): # EXTENDS super().js_assign_outstanding_line(line_id) line = self.env['account.move.line'].browse(line_id) if line.account_id.account_type == 'asset_cash' and line.statement_line_id: return line.statement_line_id.with_context( skip_payment_tolerance=True, stop_reco_at_first_partial=True, ).set_line_bank_statement_line(self.line_ids.filtered(lambda line: line.account_id.account_type in {'asset_receivable', 'liability_payable'}).ids) def js_remove_outstanding_partial(self, partial_id): # EXTENDS if st_line := self.statement_line_id: partial = self.env['account.partial.reconcile'].browse(partial_id) st_line.delete_reconciled_line((partial.credit_move_id + partial.debit_move_id).ids) else: super().js_remove_outstanding_partial(partial_id) class AccountMoveLine(models.Model): _inherit = "account.move.line" move_attachment_ids = fields.One2many('ir.attachment', compute='_compute_attachment') # Deferred management fields deferred_start_date = fields.Date( string="Start Date", compute='_compute_deferred_start_date', store=True, readonly=False, index='btree_not_null', copy=False, help="Date at which the deferred expense/revenue starts" ) deferred_end_date = fields.Date( string="End Date", index='btree_not_null', copy=False, help="Date at which the deferred expense/revenue ends" ) has_deferred_moves = fields.Boolean(compute='_compute_has_deferred_moves') has_abnormal_deferred_dates = fields.Boolean(compute='_compute_has_abnormal_deferred_dates') full_amount_switch_html = fields.Html(compute='_compute_full_amount_switch_html') def _order_to_sql(self, order, query, alias=None, reverse=False): sql_order = super()._order_to_sql(order, query, alias, reverse) preferred_aml_residual_value = self.env.context.get('preferred_aml_value') preferred_aml_currency_id = self.env.context.get('preferred_aml_currency_id') if preferred_aml_residual_value and preferred_aml_currency_id and order == self._order: currency = self.env['res.currency'].browse(preferred_aml_currency_id) # using round since currency.round(55.55) = 55.550000000000004 preferred_aml_residual_value = round(preferred_aml_residual_value, currency.decimal_places) sql_residual_currency = self._field_to_sql(alias or self._table, 'amount_residual_currency', query) sql_currency = self._field_to_sql(alias or self._table, 'currency_id', query) return SQL( "ROUND(%(residual_currency)s, %(decimal_places)s) = %(value)s " "AND %(currency)s = %(currency_id)s DESC, %(order)s", residual_currency=sql_residual_currency, decimal_places=currency.decimal_places, value=preferred_aml_residual_value, currency=sql_currency, currency_id=currency.id, order=sql_order, ) return sql_order def copy_data(self, default=None): data_list = super().copy_data(default=default) for line, values in zip(self, data_list): if 'move_reverse_cancel' in self.env.context: values['deferred_start_date'] = line.deferred_start_date values['deferred_end_date'] = line.deferred_end_date return data_list def write(self, vals): """ Prevent changing the account of a move line when there are already deferral entries. """ if 'account_id' in vals: for line in self: if ( line.has_deferred_moves and line.deferred_start_date and line.deferred_end_date and vals['account_id'] != line.account_id.id ): raise UserError(_( "You cannot change the account for a deferred line in %(move_name)s if it has already been deferred.", move_name=line.move_id.display_name )) return super().write(vals) @api.depends('balance') def _compute_full_amount_switch_html(self): for line in self: # Ignore lines with no reconciled lines or not linked to a bank statement. if not (reconciled_lines := line.reconciled_lines_excluding_exchange_diff_ids) or len(reconciled_lines) > 1 or not line.statement_line_id: line.full_amount_switch_html = False continue is_invoice = reconciled_lines.move_id.is_invoice(include_receipts=True) btn_start = markupsafe.Markup("") if reconciled_lines.currency_id.is_zero(reconciled_lines.amount_currency + line.amount_currency): lines = [ _("%(display_name_html)s will be entirely paid by the transaction.") if is_invoice else _("%(display_name_html)s will be fully reconciled by the transaction.") ] liquidity_line_amount_currency = line.move_id.line_ids.filtered(lambda line: line.account_id == line.move_id.journal_id.default_account_id).amount_currency # Means that we possibly want to come back to partial if float_compare(liquidity_line_amount_currency, reconciled_lines.amount_currency, 2) < 0: btn_start = markupsafe.Markup("") lines.append( _("You might want to record a %(btn_start)spartial payment%(btn_end)s.") if is_invoice else _("You might want to make a %(btn_start)spartial reconciliation%(btn_end)s instead.") ) else: if is_invoice: lines = [ _("%(display_name_html)s will be reduced by %(amount)s."), _("You might want to set the invoice as %(btn_start)sfully paid%(btn_end)s."), ] else: lines = [ _("%(display_name_html)s will be reduced by %(amount)s."), _("You might want to %(btn_start)sfully reconcile%(btn_end)s the document."), ] # We need to use span instead of button here because button is not displayed otherwise display_name_html = markupsafe.Markup(""" %(display_name)s """) % { 'display_name': reconciled_lines.move_id._get_move_display_name(show_ref=False), } extra_text = markupsafe.Markup("
").join(lines) % { 'amount': formatLang(self.env, line.amount_currency, currency_obj=line.currency_id), 'display_name_html': display_name_html, 'btn_start': btn_start, 'btn_end': markupsafe.Markup(""), } line.full_amount_switch_html = markupsafe.Markup("
%s
") % extra_text # ============================= START - Deferred management ==================================== def _compute_has_deferred_moves(self): for line in self: line.has_deferred_moves = line.move_id.deferred_move_ids @api.depends('deferred_start_date', 'deferred_end_date') def _compute_has_abnormal_deferred_dates(self): # In the deferred computations, we always assume that both the start and end date are inclusive # E.g: 1st January -> 31st December is *exactly* 1 year = 12 months # However, the user may instead put 1st January -> 1st January of next year which is then # 12 months + 1/30 month = 12.03 months which may result in odd amounts when deferrals are created # For this reason, we alert the user if we detect such a case # Other cases were the number of months is not round should not be handled. for line in self: line.has_abnormal_deferred_dates = ( line.deferred_start_date and line.deferred_end_date and line.deferred_start_date != line.deferred_end_date and float_compare( self.env['account.move']._get_deferred_diff_dates(line.deferred_start_date, line.deferred_end_date + relativedelta(days=1)) % 1, # end date is included 1 / 30, precision_digits=2 ) == 0 ) def _has_deferred_compatible_account(self): self.ensure_one() return ( self.move_id.is_purchase_document(include_receipts=True) and self.account_id.internal_group == 'expense' ) or ( self.move_id.is_sale_document(include_receipts=True) and self.account_id.internal_group == 'income' ) or ( self.move_id.is_entry() and self.account_id.internal_group in ('expense', 'income') ) @api.onchange('deferred_start_date', 'account_id') def _onchange_deferred_start_date(self): if not self._has_deferred_compatible_account(): self.deferred_start_date = False if self.deferred_start_date and not self.deferred_end_date: self.deferred_end_date = self.deferred_start_date @api.onchange('deferred_end_date', 'account_id') def _onchange_deferred_end_date(self): if not self._has_deferred_compatible_account(): self.deferred_end_date = False if self.deferred_end_date and not self.deferred_start_date: self.deferred_start_date = self.deferred_end_date @api.depends('deferred_end_date', 'move_id.invoice_date', 'move_id.state') def _compute_deferred_start_date(self): for line in self: if not line.deferred_start_date and line.move_id.invoice_date and line.deferred_end_date: line.deferred_start_date = line.move_id.invoice_date @api.constrains('deferred_start_date', 'deferred_end_date', 'account_id') def _check_deferred_dates(self): for line in self: if line.deferred_start_date and not line.deferred_end_date: raise UserError(_("You cannot create a deferred entry with a start date but no end date.")) elif line.deferred_start_date and line.deferred_end_date and line.deferred_start_date > line.deferred_end_date: raise UserError(_("You cannot create a deferred entry with a start date later than the end date.")) @api.model def _get_deferred_ends_of_month(self, start_date, end_date): """ :return: a list of dates corresponding to the end of each month between start_date and end_date. See test_get_ends_of_month for examples. """ dates = [] while start_date <= end_date: start_date = start_date + relativedelta(day=31) # Go to end of month dates.append(start_date) start_date = start_date + relativedelta(days=1) # Go to first day of next month return dates def _get_deferred_periods(self): """ :return: a list of tuples (start_date, end_date) during which the deferred expense/revenue is spread. If there is only one period containing the move date, it means that we don't need to defer the expense/revenue since the invoice deferral and its deferred entry will be created on the same day and will thus cancel each other. """ self.ensure_one() periods = [ (max(self.deferred_start_date, date.replace(day=1)), min(date, self.deferred_end_date), 'current') for date in self._get_deferred_ends_of_month(self.deferred_start_date, self.deferred_end_date) ] if not periods or len(periods) == 1 and periods[0][0].replace(day=1) == self.date.replace(day=1): return [] else: return periods @api.model def _get_deferred_amounts_by_line_values(self, line): return { 'account_id': line['account_id'], # line either be a dict with ids (coming from SQL query), or a real account.move.line object 'product_id': line['product_id'] if isinstance(line, dict) else line['product_id'].id, 'product_category_id': line['product_category_id'] if isinstance(line, dict) else line['product_category_id'].id, 'balance': line['balance'], 'move_id': line['move_id'], } @api.model def _get_deferred_lines_values(self, account_id, balance, ref, analytic_distribution, line=None): return { 'account_id': account_id, # line either be a dict with ids (coming from SQL query), or a real account.move.line object 'product_id': line['product_id'] if isinstance(line, dict) else line['product_id'].id, 'product_category_id': line['product_category_id'] if isinstance(line, dict) else line['product_category_id'].id, 'balance': balance, 'name': ref, 'analytic_distribution': analytic_distribution, } # ============================= END - Deferred management ==================================== def _get_computed_taxes(self): if self.move_id.deferred_original_move_ids: # If this line is part of a deferral move, do not (re)calculate its taxes automatically. # Doing so might unvoluntarily impact the tax report in deferral moves (if a default tax is set on the account). return self.tax_ids return super()._get_computed_taxes() def _compute_attachment(self): id_model2attachments = { (res_model, res_id): attachments for res_model, res_id, attachments in self.env['ir.attachment']._read_group( domain=Domain.OR(self._get_attachment_domains()), groupby=['res_model', 'res_id'], aggregates=['id:recordset'], ) } for record in self: record.move_attachment_ids = self._get_attachment_by_record(id_model2attachments, record) def action_reconcile(self): """ This function is called by the 'Reconcile' button of account.move.line's list view. It performs reconciliation between the selected lines. - If the reconciliation can be done directly we do it silently - Else, if a write-off is required we open the wizard to let the client enter required information """ self = self.filtered(lambda x: x.balance or x.amount_currency) # noqa: PLW0642 if not self: return wizard = self.env['account.reconcile.wizard'].with_context( active_model='account.move.line', active_ids=self.ids, ).new({}) return wizard._action_open_wizard() if (wizard.is_write_off_required or wizard.force_partials) else wizard.reconcile() def _get_predict_postgres_dictionary(self): lang = self.env.context.get('lang') and self.env.context.get('lang')[:2] return {'fr': 'french'}.get(lang, 'english') @api.model def _build_predictive_query(self, move_id, additional_domain=None, partner=None): move_query = self.env['account.move']._search([ ('move_type', '=', move_id.move_type), ('state', '=', 'posted'), ('partner_id', '=', (partner or move_id.partner_id).id), ('company_id', '=', move_id.journal_id.company_id.id or self.env.company.id), ], bypass_access=True) move_query.order = 'account_move.invoice_date' move_query.limit = int(self.env["ir.config_parameter"].sudo().get_param( "account.bill.predict.history.limit", '100', )) return self.env['account.move.line']._search([ ('move_id', 'in', move_query), ('display_type', '=', 'product'), ] + (additional_domain or []), bypass_access=True) @api.model def _predicted_field(self, move_id, name, partner_id, field, query=None, additional_queries=None): r"""Predict the most likely value based on the previous history. This method uses postgres tsvector in order to try to deduce a field of an invoice line based on the text entered into the name (description) field and the partner linked. We only limit the search on the previous 100 entries, which according to our tests bore the best results. However this limit parameter is configurable by creating a config parameter with the key: account.bill.predict.history.limit For information, the tests were executed with a dataset of 40 000 bills from a live database, We split the dataset in 2, removing the 5000 most recent entries and we tried to use this method to guess the account of this validation set based on the previous entries. The result is roughly 90% of success. :param str field: the sql column that has to be predicted. /!\ it is injected in the query without any checks. :param odoo.tools.query.Query query: the query object on account.move.line that is used to do the ranking, containing the right domain, limit, etc. If it is omitted, a default query is used. :param list[str] additional_queries: can be used in addition to the default query on account.move.line to fetch data coming from other tables, to have starting values for instance. /!\ it is injected in the query without any checks. """ if not name or not partner_id: return False psql_lang = self._get_predict_postgres_dictionary() description = name + ' account_move_line' # give more priority to main query than additional queries parsed_description = re.sub(r"[*&()|!':<>=%/~@,.;$\[\]]+", " ", description) parsed_description = ' | '.join(parsed_description.split()) try: main_source = (query if query is not None else self._build_predictive_query(move_id, partner=partner_id)).select( SQL("%s AS prediction", field), SQL( "setweight(to_tsvector(%s, account_move_line.name), 'B') || setweight(to_tsvector('simple', 'account_move_line'), 'A') AS document", psql_lang ), ) if "(" in field.code: # aggregate function main_source = SQL("%s %s", main_source, SQL("GROUP BY account_move_line.id, account_move_line.name, account_move_line.partner_id")) self.env.cr.execute(SQL(""" WITH account_move_line AS MATERIALIZED (%(account_move_line)s), source AS (%(source)s), ranking AS ( SELECT prediction, ts_rank(source.document, query_plain) AS rank FROM source, to_tsquery(%(lang)s, %(description)s) query_plain WHERE source.document @@ query_plain ) SELECT prediction, MAX(rank) AS ranking, COUNT(*) FROM ranking GROUP BY prediction ORDER BY ranking DESC, count DESC LIMIT 2 """, account_move_line=self._build_predictive_query(move_id, partner=partner_id).select(SQL('*')), source=SQL('(%s)', SQL(') UNION ALL (').join([main_source] + (additional_queries or []))), lang=psql_lang, description=parsed_description, )) result = self.env.cr.dictfetchall() if result: # Only confirm the prediction if it's at least 10% better than the second one if len(result) > 1 and result[0]['ranking'] < 1.1 * result[1]['ranking']: return False return result[0]['prediction'] except Exception: # In case there is an error while parsing the to_tsquery (wrong character for example) # We don't want to have a blocking traceback, instead return False _logger.exception('Error while predicting invoice line fields') return False def _predict_taxes(self): field = SQL('array_agg(account_move_line__tax_rel__tax_ids.id ORDER BY account_move_line__tax_rel__tax_ids.id)') query = self._build_predictive_query(self.move_id) query.left_join('account_move_line', 'id', 'account_move_line_account_tax_rel', 'account_move_line_id', 'tax_rel') query.left_join('account_move_line__tax_rel', 'account_tax_id', 'account_tax', 'id', 'tax_ids') query.add_where('account_move_line__tax_rel__tax_ids.active IS NOT FALSE') predicted_tax_ids = self._predicted_field(self.move_id, self.name, self.partner_id, field, query) if predicted_tax_ids == [None]: return False if predicted_tax_ids is not False and set(predicted_tax_ids) != set(self.tax_ids.ids): return predicted_tax_ids return False @api.model def _predict_specific_tax(self, move, name, partner, amount_type, amount, type_tax_use): field = SQL('array_agg(account_move_line__tax_rel__tax_ids.id ORDER BY account_move_line__tax_rel__tax_ids.id)') query = self._build_predictive_query(move, partner=partner) query.left_join('account_move_line', 'id', 'account_move_line_account_tax_rel', 'account_move_line_id', 'tax_rel') query.left_join('account_move_line__tax_rel', 'account_tax_id', 'account_tax', 'id', 'tax_ids') query.add_where(""" account_move_line__tax_rel__tax_ids.active IS NOT FALSE AND account_move_line__tax_rel__tax_ids.amount_type = %s AND account_move_line__tax_rel__tax_ids.type_tax_use = %s AND account_move_line__tax_rel__tax_ids.amount = %s """, (amount_type, type_tax_use, amount)) return self._predicted_field(move, name, partner, field, query) @api.model def _predict_specific_product(self, move, name, partner): predict_product = int(self.env['ir.config_parameter'].sudo().get_param('account_predictive_bills.predict_product', '1')) if predict_product and move.company_id.predict_bill_product: query = self._build_predictive_query( move_id=move, additional_domain=['|', ('product_id', '=', False), ('product_id.active', '=', True)], partner=partner or move.partner_id, ) return self._predicted_field(move, name, partner, SQL('account_move_line.product_id'), query) return False def _predict_product(self): predicted_product_id = self._predict_specific_product(self.move_id, self.name, self.partner_id) if predicted_product_id and predicted_product_id != self.product_id.id: return predicted_product_id return False @api.model def _predict_specific_account(self, move, name, partner): field = SQL('account_move_line.account_id') if move.is_purchase_document(True): excluded_group = 'income' else: excluded_group = 'expense' account_query = self.env['account.account']._search([ *self.env['account.account']._check_company_domain(move.company_id or self.env.company), ('internal_group', 'not in', (excluded_group, 'off')), ('account_type', 'not in', ('liability_payable', 'asset_receivable')), ], bypass_access=True) account_name = self.env['account.account']._field_to_sql('account_account', 'name') psql_lang = self._get_predict_postgres_dictionary() additional_queries = [SQL(account_query.select( SQL("account_account.id AS account_id"), SQL("setweight(to_tsvector(%(psql_lang)s, %(account_name)s), 'B') AS document", psql_lang=psql_lang, account_name=account_name), ))] query = self._build_predictive_query(move, [('account_id', 'in', account_query)], partner=partner) return self._predicted_field(move, name, partner, field, query, additional_queries) def _predict_account(self): predicted_account_id = self._predict_specific_account(self.move_id, self.name, self.partner_id) if predicted_account_id and predicted_account_id != self.account_id.id: return predicted_account_id return False def _predict_deductible_amount(self): if self.account_id and self.partner_id and self.env.user.has_group('account.group_partial_purchase_deductibility'): field = SQL('account_move_line.deductible_amount') query = self._build_predictive_query(self.move_id, [('account_id', '=', self.account_id.id)]) predicted_deductible_amount = self._predicted_field(self.move_id, self.name, self.partner_id, field, query) if predicted_deductible_amount and predicted_deductible_amount != self.deductible_amount: return predicted_deductible_amount return False @api.onchange('name') def _onchange_name_predictive(self): if ((self.move_id.quick_edit_mode or self.move_id.move_type == 'in_invoice') and self.name and self.display_type == 'product' and not self.env.context.get('disable_onchange_name_predictive', False)): if not self.product_id: predicted_product_id = self._predict_product() if predicted_product_id: # We only update the price_unit, tax_ids and name in case they evaluate to False protected_fields = ['price_unit', 'tax_ids', 'name'] to_protect = [self._fields[fname] for fname in protected_fields if self[fname]] with self.env.protecting(to_protect, self): self.product_id = predicted_product_id # In case no product has been set, the account and taxes # will not depend on any product and can thus be predicted if not self.product_id: # Predict account. predicted_account_id = self._predict_account() if predicted_account_id: self.account_id = predicted_account_id # Predict taxes predicted_tax_ids = self._predict_taxes() if predicted_tax_ids: self.tax_ids = [Command.set(predicted_tax_ids)] # Predict Deductibility predicted_deductible_amount = self._predict_deductible_amount() if predicted_deductible_amount: self.deductible_amount = predicted_deductible_amount def _read_group_select(self, aggregate_spec, query): # Enable to use HAVING clause that sum rounded values depending on the # currency precision settings. Limitation: we only handle a having # clause of one element with that specific method :sum_rounded. fname, __, func = models.parse_read_group_spec(aggregate_spec) if func != 'sum_rounded': return super()._read_group_select(aggregate_spec, query) currency_alias = query.make_alias(self._table, 'currency_id') query.add_join('LEFT JOIN', currency_alias, 'res_currency', SQL( "%s = %s", self._field_to_sql(self._table, 'currency_id', query), SQL.identifier(currency_alias, 'id'), )) return SQL( 'SUM(ROUND(%s, %s))', self._field_to_sql(self._table, fname, query), self.env['res.currency']._field_to_sql(currency_alias, 'decimal_places', query), ) def _read_group_groupby(self, alias, groupby_spec, query): # enable grouping by :abs_rounded on fields, which is useful when trying # to match positive and negative amounts if ':' in groupby_spec: fname, method = groupby_spec.split(':') if method == 'abs_rounded': # rounds with the used currency settings currency_alias = query.make_alias(self._table, 'currency_id') query.add_join('LEFT JOIN', currency_alias, 'res_currency', SQL( "%s = %s", self._field_to_sql(self._table, 'currency_id', query), SQL.identifier(currency_alias, 'id'), )) return SQL( 'ROUND(ABS(%s), %s)', self._field_to_sql(self._table, fname, query), self.env['res.currency']._field_to_sql(currency_alias, 'decimal_places', query), ) return super()._read_group_groupby(alias, groupby_spec, query)