# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import calendar from collections import defaultdict from dateutil.relativedelta import relativedelta from odoo import models, fields, _, api, Command from odoo.exceptions import UserError from odoo.tools import groupby from odoo.addons.account_accountant.models.account_move import DEFERRED_DATE_MIN, DEFERRED_DATE_MAX class DeferredReportCustomHandler(models.AbstractModel): _name = 'account.deferred.report.handler' _inherit = 'account.report.custom.handler' _description = 'Deferred Expense Report Custom Handler' def _get_deferred_report_type(self): raise NotImplementedError("This method is not implemented in the deferred report handler.") ############################################ # DEFERRED COMMON (DISPLAY AND GENERATION) # ############################################ def _get_domain(self, report, options, filter_already_generated=False, filter_not_started=False): domain = report._get_options_domain(options, "from_beginning") account_types = ('expense', 'expense_depreciation', 'expense_direct_cost') if self._get_deferred_report_type() == 'expense' else ('income', 'income_other') domain += [ ('account_id.account_type', 'in', account_types), ('deferred_start_date', '!=', False), ('deferred_end_date', '!=', False), ('deferred_end_date', '>=', options['date']['date_from']), ('move_id.date', '<=', options['date']['date_to']), ] domain += [ # Exclude if entirely inside the period '!', '&', '&', '&', '&', '&', ('deferred_start_date', '>=', options['date']['date_from']), ('deferred_start_date', '<=', options['date']['date_to']), ('deferred_end_date', '>=', options['date']['date_from']), ('deferred_end_date', '<=', options['date']['date_to']), ('move_id.date', '>=', options['date']['date_from']), ('move_id.date', '<=', options['date']['date_to']), ] if filter_already_generated: domain += [ ('deferred_end_date', '>=', options['date']['date_from']), '!', '&', ('move_id.deferred_move_ids.date', '=', options['date']['date_to']), ('move_id.deferred_move_ids.state', '=', 'posted'), ] if filter_not_started: domain += [('deferred_start_date', '>', options['date']['date_to'])] return domain @api.model def _get_select(self): return [ "account_move_line.id AS line_id", "account_move_line.account_id AS account_id", "account_move_line.partner_id AS partner_id", "account_move_line.product_id AS product_id", "account_move_line.name AS line_name", "account_move_line.deferred_start_date AS deferred_start_date", "account_move_line.deferred_end_date AS deferred_end_date", "account_move_line.deferred_end_date - account_move_line.deferred_start_date AS diff_days", "account_move_line.balance AS balance", "account_move_line.analytic_distribution AS analytic_distribution", "account_move_line__move_id.id as move_id", "account_move_line__move_id.name AS move_name", "account_move_line__account_id.name AS account_name", ] def _get_lines(self, report, options, filter_already_generated=False): domain = self._get_domain(report, options, filter_already_generated) tables, where_clause, where_params = report._query_get(options, domain=domain, date_scope='from_beginning') select_clause = ', '.join(self._get_select()) query = f""" SELECT {select_clause} FROM {tables} WHERE {where_clause} ORDER BY "account_move_line"."deferred_start_date", "account_move_line"."id" """ self.env.cr.execute(query, where_params) res = self.env.cr.dictfetchall() return res @api.model def _get_grouping_keys_deferred_lines(self, filter_already_generated=False): return ('account_id',) @api.model def _group_by_deferred_keys(self, line, filter_already_generated=False): return tuple(line[k] for k in self._get_grouping_keys_deferred_lines(filter_already_generated)) @api.model def _get_grouping_keys_deferral_lines(self): return () @api.model def _group_by_deferral_keys(self, line): return tuple(line[k] for k in self._get_grouping_keys_deferral_lines()) @api.model def _group_deferred_amounts_by_account(self, deferred_amounts_by_line, periods, is_reverse, filter_already_generated=False): """ Groups the deferred amounts by account and computes the totals for each account for each period. And the total for all accounts for each period. E.g. (where period1 = (date1, date2, label1), period2 = (date2, date3, label2), ...) { self._get_grouping_keys_deferred_lines(): { 'account_id': account1, 'amount_total': 600, period_1: 200, period_2: 400 }, self._get_grouping_keys_deferred_lines(): { 'account_id': account2, 'amount_total': 700, period_1: 300, period_2: 400 }, }, {'totals_aggregated': 1300, period_1: 500, period_2: 800} """ deferred_amounts_by_line = groupby(deferred_amounts_by_line, key=lambda x: self._group_by_deferred_keys(x, filter_already_generated)) totals_per_key = {} # {key: {**self._get_grouping_keys_deferral_lines(), total, before, current, later}} totals_aggregated_by_period = {period: 0 for period in periods + ['totals_aggregated']} sign = 1 if is_reverse else -1 for key, lines_per_key in deferred_amounts_by_line: lines_per_key = list(lines_per_key) current_key_totals = self._get_current_key_totals_dict(lines_per_key, sign) totals_aggregated_by_period['totals_aggregated'] += current_key_totals['amount_total'] for period in periods: current_key_totals[period] = sign * sum(line[period] for line in lines_per_key) totals_aggregated_by_period[period] += self.env.company.currency_id.round(current_key_totals[period]) totals_per_key[key] = current_key_totals return totals_per_key, totals_aggregated_by_period ########################### # DEFERRED REPORT DISPLAY # ########################### def _get_custom_display_config(self): return { 'templates': { 'AccountReportFilters': 'account_reports.DeferredFilters', }, } def _custom_options_initializer(self, report, options, previous_options=None): super()._custom_options_initializer(report, options, previous_options=previous_options) options_per_col_group = report._split_options_per_column_group(options) for column_dict in options['columns']: column_options = options_per_col_group[column_dict['column_group_key']] column_dict['name'] = column_options['date']['string'] column_dict['date_from'] = column_options['date']['date_from'] column_dict['date_to'] = column_options['date']['date_to'] options['columns'] = list(reversed(options['columns'])) total_column = [{ **options['columns'][0], 'name': _('Total'), 'expression_label': 'total', 'date_from': DEFERRED_DATE_MIN, 'date_to': DEFERRED_DATE_MAX, }] not_started_column = [{ **options['columns'][0], 'name': _('Not Started'), 'expression_label': 'not_started', 'date_from': options['columns'][-1]['date_to'], 'date_to': DEFERRED_DATE_MAX, }] before_column = [{ **options['columns'][0], 'name': _('Before'), 'expression_label': 'before', 'date_from': DEFERRED_DATE_MIN, 'date_to': options['columns'][0]['date_from'], }] later_column = [{ **options['columns'][0], 'name': _('Later'), 'expression_label': 'later', 'date_from': options['columns'][-1]['date_to'], 'date_to': DEFERRED_DATE_MAX, }] options['columns'] = total_column + not_started_column + before_column + options['columns'] + later_column options['column_headers'] = [] options['deferred_report_type'] = self._get_deferred_report_type() if ( self._get_deferred_report_type() == 'expense' and self.env.company.generate_deferred_expense_entries_method == 'manual' or self._get_deferred_report_type() == 'revenue' and self.env.company.generate_deferred_revenue_entries_method == 'manual' ): options['buttons'].append({'name': _('Generate entry'), 'action': 'action_generate_entry', 'sequence': 80, 'always_show': True}) def action_audit_cell(self, options, params): """ Open a list of invoices/bills and/or deferral entries for the clicked cell in a deferred report. Specifically, we show the following lines, grouped by their journal entry, filtered by the column date bounds: - Total: Lines of all invoices/bills being deferred in the current period - Not Started: Lines of all deferral entries for which the original invoice/bill date is before or in the current period, but the deferral only starts after the current period, as well as the lines of their original invoices/bills - Before: Lines of all deferral entries with a date before the current period, created by invoices/bills also being deferred in the current period, as well as the lines of their original invoices/bills - Current: Lines of all deferral entries in the current period, as well as these of their original invoices/bills - Later: Lines of all deferral entries with a date after the current period, created by invoices/bills also being deferred in the current period, as well as the lines of their original invoices/bills :param dict options: the report's `options` :param dict params: a dict containing: `calling_line_dict_id`: line id containing the optional account of the cell `column_group_id`: the column group id of the cell `expression_label`: the expression label of the cell """ report = self.env['account.report'].browse(options['report_id']) column_values = next( (column for column in options['columns'] if ( column['column_group_key'] == params.get('column_group_key') and column['expression_label'] == params.get('expression_label') )), None ) if not column_values: return column_date_from = fields.Date.to_date(column_values['date_from']) column_date_to = fields.Date.to_date(column_values['date_to']) report_date_from = fields.Date.to_date(options['date']['date_from']) report_date_to = fields.Date.to_date(options['date']['date_to']) # Corrections for comparisons if column_values['expression_label'] in ('not_started', 'later'): # Not Started and Later period start one day after `report_date_to` column_date_from = report_date_to + relativedelta(days=1) if column_values['expression_label'] == 'before': # Before period ends one day before `report_date_from` column_date_to = report_date_from - relativedelta(days=1) # calling_line_dict_id is of the format `~account.report~15|~account.account~25` model, account_id = report._get_model_info_from_id(params.get('calling_line_dict_id')) if model != 'account.account': account_id = None # Find the original lines to be deferred in the report period original_move_lines_domain = self._get_domain( report, options, filter_not_started=column_values['expression_label'] == 'not_started' ) if account_id: # We're auditing a specific account, so we only want moves containing this account original_move_lines_domain.append(('account_id', '=', account_id)) # We're getting all lines from the concerned moves. They are filtered later for flexibility. original_move = self.env['account.move.line'].search(original_move_lines_domain).move_id # For the Total period only show the original move lines line_ids = original_move.line_ids.ids # Show both the original move lines and deferral move lines for all other periods if not column_values['expression_label'] == 'total': line_ids += original_move.deferred_move_ids.line_ids.ids return { 'type': 'ir.actions.act_window', 'name': _('Deferred Entries'), 'res_model': 'account.move.line', 'domain': [('id', 'in', line_ids)], 'views': [(False, 'list'), (False, 'form'), (False, 'pivot'), (False, 'graph'), (False, 'kanban')], # Most filters are set here to allow auditing flexibility to the user 'context': { 'search_default_pl_accounts': True, 'search_default_account_id': account_id, 'date_from': column_date_from, 'date_to': column_date_to, 'search_default_date_between': True, 'expand': True, } } def _caret_options_initializer(self): return { 'deferred_caret': [ {'name': _("Journal Items"), 'action': 'open_journal_items'}, ], } def open_journal_items(self, options, params): report = self.env['account.report'].browse(options['report_id']) action = report.open_journal_items(options=options, params=params) action.get('context', {}).pop('search_default_date_between', None) action['domain'] = action.get('domain', []) + self._get_domain(report, options) return action def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None): def get_columns(totals): return [ { **report._build_column_dict( totals[( fields.Date.to_date(column['date_from']), fields.Date.to_date(column['date_to']), column['expression_label'] )], column, options=options, currency=self.env.company.currency_id, ), 'auditable': True, } for column in options['columns'] ] if warnings is not None: already_generated = ( ( self._get_deferred_report_type() == 'expense' and self.env.company.generate_deferred_expense_entries_method == 'manual' or self._get_deferred_report_type() == 'revenue' and self.env.company.generate_deferred_revenue_entries_method == 'manual' ) and self.env['account.move'].search_count( report._get_generated_deferral_entries_domain(options) ) ) if already_generated: warnings['account_reports.deferred_report_warning_already_posted'] = {'alert_type': 'warning'} lines = self._get_lines(report, options) periods = [ ( fields.Date.from_string(column['date_from']), fields.Date.from_string(column['date_to']), column['expression_label'], ) for column in options['columns'] ] deferred_amounts_by_line = self.env['account.move']._get_deferred_amounts_by_line(lines, periods) totals_per_account, totals_all_accounts = self._group_deferred_amounts_by_account(deferred_amounts_by_line, periods, self._get_deferred_report_type() == 'expense') report_lines = [] for totals_account in totals_per_account.values(): account = self.env['account.account'].browse(totals_account['account_id']) report_lines.append((0, { 'id': report._get_generic_line_id('account.account', account.id), 'name': f"{account.code} {account.name}", 'caret_options': 'deferred_caret', 'level': 1, 'columns': get_columns(totals_account), })) if totals_per_account: report_lines.append((0, { 'id': report._get_generic_line_id(None, None, markup='total'), 'name': 'Total', 'level': 1, 'columns': get_columns(totals_all_accounts), })) return report_lines ####################### # DEFERRED GENERATION # ####################### def action_generate_entry(self, options): new_deferred_moves = self._generate_deferral_entry(options) return { 'name': _('Deferred Entries'), 'type': 'ir.actions.act_window', 'views': [(False, "tree"), (False, "form")], 'domain': [('id', 'in', new_deferred_moves.ids)], 'res_model': 'account.move', 'context': { 'search_default_group_by_move': True, 'expand': True, }, 'target': 'current', } def _generate_deferral_entry(self, options): journal = self.env.company.deferred_journal_id if not journal: raise UserError(_("Please set the deferred journal in the accounting settings.")) date_from = fields.Date.to_date(DEFERRED_DATE_MIN) date_to = fields.Date.from_string(options['date']['date_to']) if date_to.day != calendar.monthrange(date_to.year, date_to.month)[1]: raise UserError(_("You cannot generate entries for a period that does not end at the end of the month.")) if self.env.company._get_violated_lock_dates(date_to, False): raise UserError(_("You cannot generate entries for a period that is locked.")) options['all_entries'] = False # We only want to create deferrals for posted moves report = self.env["account.report"].browse(options["report_id"]) self.env['account.move.line'].flush_model() lines = self._get_lines(report, options, filter_already_generated=True) deferral_entry_period = self.env['account.report']._get_dates_period(date_from, date_to, 'range', period_type='month') ref = _("Grouped Deferral Entry of %s", deferral_entry_period['string']) ref_rev = _("Reversal of Grouped Deferral Entry of %s", deferral_entry_period['string']) deferred_account = self.env.company.deferred_expense_account_id if self._get_deferred_report_type() == 'expense' else self.env.company.deferred_revenue_account_id move_lines, original_move_ids = self._get_deferred_lines(lines, deferred_account, (date_from, date_to, 'current'), self._get_deferred_report_type() == 'expense', ref) if not move_lines: raise UserError(_("No entry to generate.")) deferred_move = self.env['account.move'].with_context(skip_account_deprecation_check=True).create({ 'move_type': 'entry', 'deferred_original_move_ids': [Command.set(original_move_ids)], 'journal_id': journal.id, 'date': date_to, 'auto_post': 'at_date', 'ref': ref, }) # 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. deferred_move.write({'line_ids': move_lines}) reverse_move = deferred_move._reverse_moves() reverse_move.write({ 'date': deferred_move.date + relativedelta(days=1), 'ref': ref_rev, }) reverse_move.line_ids.name = ref_rev new_deferred_moves = deferred_move + reverse_move # We create the relation (original deferred move, deferral entry) # using SQL. This avoids a MemoryError using the ORM which will # load huge amounts of moves in memory for nothing self.env.cr.execute_values(""" INSERT INTO account_move_deferred_rel(original_move_id, deferred_move_id) VALUES %s ON CONFLICT DO NOTHING """, [ (original_move_id, deferral_move.id) for original_move_id in original_move_ids for deferral_move in new_deferred_moves ]) (deferred_move + reverse_move)._post(soft=True) return new_deferred_moves @api.model def _get_current_key_totals_dict(self, lines_per_key, sign): return { 'account_id': lines_per_key[0]['account_id'], 'product_id': lines_per_key[0]['product_id'], 'amount_total': sign * sum(line['balance'] for line in lines_per_key), 'move_ids': {line['move_id'] for line in lines_per_key}, } @api.model def _get_deferred_lines(self, lines, deferred_account, period, is_reverse, ref): """ Returns a list of Command objects to create the deferred lines of a single given period. And the move_ids of the original lines that created these deferred (to keep track of the original invoice in the deferred entries). """ if not deferred_account: raise UserError(_("Please set the deferred accounts in the accounting settings.")) deferred_amounts_by_line = self.env['account.move']._get_deferred_amounts_by_line(lines, [period]) deferred_amounts_by_key, deferred_amounts_totals = self._group_deferred_amounts_by_account(deferred_amounts_by_line, [period], is_reverse, filter_already_generated=True) if deferred_amounts_totals['totals_aggregated'] == deferred_amounts_totals[period]: return [], set() # compute analytic distribution to populate on deferred lines # structure: {self._get_grouping_keys_deferred_lines(): [analytic distribution]} # dict of keys: self._get_grouping_keys_deferred_lines() # values: dict of keys: "account.analytic.account.id" (string) # values: float anal_dist_by_key = defaultdict(lambda: defaultdict(float)) # using another var for the analytic distribution of the deferral account deferred_anal_dist = defaultdict(lambda: defaultdict(float)) for line in lines: if not line['analytic_distribution']: continue # Analytic distribution should be computed from the lines with the same _get_grouping_keys_deferred_lines(), except for # the deferred line with the deferral account which will use _get_grouping_keys_deferral_lines() full_ratio = (line['balance'] / deferred_amounts_totals['totals_aggregated']) if deferred_amounts_totals['totals_aggregated'] else 0 key_amount = deferred_amounts_by_key.get(self._group_by_deferred_keys(line, True)) key_ratio = (line['balance'] / key_amount['amount_total']) if key_amount and key_amount['amount_total'] else 0 for account_id, distribution in line['analytic_distribution'].items(): anal_dist_by_key[self._group_by_deferred_keys(line, True)][account_id] += distribution * key_ratio deferred_anal_dist[self._group_by_deferral_keys(line)][account_id] += distribution * full_ratio remaining_balance = 0 deferred_lines = [] original_move_ids = set() for key, line in deferred_amounts_by_key.items(): for balance in (-line['amount_total'], line[period]): if balance != 0 and line[period] != line['amount_total']: original_move_ids |= line['move_ids'] deferred_balance = self.env.company.currency_id.round((1 if is_reverse else -1) * balance) deferred_lines.append( Command.create( self.env['account.move.line']._get_deferred_lines_values( account_id=line['account_id'], balance=deferred_balance, ref=ref, analytic_distribution=anal_dist_by_key[key] or False, line=line, ) ) ) remaining_balance += deferred_balance grouped_by_key = { key: list(value) for key, value in groupby( deferred_amounts_by_key.values(), key=self._group_by_deferral_keys, ) } deferral_lines = [] for key, lines_per_key in grouped_by_key.items(): balance = 0 for line in lines_per_key: if line[period] != line['amount_total']: balance += self.env.company.currency_id.round((1 if is_reverse else -1) * (line['amount_total'] - line[period])) deferral_lines.append( Command.create( self.env['account.move.line']._get_deferred_lines_values( account_id=deferred_account.id, balance=balance, ref=ref, analytic_distribution=deferred_anal_dist[key] or False, line=lines_per_key[0], ) ) ) remaining_balance += balance if not self.env.company.currency_id.is_zero(remaining_balance): deferral_lines.append( Command.create({ 'account_id': deferred_account.id, 'balance': -remaining_balance, 'name': ref, }) ) return deferred_lines + deferral_lines, original_move_ids class DeferredExpenseCustomHandler(models.AbstractModel): _name = 'account.deferred.expense.report.handler' _inherit = 'account.deferred.report.handler' _description = 'Deferred Expense Custom Handler' def _get_deferred_report_type(self): return 'expense' class DeferredRevenueCustomHandler(models.AbstractModel): _name = 'account.deferred.revenue.report.handler' _inherit = 'account.deferred.report.handler' _description = 'Deferred Revenue Custom Handler' def _get_deferred_report_type(self): return 'revenue'