From ce9094599029a338c2496c76bc84c1cd1ca66b35 Mon Sep 17 00:00:00 2001 From: git_admin Date: Tue, 28 Apr 2026 07:35:25 +0000 Subject: [PATCH] Tower: upload at_accounting 18.0.1.7 (via marketplace) --- .../models/account_deferred_reports.py | 581 ++++++++++++++++++ 1 file changed, 581 insertions(+) create mode 100644 addons/at_accounting/models/account_deferred_reports.py diff --git a/addons/at_accounting/models/account_deferred_reports.py b/addons/at_accounting/models/account_deferred_reports.py new file mode 100644 index 0000000..7d13bbf --- /dev/null +++ b/addons/at_accounting/models/account_deferred_reports.py @@ -0,0 +1,581 @@ +# 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, SQL +from odoo.addons.at_accounting.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): + account_name = self.env['account.account']._field_to_sql('account_move_line__account_id', 'name') + return [ + SQL("account_move_line.id AS line_id"), + SQL("account_move_line.account_id AS account_id"), + SQL("account_move_line.partner_id AS partner_id"), + SQL("account_move_line.product_id AS product_id"), + SQL("account_move_line__product_template_id.categ_id AS product_category_id"), + SQL("account_move_line.name AS line_name"), + SQL("account_move_line.deferred_start_date AS deferred_start_date"), + SQL("account_move_line.deferred_end_date AS deferred_end_date"), + SQL("account_move_line.deferred_end_date - account_move_line.deferred_start_date AS diff_days"), + SQL("account_move_line.balance AS balance"), + SQL("account_move_line.analytic_distribution AS analytic_distribution"), + SQL("account_move_line__move_id.id as move_id"), + SQL("account_move_line__move_id.name AS move_name"), + SQL("%s AS account_name", account_name), + ] + + def _get_lines(self, report, options, filter_already_generated=False): + domain = self._get_domain(report, options, filter_already_generated) + query = report._get_report_query(options, domain=domain, date_scope='from_beginning') + select_clause = SQL(', ').join(self._get_select()) + + query = SQL( + """ + SELECT %(select_clause)s + FROM %(table_references)s + LEFT JOIN product_product AS account_move_line__product_id ON account_move_line.product_id = account_move_line__product_id.id + LEFT JOIN product_template AS account_move_line__product_template_id ON account_move_line__product_id.product_tmpl_id = account_move_line__product_template_id.id + WHERE %(search_condition)s + ORDER BY account_move_line.deferred_start_date, account_move_line.id + """, + select_clause=select_clause, + table_references=query.from_clause, + search_condition=query.where_clause, + ) + + self.env.cr.execute(query) + res = self.env.cr.dictfetchall() + return res + + @api.model + def _get_grouping_fields_deferred_lines(self, filter_already_generated=False, grouping_field='account_id'): + return (grouping_field,) + + @api.model + def _group_by_deferred_fields(self, line, filter_already_generated=False, grouping_field='account_id'): + return tuple(line[k] for k in self._get_grouping_fields_deferred_lines(filter_already_generated, grouping_field)) + + @api.model + def _get_grouping_fields_deferral_lines(self): + return () + + @api.model + def _group_by_deferral_fields(self, line): + return tuple(line[k] for k in self._get_grouping_fields_deferral_lines()) + + @api.model + def _group_deferred_amounts_by_grouping_field(self, deferred_amounts_by_line, periods, is_reverse, filter_already_generated=False, grouping_field='account_id'): + """ + 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_fields(x, filter_already_generated, grouping_field)) + totals_per_key = {} # {key: {**self._get_grouping_fields_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': 'at_accounting.DeferredFilters', + }, + } + + def _custom_options_initializer(self, report, options, previous_options): + 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() + options['deferred_grouping_field'] = previous_options.get('deferred_grouping_field') or 'account_id' + 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` + _grouping_model, grouping_record_id = report._get_model_info_from_id(params.get('calling_line_dict_id')) + + # 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 grouping_record_id: + # We're auditing a specific account, so we only want moves containing this account + original_move_lines_domain.append((options['deferred_grouping_field'], '=', grouping_record_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': [(self.env.ref('at_accounting.view_deferred_entries_tree').id, 'list')], + # Most filters are set here to allow auditing flexibility to the user + 'context': { + 'search_default_pl_accounts': True, + f'search_default_{options["deferred_grouping_field"]}': grouping_record_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 _customize_warnings(self, report, options, all_column_groups_expression_totals, warnings): + 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['at_accounting.deferred_report_warning_already_posted'] = {'alert_type': 'warning'} + + def open_journal_items(self, options, params): + report = self.env['account.report'].browse(options['report_id']) + record_model, record_id = report._get_model_info_from_id(params.get('line_id')) + domain = self._get_domain(report, options) + if record_model == 'account.account' and record_id: + domain += [('account_id', '=', record_id)] + elif record_model == 'product.product' and record_id: + domain += [('product_id', '=', record_id)] + elif record_model == 'product.category' and record_id: + domain += [('product_category_id', '=', record_id)] + return { + 'type': 'ir.actions.act_window', + 'name': _("Deferred Entries"), + 'res_model': 'account.move.line', + 'domain': domain, + 'views': [(self.env.ref('at_accounting.view_deferred_entries_tree').id, 'list')], + 'context': { + 'search_default_group_by_move': True, + 'expand': True, + } + } + + 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'] + ] + + 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, self._get_deferred_report_type()) + totals_per_grouping_field, totals_all_grouping_field = self._group_deferred_amounts_by_grouping_field( + deferred_amounts_by_line=deferred_amounts_by_line, + periods=periods, + is_reverse=self._get_deferred_report_type() == 'expense', + filter_already_generated=False, + grouping_field=options['deferred_grouping_field'], + ) + + report_lines = [] + grouping_model = self.env['account.move.line'][options['deferred_grouping_field']]._name + for totals_grouping_field in totals_per_grouping_field.values(): + grouping_record = self.env[grouping_model].browse(totals_grouping_field[options['deferred_grouping_field']]) + grouping_field_description = self.env['account.move.line'][options['deferred_grouping_field']]._description + if options['deferred_grouping_field'] == 'product_id': + grouping_field_description = _("Product") + grouping_name = grouping_record.display_name or _("(No %s)", grouping_field_description) + report_lines.append((0, { + 'id': report._get_generic_line_id(grouping_model, grouping_record.id), + 'name': grouping_name, + 'caret_options': 'deferred_caret', + 'level': 1, + 'columns': get_columns(totals_grouping_field), + })) + if totals_per_grouping_field: + report_lines.append((0, { + 'id': report._get_generic_line_id(None, None, markup='total'), + 'name': 'Total', + 'level': 1, + 'columns': get_columns(totals_all_grouping_field), + })) + + 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, "list"), (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_expense_journal_id if self._get_deferred_report_type() == "expense" else self.env.company.deferred_revenue_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, journal): + 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'], + 'product_category_id': lines_per_key[0]['product_category_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], is_reverse) + deferred_amounts_by_key, deferred_amounts_totals = self._group_deferred_amounts_by_grouping_field(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_fields_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_fields(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_fields(line, True)][account_id] += distribution * key_ratio + deferred_anal_dist[self._group_by_deferral_fields(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_fields, + ) + } + 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'